diff --git a/.github/workflows/codex-push.yml b/.github/workflows/codex-push.yml new file mode 100644 index 00000000..bdefd9a0 --- /dev/null +++ b/.github/workflows/codex-push.yml @@ -0,0 +1,44 @@ +name: Codex token push + +on: + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: + +jobs: + push-token: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: harnesses/aggregator-head-lag/go.mod + cache-dependency-path: harnesses/aggregator-head-lag/go.sum + + - name: Build scraper + working-directory: harnesses/aggregator-head-lag + run: go build -o /tmp/codex-scraper ./cmd/test-utls/ + + - name: Scrape and push token + run: | + set -euo pipefail + output=$(MODE=default /tmp/codex-scraper 2>&1) + echo "$output" | head -5 + token=$(echo "$output" | grep '^CODEX_TOKEN=' | cut -d= -f2-) + if [ -z "$token" ] || [ ${#token} -lt 100 ]; then + echo "ERROR: no token in output" + echo "$output" + exit 1 + fi + echo "Got token (len=${#token}), pushing to sidecar..." + http_code=$(curl -s -o /tmp/push_resp -w "%{http_code}" -X POST \ + -H "Content-Type: text/plain" \ + --data-raw "$token" \ + "http://57.130.19.92:8080/push") + if [ "$http_code" = "204" ]; then + echo "Push OK" + else + echo "Push failed (HTTP $http_code): $(cat /tmp/push_resp)" + exit 1 + fi diff --git a/benchmarks/app-store-ratings.yml b/benchmarks/app-store-ratings.yml new file mode 100644 index 00000000..cc8e0afb --- /dev/null +++ b/benchmarks/app-store-ratings.yml @@ -0,0 +1,242 @@ +# OpenChainBench. Bench 202 + +slug: app-store-ratings +number: "202" +title: "Crypto trading app iOS ratings: Coinbase vs Robinhood vs Crypto.com vs Bybit, live" +seo_title: "Crypto trading app App Store ratings 2026: Coinbase, Robinhood, Crypto.com, Bybit, Kraken, Invo, Fomo, PumpFun, GMGN, Moonshot" +seo_description: "{{best_name}} leads crypto trading apps on the iOS App Store at {{best_p50}} stars. Coinbase vs Robinhood vs Crypto.com vs Bybit vs Kraken vs Invo vs Fomo vs PumpFun vs GMGN vs Moonshot ranked live from iTunes ratings." +subtitle: Live Apple App Store ratings for the leading crypto trading apps on iOS. Updated every 30 minutes from the iTunes lookup API. Rating is the current average across all user reviews. Review count shows how many users voted. + +category: Trading +status: live +metric: App Store rating +unit: x +higher_is_better: true + +disclaimer: | + Ratings reflect the current all-time average user rating from the Apple App Store as returned by the iTunes lookup API. Apple does not expose star-distribution breakdowns or time-windowed averages via the public API. A high rating with few reviews carries more statistical uncertainty than the same rating with tens of thousands of reviews. The review count column shows how many users contributed to each rating. + +seo_intro: | + This benchmark ranks the top crypto trading apps on the iOS App Store by + user rating, updated live every 30 minutes from the iTunes lookup API. It + spans both centralized exchange apps (Coinbase, Robinhood, Crypto.com, Bybit, + Kraken, Binance.US) and on-chain trading terminals (Fomo, GMGN, PumpFun, + Moonshot). Robinhood leads on raw review volume with nearly 5 million ratings, + making it the most statistically reliable data point in the set. Coinbase + follows with over 1.8 million ratings. On-chain apps like Fomo have accumulated + over 7,000 ratings since launching in April 2025, signaling strong early + engagement from DeFi-native traders. + +abstract: | + The harness polls the iTunes public lookup API + (itunes.apple.com/lookup?id=&country=us) every 30 minutes for each + tracked app. The API returns averageUserRating (0-5, rounded to two decimal + places by Apple) and userRatingCount (total all-time reviews). The harness + exposes three gauges per app: app_store_rating, app_store_reviews_total, + and app_store_health. No API key is required. Apple caches the endpoint + but updates it within hours of new reviews arriving. + +methodology: + - "Source: Apple iTunes public lookup API (itunes.apple.com/lookup?id=&country=us). No authentication required. Returns averageUserRating and userRatingCount." + - "Poll cadence: harness fetches each app every 30 minutes. Ratings change slowly (daily to weekly) so 30-minute polling captures all meaningful changes while respecting Apple caching." + - "Rating: Apple computes the average across all all-time user reviews. Apple does not expose time-windowed (e.g. current version only) averages via the public API endpoint used here." + - "Review count: total number of ratings ever submitted on the US App Store. Higher = more statistically reliable average." + - "Robinhood (ID 938003185): the most-reviewed financial app on the US App Store with nearly 5 million ratings. Commission-free investing with crypto added in 2018." + - "Coinbase (ID 886427730): the largest US-regulated crypto exchange. Over 1.8 million App Store ratings." + - "Crypto.com (ID 1262148500): Singapore-headquartered super app with 100M+ users globally, offering spot, derivatives, Visa card and DeFi wallet." + - "Bybit (ID 1488296980): major global exchange strong on derivatives and spot trading." + - "Kraken (ID 1481947260): veteran US exchange with strong security reputation." + - "Binance.US (ID 1492670702): US-regulated arm of Binance, available on the US App Store." + - "Fomo (ID 6741115427): launched Apr 2025. Social copy-trading and meme coin terminal, backed by Index Ventures and USV." + - "GMGN (ID 6745328711): AI-powered trading terminal focused on Solana meme coins." + - "PumpFun (ID 6717572591): launched Oct 2024. Bonding-curve launchpad and trading app for Solana meme coins." + - "Moonshot (ID 6503993131): mobile-first meme coin launchpad with fiat onramp via Apple Pay, by DEX Screener." + - "Invo (ID 1601301148): Hyperliquid-native social trading mobile app with 170+ perp pairs and copy trading via its registered builder code on Hyperliquid." + +findings: + - "{{best_name}} leads crypto trading apps on the iOS App Store at {{best_p50}} stars." + - "Robinhood dominates on review volume with nearly 5 million ratings, making it the most statistically robust data point in the set." + - "Coinbase follows with over 1.8 million ratings and is the top pure-crypto exchange by review count." + - "Fomo has accumulated over 7,000 ratings since its April 2025 launch despite being one of the newest entrants, signaling strong mobile-native engagement." + - "On-chain trading apps (Fomo, GMGN, PumpFun, Moonshot) compete directly with major CEX apps for the same mobile trader." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/app-store-ratings + +prometheus: + window: 24h + freshness_metric: app_store_health + +metric_panels: + - id: reviews + label: Review count + label_key: app + metric: app_store_reviews_total + unit: count + higher_is_better: true + description: "Total number of user ratings submitted on the US App Store. More reviews = more statistically reliable average. Robinhood leads with 4.8M+ ratings, Coinbase follows at 1.8M+." + +ledger_columns: + - { label: "Rating", slot: p50 } + - { label: "Reviews", panel: reviews } + +faq: + - q: "Why does the App Store rating matter for a trading app?" + a: "App Store ratings are the most visible public signal of user satisfaction for mobile products. For trading apps competing for the same users, a higher rating and more reviews signals a better-received product." + - q: "How often do App Store ratings change?" + a: "Slowly. Individual ratings accumulate continuously but the average moves over days to weeks. The harness polls every 30 minutes to capture any meaningful shift; in practice the number updates at most once or twice per day." + - q: "Why is this the US App Store only?" + a: "The iTunes public lookup API returns US store data by default. Review counts and ratings can differ by country. The US store is the largest single market and the standard reference for app comparisons." + - q: "Does this include Google Play ratings?" + a: "No. This benchmark covers the US App Store only. CEX apps like Coinbase and Bybit have Android versions but on-chain apps like Fomo and PumpFun are iOS-only as of 2026." + - q: "Why does Robinhood have so many more reviews than Coinbase?" + a: "Robinhood launched in 2013 as a stock trading app and had 13 million funded accounts before adding crypto in 2018. Its larger and older user base has had more time to leave App Store reviews. Its nearly 5 million ratings make it the most statistically reliable data point in this benchmark." + - q: "What is Crypto.com and why is it included?" + a: "Crypto.com is one of the largest crypto exchanges globally with 100M+ registered users. Its iOS app combines spot trading, a crypto Visa card, DeFi wallet and staking in a single super app, making it a major competitor to Coinbase for everyday crypto users." + - q: "What is Moonshot?" + a: "Moonshot is a mobile-first meme coin launchpad built by DEX Screener, the leading token analytics platform. It differentiates via fiat onboarding through Apple Pay, targeting users who want to buy meme coins without setting up a crypto wallet first." + - q: "Why Binance.US and not Binance?" + a: "The global Binance app is not available on the US App Store due to regulatory restrictions. Binance.US is the separate US-licensed entity with its own app (ID 1492670702) available to American users." + +providers: + - slug: robinhood + name: Robinhood + tag: "US investing app, 4.8M ratings" + formula: "Apple App Store average rating (iTunes lookup API, app ID 938003185, US store, all-time average). Review count from app_store_reviews_total{app=\"robinhood\"}." + queries: + p50: app_store_rating{app="robinhood"} + p90: app_store_rating{app="robinhood"} + p99: app_store_rating{app="robinhood"} + mean: app_store_rating{app="robinhood"} + success: app_store_health{app="robinhood"} + sample_size: app_store_reviews_total{app="robinhood"} + series: app_store_rating{app="robinhood"} + + - slug: coinbase + name: Coinbase + tag: "US crypto exchange" + formula: "Apple App Store average rating (iTunes lookup API, app ID 886427730, US store, all-time average). Review count from app_store_reviews_total{app=\"coinbase\"}." + queries: + p50: app_store_rating{app="coinbase"} + p90: app_store_rating{app="coinbase"} + p99: app_store_rating{app="coinbase"} + mean: app_store_rating{app="coinbase"} + success: app_store_health{app="coinbase"} + sample_size: app_store_reviews_total{app="coinbase"} + series: app_store_rating{app="coinbase"} + + - slug: cryptocom + name: Crypto.com + tag: "Global crypto super app" + formula: "Apple App Store average rating (iTunes lookup API, app ID 1262148500, US store, all-time average). Review count from app_store_reviews_total{app=\"cryptocom\"}." + queries: + p50: app_store_rating{app="cryptocom"} + p90: app_store_rating{app="cryptocom"} + p99: app_store_rating{app="cryptocom"} + mean: app_store_rating{app="cryptocom"} + success: app_store_health{app="cryptocom"} + sample_size: app_store_reviews_total{app="cryptocom"} + series: app_store_rating{app="cryptocom"} + + - slug: bybit + name: Bybit + tag: "Global derivatives exchange" + formula: "Apple App Store average rating (iTunes lookup API, app ID 1488296980, US store, all-time average). Review count from app_store_reviews_total{app=\"bybit\"}." + queries: + p50: app_store_rating{app="bybit"} + p90: app_store_rating{app="bybit"} + p99: app_store_rating{app="bybit"} + mean: app_store_rating{app="bybit"} + success: app_store_health{app="bybit"} + sample_size: app_store_reviews_total{app="bybit"} + series: app_store_rating{app="bybit"} + + - slug: kraken + name: Kraken + tag: "US crypto exchange" + formula: "Apple App Store average rating (iTunes lookup API, app ID 1481947260, US store, all-time average). Review count from app_store_reviews_total{app=\"kraken\"}." + queries: + p50: app_store_rating{app="kraken"} + p90: app_store_rating{app="kraken"} + p99: app_store_rating{app="kraken"} + mean: app_store_rating{app="kraken"} + success: app_store_health{app="kraken"} + sample_size: app_store_reviews_total{app="kraken"} + series: app_store_rating{app="kraken"} + + - slug: fomo + name: Fomo + tag: "Social trading app, Solana" + formula: "Apple App Store average rating (iTunes lookup API, app ID 6741115427, US store, all-time average). Review count from app_store_reviews_total{app=\"fomo\"}." + queries: + p50: app_store_rating{app="fomo"} + p90: app_store_rating{app="fomo"} + p99: app_store_rating{app="fomo"} + mean: app_store_rating{app="fomo"} + success: app_store_health{app="fomo"} + sample_size: app_store_reviews_total{app="fomo"} + series: app_store_rating{app="fomo"} + + - slug: gmgn + name: GMGN + tag: "AI trading terminal, Solana" + formula: "Apple App Store average rating (iTunes lookup API, app ID 6745328711, US store, all-time average). Review count from app_store_reviews_total{app=\"gmgn\"}." + queries: + p50: app_store_rating{app="gmgn"} + p90: app_store_rating{app="gmgn"} + p99: app_store_rating{app="gmgn"} + mean: app_store_rating{app="gmgn"} + success: app_store_health{app="gmgn"} + sample_size: app_store_reviews_total{app="gmgn"} + series: app_store_rating{app="gmgn"} + + - slug: pump-fun + name: PumpFun + tag: "Solana meme launchpad app" + formula: "Apple App Store average rating (iTunes lookup API, app ID 6717572591, US store, all-time average). Review count from app_store_reviews_total{app=\"pump-fun\"}." + queries: + p50: app_store_rating{app="pump-fun"} + p90: app_store_rating{app="pump-fun"} + p99: app_store_rating{app="pump-fun"} + mean: app_store_rating{app="pump-fun"} + success: app_store_health{app="pump-fun"} + sample_size: app_store_reviews_total{app="pump-fun"} + series: app_store_rating{app="pump-fun"} + + - slug: moonshot + name: Moonshot + tag: "Meme launchpad, Apple Pay" + formula: "Apple App Store average rating (iTunes lookup API, app ID 6503993131, US store, all-time average). Review count from app_store_reviews_total{app=\"moonshot\"}." + queries: + p50: app_store_rating{app="moonshot"} + p90: app_store_rating{app="moonshot"} + p99: app_store_rating{app="moonshot"} + mean: app_store_rating{app="moonshot"} + success: app_store_health{app="moonshot"} + sample_size: app_store_reviews_total{app="moonshot"} + series: app_store_rating{app="moonshot"} + + - slug: binance-us + name: Binance.US + tag: "US-regulated Binance exchange" + formula: "Apple App Store average rating (iTunes lookup API, app ID 1492670702, US store, all-time average). Review count from app_store_reviews_total{app=\"binance-us\"}." + queries: + p50: app_store_rating{app="binance-us"} + p90: app_store_rating{app="binance-us"} + p99: app_store_rating{app="binance-us"} + mean: app_store_rating{app="binance-us"} + success: app_store_health{app="binance-us"} + sample_size: app_store_reviews_total{app="binance-us"} + series: app_store_rating{app="binance-us"} + + - slug: invo + name: Invo + tag: "Hyperliquid social trading app" + formula: "Apple App Store average rating (iTunes lookup API, app ID 1601301148, US store, all-time average). Review count from app_store_reviews_total{app=\"invo\"}." + queries: + p50: app_store_rating{app="invo"} + p90: app_store_rating{app="invo"} + p99: app_store_rating{app="invo"} + mean: app_store_rating{app="invo"} + success: app_store_health{app="invo"} + sample_size: app_store_reviews_total{app="invo"} + series: app_store_rating{app="invo"} diff --git a/benchmarks/apps/04-addendum.md b/benchmarks/apps/04-addendum.md new file mode 100644 index 00000000..9ac40f78 --- /dev/null +++ b/benchmarks/apps/04-addendum.md @@ -0,0 +1,112 @@ +# `/apps` — Addendum aux décisions ouvertes + +**Date : 9 août 2026.** Ce document tranche les trois points laissés ouverts dans +`00-EXECUTION-BRIEF.md` et corrige un passage approximatif sur le budget de recompute. + +--- + +## Décision 1 — Renommage de la colonne de classement + +`net_real_revenue` est un abus de langage dès qu'on y inclut des buybacks : ce n'est pas +du revenue au sens comptable. Le terme induit le même péché de fusion que DefiLlama. + +**Décision** : la colonne de classement s'appelle `net_value_capture_usd`, libellé UI +**« Valeur nette captée »**. La formule est affichée **en permanence** sous l'en-tête de +colonne (pas au survol) : + +``` +Valeur nette captée += trésor + holders + burn − émissions +``` + +La barre segmentée décompose visuellement les trois termes positifs. Le chiffre scalaire +reste disponible pour le tri mais n'est jamais présenté sans sa formule. + +**Cohérence avec P4** : `with_holders` est le défaut. `treasury_only` est disponible en +toggle discret. Les deux variantes sont calculées et stockées dans `fee_facts_windowed` +sous `variant IN ('with_holders', 'treasury_only')`. + +--- + +## Décision 2 — Budget de recompute : critère chiffré + +La formulation « c'est un simple GROUP BY, ça tient en 30 minutes » était paresseuse. Le +vrai goulot d'étranglement est la jointure de pricing sur `(token, hour)`. + +**Analyse** : + +- Seules les paires `(token, heure)` où un event a eu lieu sont pricées : la table de + prix est **creuse et pilotée par la demande**, pas dense. +- Les prix sont une observation (pas un artefact de méthodologie) : un recompute de + formule ne les invalide pas. Seul un changement de méthode de pricing le fait, et c'est + rare. +- Le long tail Solana (memecoins) tombe en `pool_ratio`, dérivé du swap lui-même. Ce sont + les cas les moins chers : aucun appel externe. Seuls ~50 tokens majeurs touchent Pyth. + +**Critère d'acceptation chiffré (Phase 1)** : + +``` +Benchmark sur 20M d'events synthétiques, 3 000 tokens distincts : +- Recompute complet < 30 minutes sur le VPS (4 cores, 15GB RAM) +- EXPLAIN ANALYZE ne montre aucun Seq Scan sur fee_events ou fee_facts +- Si ces deux conditions ne sont pas satisfaites simultanément : + partitionner fee_events par mois (PARTITION BY RANGE sur ts) + et fee_facts par (bucket_start, methodology_version) +``` + +Le partitionnement n'est pas activé par défaut : il complique le schéma sans bénéfice +prouvé à l'échelle MVP. Le benchmark tranche, pas le raisonnement. + +--- + +## Décision 3 — Aerodrome en T1, phase 4a + +**Constat** : indexer les swaps Aerodrome en direct getLogs est intenable (milliers de +pools × millions de swaps). Mais on n'a pas besoin des swaps. + +Les fees d'Aerodrome atterrissent dans les contrats `FeesVotingReward` (un par gauge), +via `notifyRewardAmount()` appelé par le Voter. L'event émis est : + +```solidity +// IReward.sol — vérifié sur aerodrome-finance/contracts main +event NotifyReward( + address indexed from, // Voter + address indexed reward, // token de fee (USDC, WETH, etc.) + uint256 indexed epoch, // epochStart en secondes + uint256 amount // montant en unités natives +); + +// topic0 vérifié : +// 0x52977ea98a2220a03ee9ba5cb003ada08d394ea10155483c95dc2dc77a7eb24b +``` + +Volume réel : O(epoch × gauges actives × tokens) — quelques centaines d'events par +semaine, pas un par swap. **Aerodrome est indexable en T1 sans Envio.** + +**Prix à payer documenté** : granularité epoch (hebdomadaire). La fenêtre 24h d'Aerodrome +est de résolution dégradée — l'émission AERO est répartie uniformément sur 7 jours +(cf. `aerodrome.yaml : spread_over_epoch: true`) mais les fees sont discrets. L'UI affiche +un badge « résolution hebdomadaire » sur la ligne Aerodrome pour les fenêtres 24h et 7j. +La fenêtre 30j est pleine résolution. + +**Comment découvrir les FeesVotingReward** : via `Voter.gaugeToFees(gaugeAddress)`. +Énumérer les gauges actifs depuis `Voter`, résoudre leur contrat de fees à l'init, +surveiller les nouveaux gauges à chaque epoch. + +**Aerodrome passe de Phase 4 à Phase 4a** (parallèle à Uniswap, avant Raydium qui reste +le plus complexe). + +--- + +## Correction — passage approximatif dans 01-architecture.md + +Le §7 (séquencement) listait Raydium avant Uniswap. Ordre corrigé : + +| Phase | Contenu | +|---|---| +| 1 | Socle + dYdX | +| 2 | Hyperliquid + GMX V2 | +| 3 | Page `/apps` + API publique (3 protocoles) | +| 4a | Aerodrome (T1 via NotifyReward) + Uniswap | +| 4b | Raydium (le plus complexe : accrual Solana) | +| 5 | Changelog, exports CSV, coverage page | diff --git a/benchmarks/apps/_taxonomy.yaml b/benchmarks/apps/_taxonomy.yaml new file mode 100644 index 00000000..6aa2887f --- /dev/null +++ b/benchmarks/apps/_taxonomy.yaml @@ -0,0 +1,111 @@ +# Taxonomie partagée par toutes les specs /apps. +# Un fee_event ne peut porter qu'un `component` et qu'un `beneficiary` de ces listes. +# Toute modification ici est un changement de méthodologie : bump obligatoire de +# methodology_version sur les specs affectées + recompute complet. + +schema_version: 1 + +components: + taker_fee: + included_in_gross: true + label: "Frais preneur" + maker_fee: + included_in_gross: true + label: "Frais offreur" + maker_rebate: + included_in_gross: false + label: "Rabais offreur" + note: "Montant stocké positif, beneficiary=lp. Sortie de trésorerie, pas un fee." + swap_fee: + included_in_gross: true + label: "Frais de swap AMM" + borrow_fee: + included_in_gross: true + label: "Frais d'emprunt" + note: "Payé au pool, pas à une contrepartie. Revenue réel du système." + liquidation_fee: + included_in_gross: true + label: "Frais de liquidation" + note: "Uniquement la part retenue par le protocole ou le pool. Le collatéral saisi est une perte du trader, jamais un fee." + pool_creation_fee: + included_in_gross: true + label: "Frais de création de pool" + bonding_curve_fee: + included_in_gross: true + label: "Frais de bonding curve" + + # --- Collectés mais exclus des agrégats par défaut --- + funding_payment: + included_in_gross: false + toggleable: true + label: "Paiements de funding" + rationale: >- + Transfert de somme nulle entre longs et shorts. Le protocole n'en retient rien. + L'inclure gonfle mécaniquement les perps face aux DEX spot et rend le classement + inter-catégories inutilisable. + interface_fee: + included_in_gross: true + beneficiary_forced: third_party + is_app_layer: true + excluded_from_ranking: true + rationale: >- + Réel, mais Uniswap Labs a coupé ses frontend fees lors d'UNIfication. Les compter + chez les autres fausserait la comparaison. Mesuré, affiché, hors classement. + external_bribe: + included_in_gross: false + label: "Incentives externes" + rationale: "Payé par des tiers aux voters, pas un revenue généré par le protocole." + sequencer_revenue: + included_in_gross: false + counted_in: non_trading_holder_value + rationale: >- + Cas Unichain : entre dans le même mécanisme de burn que les protocol fees, mais + n'est pas un frais de trading. Additionné séparément et visiblement. + +exclusions: + # Jamais collectés. Documentés pour que la page méthodologie soit complète. + - id: price_impact + reason: "Capté par l'inventaire du LP, non identifiable comme fee dans les events." + - id: mev + reason: "Va au searcher et au validateur, pas au protocole." + - id: gas_and_priority_fees + reason: "Coût réseau, pas revenue protocole." + - id: seized_collateral + reason: "Perte du trader, pas un frais payé au système." + - id: treasury_one_offs + reason: >- + Burns rétroactifs et distributions exceptionnelles (ex. les 100M UNI du trésor + lors d'UNIfication). Ce n'est pas du revenue récurrent ; l'inclure produirait des + pics absurdes. + +beneficiaries: + lp: { label: "Fournisseurs de liquidité", in_protocol_cut: false, in_holder_value: false } + token_holder: { label: "Détenteurs du token", in_protocol_cut: false, in_holder_value: true } + staker: { label: "Stakers / validateurs", in_protocol_cut: false, in_holder_value: true } + burn: { label: "Brûlé", in_protocol_cut: false, in_holder_value: true } + treasury: { label: "Trésor", in_protocol_cut: true, in_holder_value: false } + insurance_fund: { label: "Fonds d'assurance", in_protocol_cut: true, in_holder_value: false } + third_party: { label: "Tiers (frontends, affiliés)", in_protocol_cut: false, in_holder_value: false } + creator: { label: "Créateur du pool / token", in_protocol_cut: false, in_holder_value: false } + +# Métriques dérivées. Ce sont des VUES sur la partition ci-dessus, pas des colonnes +# indépendantes. Aucune ne peut compter deux fois le même event. +derived: + gross_fees: "Σ components[included_in_gross]" + lp_revenue: "Σ beneficiary == lp" + protocol_cut: "Σ beneficiary.in_protocol_cut" + token_holder_value: "Σ beneficiary.in_holder_value" + emissions_cost: "Σ_jours tokens_émis(j) × TWAP_usd(j)" + net_real_revenue: + with_holders: "protocol_cut + token_holder_value + non_trading_holder_value − emissions_cost" + treasury_only: "protocol_cut − emissions_cost" + default: with_holders + note: >- + `treasury_only` correspond à la définition littérale du brief mais classe Hyperliquid + comme quasi nul, ce qui contredit l'intention. `with_holders` produit le classement + décrit. Les deux sont calculées ; à trancher avant le lancement public. + +invariants: + unallocated_tolerance_bps: 50 + witness_divergence_warn_bps: 200 + witness_divergence_quarantine_bps: 500 diff --git a/benchmarks/apps/aerodrome.yaml b/benchmarks/apps/aerodrome.yaml new file mode 100644 index 00000000..2f6c9e3f --- /dev/null +++ b/benchmarks/apps/aerodrome.yaml @@ -0,0 +1,132 @@ +schema_version: 1 +slug: aerodrome +name: Aerodrome +category: dex +methodology_version: 1 +status: live +verifiability: tier_2 + +# Le protocole qui justifie la colonne net_real_revenue. +# Résultat attendu : NÉGATIF. Ce n'est pas une anomalie à corriger. + +deployments: + - id: aerodrome:base + chain: base + effective_from: "2023-08-28T00:00:00Z" + + finality: + mode: l1_confirmed + lag_seconds: 900 + + source: + kind: envio_hyperindex + canonical: true + repo: https://github.com/velodrome-finance/indexer + note: >- + T2 : dépendance à un indexer tiers. Migration vers evm_logs directs prévue + en phase 5. L'architecture ve(3,3) rend cette dépendance structurellement + risquée à long terme. + + witnesses: + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/aerodrome + expected_divergence: high + note: >- + DefiLlama ne soustrait pas les émissions. La divergence est attendue et + constitue la démonstration de la thèse du produit — ne pas la traiter + comme une erreur à réconcilier. + +allocation: + rules: + - source: pool_fees + splits: + - component: swap_fee + beneficiary: token_holder # 100% aux voters veAERO + share_source: onchain_field + field: fee_amount + - source: slipstream_unstaked + splits: + - component: swap_fee + beneficiary: lp + share_source: onchain_field + field: unstaked_fee + note: "Positions CL non-stakées : traitement distinct, ne pas confondre." + - source: external_bribes + splits: + - component: external_bribe # exclu de gross_fees par la taxonomie + beneficiary: token_holder + share_source: onchain_field + note: "Payé par des tiers. L'inclure gonflerait artificiellement Aerodrome." + + # Exemple de constante correctement encodée : bornée dans le temps, sourcée. + # Le CI échoue si source_url ou effective_from manque, ou si deux intervalles + # se chevauchent. C'est le garde-fou anti-Lido. + constants: + - name: treasury_share + value: 0.0 + effective_from: "2023-08-28T00:00:00Z" + effective_to: null + source_url: https://aerodrome.finance/docs + review_due: "2026-11-01" + +pricing: + primary: pyth_hermes + witness: chainlink + granularity: hourly_twap + fallback: pool_ratio + fallback_confidence: low + note: "Milliers de tokens sur Base. La couverture Pyth sera partielle." + +emissions: + scope: incentives_only + source: + kind: evm_logs + contract: minter + events: [Mint] + read: distributions_to_gauges + note: >- + On mesure les AERO effectivement distribués aux gauges, epoch par epoch. + PAS un taux d'inflation annualisé appliqué à la supply : le "~10.9%/an" + couramment cité est un taux d'inflation, pas un coût d'incentive, et + l'approximation ne résiste pas à un examen sérieux. + token: AERO + epoch: weekly + spread_over_epoch: true + spread_note: >- + Émissions discrètes (hebdomadaires) réparties uniformément sur 7 jours pour les + fenêtres 24h et 7j. Sans ça, le classement 24h dépendrait du jour de l'epoch. + Choix documenté et affiché sur la page méthodologie. + excluded: + - id: ve_rebase + reason: >- + Anti-dilution des lockers, pas une incentive d'acquisition de volume. + Inclus uniquement sous scope=all_inflation. + +validation: + checks: + - id: minter_conservation + kind: onchain_conservation + description: "Émissions du Minter par epoch == Σ distributions aux gauges" + tolerance_bps: 1 + note: "Doit correspondre exactement. Un écart signale un gauge manqué." + - id: voter_fee_conservation + description: "Fees distribués aux voters == Σ fees des pools" + tolerance_bps: 100 + +ui: + # Sans cette note, la ligne "lp_revenue = 0" se lit comme une donnée manquante. + annotations: + - target: lp_revenue + text: >- + Les LPs Aerodrome ne perçoivent pas de fees : ils reçoivent des émissions AERO + à la place. La valeur leur revenant est réelle mais dilutive, et apparaît en + coût d'émission plutôt qu'en revenue. + - target: net_real_revenue + text: >- + Négatif : le protocole distribue plus de valeur en émissions qu'il ne capte + en frais. C'est un résultat mesuré, pas une erreur de données. + +metadata: + chains: [base] + is_router: false + nests_within: null diff --git a/benchmarks/apps/dydx-v4.yaml b/benchmarks/apps/dydx-v4.yaml new file mode 100644 index 00000000..4a6a0e47 --- /dev/null +++ b/benchmarks/apps/dydx-v4.yaml @@ -0,0 +1,144 @@ +schema_version: 1 +slug: dydx-v4 +name: dYdX V4 +category: perps +methodology_version: 1 +status: live +verifiability: tier_2 + +# Premier protocole implémenté : REST + tout en USDC, pas d'oracle. +# Valide toute la chaîne (ledger, materializer, invariants, quarantaine) sans bruit. + +deployments: + - id: dydx-v4:dydx-chain + chain: dydx-chain + effective_from: "2023-10-26T00:00:00Z" + + finality: + mode: cosmos_finalized + lag_seconds: 10 + + source: + kind: rest + canonical: true + base_url: "https://indexer.dydx.trade/v4" + endpoints: + - /fills # source primaire + - /historicalTradingRewardAggregations/{address} + - /vault/v1/megavault/historicalPnl + pagination: by_height # curseur createdAtHeight, jamais offset temporel + note: >- + T2 : dépendance à l'indexer dYdX. Plan B : full node + indexer self-hosted + (open-source, Postgres + Redis). Objectif à 6 mois. + + witnesses: + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/dydx + - kind: onchain_megavault + description: >- + Inflows USDC vers l'adresse MegaVault ≈ 50% de Σ fills.fee. + Témoin purement on-chain, indépendant de l'indexer. + +allocation: + # ATTENTION : lire on-chain via x/revshare avant de coder. + # "100% validators/stakers, 0% trésor" était faux depuis dYdX Unlimited. + rules: + - component: taker_fee + splits: + - beneficiary: lp + share_source: onchain_config + read: revshare_megavault_fraction # ~50% + param: megavault_taker_fee_share + - beneficiary: treasury + share_source: onchain_config + read: revshare_treasury_fraction # ~10% + param: treasury_taker_fee_share + - beneficiary: staker + share_source: derived # reste (~40%) + - beneficiary: third_party + component: affiliate_fee + share_source: onchain_field + field: affiliateRevShare + note: >- + Affiliés VIP jusqu'à 50% du taker fee du trader référé. + Décomposer PAR FILL, jamais en moyenne. + + - component: maker_fee + splits: + - beneficiary: lp + share_source: onchain_config + read: revshare_megavault_fraction + - beneficiary: staker + share_source: derived + + # Rebates maker (fee négatif dans fills.fee) + - component: maker_rebate + included_in_gross: false + beneficiary: lp + note: "Montant positif stocké, gross_fees = fees bruts payés avant rebate." + + # Paramètres lus quotidiennement depuis x/revshare, versionnés dans allocation_params. + # La contrainte GIST empêche deux valeurs simultanées pour le même param. + params_source: + kind: cosmos_grpc + module: x/revshare + snapshot: daily + note: "Le lire, pas le supposer. Les paramètres changent par vote de gouvernance." + + documented_split_reference: + megavault: 0.50 + treasury_subdao: 0.10 + market_mapper: 0.00 + stakers: 0.40 + source_url: https://dydx.exchange/blog/dydx-unlimited + used_for: plausibility_check_only + alert_if_deviation_gt: 0.05 + +pricing: + fees_denomination: USDC + primary: stable_passthrough + depeg_guard_bps: 50 + note: "Tout en USDC. Pas d'oracle nécessaire pour les fees." + +emissions: + scope: incentives_only + sources: + - kind: megavault_rewards + token: DYDX + note: "Rewards MegaVault en DYDX. Zéro-somme comptablement mais coût réel." + - kind: trading_rewards + token: DYDX + status: onchain_param + note: >- + Statut = paramètre de gouvernance. Le lire via x/rewards avant chaque recompute. + Ne jamais supposer actif ou inactif. + +validation: + checks: + - id: megavault_inflows + kind: onchain_conservation + description: "Inflows USDC vers MegaVault ≈ 50% de Σ fills.fee sur 24h" + tolerance_bps: 300 + note: "Meilleur test disponible : purement on-chain, indépendant de l'indexer." + - id: boundary_test + kind: unit_test + description: "3 events à t1-1ms / t1 / t1+1ms : fenêtre [t0,t1) retourne exactement 1" + severity: blocking + - id: idempotence + kind: unit_test + description: "Injecter deux fois le même event ne change aucun chiffre" + severity: blocking + - id: recompute_speed + kind: perf + description: "Recompute complet historique dYdX < 5 minutes" + - id: quarantine_trigger + kind: integration + description: "Écart artificiel de 6% injecté dans le témoin déclenche bien la quarantaine" + severity: blocking + +metadata: + chains: [dydx-chain] + is_router: false + nests_within: null + docs: https://docs.dydx.exchange + indexer: https://indexer.dydx.trade/v4 diff --git a/benchmarks/apps/gmx-v2.yaml b/benchmarks/apps/gmx-v2.yaml new file mode 100644 index 00000000..f792ef2c --- /dev/null +++ b/benchmarks/apps/gmx-v2.yaml @@ -0,0 +1,148 @@ +schema_version: 1 +slug: gmx-v2 +name: GMX V2 +category: perps +methodology_version: 1 +status: live +website: https://gmx.io +verifiability: tier_1 + +# Exemple canonique : TOUS les splits sont lus dans l'event, aucune constante. +# C'est le pattern à copier. Voir aerodrome.yaml pour le cas où c'est impossible. + +deployments: + - id: gmx-v2:arbitrum + chain: arbitrum + effective_from: "2023-08-01T00:00:00Z" + start_block: 107737756 + + finality: + mode: l1_confirmed + lag_seconds: 900 + + source: + kind: evm_logs + canonical: true + address: "0xC8ee91A54287DB53897056e12D9819156D3822Fb" # EventEmitter + # topic0 de EventLog1. Constante du contrat, pas un paramètre métier. + topic0: "0x137a44067c8961cd7e1d876f4754a5a3a75989b4552f1843fc69c3b372def160" + # topic1 = keccak256(nom). Calculé AU RUNTIME. Un hash collé en dur et mal + # recopié retourne zéro event, ce qui est indiscernable de "pas d'activité". + event_names: + - PositionFeesCollected + - SwapFeesCollected + decoder: gmx_v2_eventlog # payload = key-value typés, décodage par clé + batch_blocks: 2000 + + witnesses: + - kind: subgraph + id: gmx-v2-arbitrum + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/gmx-v2 + note: "Adapter écrit par le protocole. Témoin, jamais source." + + - id: gmx-v2:avalanche + chain: avalanche + effective_from: "2023-08-01T00:00:00Z" + # Déploiement distinct, pot de fees distinct : la somme avec Arbitrum est + # correcte et ne constitue pas un double-comptage (cf. architecture §6.5). + source: + kind: evm_logs + canonical: true + address: "TODO_verifier_avant_activation" + topic0: "0x137a44067c8961cd7e1d876f4754a5a3a75989b4552f1843fc69c3b372def160" + event_names: [PositionFeesCollected, SwapFeesCollected] + decoder: gmx_v2_eventlog + status: pending_verification + +allocation: + rules: + - event: PositionFeesCollected + splits: + - field: positionFeeAmount + component: taker_fee + share_source: onchain_field # ← jamais un pourcentage + - field: borrowingFeeAmount + component: borrow_fee + share_source: onchain_field + - field: fundingFeeAmount + component: funding_payment # exclu de gross_fees par la taxonomie + share_source: onchain_field + - field: feeAmountForPool + beneficiary: lp + share_source: onchain_field + - field: feeReceiverAmount + beneficiary: token_holder # rachat GMX + share_source: onchain_field + - field: uiFeeAmount + beneficiary: third_party + component: interface_fee + share_source: onchain_field + + - event: SwapFeesCollected + splits: + - field: feeAmountForPool + component: swap_fee + beneficiary: lp + share_source: onchain_field + - field: feeReceiverAmount + component: swap_fee + beneficiary: token_holder + share_source: onchain_field + + # Contrôle intra-event. Attrape la majorité des régressions de décodage : + # si GMX ajoute un champ entre deux versions du contrat, cette assertion échoue + # avant que le chiffre ne soit publié. + assertions: + - name: position_fee_conservation + expr: "positionFeeAmount == feeAmountForPool + feeReceiverAmount + uiFeeAmount" + on_failure: quarantine + + # Le 63/27/10 documenté n'est JAMAIS appliqué au calcul. Il sert uniquement de + # test de plausibilité : un écart persistant est une information publiable. + documented_split_reference: + lp: 0.63 + token_holder: 0.27 + overhead: 0.10 + source_url: https://docs.gmx.io/docs/tokenomics + used_for: plausibility_check_only + alert_if_deviation_gt: 0.05 + +pricing: + # Collatéral mixte : conversion obligatoire. + primary: pyth_hermes + witness: chainlink + granularity: hourly_twap + chainlink_guards: + - sequencer_uptime_feed: required # Arbitrum : prix invalides si le sequencer était down + fallback: pool_ratio + fallback_confidence: low + +emissions: + scope: incentives_only + sources: + - kind: esgmx_distribution + token: GMX + accounting: at_emission # au moment de l'émission, pas du vesting + note: "Choix documenté : le coût est encouru quand le token est émis." + - kind: external_grant + token: ARB + note: >- + Programmes d'incentives Arbitrum. Les omettre flatterait net_real_revenue. + Source : distributions ARB vers les contrats GMX. + status: todo + +validation: + checks: + - id: fee_receiver_inflows + kind: onchain_conservation + description: "Inflows vers le fee receiver GMX ≈ Σ feeReceiverAmount" + tolerance_bps: 200 + - id: witness_divergence + kind: witness + quarantine_above_bps: 500 + +metadata: + chains: [arbitrum, avalanche] + is_router: false + nests_within: null diff --git a/benchmarks/apps/hyperliquid.yaml b/benchmarks/apps/hyperliquid.yaml new file mode 100644 index 00000000..f095932f --- /dev/null +++ b/benchmarks/apps/hyperliquid.yaml @@ -0,0 +1,175 @@ +schema_version: 1 +slug: hyperliquid +name: Hyperliquid +category: perps +methodology_version: 1 +status: live +verifiability: tier_2 # -> tier_1 une fois le node en place + +# Cas non-EVM. La question "plan B si l'API tombe ?" a une vraie réponse : le node. +# L'archive S3 et le node partagent le MÊME FORMAT, donc le décodeur est commun et +# bascule sans changement de code. Une panne REST coûte la fraîcheur, pas l'historique. + +deployments: + - id: hyperliquid:hypercore + chain: hyperliquid + effective_from: "2023-06-01T00:00:00Z" + + finality: + mode: hyperbft + lag_seconds: 5 + note: "Finalité quasi immédiate, mais la dépendance porte sur la source de données." + + # Sources par ordre de préférence. Le collector bascule automatiquement. + sources: + - kind: hl_node + rank: 1 + canonical: true + flags: ["--write-fills", "--batch-by-block"] + note: "Seule configuration T1. Objectif cible à 6 mois." + status: planned + + - kind: s3_archive + rank: 2 + canonical: true + buckets: + fills: "s3://hl-mainnet-node-data/node_fills_by_block" + misc: "s3://hl-mainnet-node-data/misc_events_by_block" + requester_pays: true + cost_warning: >- + ~0.8-1 GiB/jour tous coins confondus. Un backfill d'un an est une facture AWS + à trois chiffres. BUDGÉTER, et ne backfiller qu'une seule fois. + note: "Format identique à l'API. misc_events couvre transferts, staking, funding." + + - kind: rest + rank: 3 + endpoint: "https://api.hyperliquid.xyz/info" + method: POST + types: [metaAndAssetCtxs, fundingHistory, clearinghouseState, builderFills] + note: "Temps réel et métadonnées. Bornes INCLUSIVES : appeler à t1-1ms + re-filtrer." + + witnesses: + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/hyperliquid + - kind: onchain_af_inflows + address: "0xfefefefefefefefefefefefefefefefefefefefe" + note: "Le meilleur témoin : purement observationnel, indépendant de l'API." + +allocation: + # ATTENTION : le brief d'origine disait "99% AF / 1% HLP". C'est incomplet ET imprécis. + # (a) L'allocation de référence est ~97%, portée vers 99% pour CERTAINES catégories + # de fees par un vote de validateurs de décembre 2025. + # (b) Le brief OMET ENTIÈREMENT les déployeurs spot et HIP-3. + rules: + - component: taker_fee + splits: + - beneficiary: burn + share_source: onchain_config + read: assistance_fund_share + note: >- + Le HYPE détenu par l'AF est formellement reconnu comme brûlé depuis un vote + pondéré par le stake (85% pour) de décembre 2025. `burn` est donc correct, + pas `token_holder`. + - beneficiary: lp + share_source: derived + target: hlp_vault + + - component: maker_fee + splits: + - beneficiary: burn + share_source: onchain_config + read: assistance_fund_share + - beneficiary: lp + share_source: derived + + # JAMBE MANQUANTE DANS LE BRIEF. Obligatoire même si elle vaut zéro. + - component: taker_fee + scope: hip3_and_spot_deployed_assets + splits: + - beneficiary: third_party + share_source: onchain_field + read: deployer_fee_share + max_observed: 0.50 + note: >- + Les déployeurs spot et HIP-3 peuvent conserver jusqu'à 50% des fees générés + par LEURS assets. Les ignorer surestime le protocole. + + # Builder fees : captés par les frontends tiers, séparés des fees protocole. + - component: interface_fee + source: builderFills + splits: + - beneficiary: third_party + is_app_layer: true + share_source: onchain_field + + documented_split_reference: + assistance_fund: 0.97 + source_url: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees + used_for: plausibility_check_only + alert_if_deviation_gt: 0.01 + note: >- + SI L'ÉCART DÉPASSE 1%, L'ALLOCATION A CHANGÉ SANS ANNONCE. C'est exactement le + signal que le produit est censé détecter. + +pricing: + # Les fees sont en USDC : pas d'oracle nécessaire pour les fees eux-mêmes. + fees_denomination: USDC + primary: stable_passthrough + depeg_guard_bps: 50 + # En revanche les oraclePx de HL sont self-reported -> triangulation obligatoire. + oracle_triangulation: + against: pyth_hermes + assets: [BTC, ETH, SOL] + alert_if_divergence_gt: 0.01 + note: "Aucun tiers ne peut vérifier les prix de HL. Trianguler ou ne pas publier." + +emissions: + scope: incentives_only + total: 0 + review_due: "2026-11-01" + note: >- + Zéro émission d'incentive. À REVÉRIFIER TRIMESTRIELLEMENT, pas à supposer acquis. + Les unlocks mensuels aux contributeurs (~9.92M HYPE) sont de la dilution, pas un + coût d'acquisition de volume -> scope all_inflation uniquement. + +fee_taxonomy: + exclude: + - funding_payment # zéro-somme entre traders + separate_components: + - spot_fee # structure distincte, ne jamais additionner les taux + - twap_fill_fee # userTwapSliceFills : format distinct, décoder séparément + +validation: + checks: + - id: af_conservation + kind: onchain_conservation + description: "Inflows USDC de 0xfefe…fefe ≈ part AF de Σ fills.fee" + tolerance_bps: 200 + severity: blocking + note: "Test fort et purement observationnel. Le meilleur dont on dispose ici." + - id: s3_vs_rest_parity + kind: cross_source + description: "Σ fills archive S3 == Σ fills API REST sur 24h" + tolerance: exact + severity: blocking + note: >- + IDENTIQUE À L'UNITÉ PRÈS. Toute divergence signale un bug de format ou de + fenêtrage — à corriger avant toute publication. + - id: hlp_equity + kind: cross_check + description: "Équité du HLP vault (clearinghouseState) vs part résiduelle" + tolerance_bps: 500 + +ui: + annotations: + - target: token_holder_value + text: >- + L'AF reçoit de l'USDC, achète du HYPE, puis le brûle. On comptabilise la valeur + REÇUE (certaine), pas la valeur du HYPE brûlé (qui dépend du prix d'exécution). + Les deux sont exposées ; le classement utilise la première. + +metadata: + chains: [hyperliquid] + is_router: false + nests_within: null + docs: https://hyperliquid.gitbook.io/hyperliquid-docs diff --git a/benchmarks/apps/raydium.yaml b/benchmarks/apps/raydium.yaml new file mode 100644 index 00000000..1247c6bd --- /dev/null +++ b/benchmarks/apps/raydium.yaml @@ -0,0 +1,154 @@ +schema_version: 1 +slug: raydium +name: Raydium +category: dex +methodology_version: 1 +status: live +verifiability: tier_1 + +# Piège dominant : accrual vs claim. +# Les fees s'accumulent dans les vaults et sont réclamés au seuil de $10. +# Mesurer les inflows du wallet de buyback mesure des CLAIMS décalés de plusieurs jours. +# SOURCE PRIMAIRE = accrual par swap depuis AmmConfig. Les inflows = témoin cumulatif. + +deployments: + - id: raydium:solana + chain: solana + effective_from: "2021-02-01T00:00:00Z" + + finality: + mode: solana_finalized + lag_seconds: 30 + + source: + kind: solana_rpc + canonical: true + rpc: helius # Helius ou Triton, clé en env + method: getSignaturesForAddress # + getTransaction par sig + note: >- + Décoder les comptes PoolState + AmmConfig par pool. + getSignaturesForAddress sur les programmes collecteurs, puis getTransaction. + + programs: + amm_v4: + id: "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" + fee_collector: "PNLCQcVCD26aC7ZWgRyr5ptfaR7bBrWdTFgRWwu2tvF" + clmm: + id: "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK" + fee_collector: "projjosVCPQH49d5em7VYS7fJZzaqKixqKtus7yk416" + cpmm: + id: "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C" + fee_collector: "ProCXqRcXJjoUd1RNoo28bSizAA6EEqt9wURZYPDc5u" + + destinations: + ray_buyback: "DdHDoz94o2WJmD9myRobHCwtx1bESpHTd4SSPe6VEZaz" + treasury_clmm: "FundHfY8oo8J9KYGyfXFFuQCHe7Z1VBNmsj84eMcdYs4" + treasury_cpmm: "FUNDduJTA7XcckKHKfAoEnnhuSud2JUCUZv6opWEjrBU" + + witnesses: + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/raydium + - kind: onchain_buyback_wallet + address: "DdHDoz94o2WJmD9myRobHCwtx1bESpHTd4SSPe6VEZaz" + note: "Témoin cumulatif des claims (décalés). Pas la source primaire." + +allocation: + # Splits lus depuis AmmConfig on-chain (trade_fee_rate, protocol_fee_rate, fund_fee_rate). + # Dénominateur 10^6. Les plateformes tierces choisissent leur cpConfigId -> lire par pool. + model: amm_config_per_pool + splits_by_pool_type: + amm_v4: + lp: 0.88 + token_holder: 0.12 # buyback RAY -> DdHDoz94 + treasury: 0.00 + source_url: https://docs.raydium.io/raydium/protocol/developers/addresses + used_for: plausibility_check_only + clmm: + lp: 0.84 + token_holder: 0.12 + treasury: 0.04 + used_for: plausibility_check_only + cpmm: + lp: 0.84 + token_holder: 0.12 + treasury: 0.04 + used_for: plausibility_check_only + + rules: + - component: swap_fee + share_source: onchain_config + read: amm_config_per_pool + note: "Lire AmmConfig par pool. Ne jamais utiliser les pourcentages ci-dessus au calcul." + splits: + - beneficiary: lp + field: lp_rate + - beneficiary: token_holder + field: protocol_fee_rate + note: >- + CORRECTION AU BRIEF : le RAY est TRANSFÉRÉ vers DdHDoz94, pas brûlé par le + mécanisme. beneficiary = token_holder, pas burn. + - beneficiary: treasury + field: fund_fee_rate + + # Creator fee CPMM : additionnel au trade fee, pas un prélèvement dessus. + - component: bonding_curve_fee + source: cpmm_creator_fee + beneficiary: creator + share_source: onchain_field + note: "L'ignorer sous-estime gross_fees." + + accrual_note: >- + COMPTABILISER À L'ACCRUAL, PAR SWAP. Pas au claim. + Les fees s'accumulent dans les vaults jusqu'au seuil de $10, puis sont transférés + par batch. La latence claim->accrual peut atteindre plusieurs jours en période calme. + Les inflows du wallet buyback servent de témoin cumulatif, pas de source primaire. + +pricing: + primary: pyth_hermes + witness: chainlink + granularity: hourly_twap + fallback: pool_ratio + fallback_confidence: low + fallback_note: >- + Memecoins Solana majoritaires en volume. pool_ratio = ratio des réserves au moment + du swap, lu on-chain. Marqué confidence:low, astérisque UI. Ne jamais inventer un prix. + token2022_note: >- + Calculer sur le montant NET entrant dans le vault (après transfer fee Token-2022), + pas sur le montant signé. + +emissions: + scope: incentives_only + sources: + - kind: farm_distributions + token: RAY + note: "Émissions des farms Raydium vers les LPs. Lire les distributions effectives." + +edge_cases: + - id: clmm_fee_growth + description: >- + CLMM : fees dans le token d'entrée seulement. Suivre fee_growth_global_0/1_x64 + séparément. Pas d'accumulation symétrique. + - id: custom_amm_config + description: "Pools tiers à config custom : lire AmmConfig par pool, obligatoirement." + - id: token2022_transfer_fee + description: "Montant net entrant dans le vault, pas montant signé." + +validation: + checks: + - id: accrual_vs_claims + kind: cross_check + description: "Accruals calculés sur 30j vs claims observés (avec décalage) < 3%" + tolerance_bps: 300 + - id: buyback_balance + kind: onchain_conservation + description: "Balance cumulative de DdHDoz94 vs Σ buybacks calculés" + tolerance_bps: 500 + - id: witness_defillama + kind: witness + quarantine_above_bps: 500 + +metadata: + chains: [solana] + is_router: false + nests_within: null + docs: https://docs.raydium.io diff --git a/benchmarks/apps/uniswap.yaml b/benchmarks/apps/uniswap.yaml new file mode 100644 index 00000000..9da2e206 --- /dev/null +++ b/benchmarks/apps/uniswap.yaml @@ -0,0 +1,201 @@ +schema_version: 1 +slug: uniswap +name: Uniswap +category: dex +methodology_version: 1 +status: live +verifiability: tier_1 + +# LE PROTOCOLE LE PLUS PIÉGEUX DU SCOPE. +# Trois axes indépendants : version (v2/v3/v4) x chaîne (~11) x date d'activation. +# Un effective_from par COMBINAISON. Appliquer un taux sur une période où il valait +# zéro est le bug Lido inversé. + +deployments: + # ------------------------------------------------------------------------- + # Ethereum mainnet v2 — adresses VÉRIFIÉES + # ------------------------------------------------------------------------- + - id: uniswap:ethereum:v2 + chain: ethereum + version: v2 + # UNIfication exécutée on-chain le 27/12/2025. AVANT : zéro protocol revenue. + effective_from: "2025-12-27T20:33:00Z" + finality: { mode: beacon_finalized, lag_seconds: 800 } + + source: + kind: evm_logs + canonical: true + # Contrat immuable où s'accumulent les fees. VÉRIFIÉ (Uniswap/protocol-fees + Etherscan). + token_jar: "0xf38521f130fcCF29dB1961597bc5d2B60F995f85" + # Releaser : brûler de l'UNI pour retirer. Destinataire 0xdead. + firepit: "0x0D5Cd355e2aBEB8fb1552F56c965B867346d6721" + read: token_jar_inflows + # On compte les INFLOWS (accrual), pas les burns Firepit (claims). Les deux + # sont décalés : le jar se vide par arbitrage quand la valeur dépasse le seuil. + + witnesses: + - kind: subgraph + id: uniswap-v2-ethereum + - kind: defillama + endpoint: https://api.llama.fi/summary/fees/uniswap + note: "Divergence attendue sur les périodes pré-fee-switch." + + # ------------------------------------------------------------------------- + # Ethereum mainnet v3 + # ------------------------------------------------------------------------- + - id: uniswap:ethereum:v3 + chain: ethereum + version: v3 + effective_from: "2025-12-27T20:33:00Z" + source: + kind: evm_logs + canonical: true + token_jar: "0xf38521f130fcCF29dB1961597bc5d2B60F995f85" + v3_fee_adapter: "0x5E74C9f42EEd283bFf3744fBD1889d398d40867d" + v3_open_fee_adapter: "0xf2371551Fe3937Db7c750f4DfABe5c2fFFdcBf5A" + read: token_jar_inflows + note: >- + SEULE UNE SÉLECTION de pools v3 a été activée (80-95% des LP fees mainnet). + Lire l'état de l'adaptateur pool par pool. Ne pas supposer une activation globale. + + # ------------------------------------------------------------------------- + # Autres chaînes et v4 — À COMPLÉTER APRÈS VÉRIFICATION + # ------------------------------------------------------------------------- + - id: uniswap:arbitrum:v3 + chain: arbitrum + version: v3 + status: pending_verification + effective_from: "TODO" + source: + kind: evm_logs + token_jar: "TODO_une_adresse_par_chaine" + note: >- + Propositions 94/95 (mars 2026) ont étendu à Arbitrum, Base, OP Mainnet, Soneium, + X Layer, Worldchain, Zora, Celo. Propositions ultérieures (96, 100...) ont ajouté + BNB Chain, Polygon et l'activation v4. + ÉNUMÉRER depuis gov.uniswap.org et les transactions d'exécution. + Récupérer chaque TokenJar depuis github.com/Uniswap/protocol-fees et le VÉRIFIER + sur un explorer avant de l'inscrire ici. + + - id: uniswap:ethereum:v4 + chain: ethereum + version: v4 + status: pending_verification + effective_from: "TODO" + note: >- + Le V4FeeAdapter était marqué work-in-progress dans le repo. Confirmer son + déploiement et sa date d'activation avant d'activer ce déploiement. + +allocation: + # ATTENTION : le protocol fee est un PRÉLÈVEMENT sur les LP fees, PAS un supplément. + # Le brief d'origine affirmait l'inverse ; le traiter comme additif surestime + # gross_fees d'environ 1/6 sur tout Uniswap. + model: carve_out_from_lp_fees + + rules: + - version: v2 + # LP fees : 0.30% -> 0.25%. 0.05% du volume va au protocole. + splits: + - beneficiary: token_holder + component: swap_fee + share_source: constant + value: 0.16667 # 0.05 / 0.30 + effective_from: "2025-12-27T20:33:00Z" + effective_to: null + source_url: https://gov.uniswap.org/t/unification-proposal/25881 + note: >- + Les fee levels v2 sont hardcodés dans les contrats et la gouvernance les + active ou désactive globalement. `constant` est ici légitime, mais borné + dans le temps et sourcé — c'est ce qui manquait au cas Lido. + - beneficiary: lp + component: swap_fee + share_source: derived # gross - protocol_fee + verify_against: token_jar_inflows + + - version: v3 + # 25% des LP fees sur les tiers 0.01% et 0.05% ; 16.7% sur 0.30% et 1%. + # Ajustable pool par pool par gouvernance -> DOIT être lu on-chain. + splits: + - beneficiary: token_holder + component: swap_fee + share_source: onchain_config + contract: v3_fee_adapter + read: protocol_fee_per_pool + fallback_reference: + tier_0_01: 0.25 + tier_0_05: 0.25 + tier_0_30: 0.167 + tier_1_00: 0.167 + used_for: plausibility_check_only + - beneficiary: lp + component: swap_fee + share_source: derived + + # Fees prélevés par des hooks v4 tiers : PAS du revenue Uniswap. + third_party_rules: + - source: v4_hooks + beneficiary: third_party + component: interface_fee + is_app_layer: true + + exclusions: + - id: treasury_burn_100m_uni + date: "2025-12-27" + reason: >- + Burn rétroactif one-off de 100M UNI depuis le trésor (~$596M), destiné à + représenter ce qui aurait été brûlé si le fee switch avait été actif depuis 2018. + CE N'EST PAS DU REVENUE. L'inclure produirait un pic absurde qui discréditerait + tout le classement. + +non_trading_revenue: + - id: unichain_sequencer + component: sequencer_revenue + beneficiary: burn + counted_in: non_trading_holder_value # HORS gross_fees + formula: "sequencer_fees - l1_data_costs - optimism_share(15%)" + rationale: >- + Entre dans le même mécanisme de burn que les protocol fees, mais ce n'est pas un + frais de trading. Additionné séparément et VISIBLEMENT à part dans l'UI. + +pricing: + primary: pyth_hermes + witness: chainlink + granularity: hourly_twap + fallback: pool_ratio + fallback_confidence: low + +emissions: + scope: incentives_only + sources: [] + total: 0 + note: "Pas de liquidity mining. emissions_cost = 0." + +interface_fees: + status: disabled + since: "2025-12-27" + note: >- + Uniswap Labs a coupé ses frontend/wallet/API fees lors d'UNIfication. Les compter + chez les concurrents fausserait la comparaison — d'où excluded_from_ranking dans + la taxonomie partagée. + +validation: + checks: + - id: zero_before_activation + kind: unit_test + description: "protocol revenue == 0 avant effective_from, PAR CHAÎNE ET PAR VERSION" + severity: blocking + - id: token_jar_conservation + kind: onchain_conservation + description: "Inflows TokenJar ≈ Σ events de collecte de protocol fee" + tolerance_bps: 200 + - id: protocol_fee_ratio_v2 + kind: plausibility + description: "protocol_fee / gross_fees ≈ 1/6 sur v2" + alert_if_deviation_gt: 0.03 + note: "Un écart signale un déploiement manqué ou un effective_from erroné." + +metadata: + chains: [ethereum] # étendre après vérification + is_router: false + nests_within: null + repo: https://github.com/Uniswap/protocol-fees diff --git a/benchmarks/astar-rpc.yml b/benchmarks/astar-rpc.yml index 6f1a95f6..495c8ea5 100644 --- a/benchmarks/astar-rpc.yml +++ b/benchmarks/astar-rpc.yml @@ -4,7 +4,7 @@ slug: astar-rpc number: "154" title: Fastest free Astar Network RPC, live no-key endpoint latency seo_title: "Fastest free Astar Network RPC 2026" -seo_description: "{{best_name}} leads free Astar Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Astar Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 2 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Astar Network RPC endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Astar Network (chain 592) is Polkadot's flagship EVM and WebAssembly parachain, ASTR gas token, supporting both Solidity and ink! smart contracts. Developed by Startale (formerly Stake Technologies) and chosen by Sony for Soneium infrastructure, it exposes a standard EVM endpoint alongside Substrate RPC. The Foundation's evm.astar.network, BlastAPI, and OnFinality provide keyless access probed every 60 seconds from three regions. + Astar Network (chain 592) is Polkadot's flagship EVM and WebAssembly parachain, ASTR gas token, supporting both Solidity and ink! smart contracts. Developed by Startale (formerly Stake Technologies) and chosen by Sony for Soneium infrastructure, it exposes a standard EVM endpoint alongside Substrate RPC. OnFinality and 1RPC provide keyless access probed every 60 seconds from three regions. PublicNode returned 404 and BlastAPI's DNS is dead. abstract: | Per-chain member of the RPC latency cluster, extended to Astar Network. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Astar Network endpoint - that sustains continuous probing, 4 providers at launch, + that sustains continuous probing, 2 providers, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) so the leaderboard rewards sustained availability rather than @@ -34,16 +34,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"astar\"`. Provider coverage at launch: 4 no-key endpoints (Astar Foundation, BlastAPI, OnFinality, 1RPC)." + - "Chain scope: every query on this page is pinned to `chain=\"astar\"`. Provider coverage: 2 no-key endpoints (OnFinality, 1RPC). PublicNode returned 404; BlastAPI DNS is dead." findings: - - "{{best_name}} currently leads free Astar Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Astar Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 2 measured providers." faq: - q: "What is the fastest free Astar Network RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 2 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." - q: "Which Astar Network RPCs work without an API key?" - a: "4 endpoints sustain continuous keyless probing at launch: Astar Foundation, BlastAPI, OnFinality, 1RPC. Every listed endpoint was live-verified before inclusion." + a: "2 endpoints sustain continuous keyless probing: OnFinality and 1RPC. PublicNode returned 404 and BlastAPI's DNS is dead; both were removed." - q: "Does the fastest Astar Network RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Astar Network RPC latency measured here?" @@ -65,52 +65,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: astar-official - name: Astar Foundation - tag: Astar Foundation's official EVM 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 Astar Foundation's no-key Astar Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="astar-official", chain="astar"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="astar-official", chain="astar"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="astar-official", chain="astar"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="astar-official", chain="astar"}) / sum(ocb:rpc_call:rate_24h{provider="astar-official", chain="astar"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="astar-official", chain="astar"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="astar-official", chain="astar", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="astar-official", chain="astar", region="sgp"}[1h]) - - - slug: blastapi - name: BlastAPI - tag: BlastAPI public endpoint - 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 BlastAPI's no-key Astar Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="astar"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="astar"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="astar"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="astar"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="astar"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="astar"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="astar"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="astar"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="astar", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="astar", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="astar", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="astar", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="astar", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="astar", region="sgp"}[1h]) - - slug: onfinality name: OnFinality tag: OnFinality shared node service diff --git a/benchmarks/aurora-rpc.yml b/benchmarks/aurora-rpc.yml index e9098f69..ffb33df7 100644 --- a/benchmarks/aurora-rpc.yml +++ b/benchmarks/aurora-rpc.yml @@ -4,7 +4,7 @@ slug: aurora-rpc number: "147" title: Fastest free Aurora RPC, live no-key endpoint latency seo_title: "Fastest free Aurora RPC 2026" -seo_description: "{{best_name}} leads free Aurora RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Aurora RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Aurora RPC endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Aurora is the EVM runtime on NEAR Protocol (chain 1313161554), exposing full Ethereum tooling to NEAR's sharded execution environment. The official endpoint (`mainnet.aurora.dev`) is operated by Aurora Labs with dRPC and Ankr providing additional keyless alternatives. Every provider is live-verified before inclusion. + Aurora is the EVM runtime on NEAR Protocol (chain 1313161554), exposing full Ethereum tooling to NEAR's sharded execution environment. The official endpoint (`mainnet.aurora.dev`) is operated by Aurora Labs and is the only keyless endpoint sustaining continuous probing. Ankr and dRPC require an API key for Aurora and have been removed. abstract: | Per-chain member of the RPC latency cluster, extended to Aurora. We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every no-key public Aurora endpoint - that sustains continuous probing, 3 providers at launch, + (`eth_getBlockByNumber`) against the no-key public Aurora endpoint + that sustains continuous probing (1 provider: Aurora Labs official), every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) so the leaderboard rewards sustained availability rather than @@ -34,16 +34,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"aurora\"`. Provider coverage at launch: 3 no-key endpoints (Aurora Official, dRPC, Ankr)." + - "Chain scope: every query on this page is pinned to `chain=\"aurora\"`. Provider coverage: 1 no-key endpoint (Aurora Official). Ankr and dRPC require an API key for Aurora and have been removed." findings: - - "{{best_name}} currently leads free Aurora RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free Aurora RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Aurora RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." - q: "Which Aurora RPCs work without an API key?" - a: "3 endpoints sustain continuous keyless probing at launch: Aurora Official, dRPC, Ankr. Every listed endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: Aurora Official (mainnet.aurora.dev). Ankr returns 403 (API key required) and dRPC returns 403 (API key required) for Aurora." - q: "Does the fastest Aurora RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Aurora RPC latency measured here?" @@ -88,48 +88,3 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="aurora-official", chain="aurora", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="aurora-official", chain="aurora", region="sgp"}[1h]) - - 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 no-key Aurora endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="aurora"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="aurora"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="aurora"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="aurora"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="aurora"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="aurora"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="aurora"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="aurora"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="aurora", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="aurora", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="aurora", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="aurora", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="aurora", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="aurora", region="sgp"}[1h]) - - - slug: ankr - name: Ankr - tag: Multi-cloud RPC network, 40+ chains, keyless tier - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Ankr's no-key Aurora endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="aurora"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ankr", chain="aurora"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ankr", chain="aurora"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ankr", chain="aurora"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ankr", chain="aurora"}) / sum(ocb:rpc_call:rate_24h{provider="ankr", chain="aurora"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ankr", chain="aurora"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="aurora"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="aurora", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="aurora", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="aurora", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="aurora", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="aurora", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="aurora", region="sgp"}[1h]) diff --git a/benchmarks/blast-rpc.yml b/benchmarks/blast-rpc.yml index df93cbae..899b7b0e 100644 --- a/benchmarks/blast-rpc.yml +++ b/benchmarks/blast-rpc.yml @@ -30,7 +30,7 @@ methodology: findings: - "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." - - "`rpc.blast.io` posts ~2 ms in every region simultaneously, physically impossible for one origin. Read it as an edge-terminated endpoint: heads are fresh per our stale detection, but the latency column measures CDN termination rather than a node round trip, the same class of caution we document for Cloudflare-eth." + - "`rpc.blast.io` posts ~2 ms in every region simultaneously. On-chain response headers confirm geo-distributed nodes: `blast_blast-mainnet_fra` from eu-west, `blast_blast-mainnet_sin` from Singapore, a US node from us-east. Blast runs RPC nodes inside the same GCP datacenters as our Railway probes, producing sub-2 ms figures that reflect co-located infrastructure rather than typical user latency. A user in Paris reaches the Frankfurt node in ~40 ms; a user outside GCP will see 30-100 ms depending on location. Heads are fresh per stale detection." - "{{name:drpc}} ({{p50:drpc}}) is the honest-infrastructure comparison point: anycast consistency across the three regions with real node round trips behind it, the profile that wins it 10 of the 12 expansion chains." - "{{name:tenderly}} sits at the expansion's familiar flat ~330 ms in all regions on Blast, single-origin routing on a gateway that is genuinely quick on the major chains." @@ -44,7 +44,7 @@ faq: - q: "How is Blast RPC latency measured here?" a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 60 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - q: "Is rpc.blast.io really that fast, or is something else going on?" - a: "Something else. Two milliseconds simultaneously from Virginia, Amsterdam and Singapore is below the physical round-trip floor for any single origin, so the endpoint is answering at an anycast/CDN edge. Our stale-head detection shows the blocks it returns are current, so it is not serving a stale cache today, but edge termination means the latency figure describes the edge network, not node processing. We keep it ranked with the caveat documented, exactly as we do for Cloudflare's fast-but-permissioned Ethereum endpoint." + a: "Blast runs geo-distributed RPC nodes inside major cloud datacenters (GCP us-east, GCP europe-west4/Frankfurt, GCP asia-southeast1/Singapore). Our Railway probes run in the same three GCP regions, so each probe hits the co-located Blast node with a sub-LAN round trip. The `x-node-id` response header confirms this: `blast_blast-mainnet_fra` from eu-west, `blast_blast-mainnet_sin` from Singapore. The blocks are fresh — this is not a cache hit — but the 1-2 ms figure reflects network co-location, not what a developer outside GCP infra will experience. From a typical residential connection or a non-GCP cloud, expect 30-100 ms." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index accdd3da..db25d8a0 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -121,7 +121,7 @@ prometheus: # rank_matrix_query required when dimensions.region is declared. # label_replace maps bridge= → provider= so badge scoping works per region. -rank_matrix_query: avg by (provider, region) (label_replace(quantile_over_time(0.50, bridge_cost_percent[24h]), "provider", "$1", "bridge", "(.+)")) +rank_matrix_query: avg by (provider, region) (label_replace(quantile_over_time(0.50, bridge_cost_percent{from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h]), "provider", "$1", "bridge", "(.+)")) providers: - slug: mobula @@ -130,23 +130,23 @@ providers: 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: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="mobula", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: relay name: Relay @@ -154,23 +154,23 @@ providers: 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: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="relay", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: lifi name: LI.FI @@ -178,23 +178,23 @@ providers: 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: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="lifi", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: debridge name: deBridge @@ -202,23 +202,23 @@ providers: 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: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="debridge", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: across name: Across @@ -226,23 +226,23 @@ providers: 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", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="across", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: near-intents name: Near Intents @@ -250,23 +250,23 @@ providers: 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: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="near-intents", from_token!="6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", amount_usd="300", region="sgp"}[1h]) - slug: squid name: Squid Router @@ -274,23 +274,23 @@ providers: formula: "Per-corridor 24h median of total cost percent on a $300 USDC quote from Squid Router's v2 API, averaged across supported EVM corridors (Base to Arb). Cost = fromAmountUSD minus toAmountUSD including all relayer and gas fees." type: aggregator queries: - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="squid"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="squid"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="squid"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="squid"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="squid"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="squid"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="squid", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="squid", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="squid", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="squid", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="squid", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="squid", amount_usd="300", region="sgp"}[1h]) - slug: socket name: Socket Protocol @@ -298,20 +298,20 @@ providers: formula: "All-in cost percent p50 over 24h on a $300 USDC best-output route via Socket Protocol's v2 API (routes through Across, Stargate, CCTP), EVM corridors only (Base to Arb)." type: aggregator queries: - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket"}[24h])) - p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="socket"}[24h])) - p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="socket"}[24h])) - mean: avg(avg_over_time(bridge_cost_percent{bridge="socket"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="socket"}[24h])) - sample_size: sum(count_over_time(bridge_cost_percent{bridge="socket"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="socket"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + p90: avg(quantile_over_time(0.90, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + p99: avg(quantile_over_time(0.99, bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + mean: avg(avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + success: avg(avg_over_time(bridge_quote_success{bridge="socket", amount_usd="300"}[24h])) + sample_size: sum(count_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300"}[1h]) regions: - region: eu-west - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="eu-west"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="socket", region="eu-west"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="eu-west"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="eu-west"}[1h]) - region: us-east - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="us-east"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="socket", region="us-east"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="us-east"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="us-east"}[1h]) - region: sgp - p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", region="sgp"}[24h])) - series: avg_over_time(bridge_cost_percent{bridge="socket", region="sgp"}[1h]) + p50: avg(quantile_over_time(0.50, bridge_cost_percent{bridge="socket", amount_usd="300", region="sgp"}[24h])) + series: avg_over_time(bridge_cost_percent{bridge="socket", amount_usd="300", region="sgp"}[1h]) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index a26569f1..7362a22e 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -112,7 +112,7 @@ faq: # rank_matrix_query required by spec validator when dimensions.region is declared. # label_replace maps bridge= → provider= so badge scoping works per region. -rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (label_replace(rate(bridge_quote_latency_ms_bucket[24h]), "provider", "$1", "bridge", "(.+)"))) +rank_matrix_query: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h_provider) providers: - slug: mobula @@ -120,22 +120,22 @@ providers: tag: Aggregator + intent layer formula: "Median wall-clock ms over 24h for Mobula's quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="mobula"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="mobula"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="mobula"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="mobula"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="mobula"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="mobula"} + success: ocb:bridge_quote_success:avg24h{bridge="mobula"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="mobula"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula"}[1h]))) regions: - region: eu-west - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="eu-west"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula", region="eu-west"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="eu-west"}[1h]))) - region: us-east - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="us-east"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula", region="us-east"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="us-east"}[1h]))) - region: sgp - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="sgp"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="mobula", region="sgp"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="mobula", region="sgp"}[1h]))) - slug: relay @@ -143,22 +143,22 @@ providers: tag: Cross-chain relay formula: "Median wall-clock ms over 24h for Relay's solver-quote API to return a usable USDC route, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="relay"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="relay"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="relay"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="relay"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="relay"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="relay"} + success: ocb:bridge_quote_success:avg24h{bridge="relay"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="relay"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay"}[1h]))) regions: - region: eu-west - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="eu-west"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay", region="eu-west"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="eu-west"}[1h]))) - region: us-east - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="us-east"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay", region="us-east"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="us-east"}[1h]))) - region: sgp - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="sgp"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="relay", region="sgp"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="relay", region="sgp"}[1h]))) - slug: lifi @@ -166,22 +166,22 @@ providers: tag: Aggregator formula: "Median wall-clock ms over 24h for LI.FI's route-search API to return a usable USDC route across underlying bridges, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="lifi"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="lifi"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="lifi"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="lifi"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="lifi"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="lifi"} + success: ocb:bridge_quote_success:avg24h{bridge="lifi"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="lifi"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi"}[1h]))) regions: - region: eu-west - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="eu-west"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi", region="eu-west"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="eu-west"}[1h]))) - region: us-east - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="us-east"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi", region="us-east"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="us-east"}[1h]))) - region: sgp - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="sgp"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="lifi", region="sgp"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="lifi", region="sgp"}[1h]))) - slug: debridge @@ -189,22 +189,22 @@ providers: tag: Native-token fees formula: "Median wall-clock ms over 24h for deBridge's direct-protocol quote API to return a single-route USDC quote, sampled every 5 minutes across 4 routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="debridge"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="debridge"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="debridge"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="debridge"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="debridge"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="debridge"} + success: ocb:bridge_quote_success:avg24h{bridge="debridge"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="debridge"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge"}[1h]))) regions: - region: eu-west - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="eu-west"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge", region="eu-west"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="eu-west"}[1h]))) - region: us-east - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="us-east"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge", region="us-east"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="us-east"}[1h]))) - region: sgp - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="sgp"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="debridge", region="sgp"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="debridge", region="sgp"}[1h]))) - slug: near-intents @@ -212,22 +212,22 @@ providers: tag: Intent layer (NEAR) formula: "Median wall-clock ms over 24h for Near Intents 1Click API to return a usable USDC quote via the solver auction bus, sampled every 5 minutes across the supported routes and 3 notionals from 3 regions (eu-west, us-east, sgp)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="near-intents"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="near-intents"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="near-intents"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="near-intents"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="near-intents"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="near-intents"} + success: ocb:bridge_quote_success:avg24h{bridge="near-intents"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="near-intents"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents"}[1h]))) regions: - region: eu-west - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="eu-west"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents", region="eu-west"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="eu-west"}[1h]))) - region: us-east - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="us-east"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents", region="us-east"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="us-east"}[1h]))) - region: sgp - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="sgp"}[24h]))) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="near-intents", region="sgp"}) series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="near-intents", region="sgp"}[1h]))) - slug: across @@ -235,12 +235,12 @@ providers: tag: Intent layer formula: "Median wall-clock ms over 24h for Across's Swap API to return a usable USDC quote across supported corridors (Sol to Base, Base to Arb) from 3 regions. Arb to Sol excluded: no destination swaps to Solana." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="across"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="across"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="across"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="across"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="across"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="across"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="across"} + success: ocb:bridge_quote_success:avg24h{bridge="across"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="across"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="across"}[1h]))) - slug: squid @@ -248,12 +248,12 @@ providers: tag: Aggregator (Axelar) formula: "Median wall-clock ms for Squid Router's v2 API to return a best-output USDC route, p50 over 24h, EVM corridors only (Base to Arb). Solana source excluded: API returns server error." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="squid"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="squid"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="squid"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="squid"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="squid"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="squid"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="squid"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="squid"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="squid"} + success: ocb:bridge_quote_success:avg24h{bridge="squid"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="squid"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="squid"}[1h]))) - slug: socket @@ -261,10 +261,10 @@ providers: tag: Aggregator formula: "Median wall-clock ms for Socket Protocol's v2 API to return a best-output USDC route (via Across, Stargate, CCTP), p50 over 24h, EVM corridors only (Base to Arb)." queries: - p50: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) - p90: histogram_quantile(0.90, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[24h]))) - mean: sum(rate(bridge_quote_latency_ms_sum{bridge="socket"}[24h])) / sum(rate(bridge_quote_latency_ms_count{bridge="socket"}[24h])) - success: avg(avg_over_time(bridge_quote_success{bridge="socket"}[24h])) - sample_size: sum(increase(bridge_quote_latency_ms_count{bridge="socket"}[24h])) + p50: histogram_quantile(0.50, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="socket"}) + p90: histogram_quantile(0.90, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="socket"}) + p99: histogram_quantile(0.99, ocb:bridge_quote_latency_ms_bucket:rate24h{bridge="socket"}) + mean: ocb:bridge_quote_latency_ms_sum:rate24h{bridge="socket"} / ocb:bridge_quote_latency_ms_count:rate24h{bridge="socket"} + success: ocb:bridge_quote_success:avg24h{bridge="socket"} + sample_size: ocb:bridge_quote_latency_ms_count:increase24h{bridge="socket"} series: histogram_quantile(0.50, sum by (le) (rate(bridge_quote_latency_ms_bucket{bridge="socket"}[1h]))) diff --git a/benchmarks/chiliz-rpc.yml b/benchmarks/chiliz-rpc.yml index bf754b6a..05116b2f 100644 --- a/benchmarks/chiliz-rpc.yml +++ b/benchmarks/chiliz-rpc.yml @@ -130,26 +130,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chiliz-official", chain="chiliz", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="chiliz-official", chain="chiliz", region="sgp"}[1h]) - - - slug: drpc - name: dRPC - tag: dRPC public gateway for Chiliz Chain - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to dRPC's no-key Chiliz endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="chiliz"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="chiliz"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="chiliz"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="chiliz"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="chiliz"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="chiliz"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="chiliz"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="chiliz"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="chiliz", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="chiliz", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="chiliz", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="chiliz", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="chiliz", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="chiliz", region="sgp"}[1h]) diff --git a/benchmarks/ethereum-classic-rpc.yml b/benchmarks/ethereum-classic-rpc.yml index 6add2964..c7122e78 100644 --- a/benchmarks/ethereum-classic-rpc.yml +++ b/benchmarks/ethereum-classic-rpc.yml @@ -4,7 +4,7 @@ slug: ethereum-classic-rpc number: "194" title: Fastest free Ethereum Classic RPC, live no-key endpoint latency seo_title: "Fastest free Ethereum Classic RPC 2026" -seo_description: "{{best_name}} leads free Ethereum Classic RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Ethereum Classic RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum Classic EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Ethereum Classic (chain 61) is the original Ethereum chain post-2016 DAO fork, continuing as a Proof-of-Work EVM network with ETC as the gas token. The chain upholds code-is-law immutability and has a dedicated long-term holder community. Three keyless endpoints are probed continuously: dRPC (etc.drpc.org), ETCDesktop (etc.etcdesktop.com), and ETCMC (rpc.etcmc.net). + Ethereum Classic (chain 61) is the original Ethereum chain post-2016 DAO fork, continuing as a Proof-of-Work EVM network with ETC as the gas token. The chain upholds code-is-law immutability and has a dedicated long-term holder community. One keyless endpoint is probed continuously: dRPC (etc.drpc.org). ETCDesktop and ETCMC are no longer responding to probes and have been removed. abstract: | Per-chain member of the RPC latency cluster, extended to Ethereum Classic. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Ethereum Classic endpoint - that sustains continuous probing, 3 providers at launch, + that sustains continuous probing, 1 provider at launch, every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Ethereum Classic-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"ethereum-classic\"`. Provider coverage at launch: 3 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"ethereum-classic\"`. Provider coverage: 1 no-key endpoint (dRPC). ETCDesktop and ETCMC stopped responding to probes and have been removed." findings: - - "{{best_name}} currently leads free Ethereum Classic RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free Ethereum Classic RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Ethereum Classic RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which Ethereum Classic RPCs work without an API key?" - a: "3 endpoints sustain continuous keyless probing: dRPC, ETCDesktop, and ETCMC. Every endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: dRPC (etc.drpc.org). ETCDesktop and ETCMC stopped responding to probes and have been removed from the bench." - q: "Does the fastest Ethereum Classic RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Ethereum Classic RPC latency measured here?" @@ -84,49 +84,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ethereum-classic", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ethereum-classic", region="sgp"}[1h]) - - - slug: etcdesktop - name: ETCDesktop - tag: ETCDesktop community public RPC for Ethereum Classic - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to ETCDesktop's no-key Ethereum Classic endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="etcdesktop", chain="ethereum-classic"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="etcdesktop", chain="ethereum-classic"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="etcdesktop", chain="ethereum-classic"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="etcdesktop", chain="ethereum-classic"}) / sum(ocb:rpc_call:rate_24h{provider="etcdesktop", chain="ethereum-classic"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="etcdesktop", chain="ethereum-classic"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcdesktop", chain="ethereum-classic", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcdesktop", chain="ethereum-classic", region="sgp"}[1h]) - - - slug: etcmc - name: ETCMC - tag: ETCMC community public RPC for Ethereum Classic - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to ETCMC's no-key Ethereum Classic endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="etcmc", chain="ethereum-classic"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="etcmc", chain="ethereum-classic"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="etcmc", chain="ethereum-classic"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="etcmc", chain="ethereum-classic"}) / sum(ocb:rpc_call:rate_24h{provider="etcmc", chain="ethereum-classic"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="etcmc", chain="ethereum-classic"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="etcmc", chain="ethereum-classic", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="etcmc", chain="ethereum-classic", region="sgp"}[1h]) diff --git a/benchmarks/ethereum-rpc.yml b/benchmarks/ethereum-rpc.yml index 1136e155..ce4e2174 100644 --- a/benchmarks/ethereum-rpc.yml +++ b/benchmarks/ethereum-rpc.yml @@ -4,7 +4,7 @@ slug: ethereum-rpc number: "044" title: Fastest free Ethereum RPC, live no-key endpoint latency seo_title: "Fastest free Ethereum RPC 2026" -seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 7 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Ethereum RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 6 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Ethereum RPC endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,10 +14,10 @@ unit: ms higher_is_better: false seo_intro: | - Ethereum carries the largest free-RPC cohort we measure: 7 no-key public-read providers answering the same `eth_getBlockByNumber` probe every 60 seconds from three regions. This bench is scoped to public, read-optimised gateways only; MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share the JSON-RPC surface but serve a fundamentally different SLA (send-tx privacy, not read speed) and live in their own cluster on [mev-protect-rpc](/benchmarks/mev-protect-rpc). Cloudflare-eth is the resident cautionary tale of this cohort: sub-second HTTP 200s that increasingly carry a JSON-RPC error body (`-32046 Cannot fulfill request`), and Merkle is excluded outright after recurring Cloudflare lockouts that froze our probes for 20 minutes after a single request. If you paste a free RPC URL into an Ethereum dapp's read path, this page is the live answer to which one deserves it. + Ethereum carries one of the largest free-RPC cohorts we measure: 6 no-key public-read providers answering the same `eth_getBlockByNumber` probe every 60 seconds from three regions. This bench is scoped to public, read-optimised gateways only; MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share the JSON-RPC surface but serve a fundamentally different SLA (send-tx privacy, not read speed) and live in their own cluster on [mev-protect-rpc](/benchmarks/mev-protect-rpc). Cloudflare-eth has been removed from this bench after persistent JSON-RPC error -32603, and Merkle is excluded outright after recurring Cloudflare lockouts that froze our probes for 20 minutes after a single request. If you paste a free RPC URL into an Ethereum dapp's read path, this page is the live answer to which one deserves it. abstract: | - Per-chain member of the public-read RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public-read Ethereum endpoint that sustains continuous probing, 7 providers, every 60 seconds, from us-east, eu-west and Singapore. MEV-protection endpoints (private-mempool, anti-sandwich, PBS relays) are measured on the same probe but scored separately on [mev-protect-rpc](/benchmarks/mev-protect-rpc) because their SLA optimises for send-tx privacy rather than read latency, and mixing them here would flatter or penalise them on the wrong axis. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Ethereum-scoped answer with per-region breakdowns as a first-class dimension. + Per-chain member of the public-read RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public-read Ethereum endpoint that sustains continuous probing, 6 providers, every 60 seconds, from us-east, eu-west and Singapore. MEV-protection endpoints (private-mempool, anti-sandwich, PBS relays) are measured on the same probe but scored separately on [mev-protect-rpc](/benchmarks/mev-protect-rpc) because their SLA optimises for send-tx privacy rather than read latency, and mixing them here would flatter or penalise them on the wrong axis. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Ethereum-scoped answer with per-region breakdowns as a first-class dimension. methodology: - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." @@ -26,24 +26,24 @@ methodology: - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to chain=\"ethereum\". Provider coverage: 7 no-key public-read endpoints (PublicNode, dRPC, Tenderly, Nodies, Lava, MeowRPC, Cloudflare). MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share this probe methodology but score on [mev-protect-rpc](/benchmarks/mev-protect-rpc), because their SLA optimises for send-tx privacy, not read speed." + - "Chain scope: every query on this page is pinned to chain=\"ethereum\". Provider coverage: 6 no-key public-read endpoints (PublicNode, dRPC, Tenderly, Nodies, Lava, MeowRPC). Cloudflare removed (persistent JSON-RPC -32603). MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) share this probe methodology but score on [mev-protect-rpc](/benchmarks/mev-protect-rpc), because their SLA optimises for send-tx privacy, not read speed." findings: - - "{{best_name}} currently leads the free Ethereum RPC field at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 7 measured public-read providers, the largest cohort of any chain in the cluster." - - "Cloudflare-eth is the resident cautionary tale: sub-second HTTP 200s that increasingly carry a JSON-RPC error instead of a block number. The success-rate column, not the latency column, tells the real story." + - "{{best_name}} currently leads the free Ethereum RPC field at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 6 measured public-read providers." + - "Cloudflare-eth has been removed from the active cohort: persistent JSON-RPC error -32603 means the endpoint no longer returns usable results." - "Merkle is excluded on Ethereum by design: its endpoint sits behind an aggressive bot filter that locks out programmatic clients for ~20 minutes after one request, invisible on any status page." faq: - q: "What is the fastest free Ethereum RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 7 no-key public-read providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment. If you need MEV-protection endpoints instead of raw read speed, see [mev-protect-rpc](/benchmarks/mev-protect-rpc)." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 6 no-key public-read providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment. If you need MEV-protection endpoints instead of raw read speed, see [mev-protect-rpc](/benchmarks/mev-protect-rpc)." - q: "Which Ethereum RPCs work without an API key?" - a: "The 7 public-read providers on this page: PublicNode, dRPC, Tenderly, Nodies, Lava, MeowRPC, Cloudflare. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 60-second probe cadence is excluded rather than listed with an asterisk. MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) also expose no-key JSON-RPC but serve a different SLA and live on [mev-protect-rpc](/benchmarks/mev-protect-rpc)." + a: "The 6 public-read providers on this page: PublicNode, dRPC, Tenderly, Nodies, Lava, MeowRPC. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 60-second probe cadence is excluded rather than listed with an asterisk. Cloudflare was removed after persistent JSON-RPC error -32603. MEV-protection RPCs (Flashbots, MEV Blocker, Blink, bloXroute) also expose no-key JSON-RPC but serve a different SLA and live on [mev-protect-rpc](/benchmarks/mev-protect-rpc)." - q: "Does the fastest Ethereum RPC change by region?" a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." - q: "How is Ethereum RPC latency measured here?" a: "One identical JSON-RPC POST (`eth_getBlockByNumber`) every 60 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." - - q: "Why is Cloudflare's Ethereum RPC marked unreliable here?" - a: "Cloudflare's public Ethereum gateway switched to a permissioned mode for many JSON-RPC methods. The endpoint still responds fast with HTTP 200, but the body is increasingly a JSON-RPC error (`-32046`) rather than a usable result. We classify a call as `ok` only when the HTTP status is 200 AND the body carries a usable `result` field, so Cloudflare's real success rate is visible in the reliability column instead of hiding behind fast error responses." + - q: "Why was Cloudflare's Ethereum RPC removed?" + a: "Cloudflare's public Ethereum gateway returned persistent JSON-RPC error -32603 on our probes. The endpoint responds with HTTP 200 but the body carries an error body rather than a usable result, which means it no longer qualifies as a functional free RPC. We classify a call as `ok` only when the HTTP status is 200 AND the body carries a usable `result` field." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities @@ -202,26 +202,4 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="meowrpc", chain="ethereum", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", chain="ethereum", region="sgp"}[1h]) - - 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's no-key Ethereum endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", chain="ethereum"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cloudflare", chain="ethereum"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="cloudflare", chain="ethereum"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="cloudflare", chain="ethereum"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="cloudflare", chain="ethereum"}) / sum(ocb:rpc_call:rate_24h{provider="cloudflare", chain="ethereum"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="cloudflare", chain="ethereum"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="cloudflare", chain="ethereum"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", chain="ethereum", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", chain="ethereum", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", chain="ethereum", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", chain="ethereum", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cloudflare", chain="ethereum", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", chain="ethereum", region="sgp"}[1h]) diff --git a/benchmarks/etherlink-rpc.yml b/benchmarks/etherlink-rpc.yml index 0edd3e16..a8e0bdb3 100644 --- a/benchmarks/etherlink-rpc.yml +++ b/benchmarks/etherlink-rpc.yml @@ -107,49 +107,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="etherlink", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="etherlink", region="sgp"}[1h]) - - - slug: ankr - name: Ankr - tag: Ankr decentralized node provider for Etherlink - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Ankr's no-key Etherlink endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="etherlink"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ankr", chain="etherlink"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ankr", chain="etherlink"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ankr", chain="etherlink"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ankr", chain="etherlink"}) / sum(ocb:rpc_call:rate_24h{provider="ankr", chain="etherlink"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ankr", chain="etherlink"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="etherlink"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="etherlink", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="etherlink", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="etherlink", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="etherlink", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="etherlink", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="etherlink", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for Etherlink mainnet - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key Etherlink endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="etherlink"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="etherlink"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="etherlink"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="etherlink"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="etherlink"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="etherlink"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="etherlink"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="etherlink"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="etherlink", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="etherlink", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="etherlink", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="etherlink", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="etherlink", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="etherlink", region="sgp"}[1h]) diff --git a/benchmarks/fantom-rpc.yml b/benchmarks/fantom-rpc.yml index 5c3668e7..c3c93b74 100644 --- a/benchmarks/fantom-rpc.yml +++ b/benchmarks/fantom-rpc.yml @@ -111,29 +111,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fantom", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="fantom", region="sgp"}[1h]) - - slug: ankr - name: Ankr - tag: Ankr decentralized node provider for Fantom - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Ankr's no-key Fantom endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="fantom"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ankr", chain="fantom"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ankr", chain="fantom"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ankr", chain="fantom"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ankr", chain="fantom"}) / sum(ocb:rpc_call:rate_24h{provider="ankr", chain="fantom"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ankr", chain="fantom"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="fantom"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="fantom", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="fantom", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="fantom", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="fantom", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="fantom", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="fantom", region="sgp"}[1h]) - - slug: thirdweb name: Thirdweb tag: Thirdweb chain-ID RPC gateway for Fantom @@ -156,26 +133,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="fantom", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="fantom", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for Fantom mainnet - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key Fantom endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="fantom"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="fantom"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="fantom"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="fantom"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="fantom"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="fantom"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="fantom"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="fantom"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="fantom", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="fantom", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="fantom", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="fantom", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="fantom", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="fantom", region="sgp"}[1h]) diff --git a/benchmarks/haqq-rpc.yml b/benchmarks/haqq-rpc.yml index bd4ba78b..d397c893 100644 --- a/benchmarks/haqq-rpc.yml +++ b/benchmarks/haqq-rpc.yml @@ -130,26 +130,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="haqq-official", chain="haqq", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="haqq-official", chain="haqq", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for HAQQ 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 to Blast API's no-key HAQQ endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="haqq"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="haqq"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="haqq"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="haqq"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="haqq"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="haqq"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="haqq"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="haqq"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="haqq", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="haqq", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="haqq", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="haqq", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="haqq", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="haqq", region="sgp"}[1h]) diff --git a/benchmarks/hydration-rpc.yml b/benchmarks/hydration-rpc.yml index 423f4894..61115f2c 100644 --- a/benchmarks/hydration-rpc.yml +++ b/benchmarks/hydration-rpc.yml @@ -4,26 +4,23 @@ slug: hydration-rpc number: "186" title: Fastest free Hydration RPC, live no-key endpoint latency seo_title: "Fastest free Hydration RPC 2026" -seo_description: "{{best_name}} leads free Hydration RPC at {{best_p50}} (chain_getHeader p50, 24h). 5 no-key providers measured every 60s from 3 regions." +seo_description: "Hydration RPC bench temporarily suspended: all 5 providers are DNS dead or returning HTTP 400. Will resume when endpoints recover." subtitle: HTTP round-trip latency for chain_getHeader against every free, no-key public Hydration Polkadot parachain endpoint, audited every 60 seconds from 3 regions. category: RPCs -status: live +status: draft metric: RPC latency unit: ms higher_is_better: false seo_intro: | - Hydration (formerly HydraDX) is an Omnipool-based DEX parachain on Polkadot enabling single-sided liquidity provision across assets. HDX is the native token. Five keyless endpoints are probed continuously: Hydration's official gateway (rpc.hydradx.cloud), Dwellir, Helikon, Dotters Network, and Parachains Network. + Hydration (formerly HydraDX) is an Omnipool-based DEX parachain on Polkadot enabling single-sided liquidity provision across assets. HDX is the native token. This bench is currently suspended: all 5 previously measured providers (Hydration official, Dwellir, Helikon, Dotters Network, Parachains Network) are either DNS dead or returning HTTP 400. The bench will resume when endpoints recover. abstract: | Per-chain member of the RPC latency cluster, extended to Hydration. - We measure the round-trip latency of a single, identical JSON-RPC call - (`chain_getHeader`) against every no-key public Hydration parachain endpoint - that sustains continuous probing, 5 providers at launch, - every 60 seconds, from us-east, eu-west and Singapore. The cross-chain - view lives on the parent `rpc-capabilities` benchmark; this page is the - Hydration-scoped answer with per-region breakdowns as a first-class dimension. + This bench is currently suspended: all 5 previously measured providers + are DNS dead or returning HTTP 400. Data will resume when at least + one keyless Hydration RPC endpoint becomes reachable again. methodology: - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." @@ -31,16 +28,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + parsable hex block number), `http_err`, `jsonrpc_err`, `stale` (more than 40 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"hydration\"`. Provider coverage at launch: 5 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"hydration\"`. Bench currently suspended: all 5 previously measured providers are DNS dead or returning HTTP 400." findings: - - "{{best_name}} currently leads free Hydration RPC at {{best_p50}} (`chain_getHeader` p50, 24h) across 5 measured providers." + - "Hydration RPC bench is suspended. All previously measured providers (Hydration official, Dwellir, Helikon, Dotters Network, Parachains Network) are unreachable." faq: - q: "What is the fastest free Hydration RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`chain_getHeader` p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "This bench is currently suspended. All 5 previously measured keyless providers are DNS dead or returning HTTP 400. The bench will resume when at least one reachable endpoint is found." - q: "Which Hydration RPCs work without an API key?" - a: "5 endpoints sustain continuous keyless probing: Hydration official, Dwellir, Helikon, Dotters Network, and Parachains Network. Every endpoint was live-verified before inclusion." + a: "All 5 previously verified endpoints (Hydration official, Dwellir, Helikon, Dotters Network, Parachains Network) are currently unreachable. The bench will resume when providers recover." - q: "Does the fastest Hydration RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Hydration RPC latency measured here?" @@ -61,11 +58,14 @@ dimensions: - { value: eu-west, label: EU-West } - { value: sgp, label: Singapore } +# All 5 previously measured providers are DNS dead or returning HTTP 400. +# Suspended 2026-08-07. Will resume when endpoints recover. +# Removed: dwellir, helikon, dotters-network, parachains-network providers: - slug: hydration-official name: Hydration - tag: Hydration official public RPC gateway - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Hydration's no-key endpoint." + tag: Hydration official public RPC (currently unreachable) + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to the Hydration official no-key endpoint." queries: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hydration-official", chain="hydration"}) p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="hydration-official", chain="hydration"}) @@ -84,95 +84,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="hydration-official", chain="hydration", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="hydration-official", chain="hydration", region="sgp"}[1h]) - - - slug: dwellir - name: Dwellir - tag: Dwellir Substrate infrastructure provider - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Dwellir's no-key Hydration endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="hydration"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir", chain="hydration"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir", chain="hydration"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir", chain="hydration"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir", chain="hydration"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir", chain="hydration"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="dwellir", chain="hydration"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="hydration"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="hydration", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="hydration", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="hydration", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="hydration", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="hydration", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="hydration", region="sgp"}[1h]) - - - slug: helikon - name: Helikon - tag: Helikon community RPC for Hydration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Helikon's no-key Hydration endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helikon", chain="hydration"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="helikon", chain="hydration"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="helikon", chain="hydration"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="helikon", chain="hydration"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="helikon", chain="hydration"}) / sum(ocb:rpc_call:rate_24h{provider="helikon", chain="hydration"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="helikon", chain="hydration"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="helikon", chain="hydration"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helikon", chain="hydration", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="helikon", chain="hydration", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helikon", chain="hydration", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="helikon", chain="hydration", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="helikon", chain="hydration", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="helikon", chain="hydration", region="sgp"}[1h]) - - - slug: dotters-network - name: Dotters Network - tag: Dotters Network community RPC for Hydration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Dotters Network's no-key Hydration endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dotters-network", chain="hydration"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dotters-network", chain="hydration"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dotters-network", chain="hydration"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dotters-network", chain="hydration"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="dotters-network", chain="hydration"}) / sum(ocb:rpc_call:rate_24h{provider="dotters-network", chain="hydration"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="dotters-network", chain="hydration"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="dotters-network", chain="hydration"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dotters-network", chain="hydration", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dotters-network", chain="hydration", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dotters-network", chain="hydration", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dotters-network", chain="hydration", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dotters-network", chain="hydration", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dotters-network", chain="hydration", region="sgp"}[1h]) - - - slug: parachains-network - name: Parachains Network - tag: Parachains.network community RPC for Hydration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Parachains Network's no-key Hydration endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="parachains-network", chain="hydration"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="parachains-network", chain="hydration"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="parachains-network", chain="hydration"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="parachains-network", chain="hydration"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="parachains-network", chain="hydration"}) / sum(ocb:rpc_call:rate_24h{provider="parachains-network", chain="hydration"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="parachains-network", chain="hydration"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="parachains-network", chain="hydration"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="parachains-network", chain="hydration", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="parachains-network", chain="hydration", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="parachains-network", chain="hydration", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="parachains-network", chain="hydration", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="parachains-network", chain="hydration", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="parachains-network", chain="hydration", region="sgp"}[1h]) diff --git a/benchmarks/hyperlane-message-latency.yml b/benchmarks/hyperlane-message-latency.yml index 1d15a97d..dbcc2b1a 100644 --- a/benchmarks/hyperlane-message-latency.yml +++ b/benchmarks/hyperlane-message-latency.yml @@ -85,11 +85,11 @@ providers: tag: Ethereum → any chain, Mailbox Dispatch → destination Process delivery formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Ethereum." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="ethereum"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="ethereum"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="ethereum"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ethereum"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ethereum"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ethereum"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="ethereum"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="ethereum"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="ethereum"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ethereum"}[1h]))) @@ -98,11 +98,11 @@ providers: tag: Polygon PoS → any chain, Bor block + Mailbox delivery formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Polygon." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="polygon"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="polygon"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="polygon"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="polygon"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="polygon"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="polygon"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="polygon"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="polygon"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="polygon"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="polygon"}[1h]))) - slug: arbitrum @@ -110,11 +110,11 @@ providers: tag: Arbitrum One → any chain, sub-second L2 block + Mailbox delivery formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Arbitrum." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="arbitrum"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="arbitrum"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="arbitrum"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="arbitrum"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="arbitrum"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="arbitrum"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="arbitrum"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="arbitrum"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="arbitrum"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="arbitrum"}[1h]))) - slug: base @@ -122,11 +122,11 @@ providers: tag: Base → any chain, 2s block + Mailbox delivery formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Base." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="base"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="base"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="base"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="base"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="base"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="base"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="base"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="base"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="base"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="base"}[1h]))) - slug: sonic @@ -134,163 +134,163 @@ providers: tag: Sonic → any chain, sub-second finality + Mailbox delivery formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Sonic." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="sonic"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="sonic"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="sonic"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="sonic"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="sonic"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="sonic"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="sonic"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="sonic"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="sonic"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="sonic"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="sonic"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="sonic"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="sonic"}[1h]))) - slug: avalanche name: Avalanche tag: Avalanche C-Chain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Avalanche." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="avalanche"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="avalanche"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="avalanche"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="avalanche"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="avalanche"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="avalanche"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="avalanche"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="avalanche"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="avalanche"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="avalanche"}[1h]))) - slug: berachain name: Berachain tag: Berachain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Berachain." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="berachain"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="berachain"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="berachain"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="berachain"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="berachain"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="berachain"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="berachain"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="berachain"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="berachain"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="berachain"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="berachain"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="berachain"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="berachain"}[1h]))) - slug: bnb name: BNB Chain tag: BNB Chain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from BNB Chain." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="bnb"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="bnb"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="bnb"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="bnb"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="bnb"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="bnb"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="bnb"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="bnb"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="bnb"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="bnb"}[1h]))) - slug: celo name: Celo tag: Celo → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Celo." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="celo"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="celo"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="celo"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="celo"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="celo"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="celo"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="celo"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="celo"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="celo"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="celo"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="celo"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="celo"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="celo"}[1h]))) - slug: injective name: Injective tag: Injective (Cosmos) → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Injective." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="injective"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="injective"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="injective"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="injective"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="injective"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="injective"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="injective"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="injective"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="injective"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="injective"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="injective"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="injective"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="injective"}[1h]))) - slug: ink name: Ink tag: Ink L2 → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Ink." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ink"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ink"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ink"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="ink"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="ink"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="ink"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ink"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ink"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="ink"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="ink"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="ink"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="ink"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="ink"}[1h]))) - slug: linea name: Linea tag: Linea → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Linea." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="linea"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="linea"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="linea"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="linea"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="linea"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="linea"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="linea"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="linea"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="linea"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="linea"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="linea"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="linea"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="linea"}[1h]))) - slug: mantle name: Mantle tag: Mantle → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Mantle." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="mantle"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="mantle"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="mantle"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="mantle"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="mantle"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="mantle"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="mantle"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="mantle"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="mantle"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="mantle"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="mantle"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="mantle"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="mantle"}[1h]))) - slug: moonbeam name: Moonbeam tag: Moonbeam parachain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Moonbeam." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="moonbeam"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="moonbeam"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="moonbeam"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="moonbeam"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="moonbeam"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="moonbeam"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="moonbeam"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="moonbeam"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="moonbeam"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="moonbeam"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="moonbeam"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="moonbeam"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="moonbeam"}[1h]))) - slug: optimism name: Optimism tag: Optimism → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Optimism." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="optimism"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="optimism"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="optimism"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="optimism"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="optimism"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="optimism"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="optimism"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="optimism"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="optimism"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="optimism"}[1h]))) - slug: scroll name: Scroll tag: Scroll → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Scroll." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="scroll"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="scroll"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="scroll"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="scroll"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="scroll"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="scroll"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="scroll"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="scroll"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="scroll"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="scroll"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="scroll"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="scroll"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="scroll"}[1h]))) - slug: solana name: Solana tag: Solana → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Solana." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="solana"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="solana"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="solana"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="solana"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="solana"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="solana"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="solana"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="solana"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="solana"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="solana"}[1h]))) - slug: unichain name: Unichain tag: Unichain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from Unichain." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="unichain"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="unichain"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="unichain"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="unichain"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="unichain"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="unichain"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="unichain"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="unichain"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="unichain"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="unichain"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="unichain"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="unichain"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="unichain"}[1h]))) - slug: world-chain name: World Chain tag: World Chain → any chain formula: "50th percentile over 24h of Hyperlane end-to-end delivery latency for messages sourced from World Chain." queries: - p50: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="world-chain"}[24h]))) - p90: histogram_quantile(0.90, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="world-chain"}[24h]))) - p99: histogram_quantile(0.99, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="world-chain"}[24h]))) - mean: sum(rate(hyperlane_message_latency_milliseconds_sum{source_chain="world-chain"}[24h])) / sum(rate(hyperlane_message_latency_milliseconds_count{source_chain="world-chain"}[24h])) - sample_size: sum(increase(hyperlane_message_seen_total{source_chain="world-chain"}[24h])) + p50: histogram_quantile(0.50, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="world-chain"}) + p90: histogram_quantile(0.90, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="world-chain"}) + p99: histogram_quantile(0.99, ocb:hyperlane_msg_latency_ms_bucket:rate24h{source_chain="world-chain"}) + mean: ocb:hyperlane_msg_latency_ms_sum:rate24h{source_chain="world-chain"} / ocb:hyperlane_msg_latency_ms_count:rate24h{source_chain="world-chain"} + sample_size: ocb:hyperlane_msg_seen:increase24h{source_chain="world-chain"} series: histogram_quantile(0.50, sum by(le) (rate(hyperlane_message_latency_milliseconds_bucket{source_chain="world-chain"}[1h]))) diff --git a/benchmarks/indexing-freshness.yml b/benchmarks/indexing-freshness.yml deleted file mode 100644 index 67cc1f92..00000000 --- a/benchmarks/indexing-freshness.yml +++ /dev/null @@ -1,127 +0,0 @@ -# OpenChainBench. Bench № 070 - -slug: indexing-freshness -number: "070" -title: Freshest wallet data API. Zerion, Allium, Mobula -seo_title: "Freshest wallet data API 2026" -seo_description: "How fast do wallet APIs index a new transaction? Zerion, Allium and Mobula measured live on organic Base transfers, second by second." -subtitle: Seconds between an organic transfer confirming on Base and the moment each wallet data API first returns it, measured continuously on real user transactions. -category: Aggregators -status: live -metric: Indexing freshness -unit: sec -higher_is_better: false - -seo_intro: | - Every wallet app, portfolio tracker and exchange faces the same - question. when a user receives funds, how long before the API my - app is built on actually shows it. Providers advertise "real-time" - and "sub-second indexing"; none publish comparable numbers. This - benchmark measures it the only way that cannot be gamed. we pick a - random, organic native transfer from the newest Base block, real - user, different wallet every time, and poll each provider's wallet - transactions API until the tx hash appears. Because the ground - truth is a random real transaction there is no benchmark wallet a - provider could special-case, and every measurement is publicly - re-verifiable from the tx hash. Cohort. Zerion, Allium, - GoldRush (Covalent) and Mobula, all probed with the identical - schedule from the same host. The timing context matters in 2026. - Dune Sim is sunsetting and SimpleHash is gone, so teams are - choosing a replacement wallet data API right now, and freshness is - the spec sheet line that separates them. - -abstract: | - We measure the visibility lag of wallet data APIs. the time between - an organic native transfer confirming on Base (T0 = the instant our - own RPC observes the containing block) and the first poll at which - each provider's wallet transactions endpoint returns that tx hash. - One probe event per 10 minutes; each event uses a fresh random - transaction from a fresh block, so wallets are cold and results - include any lazy, on-demand indexing path a provider runs for - never-before-queried addresses. Detection is parser-free (tx hash - substring in the raw response), the poll schedule is front-loaded - (1s to 120s, identical for every provider), and per-provider - monthly quota guards keep the probe inside every free tier. An - event a provider has not indexed within 120 seconds counts as - missed, which feeds the reliability column, because an API that - never shows the deposit is worse than a slow one. - -methodology: - - "Ground truth: one probe event per 10 minutes. The harness watches new Base blocks through its own RPC and picks one random plain native transfer (value > 0, empty calldata, OP-stack system deposit excluded). T0 is the instant the harness observes the containing block; the same T0 is used for every provider." - - "Anti-gaming by construction: the measured wallet is a random real user's address, different on every event, so no provider can whitelist or pre-warm a known benchmark wallet. Every measurement is re-verifiable by anyone from the public tx hash." - - "Poll schedule: each provider's wallet transactions endpoint is polled at 1, 2, 3, 4, 6, 8, 11, 15, 20, 26, 34, 45, 60, 80, 100 and 120 seconds after T0. Reported lag is the first poll at which the response contains the tx hash, an upper bound with resolution equal to the gap between consecutive polls." - - "Detection is parser-free: the raw response body is scanned for the tx hash substring. Every cohort API returns the hash verbatim, so no provider gains or loses from response schema differences." - - "Cold wallets by design: because each event uses a never-before-queried address, results include any on-demand indexing path a provider runs for new wallets. This mirrors the experience of a user opening an app on a fresh address, and it is disclosed here because warm, continuously-queried wallets may see lower lags." - - "Classification: found (lag recorded), missed (not indexed within 120s), api_error (provider errored on every poll of the event). Percentiles are computed from the histogram of found lags over 24h; the miss rate is published alongside because latency without reliability is a misleading ranking signal." - - "Quota fairness: per-provider monthly call budgets sized to each free tier with headroom, enforced by a guard that pauses probing at 90%. Allium participates in every third event (20k calls/month free tier); all other providers join every event. Sample sizes per provider are published." - - "Scope: Base mainnet, single probe region. Freshness lags are measured in seconds while cross-region network deltas are milliseconds, so multi-region probing would add cost without changing the ranking; this is revisited if two providers converge within one poll-step of each other." - -findings: - - "{{best_name}} currently leads at {{best_p50}} (p50 of found lags, 24h) across organic Base transfers." - - "{{name:zerion}} sits at {{p50:zerion}}. Early runs showed a bimodal pattern, some events indexed in about a second and others surfacing several seconds later, consistent with a caching layer in front of the wallet endpoint." - - "{{name:goldrush}} trails at {{p50:goldrush}} on this probe. Its unified multi-chain schema trades freshness for breadth, a real trade-off teams should weigh explicitly." - - "Miss rates matter more than medians: an API that fails to show a deposit within two minutes breaks the user flow entirely, so read the reliability column before the latency one." - -faq: - - q: "Which wallet data API shows new transactions fastest?" - a: "Per the live leaderboard above: {{best_name}} at {{best_p50}} (p50 over 24h of organic Base transfers). The ranking re-sorts continuously as probe events land every 10 minutes. Check the miss-rate column too, a provider that occasionally never indexes a transfer is worse for a wallet app than one that is a second slower on median." - - q: "How is indexing freshness measured here?" - a: "The harness picks a random organic native transfer from the newest Base block, records T0 when its own RPC observes the block, then polls every provider's wallet transactions endpoint on an identical front-loaded schedule (1s to 120s) until the tx hash appears in the raw response. The lag is the first successful poll. Events a provider has not indexed within 120 seconds count as missed. The full harness is open source and each measurement can be re-verified from the public tx hash." - - q: "Why use random real transactions instead of a controlled test wallet?" - a: "Two reasons. First, integrity: a fixed benchmark wallet could be whitelisted or pre-warmed by a provider; a random real user's transfer, different every event, cannot. Second, realism: cold, never-before-queried addresses exercise any lazy indexing path a provider runs for new wallets, which is exactly what a new user experiences. The trade-off, disclosed in the methodology, is that continuously-queried warm wallets may see lower lags than reported here." - - q: "Does this benchmark cover more chains than Base?" - a: "Not yet. Base was chosen first for its high volume of plain native transfers (dense organic ground truth) and 2-second blocks. The harness is chain-agnostic and additional EVM chains join by adding an RPC endpoint, subject to each provider's free-tier quota budget. Chain coverage itself differs per provider and is part of what teams should evaluate." - - q: "Why does freshness matter when choosing a wallet API in 2026?" - a: "Because the market is consolidating. Dune Sim is sunsetting in August 2026 and SimpleHash shut down in 2025, so many teams are migrating to a new wallet data API right now. Providers advertise real-time indexing but publish no comparable numbers; deposit visibility lag is the difference between a user seeing their funds arrive and a support ticket. This page is the only continuously measured, provider-neutral comparison of that number." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/indexing-freshness - -prometheus: - window: 24h - freshness_metric: indexing_freshness_seconds - -providers: - - slug: mobula - name: Mobula - tag: Wallet + market data API, 50+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Mobula's wallet transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="mobula"}[24h]))) - success: sum(increase(indexing_probe_total{provider="mobula", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="mobula", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="mobula"}[1h]) - - slug: zerion - name: Zerion - tag: Wallet API behind the Zerion app, 25+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Zerion's wallet transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="zerion"}[24h]))) - success: sum(increase(indexing_probe_total{provider="zerion", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="zerion", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="zerion"}[1h]) - - slug: goldrush - name: GoldRush - tag: Covalent's multi-chain wallet API, unified schema - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and GoldRush's transactions endpoint first returning it, from the shared histogram estimator." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="goldrush"}[24h]))) - success: sum(increase(indexing_probe_total{provider="goldrush", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="goldrush", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="goldrush"}[1h]) - - slug: allium - name: Allium - tag: Enterprise realtime wallet APIs, 100+ chains - formula: "Median (p50) over 24h of visibility lag in seconds between an organic Base transfer confirming and Allium's wallet transactions endpoint first returning it. Allium joins every third event to respect its free-tier quota." - queries: - p50: histogram_quantile(0.5, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - p90: histogram_quantile(0.9, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - p99: histogram_quantile(0.99, sum by (le) (increase(indexing_freshness_seconds_histogram_bucket{provider="allium"}[24h]))) - success: sum(increase(indexing_probe_total{provider="allium", result="found"}[24h])) / sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) - sample_size: sum(increase(indexing_probe_total{provider="allium", result=~"found|missed"}[24h])) - series: avg_over_time(indexing_freshness_seconds{provider="allium"}[1h]) diff --git a/benchmarks/kaia-rpc.yml b/benchmarks/kaia-rpc.yml index 24a1c0b3..38c54c32 100644 --- a/benchmarks/kaia-rpc.yml +++ b/benchmarks/kaia-rpc.yml @@ -4,7 +4,7 @@ slug: kaia-rpc number: "098" title: Fastest free Kaia RPC, live no-key EVM endpoint latency seo_title: "Fastest free Kaia RPC 2026" -seo_description: "{{best_name}} leads free Kaia RPC at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50, 24h). 2 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Kaia RPC at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for the `eth_getBlockByNumber(\"latest\", false)` method against every free, no-key public Kaia EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Kaia is the EVM L1 formed by the Klaytn + Finschia merger in Aug 2024, with Istanbul BFT consensus, KAIA gas token, and dominant footprint in Korea + Japan through LINE and Kakao integrations. Standard EVM JSON-RPC. Excluded by the launch audit: BlockPI (`kaia.blockpi.network` returned `Apikey not found`), Nodies (`klaytn-pokt.nodies.app` requires paid subscription), Alchemy public (`KAIA_MAINNET not enabled for this app` on public tier), AllThatNode + OnFinality + Chainstack + Tatum + NowNodes (DNS-fail or key required). Every provider live-verified with 4 consecutive keyless probes at launch. + Kaia is the EVM L1 formed by the Klaytn + Finschia merger in Aug 2024, with Istanbul BFT consensus, KAIA gas token, and dominant footprint in Korea + Japan through LINE and Kakao integrations. Standard EVM JSON-RPC. Excluded: dRPC (500 paid plan only), BlockPI (`kaia.blockpi.network` returned `Apikey not found`), Nodies (`klaytn-pokt.nodies.app` requires paid subscription), Alchemy public (`KAIA_MAINNET not enabled for this app` on public tier), AllThatNode + OnFinality + Chainstack + Tatum + NowNodes (DNS-fail or key required). Every provider live-verified with 4 consecutive keyless probes at launch. abstract: | Per-chain member of the RPC latency cluster, extended to Kaia. We measure the round-trip latency of a single, identical JSON-RPC call - (`eth_getBlockByNumber(\"latest\", false)`) against every no-key public Kaia endpoint - that sustains continuous probing, 2 providers at launch, + (`eth_getBlockByNumber(\"latest\", false)`) against the no-key public Kaia endpoint + that sustains continuous probing (1 provider: Kaia Foundation official), every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with a Kaia-scaled staleness gap (25 blocks at @@ -35,16 +35,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 25 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"kaia\"`. Provider coverage at launch: 2 no-key endpoints (Kaia Foundation, dRPC)." + - "Chain scope: every query on this page is pinned to `chain=\"kaia\"`. Provider coverage: 1 no-key endpoint (Kaia Foundation). dRPC removed (500 paid plan only)." findings: - - "{{best_name}} currently leads free Kaia RPC at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50, 24h) across 2 measured providers." + - "{{best_name}} currently leads free Kaia RPC at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Kaia RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 2 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." - q: "Which Kaia RPCs work without an API key?" - a: "2 endpoints sustain continuous keyless probing at launch: Kaia Foundation, dRPC. Every listed endpoint was live-verified with a `eth_getBlockByNumber(\"latest\", false)` POST returning a parsable response before inclusion. See the methodology section for the specific endpoints that were audited and excluded." + a: "1 endpoint sustains continuous keyless probing: Kaia Foundation (public-en.node.kaia.io). dRPC was removed after returning 500 (paid plan only). See the methodology section for the specific endpoints that were audited and excluded." - q: "Does the fastest Kaia RPC change by region?" a: "Often. Public infra concentrates in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Kaia RPC latency measured here, technically?" @@ -89,26 +89,4 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="kaia-official", chain="kaia", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="kaia-official", chain="kaia", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: Multi-provider RPC load balancer, edge-close routing - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key kaia RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="kaia"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="kaia"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="kaia"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="kaia"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="kaia"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="kaia"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="kaia"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="kaia"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="kaia", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="kaia", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="kaia", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="kaia", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="kaia", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="kaia", region="sgp"}[1h]) diff --git a/benchmarks/kusama-rpc.yml b/benchmarks/kusama-rpc.yml index a69eb843..3f6e96d9 100644 --- a/benchmarks/kusama-rpc.yml +++ b/benchmarks/kusama-rpc.yml @@ -133,72 +133,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="kusama", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="kusama", region="sgp"}[1h]) - - - slug: ibp - name: IBP Network - tag: Kusama governance-funded infrastructure provider - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to IBP Network's no-key Kusama endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ibp", chain="kusama"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ibp", chain="kusama"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ibp", chain="kusama"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ibp", chain="kusama"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ibp", chain="kusama"}) / sum(ocb:rpc_call:rate_24h{provider="ibp", chain="kusama"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ibp", chain="kusama"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ibp", chain="kusama"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ibp", chain="kusama", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ibp", chain="kusama", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ibp", chain="kusama", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ibp", chain="kusama", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ibp", chain="kusama", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ibp", chain="kusama", region="sgp"}[1h]) - - - slug: dwellir - name: Dwellir - tag: Dwellir Substrate infrastructure provider - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Dwellir's no-key Kusama endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="kusama"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="dwellir", chain="kusama"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="dwellir", chain="kusama"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="dwellir", chain="kusama"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="dwellir", chain="kusama"}) / sum(ocb:rpc_call:rate_24h{provider="dwellir", chain="kusama"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="dwellir", chain="kusama"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="kusama"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="kusama", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="kusama", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="kusama", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="kusama", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="dwellir", chain="kusama", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="dwellir", chain="kusama", region="sgp"}[1h]) - - - slug: "1rpc" - name: 1RPC - tag: 1RPC privacy-preserving relay for Kusama - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to 1RPC's no-key Kusama endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="kusama"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="kusama"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="kusama"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="kusama"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="kusama"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="kusama"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="kusama"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="kusama"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="kusama", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="kusama", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="kusama", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="kusama", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="kusama", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="kusama", region="sgp"}[1h]) diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index 73ba0d3c..b8889763 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -4,7 +4,7 @@ slug: l1-finality number: "006" title: Fastest L1 blockchain finality, live across 11 chains seo_title: "Fastest L1 finality 2026" -seo_description: "Fastest L1 blockchain finality measured live across 11 chains: Gram, SUI, Hedera, Stellar, Solana, Ethereum, Bitcoin and more. Real times, not whitepaper claims." +seo_description: "{{best_name}} leads L1 finality at {{best_p50}} (p50, 24h) across 11 chains. Ethereum, Solana, Bitcoin, SUI, Gram: live wall-clock measurements, not whitepaper claims." 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 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. diff --git a/benchmarks/memecoin-platforms.yml b/benchmarks/memecoin-platforms.yml new file mode 100644 index 00000000..cb651588 --- /dev/null +++ b/benchmarks/memecoin-platforms.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench 203 + +slug: memecoin-platforms +number: "203" +title: "Solana memecoin platform fee rates: Axiom vs Fomo vs pump.fun vs GMGN vs Trojan, live 24h" +seo_title: "Solana memecoin platform fees 2026: Axiom, Fomo, pump.fun, GMGN, Trojan, Photon, Maestro" +seo_description: "{{best_name}} leads Solana memecoin platforms at {{best_p50}} take rate. Axiom vs Fomo vs pump.fun vs GMGN vs Trojan ranked by protocol revenue per dollar traded, live from Dune fee wallet monitoring." +subtitle: Live 24-hour protocol fee revenue divided by trading volume for each Solana memecoin platform. Fees sourced from Dune Analytics (on-chain fee wallet inflows, refreshed every 6 hours). Volume from Mobula lighthouse (refreshed every 15 minutes). + +category: Trading +status: live +metric: 24h take rate +unit: pct +higher_is_better: false + +disclaimer: | + Take rate = fees / volume * 100. Fees are on-chain inflows to known fee wallets (Dune query 8316589, refreshed every 6h). Fomo fees come from DeFiLlama (on-chain USDC + off-chain relay). Volume is total Mobula-attributed volume; the take rate is a lower bound since platforms with variable or waivable fees will show lower observed rates. + +seo_intro: | + Solana memecoin platforms charge fees in different ways and at different rates, + making direct comparison difficult without on-chain data. This benchmark + measures the actual protocol revenue collected over 24 hours by monitoring + known fee wallets on Dune Analytics, then divides by the total trading volume + attributed to each platform by Mobula's lighthouse aggregator. Axiom leads on + absolute protocol revenue, consistent with its 0.9% per-trade fee applied + across high-volume memecoin flow. The observed take rate for GMGN and Trojan + is lower than their stated fee because Mobula's volume attribution is broader + than the set of trades on which the platforms actually charge. pump.fun cut its + bonding-curve trading fees to 0% in August 2026, so its observed take rate has + dropped near zero; protocol revenue now comes from graduation fees only. + +abstract: | + Fee data: Dune query 8316589 monitors native SOL inflows (via solana.account_activity, + address column) and USDC/WSOL inflows (via token_balance_owner column) to known + protocol fee wallets for each platform, summed over a rolling 24-hour window. + Executed every 6 hours (costs Dune credits); cached results read every 15 minutes. + Fomo exception: Fomo combines on-chain USDC sweeps with off-chain relay fees + stored in a private Dune table accessible only to DeFiLlama; fees for Fomo come + from DeFiLlama /summary/fees/fomo chainBreakdown[Solana]. Volume data: Mobula + lighthouse (api.mobula.io/api/2/market/lighthouse) polled every 15 minutes. + Trading platforms from byPlatform; pump.fun from byLaunchpad[name=PumpFun]. + Two take-rate metrics: fee_rate_pct = fees / Mobula total volume (lower bound); + fee_paying_rate_pct = fees / fee-paying volume (Dune tx join to dex_solana.trades, + comparable across platforms). coverage_pct = fee-paying / Mobula volume. + +methodology: + - "Fee source (all except Fomo): Dune Analytics query 8316589 on solana.account_activity. Native SOL inflows matched by address = fee wallet; USDC/WSOL inflows matched by token_balance_owner = fee wallet. Summed over NOW() - 1 DAY. Executed every 6h; cached result read every 15min." + - "Fee source (Fomo): DeFiLlama /summary/fees/fomo, chainBreakdown[Solana].total24h. DeFiLlama combines on-chain USDC inflows to R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX and off-chain relay fees from dune.tryfomo.fomo_relay_fees (private Dune table)." + - "Volume source (Mobula): Mobula lighthouse byPlatform for trading terminals; byLaunchpad[name=PumpFun] for pump.fun (bonding curve + PumpSwap graduated AMM). Updated every 15 minutes. This is total attributed volume; includes trades that may not have generated a fee." + - "Volume source (fee-paying, Dune): For each transaction that generated a native SOL, USDC, or WSOL inflow to a fee wallet, the largest swap trade in dex_solana.trades with matching tx_id is taken (MAX amount_usd per tx, to avoid double-counting multi-hop routes). Sum across all such transactions = fee_paying_volume_usd_24h. This denominator is self-consistent with the fee numerator." + - "Two take-rate metrics: memecoin_platform_fee_rate_pct = fees / Mobula total volume (lower bound, shows attribution breadth); memecoin_platform_fee_paying_rate_pct = fees / fee-paying volume (comparable across platforms, closer to the actual per-trade fee rate). memecoin_platform_coverage_pct = fee-paying volume / Mobula volume (shows what fraction of attributed volume generated a fee)." + - "SOL price: CoinGecko simple/price, refreshed every 5 minutes. Used to convert native SOL and WSOL inflows to USD." + - "GMGN: 9 fee wallets verified by DeFiLlama adapter (master, 2026-06) as pure collectors sweeping to a single treasury. DeFiLlama Solana-only fees match ours within 5%, confirming the wallet list is complete. GMGN's $772K/day DeFiLlama total includes BSC, Base, Ethereum, Monad and other EVM chains; this bench is Solana-only." + - "pump.fun: fees captured via native SOL and WSOL inflows to the 9 known protocol fee wallets. pump.fun cut its bonding-curve trading fees to 0% in August 2026; observed fee revenue post that date reflects token graduation fees only. Creator fees (0.30%) and LP fees flow to separate recipients not tracked here." + - "Trojan: fee wallets capture the per-swap protocol transfer; DeFiLlama Solana match at 6% confirms fees are in the swap transaction, not a separate settlement." + +findings: + - "{{best_name}} has the highest observed take rate at {{best_p50}} over the last 24h." + - "Axiom leads on absolute protocol revenue at roughly $800K per day, consistent with its 0.9% stated fee applied across high-volume memecoin flow." + - "Using the self-consistent fee-paying volume denominator (Dune tx join), Axiom shows ~1.25%, GMGN ~1.73%, Trojan ~1.35%, and Maestro ~1.87%, clustered closer to stated rates than the Mobula-volume figures, which are depressed by volume attribution breadth." + - "pump.fun cut its bonding-curve trading fees to 0% in August 2026; its observed take rate has dropped near zero as protocol fee revenue is now only graduation fees. Creator and LP fee shares flow to separate recipients." + - "GMGN and Trojan show ~8% coverage (fee-paying volume / Mobula volume), meaning only 8% of attributed volume generates a fee inflow. Their Dune fee captures match DeFiLlama Solana within 5-6%, confirming the wallet list is complete." + - "Fomo fees include both on-chain USDC sweeps and off-chain relay fees; the latter are not directly capturable via Dune without access to Fomo's private data feed." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/memecoin-platforms + +prometheus: + window: 24h + freshness_metric: memecoin_platform_health + +faq: + - q: "What is a take rate?" + a: "Take rate is the fraction of trading volume captured as protocol revenue: fees_usd_24h / volume_usd_24h * 100. A platform with a 1% per-trade fee and full fee coverage on all attributed volume would show a 1% take rate. Lower rates indicate either a lower stated fee, waived fees on some trades, or a volume denominator that includes trades where no fee was charged." + - q: "Why is GMGN's take rate 0.14% if they charge 1%?" + a: "GMGN charges 1% per trade, and their fee wallets do collect ~$155K/day on Solana, matching DeFiLlama's Solana-only figure within 5%. But Mobula's lighthouse attributes $108M in volume to GMGN, which is broader than the volume on which GMGN actually collects a fee. The 0.14% figure is the ratio of fees to total attributed volume, not the per-trade fee rate. GMGN's total revenue across all chains (BSC, Base, Ethereum, Monad, etc.) is ~$772K/day per DeFiLlama." + - q: "Why does pump.fun show a near-zero take rate?" + a: "pump.fun cut its bonding-curve trading fees to 0% in August 2026, eliminating its per-swap protocol revenue. The Dune fee wallet monitors now capture primarily token graduation fees rather than trading fees. The byLaunchpad volume denominator remains high ($100M+), so the computed take rate is near zero. Pre-August 2026, pump.fun ran a 0.95% protocol share on buys and sells, producing ~0.45% observed take rate." + - q: "Why is Fomo sourced from DeFiLlama instead of Dune directly?" + a: "Fomo collects fees in two ways: on-chain USDC sweeps to a fee wallet, and off-chain relay fees reported to a private Dune table (dune.tryfomo.fomo_relay_fees) that only Fomo's team can write to. DeFiLlama has access to this private table via a partnership. Without it, on-chain monitoring alone would capture only a fraction of Fomo's revenue." + - q: "How reliable are the fee wallet addresses?" + a: "GMGN's 9 Solana wallets are verified by DeFiLlama's adapter (reviewed 2026-06) as pure fee collectors with no inter-wallet flows. Our 5% match to DeFiLlama Solana confirms the list is current. Axiom, Trojan, Photon, and Maestro wallets were identified by reverse-engineering tagged trades and cross-checked against DeFiLlama. A monthly diff of the DeFiLlama adapter detects any rotation." + - q: "What is memecoin_platform_fee_paying_rate_pct and how does it differ from fee_rate_pct?" + a: "memecoin_platform_fee_rate_pct uses Mobula's total attributed volume as the denominator, which is a lower bound because Mobula attributes more trades to each platform than those that actually paid a fee. memecoin_platform_fee_paying_rate_pct uses only the volume of trades that generated a fee inflow (from Dune: for each fee-receiving transaction, we look up the largest swap in dex_solana.trades via tx_id match and sum those). This denominator is self-consistent with the fee numerator, so the resulting rate is comparable across platforms and approximates the actual per-trade fee. memecoin_platform_coverage_pct shows what fraction of Mobula volume actually generated a fee." + - q: "What is memecoin_dune_data_freshness_seconds?" + a: "It is the Unix timestamp of the most recent block_time seen in the Dune query results. If Dune's solana.account_activity table lags, the nominal 24-hour window silently shrinks. A gap of more than 4 hours between this metric and the current time indicates stale data." + +providers: + - slug: axiom + name: Axiom + tag: "Solana trading terminal, 0.9% fee" + formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Fees: native SOL inflows to 20 known fee wallets. Fee-paying volume: largest swap in each fee-receiving tx from dex_solana.trades." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="axiom"} + p90: memecoin_platform_fee_paying_rate_pct{platform="axiom"} + p99: memecoin_platform_fee_paying_rate_pct{platform="axiom"} + mean: memecoin_platform_fee_paying_rate_pct{platform="axiom"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="axiom"} + series: memecoin_platform_fee_paying_rate_pct{platform="axiom"} + + - slug: fomo + name: Fomo + tag: "Solana mobile trading app" + formula: "24h protocol fee revenue (DeFiLlama Solana) / 24h volume (Mobula lighthouse). Fomo fees include on-chain USDC + off-chain relay fees via DeFiLlama; fee-paying volume not available for Fomo." + queries: + p50: memecoin_platform_fee_rate_pct{platform="fomo"} + p90: memecoin_platform_fee_rate_pct{platform="fomo"} + p99: memecoin_platform_fee_rate_pct{platform="fomo"} + mean: memecoin_platform_fee_rate_pct{platform="fomo"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="fomo"} + series: memecoin_platform_fee_rate_pct{platform="fomo"} + + - slug: pump-fun + name: pump.fun + tag: "Solana meme launchpad + PumpSwap AMM" + formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Captures protocol share (0.95%) on bonding curve and PumpSwap trades; creator and LP shares excluded." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"} + p90: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"} + p99: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"} + mean: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="pump-fun"} + series: memecoin_platform_fee_paying_rate_pct{platform="pump-fun"} + + - slug: gmgn + name: GMGN + tag: "Solana AI trading terminal, 1% fee" + formula: "24h Solana protocol fee revenue (Dune, 9 wallet addresses) / 24h fee-paying volume (Dune tx join). Total revenue across all chains is ~5x higher per DeFiLlama." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="gmgn"} + p90: memecoin_platform_fee_paying_rate_pct{platform="gmgn"} + p99: memecoin_platform_fee_paying_rate_pct{platform="gmgn"} + mean: memecoin_platform_fee_paying_rate_pct{platform="gmgn"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="gmgn"} + series: memecoin_platform_fee_paying_rate_pct{platform="gmgn"} + + - slug: photon + name: Photon + tag: "Solana trading terminal, ~1% fee" + formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join)." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="photon"} + p90: memecoin_platform_fee_paying_rate_pct{platform="photon"} + p99: memecoin_platform_fee_paying_rate_pct{platform="photon"} + mean: memecoin_platform_fee_paying_rate_pct{platform="photon"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="photon"} + series: memecoin_platform_fee_paying_rate_pct{platform="photon"} + + - slug: trojan + name: Trojan + tag: "Solana Telegram bot, ~0.9% fee" + formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join). Fee is collected in the swap transaction; DeFiLlama Solana match within 6% confirms capture." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="trojan"} + p90: memecoin_platform_fee_paying_rate_pct{platform="trojan"} + p99: memecoin_platform_fee_paying_rate_pct{platform="trojan"} + mean: memecoin_platform_fee_paying_rate_pct{platform="trojan"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="trojan"} + series: memecoin_platform_fee_paying_rate_pct{platform="trojan"} + + - slug: maestro + name: Maestro + tag: "Solana Telegram bot, ~1% fee" + formula: "24h protocol fee revenue (Dune wallet inflows) / 24h fee-paying volume (Dune tx join)." + queries: + p50: memecoin_platform_fee_paying_rate_pct{platform="maestro"} + p90: memecoin_platform_fee_paying_rate_pct{platform="maestro"} + p99: memecoin_platform_fee_paying_rate_pct{platform="maestro"} + mean: memecoin_platform_fee_paying_rate_pct{platform="maestro"} + success: memecoin_platform_health + sample_size: memecoin_platform_fees_usd_24h{platform="maestro"} + series: memecoin_platform_fee_paying_rate_pct{platform="maestro"} diff --git a/benchmarks/monad-rpc.yml b/benchmarks/monad-rpc.yml index 8a14e1a5..0f4885d8 100644 --- a/benchmarks/monad-rpc.yml +++ b/benchmarks/monad-rpc.yml @@ -47,7 +47,7 @@ methodology: findings: - "{{best_name}} currently leads the free Monad RPC field at {{best_p50}} (p50, 24h) across 5 measured providers." - - "{{name:monad-official}} answers at {{p50:monad-official}}. The Foundation fronts its primary endpoint with QuickNode infrastructure and rate-limits at 25 rps, generous for development traffic." + - "{{name:monad-official}} answers at {{p50:monad-official}}. On-chain response headers (`x-node-id: monad_monad-mainnet_fra`) confirm geo-distributed nodes co-located with our Railway probes in GCP datacenters (Frankfurt for eu-west, equivalent US and Singapore nodes). The sub-2 ms figure reflects network co-location, not what a developer outside GCP infra will experience — expect 30-120 ms from a typical connection. Rate-limit: 25 rps." - "{{name:drpc}} sits at {{p50:drpc}} and {{name:tenderly}} at {{p50:tenderly}}: the multi-chain gateways reached Monad within weeks of mainnet, but route through their global meshes rather than chain-local nodes." - "{{name:bloxroute}} at {{p50:bloxroute}} is the outlier entry: a BDN operator exposing a keyless endpoint, unusual for a company whose core product is paid low-latency access." diff --git a/benchmarks/moonriver-rpc.yml b/benchmarks/moonriver-rpc.yml index a8f62b3e..e62325da 100644 --- a/benchmarks/moonriver-rpc.yml +++ b/benchmarks/moonriver-rpc.yml @@ -4,7 +4,7 @@ slug: moonriver-rpc number: "135" title: Fastest free Moonriver RPC, live no-key EVM endpoint latency seo_title: "Fastest free Moonriver RPC 2026" -seo_description: "{{best_name}} leads free Moonriver RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 4 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Moonriver RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 3 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for the `eth_getBlockByNumber(\"latest\", false)` method against every free, no-key public Moonriver EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Moonriver is the Kusama canary network for Moonbeam (chain 1285), a full EVM parachain with Ethereum-compatible tooling, MOVR gas token, ~12 s block time under Nominated Proof of Stake, serving as the staging ground for Moonbeam features before they reach Polkadot. The 2026-08-03 audit confirms 4 clean keyless providers: the Moonbeam Foundation official endpoint (rpc.api.moonriver.moonbeam.network), PublicNode (moonriver-rpc.publicnode.com), OnFinality (moonriver.api.onfinality.io/public), and UnitedBloc (moonriver.unitedbloc.com). Excluded: Ankr (requires key for Moonriver), dRPC (no moonriver route). + Moonriver is the Kusama canary network for Moonbeam (chain 1285), a full EVM parachain with Ethereum-compatible tooling, MOVR gas token, ~12 s block time under Nominated Proof of Stake, serving as the staging ground for Moonbeam features before they reach Polkadot. The 2026-08-03 audit confirms 3 clean keyless providers: the Moonbeam Foundation official endpoint (rpc.api.moonriver.moonbeam.network), PublicNode (moonriver-rpc.publicnode.com), and OnFinality (moonriver.api.onfinality.io/public). Excluded: UnitedBloc (moonriver.unitedbloc.com DNS dead), Ankr (requires key for Moonriver), dRPC (no moonriver route). abstract: | Per-chain member of the RPC latency cluster, extended to Moonriver. We measure the round-trip latency of a single, identical JSON-RPC call (`eth_getBlockByNumber(\"latest\", false)`) against every no-key public - Moonriver endpoint that sustains continuous probing, 4 providers - measured, every 60 seconds, from us-east, eu-west and Singapore. The + Moonriver endpoint that sustains continuous probing, 3 providers, + every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with a Moonriver-scaled staleness gap (20 blocks at Moonriver's ~12 s block time). The cross-chain view @@ -35,16 +35,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"moonriver\"`. Provider coverage: 4 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"moonriver\"`. Provider coverage: 3 no-key endpoints. UnitedBloc removed (DNS dead)." findings: - - "{{best_name}} currently leads free Moonriver RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Moonriver RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." faq: - q: "What is the fastest free Moonriver RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 4 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." - q: "Which Moonriver RPCs work without an API key?" - a: "4 endpoints sustain continuous keyless probing: Moonbeam Foundation official (rpc.api.moonriver.moonbeam.network), PublicNode (moonriver-rpc.publicnode.com), OnFinality (moonriver.api.onfinality.io/public), and UnitedBloc (moonriver.unitedbloc.com). Every listed endpoint was live-verified with a `eth_getBlockByNumber` POST returning a parsable response before inclusion." + a: "3 endpoints sustain continuous keyless probing: Moonbeam Foundation official (rpc.api.moonriver.moonbeam.network), PublicNode (moonriver-rpc.publicnode.com), and OnFinality (moonriver.api.onfinality.io/public). UnitedBloc (moonriver.unitedbloc.com) was removed after its DNS went dead." - q: "Does the fastest Moonriver RPC change by region?" a: "Often. Public infra concentrates in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Moonriver RPC latency measured here, technically?" @@ -135,25 +135,3 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="moonriver", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="moonriver", region="sgp"}[1h]) - - slug: unitedbloc - name: UnitedBloc - tag: UnitedBloc community node operator public RPC for Moonriver - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to UnitedBloc's no-key moonriver RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="unitedbloc", chain="moonriver"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="unitedbloc", chain="moonriver"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="unitedbloc", chain="moonriver"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="unitedbloc", chain="moonriver"}) / sum(ocb:rpc_call:rate_24h{provider="unitedbloc", chain="moonriver"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="unitedbloc", chain="moonriver"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unitedbloc", chain="moonriver", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="unitedbloc", chain="moonriver", region="sgp"}[1h]) diff --git a/benchmarks/oraichain-rpc.yml b/benchmarks/oraichain-rpc.yml index 36fe5517..dd578387 100644 --- a/benchmarks/oraichain-rpc.yml +++ b/benchmarks/oraichain-rpc.yml @@ -107,26 +107,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="orai-official", chain="oraichain", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="orai-official", chain="oraichain", region="sgp"}[1h]) - - - slug: lavenderfive - name: LavenderFive - tag: LavenderFive validator and RPC provider for Cosmos chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single Tendermint status POST sent every 60s from 3 regions to LavenderFive's no-key Oraichain endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="oraichain"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lavenderfive", chain="oraichain"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lavenderfive", chain="oraichain"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lavenderfive", chain="oraichain"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="lavenderfive", chain="oraichain"}) / sum(ocb:rpc_call:rate_24h{provider="lavenderfive", chain="oraichain"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="lavenderfive", chain="oraichain"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="oraichain"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="oraichain", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="oraichain", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="oraichain", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="oraichain", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lavenderfive", chain="oraichain", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="lavenderfive", chain="oraichain", region="sgp"}[1h]) diff --git a/benchmarks/peaq-rpc.yml b/benchmarks/peaq-rpc.yml index 7a7c34f4..792d0ac2 100644 --- a/benchmarks/peaq-rpc.yml +++ b/benchmarks/peaq-rpc.yml @@ -85,29 +85,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="peaq", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="peaq", region="sgp"}[1h]) - - slug: peaq-official - name: Peaq Foundation - tag: Peaq Foundation official public RPC endpoint - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions to Peaq Foundation's no-key endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="peaq-official", chain="peaq"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="peaq-official", chain="peaq"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="peaq-official", chain="peaq"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="peaq-official", chain="peaq"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="peaq-official", chain="peaq"}) / sum(ocb:rpc_call:rate_24h{provider="peaq-official", chain="peaq"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="peaq-official", chain="peaq"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="peaq-official", chain="peaq"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="peaq-official", chain="peaq", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="peaq-official", chain="peaq", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="peaq-official", chain="peaq", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="peaq-official", chain="peaq", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="peaq-official", chain="peaq", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="peaq-official", chain="peaq", region="sgp"}[1h]) - - slug: onfinality name: OnFinality tag: OnFinality Substrate public gateway for Peaq diff --git a/benchmarks/perp-cost-slope.yml b/benchmarks/perp-cost-slope.yml index ee8f3802..f8787ac6 100644 --- a/benchmarks/perp-cost-slope.yml +++ b/benchmarks/perp-cost-slope.yml @@ -87,9 +87,8 @@ per_chain_explainer: or higher. findings: - - "{{best_name}} leads the cost-slope leaderboard at {{best_p50}} (24h average). A slope of 1.00 means the venue charges the same basis points per unit of notional at $1M as at $1k." - - "{{name:gains}} and {{name:gmx}} are the only venues in the cohort that structurally score 1.00: they use oracle pricing with no orderbook, so the fee is a flat percentage of position size regardless of trade size." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} cost slope (24h avg). The deepest onchain orderbook in the cohort keeps its impact low, but it still exceeds 1.00 at $1M notional on most assets." + - "{{name:gains}} and {{name:gmx}} always score 1.00 by construction, not by performance: oracle-priced venues charge a flat percentage of notional with no orderbook, so the $1M all-in is identical to the $1k all-in in basis points regardless of market conditions. Their top leaderboard position reflects fee structure, not execution quality." + - "Among orderbook venues, {{name:hyperliquid}} sits at {{p50:hyperliquid}} cost slope (24h avg) — the lowest impact in the cohort at $1M notional, driven by the deepest onchain book." - "Venues showing N/A in the slope column cannot reliably fill a $1M market order. The fill-rate panel shows what fraction of measurement cycles the $1M tier was successfully absorbed." - "The cost slope is asset-specific: use the chain tabs to compare ETH (deepest books), BTC (tightest books) and SOL (thinnest books) separately." diff --git a/benchmarks/perp-protocol-longevity.yml b/benchmarks/perp-protocol-longevity.yml index efedea82..f17f01b3 100644 --- a/benchmarks/perp-protocol-longevity.yml +++ b/benchmarks/perp-protocol-longevity.yml @@ -59,7 +59,7 @@ methodology: - "Incident criteria: direct theft or permanent loss of user funds via a documented smart contract vulnerability or privileged key compromise. Oracle manipulation without contract exploit is excluded. Market-structure events (liquidation cascades, large-position forced close) are excluded." - "gains.trade: launched 2021-12-01. No incidents in tracked sources as of registry date 2026-08-02. Clean streak = days since 2021-12-01." - "GMX v2: launched 2023-08-01. Incident 2025-07-09: reentrancy in vault and order-flow logic, estimated loss USD 42 000 000, source coinperps.xyz post-mortem. Clean streak = days since 2025-07-09." - - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event (large-position liquidation cascade triggering a governance emergency), not a smart contract exploit, and is excluded per criteria." + - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event, not a smart contract exploit, and is excluded per criteria. DeFiLlama enrichment filters entries with no confirmed financial loss (amount null or 0) to avoid false positives such as unrelated projects that share a name substring." - "dYdX v4: launched on the Cosmos appchain 2023-10-01. No protocol exploit on v4. dYdX v1 and v2 on Ethereum had oracle manipulation events that are excluded here (different contract, different era)." - "Lighter: launched 2023-07-01. No incidents in tracked sources." - "Paradex: launched 2023-10-01. No incidents in tracked sources." diff --git a/benchmarks/ronin-rpc.yml b/benchmarks/ronin-rpc.yml index 13931024..72e4edc6 100644 --- a/benchmarks/ronin-rpc.yml +++ b/benchmarks/ronin-rpc.yml @@ -4,7 +4,7 @@ slug: ronin-rpc number: "110" title: Fastest free Ronin RPC, live no-key EVM endpoint latency seo_title: "Fastest free Ronin RPC 2026" -seo_description: "{{best_name}} leads free Ronin RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 4 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Ronin RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 3 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for the `eth_getBlockByNumber(\"latest\", false)` method against every free, no-key public Ronin EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Ronin is an EVM L1 (chain 2020) operated by Sky Mavis, purpose-built for Web3 gaming and home of Axie Infinity, Pixels and a broader gaming ecosystem. RON gas token, delegated proof-of-stake validator set, 3 s block cadence. 4 clean keyless providers live-verified at launch: `ronin-official` (api.roninchain.com), dRPC, Tenderly Gateway, Thirdweb. Excluded by the launch audit: Ronin Tech secondary domain (DNS-fail), PublicNode (no `ronin` subdomain), lgns.net (DNS-fail). + Ronin is an EVM L1 (chain 2020) operated by Sky Mavis, purpose-built for Web3 gaming and home of Axie Infinity, Pixels and a broader gaming ecosystem. RON gas token, delegated proof-of-stake validator set, 3 s block cadence. 3 clean keyless providers: `ronin-official` (api.roninchain.com), Tenderly Gateway, Thirdweb. Excluded: dRPC (500 persistent internal error after 3 consecutive attempts), Ronin Tech secondary domain (DNS-fail), PublicNode (no `ronin` subdomain), lgns.net (DNS-fail). abstract: | Per-chain member of the RPC latency cluster, extended to Ronin. We measure the round-trip latency of a single, identical JSON-RPC call (`eth_getBlockByNumber(\"latest\", false)`) against every no-key public - Ronin endpoint that sustains continuous probing, 4 providers - at launch, every 60 seconds, from us-east, eu-west and Singapore. The + Ronin endpoint that sustains continuous probing, 3 providers, + every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with a Ronin-scaled staleness gap (25 blocks at Ronin's ~3 s block time). The cross-chain view @@ -35,16 +35,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 25 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"ronin\"`. Provider coverage at launch: 4 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"ronin\"`. Provider coverage: 3 no-key endpoints. dRPC removed (500 persistent internal error)." findings: - - "{{best_name}} currently leads free Ronin RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Ronin RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." faq: - q: "What is the fastest free Ronin RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 4 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." - q: "Which Ronin RPCs work without an API key?" - a: "4 endpoints sustain continuous keyless probing at launch. Every listed endpoint was live-verified with a `eth_getBlockByNumber` POST returning a parsable response before inclusion. See the methodology section for the endpoints that were audited and excluded." + a: "3 endpoints sustain continuous keyless probing: Sky Mavis official, Tenderly Gateway, Thirdweb. dRPC was removed after persistent 500 internal errors. Every listed endpoint was live-verified with a `eth_getBlockByNumber` POST returning a parsable response before inclusion." - q: "Does the fastest Ronin RPC change by region?" a: "Often. Public infra concentrates in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Ronin RPC latency measured here, technically?" @@ -89,29 +89,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ronin-official", chain="ronin", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="ronin-official", chain="ronin", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: dRPC public gateway routing to Ronin node pool - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key ronin RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ronin"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="ronin"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="ronin"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="ronin"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="ronin"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="ronin"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="ronin"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ronin"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ronin", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ronin", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ronin", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ronin", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="ronin", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="ronin", region="sgp"}[1h]) - - slug: tenderly name: Tenderly Gateway tag: Tenderly Gateway public RPC for Ronin diff --git a/benchmarks/sei-rpc.yml b/benchmarks/sei-rpc.yml index 933eaf48..97776621 100644 --- a/benchmarks/sei-rpc.yml +++ b/benchmarks/sei-rpc.yml @@ -4,7 +4,7 @@ slug: sei-rpc number: "108" title: Fastest free Sei RPC, live no-key EVM endpoint latency seo_title: "Fastest free Sei RPC 2026" -seo_description: "{{best_name}} leads free Sei RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 4 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Sei RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h). 3 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for the `eth_getBlockByNumber(\"latest\", false)` method against every free, no-key public Sei EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,14 +14,14 @@ unit: ms higher_is_better: false seo_intro: | - Sei is a Cosmos SDK L1 (chain 1329) with a parallel-execution EVM layer bolted onto Tendermint consensus, launched by Sei Labs and positioned as a high-throughput trading chain. Standard EVM JSON-RPC on the EVM side; the Cosmos JSON-RPC would need a separate `Kind:"cosmos"` cluster entry as we did for Osmosis. Excluded by the launch audit: PublicNode (no `sei-evm` subdomain), Tenderly public (no `sei` route), BlockPI (`Apikey not found`), MeowRPC (defunct), AllThatNode + Basement Nodes + Node75 (host unresolvable or key-gated). The 2026-07-03 sweep had originally excluded Sei because dRPC cached `eth_blockNumber`; the re-audit on 2026-07-26 confirms dRPC no longer caches and Thirdweb + Stakeme have come online, restoring 4 clean keyless providers. Every listed provider live-verified with 4 consecutive keyless probes at re-launch. + Sei is a Cosmos SDK L1 (chain 1329) with a parallel-execution EVM layer bolted onto Tendermint consensus, launched by Sei Labs and positioned as a high-throughput trading chain. Standard EVM JSON-RPC on the EVM side; the Cosmos JSON-RPC would need a separate `Kind:"cosmos"` cluster entry as we did for Osmosis. 3 clean keyless providers measured: sei-official, Thirdweb, Stakeme. Excluded: dRPC (500 persistent internal error after 3 consecutive attempts), PublicNode (no `sei-evm` subdomain), Tenderly public (no `sei` route), BlockPI (`Apikey not found`), MeowRPC (defunct), AllThatNode + Basement Nodes + Node75 (host unresolvable or key-gated). abstract: | Per-chain member of the RPC latency cluster, extended to Sei. We measure the round-trip latency of a single, identical JSON-RPC call (`eth_getBlockByNumber(\"latest\", false)`) against every no-key public - Sei endpoint that sustains continuous probing, 4 providers - at launch, every 60 seconds, from us-east, eu-west and Singapore. The + Sei endpoint that sustains continuous probing, 3 providers, + every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with a Sei-scaled staleness gap (25 blocks at Sei's ~1 s block time). The cross-chain view @@ -35,16 +35,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 25 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"sei\"`. Provider coverage at launch: 4 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"sei\"`. Provider coverage: 3 no-key endpoints. dRPC removed (500 persistent internal error)." findings: - - "{{best_name}} currently leads free Sei RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Sei RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." faq: - q: "What is the fastest free Sei RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 4 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber(\"latest\", false)` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples. Use the region tabs to see the leader from the origin closest to your deployment." - q: "Which Sei RPCs work without an API key?" - a: "4 endpoints sustain continuous keyless probing at launch. Every listed endpoint was live-verified with a `eth_getBlockByNumber` POST returning a parsable response before inclusion. See the methodology section for the endpoints that were audited and excluded." + a: "3 endpoints sustain continuous keyless probing: Sei Labs official, Thirdweb, and Stakeme. dRPC was removed after persistent 500 internal errors. Every listed endpoint was live-verified with a `eth_getBlockByNumber` POST returning a parsable response before inclusion." - q: "Does the fastest Sei RPC change by region?" a: "Often. Public infra concentrates in specific regions; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is Sei RPC latency measured here, technically?" @@ -89,29 +89,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sei-official", chain="sei", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="sei-official", chain="sei", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: dRPC gateway routing to Sei EVM node pool - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key sei RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sei"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="sei"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="sei"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="sei"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="sei"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="sei"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="sei"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sei"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sei", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sei", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sei", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sei", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sei", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sei", region="sgp"}[1h]) - - slug: thirdweb name: Thirdweb tag: Thirdweb public RPC gateway for Sei EVM diff --git a/benchmarks/solana-avg-trade-size.yml b/benchmarks/solana-avg-trade-size.yml new file mode 100644 index 00000000..b2e22708 --- /dev/null +++ b/benchmarks/solana-avg-trade-size.yml @@ -0,0 +1,198 @@ +# OpenChainBench. Bench 206 + +slug: solana-avg-trade-size +number: "206" +title: "Average swap size on Solana: pump.fun vs GMGN vs Fomo vs Axiom, live" +seo_title: "Average trade size by Solana trading platform 2026: pump.fun vs GMGN vs Axiom vs Fomo" +seo_description: "{{best_name}} has the largest average swap size at ${{best_p50}} per trade. pump.fun vs GMGN vs Axiom vs Fomo vs Trojan ranked by average trade size, live from Mobula lighthouse." +subtitle: Live average swap size in USD per attributed transaction for each Solana trading platform and launchpad. Polled every 15 minutes from Mobula lighthouse. Reveals the trader profile of each platform, from retail micro-trades to sophisticated large-order flow. + +category: Trading +status: live +metric: Avg swap size +unit: usd +higher_is_better: true + +disclaimer: | + Average swap size is computed as 24h attributed volume divided by 24h attributed trade count from Mobula lighthouse. Both numerator and denominator include bot and MEV activity; the metric reflects all on-chain swaps attributed to each platform, not human-only trades. pump.fun volume comes from the byLaunchpad bucket (bonding-curve swaps via pump.fun's own frontend); all other platforms come from byPlatform. Attribution is mutually exclusive per Mobula's referral-tag methodology. + +seo_intro: | + The average swap size on Solana varies by several multiples across platforms, + revealing sharply different trader profiles. pump.fun consistently sits at the + bottom of the leaderboard, reflecting the micro-cap degen activity of its + bonding-curve launchpad and the heavy bot sniping that inflates trade count while + compressing average size. GMGN and Trojan serve more sophisticated traders running + larger positions and consistently rank at the top. Fomo sits in the middle, + positioning between retail and professional. This benchmark tracks average swap + size live across all major Solana trading frontends and launchpads, updated every + 15 minutes from Mobula lighthouse attribution data. + +abstract: | + The harness polls Mobula lighthouse (api.mobula.io/api/2/market/lighthouse) every + 15 minutes and records volumeUSD.24h and trades.24h for each platform and launchpad. + Average swap size is derived as volumeUSD.24h divided by trades.24h. The harness + exposes solana_platform_volume_24h_usd, solana_platform_trades_24h and + solana_platform_health per platform. pump.fun is sourced from byLaunchpad and + cross-published into the platform gauge; all others come from byPlatform. + +methodology: + - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), poll every 15 min." + - "Average swap size: solana_platform_volume_24h_usd / solana_platform_trades_24h, computed in PromQL." + - "Attribution: Mobula assigns each on-chain swap to a platform via referral tags, program IDs and memo fields embedded in transactions. Each swap is attributed to exactly one platform." + - "Bot inclusion: all attributed swaps are counted including bots and MEV. Platforms with high bot activity (notably pump.fun snipers) show lower average sizes than human-only baselines would suggest." + - "pump.fun: sourced from byLaunchpad (bonding-curve swaps on pump.fun's own site). Trades routed through GMGN or Axiom to pump.fun tokens count under those platforms." + - "Meteora DBC excluded: Meteora DBC counts LP provision events and micro-rebalances as trades, making its avg size of ~$12 not comparable to pure swap activity." + +findings: + - "{{best_name}} attracts the largest average swap at ${{best_p50}} per transaction." + - "pump.fun has the smallest average swap size in the set, dominated by bot sniping and micro-cap degen retail flow on its bonding-curve launchpad." + - "GMGN and Trojan consistently lead on average swap size, serving more sophisticated traders with larger position sizes." + - "Fomo sits mid-market between retail launchpad activity and professional terminals." + - "Trade count tells the other story: pump.fun processes more trades per day than all other platforms combined." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-launchpad-wars + +prometheus: + window: 24h + freshness_metric: solana_platform_trades_24h + +faq: + - q: "What does average swap size measure?" + a: "Average swap size is total 24h attributed volume divided by total 24h attributed trade count. It reflects the typical notional size of a single swap on each platform. A platform with many small trades will show a low average; one with fewer but larger trades will show a high average." + - q: "Why does pump.fun have so many trades?" + a: "pump.fun's bonding curve generates enormous transaction volume from sniping bots, copy-trade bots and retail buyers all racing to buy newly launched tokens in the first seconds. The 3.7 million daily trades include a large proportion of automated activity, which also drags the average size down toward $35." + - q: "Is this apple-to-apple between pump.fun and the other platforms?" + a: "Directionally yes. Mobula attributes each swap to exactly one frontend using referral tags. A pump.fun token bought via GMGN counts under GMGN, not pump.fun. So both reflect genuine frontend-attributed activity. The difference is that pump.fun's activity skews heavily toward bots and micro-trades on bonding-curve launches, while GMGN and Axiom serve more secondary-market trading." + - q: "Why is Fomo's average trade size lower than GMGN despite similar positioning?" + a: "Fomo attracts a broader retail base through its social copy-trading and wallet features, which pulls the average down compared to GMGN's power-trader demographic. Fomo also has notably low protocol fees in Mobula's data relative to DeFiLlama, suggesting part of its fee model may not be captured by on-chain referral attribution." + +providers: + - slug: pump-fun + name: pump.fun + tag: "Solana meme launchpad" + formula: "Average swap size in USD on pump.fun's bonding curve (solana_platform_volume_24h_usd{platform=pump-fun} / solana_platform_trades_24h{platform=pump-fun}, Mobula lighthouse byLaunchpad, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"} + p90: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"} + p99: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"} + mean: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"} + success: solana_platform_health{platform="pump-fun"} + sample_size: solana_platform_trades_24h{platform="pump-fun"} + series: solana_platform_volume_24h_usd{platform="pump-fun"} / solana_platform_trades_24h{platform="pump-fun"} + + - slug: gmgn + name: GMGN + tag: "AI trading terminal on Solana" + formula: "Average swap size in USD routed through GMGN (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"} + p90: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"} + p99: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"} + mean: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"} + success: solana_platform_health{platform="gmgn"} + sample_size: solana_platform_trades_24h{platform="gmgn"} + series: solana_platform_volume_24h_usd{platform="gmgn"} / solana_platform_trades_24h{platform="gmgn"} + + - slug: trojan + name: Trojan + tag: "Telegram trading bot on Solana" + formula: "Average swap size in USD routed through Trojan (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"} + p90: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"} + p99: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"} + mean: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"} + success: solana_platform_health{platform="trojan"} + sample_size: solana_platform_trades_24h{platform="trojan"} + series: solana_platform_volume_24h_usd{platform="trojan"} / solana_platform_trades_24h{platform="trojan"} + + - slug: maestro + name: Maestro + tag: "Telegram trading bot on Solana" + formula: "Average swap size in USD routed through Maestro (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"} + p90: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"} + p99: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"} + mean: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"} + success: solana_platform_health{platform="maestro"} + sample_size: solana_platform_trades_24h{platform="maestro"} + series: solana_platform_volume_24h_usd{platform="maestro"} / solana_platform_trades_24h{platform="maestro"} + + - slug: fomo + name: Fomo + tag: "Solana trading platform" + formula: "Average swap size in USD routed through Fomo (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"} + p90: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"} + p99: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"} + mean: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"} + success: solana_platform_health{platform="fomo"} + sample_size: solana_platform_trades_24h{platform="fomo"} + series: solana_platform_volume_24h_usd{platform="fomo"} / solana_platform_trades_24h{platform="fomo"} + + - slug: axiom + name: Axiom + tag: "Web trading terminal on Solana" + formula: "Average swap size in USD routed through Axiom (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"} + p90: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"} + p99: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"} + mean: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"} + success: solana_platform_health{platform="axiom"} + sample_size: solana_platform_trades_24h{platform="axiom"} + series: solana_platform_volume_24h_usd{platform="axiom"} / solana_platform_trades_24h{platform="axiom"} + + - slug: photon + name: Photon + tag: "Solana trading terminal" + formula: "Average swap size in USD routed through Photon (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"} + p90: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"} + p99: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"} + mean: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"} + success: solana_platform_health{platform="photon"} + sample_size: solana_platform_trades_24h{platform="photon"} + series: solana_platform_volume_24h_usd{platform="photon"} / solana_platform_trades_24h{platform="photon"} + + - slug: padre + name: Padre + tag: "AI Telegram bot on Solana" + formula: "Average swap size in USD routed through Padre (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"} + p90: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"} + p99: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"} + mean: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"} + success: solana_platform_health{platform="padre"} + sample_size: solana_platform_trades_24h{platform="padre"} + series: solana_platform_volume_24h_usd{platform="padre"} / solana_platform_trades_24h{platform="padre"} + + - slug: phantom + name: Phantom + tag: "Solana wallet, DEX aggregator" + formula: "Average swap size in USD routed through Phantom wallet (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"} + p90: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"} + p99: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"} + mean: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"} + success: solana_platform_health{platform="phantom"} + sample_size: solana_platform_trades_24h{platform="phantom"} + series: solana_platform_volume_24h_usd{platform="phantom"} / solana_platform_trades_24h{platform="phantom"} + + - slug: bloom + name: Bloom + tag: "Solana trading terminal" + formula: "Average swap size in USD routed through Bloom (Mobula lighthouse byPlatform, 15 min poll)." + queries: + p50: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"} + p90: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"} + p99: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"} + mean: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"} + success: solana_platform_health{platform="bloom"} + sample_size: solana_platform_trades_24h{platform="bloom"} + series: solana_platform_volume_24h_usd{platform="bloom"} / solana_platform_trades_24h{platform="bloom"} diff --git a/benchmarks/solana-dex-volume.yml b/benchmarks/solana-dex-volume.yml new file mode 100644 index 00000000..1d77fd23 --- /dev/null +++ b/benchmarks/solana-dex-volume.yml @@ -0,0 +1,200 @@ +# OpenChainBench. Bench 205 + +slug: solana-dex-volume +number: "205" +title: "Solana DEX volume: pump.fun vs Axiom vs GMGN vs Fomo, live 24h" +seo_title: "Solana DEX trading volume 2026: pump.fun vs Axiom vs GMGN vs Fomo vs Trojan vs Photon vs BullX" +seo_description: "{{best_name}} leads Solana DEX trading platforms at ${{best_p50}} 24h volume. pump.fun vs Axiom vs GMGN vs Fomo vs Trojan vs Photon vs BullX ranked live from DeFiLlama." +subtitle: Live 24-hour trading volume and protocol revenue for the top Solana DEX platforms. Updated every 30 minutes from DeFiLlama. The fee column shows what each protocol earns, not what users pay in gas. + +category: Trading +status: live +metric: 24h volume +unit: usd +higher_is_better: true + +disclaimer: | + Volume and revenue data sourced from DeFiLlama's DEX and fees APIs. DeFiLlama attributes volume via on-chain referral tags and program IDs; platforms without standard tags may be undercounted. Figures are 24-hour and 7-day rolling totals updated every 30 minutes. Revenue uses DeFiLlama's dailyRevenue dataType, stripping LP fees. Terminal volume (Axiom, GMGN, Fomo) and launchpad volume (pump.fun) use different attribution methods and are not directly additive. + +seo_intro: | + The Solana DEX market in summer 2026 is split between pump.fun's launchpad, + which dominates early-stage token trading, and a set of competing terminals + (Axiom, GMGN, Fomo) targeting active traders on graduated tokens. pump.fun + cut its bonding-curve trading fees to zero on August 7 2026 in response to + Fomo briefly overtaking Axiom in daily revenue the day before, reshaping the + competitive landscape overnight. Axiom and GMGN each process around $70 + million per day in terminal volume. Fomo reached $50 million daily volume and + crossed Axiom on revenue for the first time on August 6 2026. This benchmark + tracks all seven platforms in real time from DeFiLlama's DEX data feed, + refreshed every 30 minutes. + +abstract: | + The harness polls DeFiLlama's public DEX API (api.llama.fi/summary/dexs/{slug}) + and fees API (api.llama.fi/summary/fees/{slug}?dataType=dailyRevenue) every 30 + minutes for each tracked platform. No API key is required. DeFiLlama attributes + swap volume using on-chain referral tags and program IDs embedded in transactions. + The harness exposes six Prometheus gauges per platform: defillama_dex_volume_24h_usd, + defillama_dex_volume_7d_usd, defillama_dex_fees_24h_usd, defillama_dex_fees_7d_usd, + defillama_dex_take_rate and defillama_dex_health. + +methodology: + - "Source: DeFiLlama public DEX API (api.llama.fi/summary/dexs/{slug}) and fees API (api.llama.fi/summary/fees/{slug}?dataType=dailyRevenue). No authentication required." + - "Poll cadence: harness fetches each platform every 30 minutes and updates Prometheus gauges immediately." + - "Volume: total24h and total7d fields from DeFiLlama's DEX endpoint, sum of all swap notional values in USD attributed to this platform." + - "Revenue: total24h and total7d from the fees endpoint with dataType=dailyRevenue. This strips LP fees that stay in pool accounts and returns only the protocol's own share." + - "Take rate: protocol revenue divided by trading volume (defillama_dex_fees_24h_usd / defillama_dex_volume_24h_usd). Measures how much of each dollar traded the platform captures." + - "Attribution: DeFiLlama uses on-chain referral tags, program IDs and memo fields. Platforms without standard tagging may be undercounted." + - "pump.fun (slug pump.fun, layer Launchpad): the original bonding-curve launchpad. Users trade tokens directly on the curve; a token graduates to PumpSwap's AMM at roughly $69K market cap. Cut bonding-curve fees to zero on Aug 7 2026." + - "Axiom (slug axiom, layer Terminal): web-based Solana trading terminal. 0.25 to 0.5% platform fee on routed swaps. Revenue briefly overtaken by Fomo on Aug 6 2026." + - "GMGN (slug gmgn, layer Terminal): AI-powered trading terminal on Solana. Approximately 1% fee on routed swaps." + - "fomo Wallet (slug fomo-wallet, layer Terminal): Fomo's DEX routing layer. 0.5% platform fee. First crossed Axiom on daily revenue Aug 6 2026." + - "Trojan (slug trojan, layer Bot): Telegram trading bot on Solana. Lower volume than web terminals but serves a distinct Telegram-native user base." + - "Photon (slug photon, layer Terminal): Solana trading terminal targeted at advanced traders." + - "BullX (slug bullx, layer Terminal): Solana and multi-chain trading terminal. Volume fell sharply from its 2025 highs after competitors expanded; now a fraction of peak levels." + - "PumpSwap is pump.fun's backend AMM where graduated tokens trade. It is not tracked here because its volume overlaps with terminal volume: when Axiom or GMGN route a swap to PumpSwap, that trade appears in both the terminal's figures and PumpSwap's. Tracking PumpSwap alongside the terminals would double-count activity." + - "Phantom: Phantom Wallet is the largest Solana wallet by user count but does not have its own DeFiLlama DEX slug. Phantom routes swaps through Jupiter and other aggregators, which appear in those protocols' volume figures." + +findings: + - "{{best_name}} leads Solana DEX platforms at ${{best_p50}} 24h volume." + - "pump.fun dominates early-stage token volume with its bonding-curve launchpad, even after cutting trading fees to zero on Aug 7 2026." + - "Axiom and GMGN are the leading independent trading terminals, each routing around $70 million per day on graduated tokens." + - "Fomo briefly outpaced Axiom in daily revenue on Aug 6 2026, the event that triggered pump.fun's zero-fee response the following day." + - "The take rate column reveals the business model split: pump.fun earns on token launches and lifecycle fees, while Axiom, GMGN and Fomo charge frontend routing fees on each swap." + - "BullX is tracked but operates at a fraction of peak volume following market share losses in 2026." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-dex-volume + +prometheus: + window: 24h + freshness_metric: defillama_dex_volume_24h_usd + +metric_panels: + - id: fees + label: Protocol revenue (24h) + label_key: platform + metric: defillama_dex_fees_24h_usd + unit: usd + higher_is_better: true + description: "Protocol revenue captured by each platform in the last 24 hours (DeFiLlama dailyRevenue). Excludes LP fees that stay in pool accounts." + + - id: volume_7d + label: 7-day volume + label_key: platform + metric: defillama_dex_volume_7d_usd + unit: usd + higher_is_better: true + description: "Total DEX trading volume over the last 7 days." + + - id: take_rate + label: Take rate + label_key: platform + metric: defillama_dex_take_rate + unit: pct + higher_is_better: false + description: "Protocol revenue as a fraction of trading volume (revenue / volume). Higher means the platform captures more of each dollar traded." + +ledger_columns: + - { label: "24h Volume", slot: p50 } + - { label: "24h Revenue", panel: fees } + - { label: "7d Volume", panel: volume_7d } + +faq: + - q: "Why is PumpSwap not tracked here?" + a: "PumpSwap is pump.fun's backend AMM where tokens trade after graduating from the bonding curve. It is excluded because its volume overlaps with the terminal volume already tracked: when Axiom, GMGN or Fomo routes a swap to PumpSwap, that trade appears in both the terminal's DeFiLlama figures and PumpSwap's. Including PumpSwap alongside the terminals would double-count that activity and distort the comparison. The bench tracks platforms where users go directly to trade, not the underlying AMM they route through." + - q: "What is Fomo?" + a: "Fomo (fomo.family) is a Solana trading platform and wallet launched in April 2025 by a team backed by Index Ventures and USV. It charges 0.5% on routed swaps and briefly led Solana daily DEX revenue on August 6 2026, overtaking Axiom for the first time." + - q: "Why did pump.fun cut its fees to zero?" + a: "On August 6 2026, Fomo briefly overtook Axiom in daily protocol revenue, signaling that frontend trading terminals were capturing an increasing share of the Solana DEX market. pump.fun responded on August 7 by eliminating bonding-curve trading fees, making it cheaper to launch and trade new tokens on pump.fun than on competing platforms." + - q: "Why is Phantom not tracked here?" + a: "Phantom is the largest Solana wallet by user count but it routes swaps through aggregators like Jupiter rather than operating its own DEX contract. DeFiLlama does not assign Phantom a DEX slug because its on-chain footprint is the aggregator's, not Phantom's. Those swaps appear in Jupiter's figures. This bench tracks the platforms that DeFiLlama can attribute volume to directly." + - q: "How is this different from the Solana trading platform leaderboard (bench 201)?" + a: "Bench 201 uses Mobula's lighthouse API to attribute swaps to the frontend UI (GMGN, Axiom, Fomo, etc.). This bench uses DeFiLlama's protocol-level DEX data, which tracks on-chain referral tags and program IDs. The two data sources cross-validate each other and may differ when users route through multiple aggregators." + - q: "Why does Photon show less volume than GMGN and Axiom?" + a: "Photon targets advanced traders and has a smaller but higher-frequency user base. Volume varies by day and market conditions. DeFiLlama attribution may also undercount platforms that do not embed standard on-chain referral tags." + - q: "What is the take rate and why does it matter?" + a: "The take rate is protocol revenue divided by trading volume. It shows how much of each dollar traded the platform keeps. pump.fun has a high take rate because it earns creator fees, graduation fees and protocol fees across the token lifecycle. Terminals like Axiom, GMGN and Fomo earn a routing fee on each swap. Comparing take rates reveals which platforms extract more value per dollar of activity." + +providers: + - slug: pump-fun + name: pump.fun + tag: "Launchpad, Solana" + formula: "24h DEX trading volume in USD for pump.fun bonding curve (DeFiLlama summary/dexs/pump.fun, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="pump-fun"} + p90: defillama_dex_volume_24h_usd{platform="pump-fun"} + p99: defillama_dex_volume_24h_usd{platform="pump-fun"} + mean: defillama_dex_volume_24h_usd{platform="pump-fun"} + success: defillama_dex_health{platform="pump-fun"} + series: defillama_dex_volume_24h_usd{platform="pump-fun"} + + - slug: axiom + name: Axiom + tag: "Terminal, Solana" + formula: "24h DEX trading volume in USD for Axiom (DeFiLlama summary/dexs/axiom, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="axiom"} + p90: defillama_dex_volume_24h_usd{platform="axiom"} + p99: defillama_dex_volume_24h_usd{platform="axiom"} + mean: defillama_dex_volume_24h_usd{platform="axiom"} + success: defillama_dex_health{platform="axiom"} + series: defillama_dex_volume_24h_usd{platform="axiom"} + + - slug: gmgn + name: GMGN + tag: "Terminal, Solana" + formula: "24h DEX trading volume in USD for GMGN (DeFiLlama summary/dexs/gmgn, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="gmgn"} + p90: defillama_dex_volume_24h_usd{platform="gmgn"} + p99: defillama_dex_volume_24h_usd{platform="gmgn"} + mean: defillama_dex_volume_24h_usd{platform="gmgn"} + success: defillama_dex_health{platform="gmgn"} + series: defillama_dex_volume_24h_usd{platform="gmgn"} + + - slug: fomo + name: Fomo + tag: "Terminal, Solana" + formula: "24h DEX trading volume in USD for Fomo Wallet (DeFiLlama summary/dexs/fomo-wallet, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="fomo"} + p90: defillama_dex_volume_24h_usd{platform="fomo"} + p99: defillama_dex_volume_24h_usd{platform="fomo"} + mean: defillama_dex_volume_24h_usd{platform="fomo"} + success: defillama_dex_health{platform="fomo"} + series: defillama_dex_volume_24h_usd{platform="fomo"} + + - slug: trojan + name: Trojan + tag: "Bot, Telegram" + formula: "24h DEX trading volume in USD for Trojan (DeFiLlama summary/dexs/trojan, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="trojan"} + p90: defillama_dex_volume_24h_usd{platform="trojan"} + p99: defillama_dex_volume_24h_usd{platform="trojan"} + mean: defillama_dex_volume_24h_usd{platform="trojan"} + success: defillama_dex_health{platform="trojan"} + series: defillama_dex_volume_24h_usd{platform="trojan"} + + - slug: photon + name: Photon + tag: "Terminal, Solana" + formula: "24h DEX trading volume in USD for Photon (DeFiLlama summary/dexs/photon, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="photon"} + p90: defillama_dex_volume_24h_usd{platform="photon"} + p99: defillama_dex_volume_24h_usd{platform="photon"} + mean: defillama_dex_volume_24h_usd{platform="photon"} + success: defillama_dex_health{platform="photon"} + series: defillama_dex_volume_24h_usd{platform="photon"} + + - slug: bullx + name: BullX + tag: "Terminal, multi-chain" + formula: "24h DEX trading volume in USD for BullX (DeFiLlama summary/dexs/bullx, total24h, updated every 30 min)." + queries: + p50: defillama_dex_volume_24h_usd{platform="bullx"} + p90: defillama_dex_volume_24h_usd{platform="bullx"} + p99: defillama_dex_volume_24h_usd{platform="bullx"} + mean: defillama_dex_volume_24h_usd{platform="bullx"} + success: defillama_dex_health{platform="bullx"} + series: defillama_dex_volume_24h_usd{platform="bullx"} diff --git a/benchmarks/solana-launchpad-wars.yml b/benchmarks/solana-launchpad-wars.yml new file mode 100644 index 00000000..627b3d2c --- /dev/null +++ b/benchmarks/solana-launchpad-wars.yml @@ -0,0 +1,176 @@ +# OpenChainBench. Bench 200 + +slug: solana-launchpad-wars +number: "200" +title: "Solana meme launchpad leaderboard: PumpFun vs Flap vs Bankr, live 24h volume" +seo_title: "Solana meme launchpad comparison 2026: PumpFun, Flap, Bankr, LetsCash" +seo_description: "{{best_name}} leads Solana meme launchpads at ${{best_p50}} 24h volume. PumpFun vs Flap vs Bankr vs LetsCash vs Meteora DBC vs NOXA Fun ranked live from Mobula lighthouse data." +subtitle: Live 24-hour trading volume for each Solana meme coin launchpad. Polled every 15 minutes from Mobula lighthouse, which aggregates on-chain swap events across all bonding-curve programs on Solana. + +category: Trading +status: live +metric: 24h volume +unit: usd +higher_is_better: true + +disclaimer: | + Volume figures are sourced from Mobula's lighthouse aggregator, which indexes on-chain swap events. Data is refreshed every 15 minutes. A launchpad with zero volume may indicate no activity in the last 24 hours or a temporary data gap from Mobula. Fees shown are protocol-side fees detected on-chain; off-chain relay or referral fees are excluded. + +seo_intro: | + Solana's meme coin launchpad market is dominated by PumpFun but is far from a + monopoly. Flap, Bankr, LetsCash, NOXA Fun and Meteora DBC all run active + bonding-curve programs on Solana competing for the same launch traffic. This + benchmark tracks the 24-hour trading volume, protocol fees and trade count for + each platform in real time. PumpFun consistently leads with over 100 million + USD in daily volume. In August 2026 PumpFun cut its bonding-curve trading fees + to zero, so its daily protocol fee revenue now comes primarily from token + graduations rather than per-swap charges. Flap and Meteora DBC occupy the + challenger positions. The fee efficiency column (fees divided by volume) + reveals which launchpad extracts the most revenue per dollar traded. + +abstract: | + The harness polls the Mobula lighthouse endpoint (api.mobula.io/api/2/market/lighthouse) + every 15 minutes. Mobula aggregates all on-chain swap events into rolling windows + (15 min, 1 h, 6 h, 24 h) per launchpad and per trading platform. The harness + exposes three gauges per launchpad: solana_launchpad_volume_24h_usd, + solana_launchpad_fees_24h_usd and solana_launchpad_trades_24h. All launchpads + returned by the API are tracked automatically. The benchmark YAML selects the + eight highest-volume Solana-native launchpads as providers. + +methodology: + - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), byLaunchpad field. Mobula indexes all Solana bonding-curve programs and aggregates swap events into 24h rolling windows updated approximately every minute on Mobula's side." + - "Poll cadence: harness fetches the endpoint every 15 minutes and updates Prometheus gauges. Prometheus scrapes :2112/metrics every 60 seconds." + - "Volume: sum of all swap event notional values (in USD at time of swap) within the last 24 hours for swaps routed through this launchpad's program. Higher = more active market." + - "Fees: on-chain protocol fees collected by the launchpad program within the last 24 hours. Off-chain relay fees and referral fees are not included." + - "Trades: count of individual swap events within the last 24 hours." + - "Launchpad scope: this bench covers bonding-curve launchpads (PumpFun, Flap, Bankr, LetsCash, NOXA Fun, Meteora DBC, Raydium Launchlab). Trading frontends that route through multiple launchpads (GMGN, Axiom, Fomo, Trojan) are a separate bench." + - "PumpFun note: PumpFun volume here is bonding-curve activity only. Tokens that graduate from the bonding curve to PumpSwap AMM are counted separately under PumpSwap in the DEX view and are not included here." + +findings: + - "{{best_name}} leads Solana meme launchpads at ${{best_p50}} 24h volume." + - "PumpFun commands the market with over $100 million in daily bonding-curve volume, roughly 70 percent of total tracked launchpad volume. Protocol fee revenue is now primarily from token graduations after cutting bonding-curve trading fees to zero in August 2026." + - "Flap and Meteora DBC are the closest challengers, each processing tens of millions in daily volume. Flap has the highest fee-to-volume ratio among active launchpads." + - "Bankr, LetsCash and NOXA Fun each run active programs with single-digit millions in daily volume and tens of thousands in daily fees." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-launchpad-wars + +prometheus: + window: 24h + freshness_metric: solana_launchpad_volume_24h_usd + +faq: + - q: "Which Solana meme launchpad has the most volume?" + a: "PumpFun leads with over $100 million in 24h bonding-curve volume, roughly 70 percent of total tracked launchpad volume. Flap and Meteora DBC are the closest challengers. The leaderboard updates every 15 minutes from live Mobula lighthouse data." + - q: "What counts as launchpad volume here?" + a: "On-chain swap events routed through the launchpad's bonding-curve program, aggregated over a rolling 24-hour window by Mobula. For PumpFun this is the bonding-curve phase only; tokens that have graduated to the PumpSwap AMM are not included in this figure." + - q: "How does PumpFun compare to Flap on fees?" + a: "PumpFun cut its bonding-curve trading fees to zero in August 2026, so its daily protocol fee revenue now comes almost entirely from graduation fees rather than per-swap charges. Flap charges a higher per-trade fee rate. The solana_launchpad_fees_24h_usd gauge tracks live fees for each launchpad." + - q: "What is Meteora DBC?" + a: "Meteora Dynamic Bonding Curve is Meteora's launchpad product on Solana, competing directly with PumpFun. It uses a configurable bonding curve and routes graduated tokens into Meteora's DLMM liquidity pools." + - q: "Are trading bots like GMGN and Axiom included?" + a: "No. GMGN, Axiom, Fomo and Trojan are trading frontends that route orders through multiple launchpads and DEXes. Their volume is attributable to the underlying programs, not to a single launchpad. A separate bench tracks the trading platform leaderboard." + +providers: + - slug: pumpfun + name: PumpFun + tag: "Solana launchpad, #1 by volume" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=PumpFun].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="pumpfun"} + p90: solana_launchpad_volume_24h_usd{launchpad="pumpfun"} + p99: solana_launchpad_volume_24h_usd{launchpad="pumpfun"} + mean: solana_launchpad_volume_24h_usd{launchpad="pumpfun"} + success: solana_launchpad_health{launchpad="pumpfun"} + sample_size: solana_launchpad_trades_24h{launchpad="pumpfun"} + series: solana_launchpad_volume_24h_usd{launchpad="pumpfun"} + + - slug: flap + name: Flap + tag: "Solana launchpad, top fee rate" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Flap].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="flap"} + p90: solana_launchpad_volume_24h_usd{launchpad="flap"} + p99: solana_launchpad_volume_24h_usd{launchpad="flap"} + mean: solana_launchpad_volume_24h_usd{launchpad="flap"} + success: solana_launchpad_health{launchpad="flap"} + sample_size: solana_launchpad_trades_24h{launchpad="flap"} + series: solana_launchpad_volume_24h_usd{launchpad="flap"} + + - slug: meteora-dbc + name: Meteora DBC + tag: "Meteora DBC, Solana launchpad" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Meteora DBC].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"} + p90: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"} + p99: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"} + mean: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"} + success: solana_launchpad_health{launchpad="meteora-dbc"} + sample_size: solana_launchpad_trades_24h{launchpad="meteora-dbc"} + series: solana_launchpad_volume_24h_usd{launchpad="meteora-dbc"} + + - slug: noxa-fun + name: NOXA Fun + tag: "Solana meme launchpad" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=NOXA Fun].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"} + p90: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"} + p99: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"} + mean: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"} + success: solana_launchpad_health{launchpad="noxa-fun"} + sample_size: solana_launchpad_trades_24h{launchpad="noxa-fun"} + series: solana_launchpad_volume_24h_usd{launchpad="noxa-fun"} + + - slug: bankr + name: Bankr + tag: "Solana meme launchpad" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Bankr].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="bankr"} + p90: solana_launchpad_volume_24h_usd{launchpad="bankr"} + p99: solana_launchpad_volume_24h_usd{launchpad="bankr"} + mean: solana_launchpad_volume_24h_usd{launchpad="bankr"} + success: solana_launchpad_health{launchpad="bankr"} + sample_size: solana_launchpad_trades_24h{launchpad="bankr"} + series: solana_launchpad_volume_24h_usd{launchpad="bankr"} + + - slug: letscash + name: LetsCash + tag: "Solana meme launchpad" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=LetsCash].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="letscash"} + p90: solana_launchpad_volume_24h_usd{launchpad="letscash"} + p99: solana_launchpad_volume_24h_usd{launchpad="letscash"} + mean: solana_launchpad_volume_24h_usd{launchpad="letscash"} + success: solana_launchpad_health{launchpad="letscash"} + sample_size: solana_launchpad_trades_24h{launchpad="letscash"} + series: solana_launchpad_volume_24h_usd{launchpad="letscash"} + + - slug: dontblink + name: dontblink + tag: "Solana meme launchpad" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=dontblink].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="dontblink"} + p90: solana_launchpad_volume_24h_usd{launchpad="dontblink"} + p99: solana_launchpad_volume_24h_usd{launchpad="dontblink"} + mean: solana_launchpad_volume_24h_usd{launchpad="dontblink"} + success: solana_launchpad_health{launchpad="dontblink"} + sample_size: solana_launchpad_trades_24h{launchpad="dontblink"} + series: solana_launchpad_volume_24h_usd{launchpad="dontblink"} + + - slug: raydium-launchlab + name: Raydium Launchlab + tag: "Raydium launchpad on Solana" + formula: "24h bonding-curve swap volume in USD. Source: Mobula lighthouse byLaunchpad[name=Raydium Launchlab].volumeUSD.24h, updated every 15 min." + queries: + p50: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"} + p90: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"} + p99: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"} + mean: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"} + success: solana_launchpad_health{launchpad="raydium-launchlab"} + sample_size: solana_launchpad_trades_24h{launchpad="raydium-launchlab"} + series: solana_launchpad_volume_24h_usd{launchpad="raydium-launchlab"} diff --git a/benchmarks/solana-trading-platform-wars.yml b/benchmarks/solana-trading-platform-wars.yml new file mode 100644 index 00000000..fe2b82d6 --- /dev/null +++ b/benchmarks/solana-trading-platform-wars.yml @@ -0,0 +1,185 @@ +# OpenChainBench. Bench 201 + +slug: solana-trading-platform-wars +number: "201" +title: "Solana trading platform leaderboard: pump.fun vs GMGN vs Axiom vs Fomo, live 24h volume" +seo_title: "Solana trading platform comparison 2026: pump.fun, GMGN, Axiom, Fomo, Trojan" +seo_description: "{{best_name}} leads Solana trading platforms at ${{best_p50}} 24h volume. pump.fun vs GMGN vs Axiom vs Fomo vs Trojan vs Padre vs Phantom ranked live from Mobula lighthouse data." +subtitle: Live 24-hour trading volume for each Solana trading platform and Telegram bot. Polled every 15 minutes from Mobula lighthouse, which aggregates on-chain swap events routed through each frontend across all Solana DEX programs. + +category: Trading +status: live +metric: 24h volume +unit: usd +higher_is_better: true + +disclaimer: | + Volume figures are sourced from Mobula's lighthouse aggregator, which indexes on-chain swap events attributed to each trading platform or Telegram bot. Data is refreshed every 15 minutes. Platforms with zero volume may indicate no attributable on-chain activity in the last 24 hours or a temporary attribution gap in Mobula's indexing. + +seo_intro: | + Solana's trading frontend market is a contested race between GMGN, Axiom and + Fomo, with Trojan and Padre close behind as the dominant Telegram bots. This + benchmark tracks the 24-hour trading volume routed through each platform in + real time using Mobula's lighthouse aggregator, which attributes on-chain + swap events to the originating frontend. GMGN consistently ranks at or near + the top, regularly processing over $100 million in daily volume on active + days. Axiom competes closely behind and the two regularly trade places as + market conditions shift. Fomo has been narrowing the gap and on active days + has matched or exceeded Axiom, making the top three increasingly competitive. + Trojan and Padre serve the Telegram-native trader segment, each capturing + significant bot-driven flow. Phantom routes wallet-initiated swaps through + its interface, representing users who prefer the wallet UI over dedicated + trading terminals. The fee column shows which platforms capture the most + protocol revenue per dollar traded; see methodology for Fomo's fee source. + +abstract: | + The harness polls the Mobula lighthouse endpoint (api.mobula.io/api/2/market/lighthouse) + every 15 minutes. Mobula attributes each on-chain swap event to the originating + trading platform or bot using referral tags, program IDs and memo fields embedded + in transactions. The harness exposes three gauges per platform: + solana_platform_volume_24h_usd, solana_platform_fees_24h_usd and + solana_platform_health. All platforms returned by the API are tracked + automatically. The benchmark YAML selects the highest-volume Solana-native + trading platforms as providers. + +methodology: + - "Source: Mobula lighthouse (api.mobula.io/api/2/market/lighthouse), byPlatform field. Mobula attributes swap events using on-chain referral tags, program IDs and memo fields." + - "Poll cadence: harness fetches the endpoint every 15 minutes and updates Prometheus gauges. Prometheus scrapes :2112/metrics every 60 seconds." + - "Volume: sum of all swap event notional values (in USD at time of swap) within the last 24 hours for swaps attributed to this platform. Higher = more user activity routed through this frontend." + - "Attribution: Mobula uses on-chain referral tags, program IDs and memo fields. Each swap is attributed to exactly one platform. Platforms without standard referral tags may be undercounted." + - "pump.fun: sourced from Mobula's byLaunchpad bucket (bonding-curve swaps via pump.fun's own frontend). PumpSwap trades routed through GMGN or Axiom count under those platforms." + - "Phantom: wallet-initiated swaps via Jupiter or other aggregators, not active trading sessions." + +findings: + - "{{best_name}} leads Solana trading platforms at ${{best_p50}} 24h volume." + - "GMGN and Axiom consistently rank at the top of the leaderboard and regularly trade places. Both serve power traders with web-based terminals; GMGN emphasizes AI copy-trading while Axiom focuses on execution speed." + - "Fomo has been closing the gap on Axiom and on competitive days overtakes it, making the top three a genuinely contested race rather than a fixed hierarchy." + - "Trojan and Padre serve the Telegram-native segment and together account for a significant fraction of bot-driven Solana swap flow." + - "Phantom is the dominant Solana wallet by installs; its volume here represents wallet-initiated swaps via Jupiter, a different usage pattern than active trading sessions." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-trading-platform-wars + +prometheus: + window: 24h + freshness_metric: solana_platform_volume_24h_usd + +faq: + - q: "Which Solana trading platform has the most volume?" + a: "GMGN and Axiom consistently trade places at the top, and Fomo has been narrowing the gap. The leaderboard is live and updates every 15 minutes, so the current ranking is always visible at the top of the page. Headlines claiming one platform has overtaken another are often based on a single day or a specific metric; this bench tracks the rolling 24h window continuously." + - q: "Does the pump.fun volume here include PumpSwap?" + a: "Partially. The pump.fun line comes from Mobula's byLaunchpad bucket, which covers bonding-curve swaps via pump.fun's own frontend. PumpSwap (the AMM for graduated tokens) is a separate program launched in 2025. Direct PumpSwap trades via pump.fun's interface may be partially included depending on Mobula's indexing of the PumpSwap program. Trades on pump.fun tokens routed through GMGN or Axiom to PumpSwap count under those platforms, not under pump.fun." + - q: "What is the difference between GMGN and Axiom?" + a: "Both are web-based Solana trading terminals targeting power traders. GMGN emphasizes AI-powered wallet analysis and copy trading. Axiom is a terminal-style platform focused on chart speed and order execution. Both route through Jupiter and direct DEX pools." + - q: "Why does Fomo's fee column use a different source?" + a: "Fomo collects protocol fees in USDC swept in batches to a dedicated fee wallet, separate from individual swap transactions. Mobula's attribution tracks fees via referral tags embedded per trade and misses the batch sweeps, understating Fomo fees by roughly 9x. The fee column for Fomo uses DeFiLlama, which monitors the Fomo fee wallet directly and captures the full revenue. All other platforms use Mobula." + - q: "Are Trojan and Padre Telegram bots?" + a: "Yes. Trojan and Padre are Telegram-native trading bots that execute Solana swaps on behalf of users. They embed referral tags in transactions which Mobula uses to attribute volume." + - q: "How is trading platform volume different from launchpad volume?" + a: "Launchpad volume (bench 200) counts swaps through bonding-curve programs like pump.fun or Flap. Platform volume here counts swaps attributed to a trading frontend regardless of which underlying DEX or launchpad was used. Attribution is mutually exclusive per Mobula's referral tag system: a trade on pump.fun tokens routed through GMGN counts under GMGN, not under pump.fun." + - q: "Why does BullX show zero or low volume?" + a: "BullX has limited Solana attribution in Mobula's lighthouse data. BullX primarily targets EVM chains and its Solana swap volume may not be tagged with referral data that Mobula can attribute. DEX volume for BullX across chains is tracked in bench 205." + +providers: + - slug: pump-fun + name: pump.fun + tag: "Solana meme launchpad, bonding curve" + formula: "24h swap volume in USD on pump.fun's bonding curve via pump.fun's own frontend (Mobula lighthouse byLaunchpad[name=PumpFun].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="pump-fun"} + p90: solana_platform_volume_24h_usd{platform="pump-fun"} + p99: solana_platform_volume_24h_usd{platform="pump-fun"} + mean: solana_platform_volume_24h_usd{platform="pump-fun"} + success: solana_platform_health{platform="pump-fun"} + sample_size: solana_platform_fees_24h_usd{platform="pump-fun"} + series: solana_platform_volume_24h_usd{platform="pump-fun"} + + - slug: gmgn + name: GMGN + tag: "AI trading terminal on Solana" + formula: "24h swap volume in USD attributed to GMGN (Mobula lighthouse byPlatform[name=GMGN].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="gmgn"} + p90: solana_platform_volume_24h_usd{platform="gmgn"} + p99: solana_platform_volume_24h_usd{platform="gmgn"} + mean: solana_platform_volume_24h_usd{platform="gmgn"} + success: solana_platform_health{platform="gmgn"} + sample_size: solana_platform_fees_24h_usd{platform="gmgn"} + series: solana_platform_volume_24h_usd{platform="gmgn"} + + - slug: axiom + name: Axiom + tag: "Web trading terminal on Solana" + formula: "24h swap volume in USD attributed to Axiom (Mobula lighthouse byPlatform[name=Axiom].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="axiom"} + p90: solana_platform_volume_24h_usd{platform="axiom"} + p99: solana_platform_volume_24h_usd{platform="axiom"} + mean: solana_platform_volume_24h_usd{platform="axiom"} + success: solana_platform_health{platform="axiom"} + sample_size: solana_platform_fees_24h_usd{platform="axiom"} + series: solana_platform_volume_24h_usd{platform="axiom"} + + - slug: fomo + name: Fomo + tag: "Solana trading platform" + formula: "24h swap volume in USD attributed to Fomo (Mobula lighthouse byPlatform[name=Fomo].volumeUSD.24h, updated every 15 min). Fees from DeFiLlama (dailyRevenue)." + queries: + p50: solana_platform_volume_24h_usd{platform="fomo"} + p90: solana_platform_volume_24h_usd{platform="fomo"} + p99: solana_platform_volume_24h_usd{platform="fomo"} + mean: solana_platform_volume_24h_usd{platform="fomo"} + success: solana_platform_health{platform="fomo"} + sample_size: defillama_dex_fees_24h_usd{platform="fomo"} + series: solana_platform_volume_24h_usd{platform="fomo"} + + - slug: trojan + name: Trojan + tag: "Telegram trading bot on Solana" + formula: "24h swap volume in USD attributed to Trojan (Mobula lighthouse byPlatform[name=Trojan].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="trojan"} + p90: solana_platform_volume_24h_usd{platform="trojan"} + p99: solana_platform_volume_24h_usd{platform="trojan"} + mean: solana_platform_volume_24h_usd{platform="trojan"} + success: solana_platform_health{platform="trojan"} + sample_size: solana_platform_fees_24h_usd{platform="trojan"} + series: solana_platform_volume_24h_usd{platform="trojan"} + + - slug: padre + name: Padre + tag: "AI Telegram bot on Solana" + formula: "24h swap volume in USD attributed to Padre (Mobula lighthouse byPlatform[name=Padre].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="padre"} + p90: solana_platform_volume_24h_usd{platform="padre"} + p99: solana_platform_volume_24h_usd{platform="padre"} + mean: solana_platform_volume_24h_usd{platform="padre"} + success: solana_platform_health{platform="padre"} + sample_size: solana_platform_fees_24h_usd{platform="padre"} + series: solana_platform_volume_24h_usd{platform="padre"} + + - slug: photon + name: Photon + tag: "Solana trading terminal" + formula: "24h swap volume in USD attributed to Photon (Mobula lighthouse byPlatform[name=Photon].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="photon"} + p90: solana_platform_volume_24h_usd{platform="photon"} + p99: solana_platform_volume_24h_usd{platform="photon"} + mean: solana_platform_volume_24h_usd{platform="photon"} + success: solana_platform_health{platform="photon"} + sample_size: solana_platform_fees_24h_usd{platform="photon"} + series: solana_platform_volume_24h_usd{platform="photon"} + + - slug: maestro + name: Maestro + tag: "Telegram trading bot on Solana" + formula: "24h swap volume in USD attributed to Maestro (Mobula lighthouse byPlatform[name=Maestro].volumeUSD.24h, updated every 15 min)." + queries: + p50: solana_platform_volume_24h_usd{platform="maestro"} + p90: solana_platform_volume_24h_usd{platform="maestro"} + p99: solana_platform_volume_24h_usd{platform="maestro"} + mean: solana_platform_volume_24h_usd{platform="maestro"} + success: solana_platform_health{platform="maestro"} + sample_size: solana_platform_fees_24h_usd{platform="maestro"} + series: solana_platform_volume_24h_usd{platform="maestro"} diff --git a/benchmarks/solana-unique-traders.yml b/benchmarks/solana-unique-traders.yml new file mode 100644 index 00000000..41e81807 --- /dev/null +++ b/benchmarks/solana-unique-traders.yml @@ -0,0 +1,165 @@ +# OpenChainBench. Bench 207 + +slug: solana-unique-traders +number: "207" +title: "Unique daily traders on Solana: pump.fun vs GMGN vs Fomo vs Axiom vs Trojan, live" +seo_title: "Unique daily active traders by Solana platform 2026: pump.fun vs GMGN vs Axiom vs Fomo vs Trojan" +seo_description: "{{best_name}} has the most unique daily traders at {{best_p50}} wallets in 24h. pump.fun vs GMGN vs Fomo vs Axiom vs Trojan ranked by unique daily active users, live from Dune Analytics." +subtitle: Live count of unique wallet addresses that executed a trade through each Solana platform in the last 24 hours. Refreshed hourly via Dune Analytics. Measures real user breadth, not volume, revealing which platforms serve the most distinct traders vs which are dominated by bots or whale concentration. + +category: Trading +status: live +metric: Unique traders 24h +unit: count +higher_is_better: true + +disclaimer: | + The metric counts unique transaction IDs (not unique wallet addresses) whose transactions touched a known platform fee wallet in the rolling 24h window. This is a proxy for unique daily active traders; the actual unique-wallet count is typically 3-10x lower since active traders execute multiple trades per day. Counts include bots, MEV searchers, and multi-wallet traders. A transaction that touches two platform fee wallets is counted in both. pump.fun tracking relies on its 9 on-chain fee wallets; since pump.fun cut bonding-curve trading fees to 0% on 2026-08-07, its count reflects only transactions generating graduation and creator fees and substantially understates true trading activity. + +seo_intro: | + GMGN leads Solana trading terminals by unique daily active wallets, serving a broad + segment of retail and power traders with AI-powered copy trading and wallet analysis. + Axiom and Fomo each attract tens of thousands of unique daily wallets, while Trojan + and Maestro serve a smaller but high-conviction base of Telegram power users. + pump.fun's unique trader count is substantially undercounted since August 7, 2026, + when its bonding-curve trading fees were cut to zero; fee wallet attribution now + captures only graduation and creator fee events rather than swap activity. This + benchmark tracks unique daily active wallets per platform live, updated hourly from + Dune Analytics on-chain data, providing a user-breadth view that volume metrics + alone cannot reveal. + +abstract: | + The harness wraps the Dune Analytics API. A single saved query counts distinct + fee_payer addresses in solana.transactions that touched each platform's known fee + wallet addresses in the rolling 24-hour window, joined from solana.account_activity + for efficiency. All platforms including pump.fun use the same fee wallet attribution + approach against solana.account_activity (standard Dune tier). The harness re-executes + the query every hour, polls for completion, then updates the Prometheus gauge + solana_platform_unique_traders_24h per platform. Between executions it serves the + latest cached result every 15 minutes. + +methodology: + - "Source: Dune Analytics (dune.com). Query updated hourly, results fetched every 15 min." + - "All platforms: COUNT(DISTINCT tx_id) from solana.account_activity WHERE address = one of the platform's known fee wallet addresses in the rolling 24h window." + - "Metric represents unique fee-touching transactions per platform, which is a 3-10x upward proxy for unique daily active wallets (power traders execute multiple transactions per day)." + - "pump.fun: tracked via 9 on-chain fee wallets. Since pump.fun cut bonding-curve trading fees to 0% on 2026-08-07, its count primarily reflects graduation and creator fee events, substantially understating true trading activity." + - "Fee wallet sources: DeFiLlama dimension-adapters (master, 2026-08), verified on-chain." + - "Bot inclusion: all transactions counted including automated traders, sniping bots, and copy-trade bots." + - "Double-count across platforms: a transaction that touches two platform fee wallets is counted in both. Counts are not mutually exclusive across platforms." + +findings: + - "{{best_name}} leads Solana platforms with {{best_p50}} unique daily traders." + - "GMGN leads among trading terminals by unique daily wallets, reflecting its broad retail and power-trader user base." + - "Trojan and Maestro serve a smaller but highly active Telegram-native segment, with fewer unique wallets but higher average trade size." + - "Unique traders and volume rankings diverge: Axiom processes more volume than Fomo but has a similar unique-wallet count, suggesting larger average position sizes." + - "pump.fun unique trader count is substantially undercounted post 2026-08-07 due to its zero-fee policy; fee wallet attribution captures only graduation and creator fee events, not swap activity." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-unique-traders + +prometheus: + window: 24h + freshness_metric: solana_platform_unique_traders_24h + +faq: + - q: "What does unique daily traders measure?" + a: "Unique daily traders counts the number of distinct wallet addresses (fee_payer in Solana transactions) that executed at least one trade through a platform in the rolling 24-hour window. It measures user breadth, not volume or frequency. A wallet that trades 1000 times counts as 1 unique trader." + - q: "Why does pump.fun have so many more unique traders than GMGN?" + a: "pump.fun is the default entry point for new Solana users and meme coin traders. Its bonding curve launch mechanic triggers massive multi-wallet bot activity as snipers race to buy newly created tokens. GMGN serves a more deliberate, power-trader segment that skews toward fewer unique wallets but larger position sizes." + - q: "Is this apple-to-apple between pump.fun and the others?" + a: "The methodology is consistent: all platforms use fee wallet attribution from solana.account_activity. However, pump.fun's results are substantially undercounted since August 7, 2026, when its bonding-curve trading fees were cut to zero. Fee wallet attribution now captures only graduation and creator fee events, not the hundreds of thousands of daily swaps. For trading terminals (GMGN, Axiom, Fomo, Trojan, Maestro), the comparison is directly apple-to-apple." + - q: "Why might Fomo's unique trader count be understated?" + a: "Fomo collects protocol fees in USDC via a single fee wallet. Some of Fomo's fee revenue is swept in batches rather than collected per-trade, which may mean some user transactions do not touch the fee wallet directly. The count reflects wallets whose transactions include the fee wallet as an account key." + - q: "How is this different from bench 201 (24h volume)?" + a: "Bench 201 ranks platforms by 24h swap volume in USD. This bench ranks by unique daily active wallets. A platform can have high volume with few wallets (whale-concentrated) or low volume with many wallets (retail-dominated). Both metrics together reveal the full picture." + +providers: + - slug: pump-fun + name: pump.fun + tag: "Solana meme launchpad" + formula: "Unique wallet addresses (distinct fee_payer) whose transaction touched a pump.fun fee wallet in the last 24h (Dune: solana.account_activity, hourly refresh). Substantially undercounted post 2026-08-07 due to zero-fee policy." + queries: + p50: solana_platform_unique_traders_24h{platform="pump-fun"} + p90: solana_platform_unique_traders_24h{platform="pump-fun"} + p99: solana_platform_unique_traders_24h{platform="pump-fun"} + mean: solana_platform_unique_traders_24h{platform="pump-fun"} + success: solana_platform_trader_health{platform="pump-fun"} + sample_size: solana_platform_unique_traders_24h{platform="pump-fun"} + series: solana_platform_unique_traders_24h{platform="pump-fun"} + + - slug: gmgn + name: GMGN + tag: "AI trading terminal on Solana" + formula: "Unique wallet addresses (distinct fee_payer) in transactions touching GMGN fee wallets in the last 24h (Dune: solana.account_activity + solana.transactions, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="gmgn"} + p90: solana_platform_unique_traders_24h{platform="gmgn"} + p99: solana_platform_unique_traders_24h{platform="gmgn"} + mean: solana_platform_unique_traders_24h{platform="gmgn"} + success: solana_platform_trader_health{platform="gmgn"} + sample_size: solana_platform_unique_traders_24h{platform="gmgn"} + series: solana_platform_unique_traders_24h{platform="gmgn"} + + - slug: axiom + name: Axiom + tag: "Web trading terminal on Solana" + formula: "Unique wallet addresses in transactions touching Axiom fee wallets in the last 24h (Dune, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="axiom"} + p90: solana_platform_unique_traders_24h{platform="axiom"} + p99: solana_platform_unique_traders_24h{platform="axiom"} + mean: solana_platform_unique_traders_24h{platform="axiom"} + success: solana_platform_trader_health{platform="axiom"} + sample_size: solana_platform_unique_traders_24h{platform="axiom"} + series: solana_platform_unique_traders_24h{platform="axiom"} + + - slug: fomo + name: Fomo + tag: "Solana trading platform" + formula: "Unique wallet addresses in transactions touching Fomo USDC fee wallet in the last 24h (Dune, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="fomo"} + p90: solana_platform_unique_traders_24h{platform="fomo"} + p99: solana_platform_unique_traders_24h{platform="fomo"} + mean: solana_platform_unique_traders_24h{platform="fomo"} + success: solana_platform_trader_health{platform="fomo"} + sample_size: solana_platform_unique_traders_24h{platform="fomo"} + series: solana_platform_unique_traders_24h{platform="fomo"} + + - slug: trojan + name: Trojan + tag: "Telegram trading bot on Solana" + formula: "Unique wallet addresses in transactions touching Trojan fee wallets in the last 24h (Dune, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="trojan"} + p90: solana_platform_unique_traders_24h{platform="trojan"} + p99: solana_platform_unique_traders_24h{platform="trojan"} + mean: solana_platform_unique_traders_24h{platform="trojan"} + success: solana_platform_trader_health{platform="trojan"} + sample_size: solana_platform_unique_traders_24h{platform="trojan"} + series: solana_platform_unique_traders_24h{platform="trojan"} + + - slug: photon + name: Photon + tag: "Solana trading terminal" + formula: "Unique wallet addresses in transactions touching Photon fee wallet in the last 24h (Dune, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="photon"} + p90: solana_platform_unique_traders_24h{platform="photon"} + p99: solana_platform_unique_traders_24h{platform="photon"} + mean: solana_platform_unique_traders_24h{platform="photon"} + success: solana_platform_trader_health{platform="photon"} + sample_size: solana_platform_unique_traders_24h{platform="photon"} + series: solana_platform_unique_traders_24h{platform="photon"} + + - slug: maestro + name: Maestro + tag: "Telegram trading bot on Solana" + formula: "Unique wallet addresses in transactions touching Maestro fee wallets in the last 24h (Dune, hourly refresh)." + queries: + p50: solana_platform_unique_traders_24h{platform="maestro"} + p90: solana_platform_unique_traders_24h{platform="maestro"} + p99: solana_platform_unique_traders_24h{platform="maestro"} + mean: solana_platform_unique_traders_24h{platform="maestro"} + success: solana_platform_trader_health{platform="maestro"} + sample_size: solana_platform_unique_traders_24h{platform="maestro"} + series: solana_platform_unique_traders_24h{platform="maestro"} diff --git a/benchmarks/songbird-rpc.yml b/benchmarks/songbird-rpc.yml index db727d94..8510877d 100644 --- a/benchmarks/songbird-rpc.yml +++ b/benchmarks/songbird-rpc.yml @@ -4,7 +4,7 @@ slug: songbird-rpc number: "192" title: Fastest free Songbird RPC, live no-key endpoint latency seo_title: "Fastest free Songbird RPC 2026" -seo_description: "{{best_name}} leads free Songbird RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 4 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Songbird RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Songbird EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Songbird (chain 19, SGB) is Flare Network's canary network, a production EVM-compatible chain used to test and validate Flare's FTSO oracle system before mainnet deployment. The Flare Foundation operates four keyless endpoints: the official Songbird gateway (songbird-api.flare.network/ext/C/rpc), dRPC (songbird.drpc.org), Blast API, and the community node sgb.ftso.com.au. + Songbird (chain 19, SGB) is Flare Network's canary network, a production EVM-compatible chain used to test and validate Flare's FTSO oracle system before mainnet deployment. One keyless endpoint is probed continuously: the official Songbird gateway (songbird-api.flare.network/ext/C/rpc). The FTSO AU community node (songbird.rpc.ftso.au) was removed after DNS went dead. abstract: | Per-chain member of the RPC latency cluster, extended to Songbird. We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every no-key public Songbird endpoint - that sustains continuous probing, 4 providers at launch, + (`eth_getBlockByNumber`) against the no-key public Songbird endpoint + that sustains continuous probing (1 provider: Flare Foundation official), every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Songbird-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"songbird\"`. Provider coverage at launch: 4 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"songbird\"`. Provider coverage: 1 no-key endpoint (Flare Foundation official). FTSO AU removed after DNS went dead." findings: - - "{{best_name}} currently leads free Songbird RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 4 measured providers." + - "{{best_name}} currently leads free Songbird RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Songbird RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 4 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which Songbird RPCs work without an API key?" - a: "4 endpoints sustain continuous keyless probing: Flare Foundation official, dRPC, Blast API, and ftso.com.au community node. Every endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: Flare Foundation official (songbird-api.flare.network/ext/C/rpc). The FTSO AU community node was removed after its DNS went dead." - q: "Does the fastest Songbird RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Songbird RPC latency measured here?" @@ -85,71 +85,3 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="flare-official", chain="songbird", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="flare-official", chain="songbird", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: dRPC public gateway for Songbird canary 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 to dRPC's no-key Songbird endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="songbird"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="songbird"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="songbird"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="songbird"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="songbird"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="songbird"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="songbird"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="songbird"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="songbird", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="songbird", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="songbird", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="songbird", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="songbird", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="songbird", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for Songbird - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key Songbird endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="songbird"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="songbird"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="songbird"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="songbird"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="songbird"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="songbird"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="songbird"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="songbird"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="songbird", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="songbird", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="songbird", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="songbird", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="songbird", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="songbird", region="sgp"}[1h]) - - - slug: ftso-au - name: FTSO AU - tag: ftso.com.au community Songbird RPC node - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to ftso.com.au's no-key Songbird endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ftso-au", chain="songbird"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="ftso-au", chain="songbird"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="ftso-au", chain="songbird"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="ftso-au", chain="songbird"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="ftso-au", chain="songbird"}) / sum(ocb:rpc_call:rate_24h{provider="ftso-au", chain="songbird"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="ftso-au", chain="songbird"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="ftso-au", chain="songbird"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ftso-au", chain="songbird", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ftso-au", chain="songbird", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ftso-au", chain="songbird", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ftso-au", chain="songbird", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ftso-au", chain="songbird", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="ftso-au", chain="songbird", region="sgp"}[1h]) diff --git a/benchmarks/telos-rpc.yml b/benchmarks/telos-rpc.yml index 53136be7..09ed0d0f 100644 --- a/benchmarks/telos-rpc.yml +++ b/benchmarks/telos-rpc.yml @@ -4,7 +4,7 @@ slug: telos-rpc number: "195" title: Fastest free Telos EVM RPC, live no-key endpoint latency seo_title: "Fastest free Telos EVM RPC 2026" -seo_description: "{{best_name}} leads free Telos EVM RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Telos EVM RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Telos EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Telos EVM (chain 40) is a high-performance EVM-compatible blockchain with sub-0.5 second block times, TLOS as the gas token, and a carbon-neutral operational commitment. One of the longest-running EVM-compatible chains, live since 2021. Three keyless endpoints are probed continuously: Telos Foundation official (mainnet.telos.net/evm), dRPC (telos.drpc.org), and Blast API. + Telos EVM (chain 40) is a high-performance EVM-compatible blockchain with sub-0.5 second block times, TLOS as the gas token, and a carbon-neutral operational commitment. One of the longest-running EVM-compatible chains, live since 2021. One keyless endpoint is probed continuously: dRPC (telos.drpc.org). The Telos Foundation official endpoint returned 404 and has been removed. abstract: | Per-chain member of the RPC latency cluster, extended to Telos EVM. We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every no-key public Telos EVM endpoint - that sustains continuous probing, 3 providers at launch, + (`eth_getBlockByNumber`) against the no-key public Telos EVM endpoint + that sustains continuous probing (1 provider: dRPC), every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Telos-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"telos\"`. Provider coverage at launch: 3 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"telos\"`. Provider coverage: 1 no-key endpoint (dRPC). Telos Foundation official returned 404 and has been removed." findings: - - "{{best_name}} currently leads free Telos EVM RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free Telos EVM RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h), 1 measured provider." faq: - q: "What is the fastest free Telos EVM RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which Telos EVM RPCs work without an API key?" - a: "3 endpoints sustain continuous keyless probing: Telos Foundation official, dRPC, and Blast API. Every endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: dRPC (telos.drpc.org). The Telos Foundation official endpoint (mainnet.telos.net/evm) returned 404 and has been removed." - q: "Does the fastest Telos RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Telos EVM RPC latency measured here?" @@ -62,29 +62,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: telos-official - name: Telos Foundation - tag: Telos Foundation official public EVM 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 to Telos Foundation's no-key endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="telos-official", chain="telos"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="telos-official", chain="telos"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="telos-official", chain="telos"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="telos-official", chain="telos"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="telos-official", chain="telos"}) / sum(ocb:rpc_call:rate_24h{provider="telos-official", chain="telos"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="telos-official", chain="telos"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="telos-official", chain="telos"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="telos-official", chain="telos", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="telos-official", chain="telos", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="telos-official", chain="telos", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="telos-official", chain="telos", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="telos-official", chain="telos", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="telos-official", chain="telos", region="sgp"}[1h]) - - slug: drpc name: dRPC tag: dRPC public gateway for Telos EVM @@ -107,26 +84,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="telos", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="telos", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for Telos EVM - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key Telos endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="telos"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="telos"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="telos"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="telos"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="telos"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="telos"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="telos"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="telos"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="telos", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="telos", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="telos", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="telos", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="telos", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="telos", region="sgp"}[1h]) diff --git a/benchmarks/token-quote-coverage.yml b/benchmarks/token-quote-coverage.yml index f0c2874c..73f2430b 100644 --- a/benchmarks/token-quote-coverage.yml +++ b/benchmarks/token-quote-coverage.yml @@ -77,7 +77,7 @@ methodology: - "Aggregator endpoints: Jupiter lite-api.jup.ag/swap/v1/quote (Solana), Mobula /api/2/swap/quoting (EVM + Solana, API key), Relay api.relay.link/quote (EVM + Solana, no key), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM only, no key), ParaSwap apiv5.paraswap.io/prices (EVM only, no key). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome." - "Jupiter strictMode=false: queried without the curated-token filter so Jupiter will attempt routing for any token with a discoverable on-chain pool, matching how most wallets call the API. strictMode=true would trivially exclude new tokens and defeat the purpose of the benchmark." - "Chain-scope enforcement: Jupiter probes Solana-only. KyberSwap and ParaSwap probe EVM only (Base, BNB, Robinhood). Mobula and Relay probe all four chains including Solana. Providers are never penalised for chains they do not support: null cells are excluded from cross-chain aggregates." - - "Coverage metric: three Prometheus counters per probe. token_quote_coverage_attempts_total (every probe), token_quote_api_ok_total (HTTP 200 received, regardless of outAmount), and token_quote_coverage_success_total (HTTP 200 + outAmount > 0). The headline coverage rate (p50 slot) remains success / attempts for historical continuity. Two derived slots expose the split: availability = api_ok / attempts (is the API reachable and returning 200?); coverage_given_available = success / api_ok (given the API responded, did it find a route?)." + - "Coverage metric: three counters per probe: attempts_total (every probe), api_ok_total (HTTP 200 received), success_total (HTTP 200 + outAmount > 0). Headline rate = success / attempts. Two derived slots: availability = api_ok / attempts; coverage_given_available = success / api_ok." - "Probe cadence and deduplication: each batch cycles through the current Dexscreener token-boosts feed. Tokens are not deduplicated across batches; the same token may appear in multiple 30-minute windows if it remains boosted. The score therefore reflects freshness of the aggregator's current routing graph, not historical coverage depth." - "Single origin: all probes originate from the eu-west OCB VPS. Regional latency is not a factor in this bench (the outcome is binary hit/miss, not latency). No region dimension in v1." diff --git a/benchmarks/trading-app-execution.yml b/benchmarks/trading-app-execution.yml new file mode 100644 index 00000000..5394ae19 --- /dev/null +++ b/benchmarks/trading-app-execution.yml @@ -0,0 +1,62 @@ +# OpenChainBench. Bench 204 + +slug: trading-app-execution +number: "204" +title: "Trading app execution quality: Axiom vs GMGN vs Trojan vs Maestro, Solana + EVM" +seo_title: "Solana trading bot execution quality 2026: Axiom, GMGN, Trojan, Maestro, pump.fun" +seo_description: "Compare on-chain execution quality for Solana trading apps: avg platform fee, priority fee, Jito bundle rate, CU price. Axiom vs GMGN vs Trojan vs Maestro vs pump.fun vs Photon, measured passively from fee accounts, updated hourly." +subtitle: Passive on-chain execution metrics for the top Solana trading apps and Telegram bots. Measured directly from fee-account transactions, no synthetic trades. Priority fee, Jito rate, CU price, and platform fee per transaction. EVM chain revenue (Ethereum, BSC, Base) on the chain tabs. + +category: Trading +status: live +metric: Avg platform fee +unit: sol +higher_is_better: false + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-exec + +abstract: | + The harness monitors the on-chain fee-receiver accounts for each trading app using + the Solana JSON-RPC getSignaturesForAddress method. For every incoming fee transaction, + it records the priority fee, compute units consumed, CU price, presence of a Jito tip, + and the SOL amount transferred to the fee account. Samples are bucketed by hour in + PostgreSQL and materialised every 5 minutes. No trades are simulated; the collector + reads only existing on-chain activity. For EVM chains (Ethereum, BSC, Base), a separate + collector tracks USDC and native ETH/BNB transfers to each platform's fee address using + eth_getLogs on public RPCs; no trace API required. + +methodology: + - "Solana source: Helius RPC getSignaturesForAddress on each platform's fee wallet. 100 transactions sampled per poll (every ~5 min)." + - "Priority fee: transaction fee minus the 5 000-lamport base fee per signature. Paid by the user to compete for block space." + - "CU price: priority fee divided by compute units consumed (microlamports/CU). p50/p95 from sampled CU data." + - "Jito rate: fraction of sampled transactions tipping one of the 8 official Jito tip accounts." + - "Platform fee: average SOL transferred to the platform's fee account per incoming transaction." + - "Tx count: total transactions at the fee account over the window, from raw getSignaturesForAddress counts (not just the 100-tx sample)." + - "EVM source: eth_getLogs for USDC (ERC-20) transfers + native ETH/BNB transfers to each platform's fee address. Base chain: USDC only (no free trace API for native)." + - "EVM cadence: block-by-block scanning, bootstrapped 30 days back on first run, then continuous." + - "All measurements are passive; no test transactions, no funded wallets, no on-chain footprint." + +findings: + - "Axiom leads Solana trading apps by transaction volume, processing over 400K fee transactions per 24h across its 20 fee wallets." + - "GMGN charges the highest average platform fee per transaction among actively-charging platforms, reflecting its higher-notional trader base." + - "Maestro has the highest Jito bundle rate, indicating MEV-sensitive routing for its Telegram bot users." + - "Photon has the highest CU price p95, showing aggressive compute-unit bidding during peak hours." + - "pump.fun fee wallets have received near-zero inflows since August 7 2026 when the bonding-curve was paused; its platform fee row reflects gas only." + +faq: + - q: "What is platform fee vs priority fee?" + a: "Priority fee is paid to Solana validators to compete for block space; it does not go to the trading app. Platform fee is the SOL transferred to the trading app's own fee wallet per transaction, which is the actual revenue the platform captures from each trade." + - q: "Why does Trojan show a near-zero Jito rate?" + a: "Trojan does not appear to use Jito MEV bundles for its standard execution path. This is consistent with its design as a Telegram bot that prioritises simplicity over MEV protection." + - q: "How is transaction count measured?" + a: "Tx count comes from getSignaturesForAddress on the platform's fee wallet(s), which gives the total number of transactions that sent fees to that address. This is the full population count, not a sample; the 100-tx sample is used only for fee amount and CU metrics." + - q: "Why are EVM revenues lower than Solana revenues?" + a: "Most platforms primarily target Solana users. EVM fee collection varies: some platforms charge mostly in native ETH/BNB via internal transactions which are not traceable without a trace API. The EVM figures reflect directly visible ERC-20 (USDC) and native transfers only." + - q: "How often is data updated?" + a: "Solana metrics update every 5 minutes (materialiser cycle). EVM revenue updates block-by-block continuously. The page reflects the state at the last materialiser run, shown in the table header." + - q: "Which platforms are tracked on EVM chains?" + a: "GMGN, Maestro, and Banana Gun are tracked on Ethereum. GMGN, Maestro, Axiom, and Banana Gun are tracked on BSC. GMGN, Maestro, and Banana Gun are tracked on Base. pump.fun Terminal is tracked on all three EVM chains." + +# No prometheus section -- this bench uses a custom page renderer +# at src/app/benchmarks/trading-app-execution/page.tsx that reads +# directly from the exec REST API (exec.openchainbench.com). diff --git a/benchmarks/tron-rpc.yml b/benchmarks/tron-rpc.yml index 627b2521..1657e029 100644 --- a/benchmarks/tron-rpc.yml +++ b/benchmarks/tron-rpc.yml @@ -4,7 +4,7 @@ slug: tron-rpc number: "093" title: Fastest free TRON RPC, live no-key JSON-RPC endpoint latency seo_title: "Fastest free TRON RPC 2026" -seo_description: "{{best_name}} leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key JSON-RPC-compat providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 2 no-key JSON-RPC-compat providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for `eth_getBlockByNumber("latest", false)` against every free, no-key public TRON JSON-RPC-compatible endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -20,8 +20,9 @@ seo_intro: | Ethereum method names (`eth_blockNumber`, `eth_getBlockByNumber`, `eth_getBalance`). This bench measures the JSON-RPC surface because that is the path every TRON-compatible EVM wallet, TronWeb bridge - and cross-chain bot integrates against. Provider cohort at launch: - TronGrid (Tron Foundation official), dRPC and PublicNode / Allnodes. + and cross-chain bot integrates against. Provider cohort: + TronGrid (Tron Foundation official) and PublicNode / Allnodes. + dRPC was removed after the TRON JSON-RPC method became unavailable. Ankr's JSON-RPC path is keyless in branding but rejected our probes with `API key is not allowed to access blockchain`; Chainstack, NOWNodes, GetBlock, Tatum and BlockPI require API keys on the TRON @@ -36,7 +37,7 @@ abstract: | single, identical JSON-RPC call (`eth_getBlockByNumber("latest", false)`) against every no-key public TRON JSON-RPC endpoint that sustains continuous probing, - 3 providers at launch, every 60 seconds, from us-east, eu-west and + 2 providers, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) with the EVM staleness gap tuned for TRON's ~3 s block time (25 blocks ≈ 75 @@ -51,22 +52,21 @@ methodology: - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 25 blocks behind the cross-provider tip, roughly 75 seconds at TRON's ~3 s block time), `timeout`. Latency without reliability is a misleading ranking signal." - "Surface scope: only the EVM-compatible JSON-RPC surface (`/jsonrpc` endpoints) is measured, because that is the path every cross-chain wallet, TronWeb / EVM bridge and arbitrage bot integrates against. The native TRON REST API (`wallet/getnowblock`, `wallet/triggersmartcontract`) is out of scope for this bench; a chain-specific `tron-rest` bench can be added later when there is clear demand from integrators using TronWeb directly." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"tron\"`. Provider coverage at launch: 3 no-key JSON-RPC endpoints (TronGrid, dRPC, PublicNode). The TRON JSON-RPC provider market is materially smaller than the EVM one; most TRON infra vendors expose only the native TRON REST API keyless, and gate the JSON-RPC path behind an API key." - - "Excluded by the launch audit: Ankr (`rpc.ankr.com/tron_jsonrpc` returned `API key is not allowed to access blockchain` without a key), Chainstack / NOWNodes / GetBlock / Tatum / BlockPI (all require an API key on the TRON JSON-RPC path even though branded as public), OnFinality + Blast API + AllThatNode (no public TRON JSON-RPC gateway), Tron Foundation's `rpc.trongrid.io` (DNS not resolving; the correct official host is `api.trongrid.io/jsonrpc`)." + - "Chain scope: every query on this page is pinned to `chain=\"tron\"`. Provider coverage: 2 no-key JSON-RPC endpoints (TronGrid, PublicNode). dRPC removed (method not available). The TRON JSON-RPC provider market is materially smaller than the EVM one; most TRON infra vendors expose only the native TRON REST API keyless, and gate the JSON-RPC path behind an API key." + - "Excluded by the launch audit: dRPC (method not available), Ankr (key required on tron_jsonrpc path), Chainstack / NOWNodes / GetBlock / Tatum / BlockPI (key required on JSON-RPC path), OnFinality + Blast API + AllThatNode (no public TRON JSON-RPC gateway), Tron Foundation's rpc.trongrid.io (DNS dead; correct host is api.trongrid.io/jsonrpc)." findings: - - "{{best_name}} currently leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free TRON RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h) across 2 measured providers." - "{{name:trongrid}} is Tron Foundation's official public API gateway; the bench measures the same `api.trongrid.io/jsonrpc` endpoint that every TronWeb + tronbox integrator points at first. Latency here reflects what a keyless TRON-EVM integrator sees end-to-end." - - "{{name:drpc}} routes TRON JSON-RPC through its multi-provider load balancer; latency here is a good proxy for what a cross-chain integrator (multi-chain wallets, bridges) can expect from a load-balanced setup." - "{{name:publicnode}} extends its universal multi-chain footprint to TRON JSON-RPC without a keyed path; the reliability column matters as much as p50 since TRON JSON-RPC compat is a thin layer over the native TRON API and can drift under load." faq: - q: "What is the fastest free TRON RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (eth_getBlockByNumber p50 over the last 24h), measured against 3 no-key JSON-RPC providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + a: "{{best_name}} currently leads at {{best_p50}} (eth_getBlockByNumber p50 over the last 24h), measured against 2 no-key JSON-RPC providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." - q: "Which TRON RPCs work without an API key?" - a: "Three JSON-RPC endpoints sustain continuous keyless probing at launch: TronGrid (`api.trongrid.io/jsonrpc`), dRPC (`tron.drpc.org`) and PublicNode / Allnodes (`tron.publicnode.com/jsonrpc`). Every listed endpoint was live-verified with an `eth_getBlockByNumber` POST returning a parsable hex head before inclusion. Excluded by the audit: Ankr's TRON JSON-RPC path rejects keyless probes with `API key is not allowed to access blockchain`; Chainstack, NOWNodes, GetBlock, Tatum and BlockPI require an API key on the TRON JSON-RPC path even though branded as public; OnFinality, Blast API and AllThatNode have no public TRON JSON-RPC gateway. The TRON JSON-RPC provider market is materially smaller than the EVM one , most TRON infra vendors expose only the native TRON REST API keyless." - - q: "Why only 3 providers when other RPC benches have 5+?" - a: "TRON's JSON-RPC compat layer is thinner than the EVM one, and most infra vendors gate it behind an API key even when their native TRON REST endpoints are keyless. The 3 providers on this page are all keyless endpoints that responded correctly to a plain POST during the launch audit; when other vendors open a keyless JSON-RPC path we will add them. Latency and reliability numbers here are still meaningful because the 3 providers span a Foundation-official endpoint, a multi-provider aggregator (dRPC) and a universal multi-chain gateway (PublicNode) , the three infra archetypes an integrator would compare against each other anyway." + a: "Two JSON-RPC endpoints sustain continuous keyless probing: TronGrid (`api.trongrid.io/jsonrpc`) and PublicNode / Allnodes (`tron.publicnode.com/jsonrpc`). dRPC was removed after the TRON JSON-RPC method became unavailable. Ankr's TRON JSON-RPC path rejects keyless probes with `API key is not allowed to access blockchain`; Chainstack, NOWNodes, GetBlock, Tatum and BlockPI require an API key on the TRON JSON-RPC path even though branded as public; OnFinality, Blast API and AllThatNode have no public TRON JSON-RPC gateway." + - q: "Why only 2 providers when other RPC benches have 5+?" + a: "TRON's JSON-RPC compat layer is thinner than the EVM one, and most infra vendors gate it behind an API key even when their native TRON REST endpoints are keyless. dRPC was removed after the TRON JSON-RPC method became unavailable. The 2 providers on this page are all keyless endpoints that responded correctly to a plain POST; when other vendors open a keyless JSON-RPC path we will add them." - q: "Does this bench cover the native TRON REST API (`wallet/getnowblock`)?" a: "No. This bench scopes only the EVM-compatible JSON-RPC surface (`/jsonrpc` endpoints), because that is the path cross-chain wallets, TronWeb bridges, and arbitrage bots integrate against. The native TRON REST API surface (used by TronWeb + TronBox directly) has different method semantics (address encoding, resource model, `wallet/*` vs `walletsolidity/*` paths) and would need a chain-specific probe added. That will land as a separate `tron-rest` bench when the demand from integrators using TronWeb directly is clear." - q: "Does the fastest TRON RPC change by region?" @@ -116,29 +116,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="trongrid", chain="tron", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="trongrid", chain="tron", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: Multi-provider RPC load balancer, edge-close routing - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber(\"latest\", false)` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key TRON JSON-RPC endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="tron"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="tron"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="tron"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="tron"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="tron"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="tron"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="tron", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="tron", region="sgp"}[1h]) - - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, keyless TRON JSON-RPC path diff --git a/benchmarks/warden-rpc.yml b/benchmarks/warden-rpc.yml index 0bb99648..c342e0ac 100644 --- a/benchmarks/warden-rpc.yml +++ b/benchmarks/warden-rpc.yml @@ -4,7 +4,7 @@ slug: warden-rpc number: "197" title: Fastest free Warden Protocol RPC, live no-key endpoint latency seo_title: "Fastest free Warden Protocol RPC 2026" -seo_description: "{{best_name}} leads free Warden Protocol RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 3 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free Warden Protocol RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 2 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public Warden Protocol EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - Warden Protocol is a modular intent-based L1 for cross-chain smart contract security, built by the Qredo team with WARD as the native gas token. EVM-compatible with Cosmos SDK internals, enabling omnichain key management and intent execution via SpaceWard. Three keyless EVM endpoints are probed continuously: PublicNode (warden-evm-rpc.publicnode.com), dRPC (warden.drpc.org), and the Warden Foundation official endpoint (evm.wardenprotocol.org). + Warden Protocol is a modular intent-based L1 for cross-chain smart contract security, built by the Qredo team with WARD as the native gas token. EVM-compatible with Cosmos SDK internals, enabling omnichain key management and intent execution via SpaceWard. Two keyless EVM endpoints are probed continuously: PublicNode (warden-evm-rpc.publicnode.com) and the Warden Foundation official endpoint (evm.wardenprotocol.org). abstract: | Per-chain member of the RPC latency cluster, extended to Warden Protocol. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public Warden EVM endpoint - that sustains continuous probing, 3 providers at launch, + that sustains continuous probing, 2 providers at launch, every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the Warden-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"warden\"`. Provider coverage at launch: 3 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"warden\"`. Provider coverage at launch: 2 no-key endpoints." findings: - - "{{best_name}} currently leads free Warden Protocol RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 3 measured providers." + - "{{best_name}} currently leads free Warden Protocol RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 2 measured providers." faq: - q: "What is the fastest free Warden Protocol RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 2 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which Warden Protocol RPCs work without an API key?" - a: "3 endpoints sustain continuous keyless probing: PublicNode, dRPC, and the Warden Foundation official endpoint. Every endpoint was live-verified before inclusion." + a: "2 endpoints sustain continuous keyless probing: PublicNode and the Warden Foundation official endpoint. Every endpoint was live-verified before inclusion." - q: "Does the fastest Warden RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin." - q: "How is Warden Protocol RPC latency measured here?" @@ -85,29 +85,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="warden", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="warden", region="sgp"}[1h]) - - slug: drpc - name: dRPC - tag: dRPC public gateway for Warden Protocol - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to dRPC's no-key Warden endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="warden"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="warden"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="warden"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="warden"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="warden"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="warden"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="warden"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="warden"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="warden", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="warden", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="warden", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="warden", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="warden", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="warden", region="sgp"}[1h]) - - slug: warden-official name: Warden Foundation tag: Warden Foundation official public EVM RPC diff --git a/benchmarks/wemix-rpc.yml b/benchmarks/wemix-rpc.yml index 9b3772d5..62f9b5ac 100644 --- a/benchmarks/wemix-rpc.yml +++ b/benchmarks/wemix-rpc.yml @@ -107,49 +107,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="wemix", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="wemix", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for WEMIX 3.0 - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key WEMIX endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="wemix"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="wemix"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="wemix"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="wemix"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="wemix"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="wemix"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="wemix"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="wemix"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="wemix", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="wemix", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="wemix", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="wemix", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="wemix", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="wemix", region="sgp"}[1h]) - - - slug: "1rpc" - name: 1RPC - tag: 1RPC privacy-preserving relay for WEMIX - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to 1RPC's no-key WEMIX endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="wemix"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="wemix"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="wemix"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="wemix"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="wemix"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="wemix"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="wemix"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="wemix"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="wemix", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="wemix", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="wemix", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="wemix", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="wemix", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="wemix", region="sgp"}[1h]) diff --git a/benchmarks/xdc-rpc.yml b/benchmarks/xdc-rpc.yml index e4c64a92..493e78f5 100644 --- a/benchmarks/xdc-rpc.yml +++ b/benchmarks/xdc-rpc.yml @@ -4,7 +4,7 @@ slug: xdc-rpc number: "153" title: Fastest free XDC Network RPC, live no-key endpoint latency seo_title: "Fastest free XDC Network RPC 2026" -seo_description: "{{best_name}} leads free XDC Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free XDC Network RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 2 no-key providers measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public XDC Network RPC endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - XDC Network (chain 50) is an EVM-compatible hybrid blockchain targeting enterprise and trade-finance use cases, XDC gas token, delegated proof of stake consensus, designed for ISO 20022-compliant financial applications. The XDC Foundation's rpc.xinfin.network and additional endpoints from Ankr and the community provide keyless access probed every 60 seconds from three regions. + XDC Network (chain 50) is an EVM-compatible hybrid blockchain targeting enterprise and trade-finance use cases, XDC gas token, delegated proof of stake consensus, designed for ISO 20022-compliant financial applications. Two keyless endpoints are probed continuously: Ankr and XDC eRPC (erpc.xinfin.network). XDC Foundation official, XDCrpc, and XDC.org stopped responding to probes and have been removed. abstract: | Per-chain member of the RPC latency cluster, extended to XDC Network. We measure the round-trip latency of a single, identical RPC call (`eth_getBlockByNumber`) against every no-key public XDC Network endpoint - that sustains continuous probing, 5 providers at launch, + that sustains continuous probing, 2 providers, every 60 seconds, from us-east, eu-west and Singapore. The harness classifies every response (ok / http_err / jsonrpc_err / stale / timeout) so the leaderboard rewards sustained availability rather than @@ -34,16 +34,16 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"xdc\"`. Provider coverage at launch: 5 no-key endpoints (XDC Foundation, Ankr, XDCrpc, XDC eRPC, XDC.org)." + - "Chain scope: every query on this page is pinned to `chain=\"xdc\"`. Provider coverage: 2 no-key endpoints (Ankr, XDC eRPC). XDC Foundation official, XDCrpc and XDC.org stopped responding to probes and have been removed." findings: - - "{{best_name}} currently leads free XDC Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." + - "{{best_name}} currently leads free XDC Network RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 2 measured providers." faq: - q: "What is the fastest free XDC Network RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 2 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples." - q: "Which XDC Network RPCs work without an API key?" - a: "5 endpoints sustain continuous keyless probing at launch: XDC Foundation, Ankr, XDCrpc, XDC eRPC, XDC.org. Every listed endpoint was live-verified before inclusion." + a: "2 endpoints sustain continuous keyless probing: Ankr and XDC eRPC (erpc.xinfin.network). XDC Foundation official, XDCrpc and XDC.org stopped responding to probes and have been removed." - q: "Does the fastest XDC Network RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is XDC Network RPC latency measured here?" @@ -65,29 +65,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: xdc-official - name: XDC Foundation - tag: XDC Foundation's primary public RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDC Foundation's no-key XDC Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdc-official", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdc-official", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdc-official", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdc-official", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdc-official", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdc-official", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-official", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-official", chain="xdc", region="sgp"}[1h]) - - slug: ankr name: Ankr tag: Multi-cloud RPC network @@ -111,29 +88,6 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="ankr", chain="xdc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="ankr", chain="xdc", region="sgp"}[1h]) - - slug: xdcrpc - name: XDCrpc - tag: Community-operated XDC public RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDCrpc's no-key XDC Network endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdcrpc", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdcrpc", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdcrpc", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdcrpc", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdcrpc", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdcrpc", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdcrpc", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdcrpc", chain="xdc", region="sgp"}[1h]) - - slug: xdc-erpc name: XDC eRPC tag: XDC Foundation alternate public RPC gateway @@ -156,26 +110,3 @@ providers: - region: ap-southeast p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-erpc", chain="xdc", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="xdc-erpc", chain="xdc", region="sgp"}[1h]) - - - slug: xdc-org - name: XDC.org - tag: XDC Foundation third public RPC mirror - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to XDC Foundation's rpc.xdc.org endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="xdc-org", chain="xdc"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="xdc-org", chain="xdc"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="xdc-org", chain="xdc"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="xdc-org", chain="xdc"}) / sum(ocb:rpc_call:rate_24h{provider="xdc-org", chain="xdc"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="xdc-org", chain="xdc"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="xdc-org", chain="xdc", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="xdc-org", chain="xdc", region="sgp"}[1h]) diff --git a/benchmarks/zetachain-rpc.yml b/benchmarks/zetachain-rpc.yml index 2b9a1bba..47795c7f 100644 --- a/benchmarks/zetachain-rpc.yml +++ b/benchmarks/zetachain-rpc.yml @@ -4,7 +4,7 @@ slug: zetachain-rpc number: "187" title: Fastest free ZetaChain RPC, live no-key endpoint latency seo_title: "Fastest free ZetaChain RPC 2026" -seo_description: "{{best_name}} leads free ZetaChain RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 5 no-key providers measured every 60s from 3 regions." +seo_description: "{{best_name}} leads free ZetaChain RPC at {{best_p50}} (eth_getBlockByNumber p50, 24h). 1 no-key provider measured every 60s from 3 regions." subtitle: HTTP round-trip latency for eth_getBlockByNumber against every free, no-key public ZetaChain EVM endpoint, audited every 60 seconds from 3 regions. category: RPCs @@ -14,13 +14,13 @@ unit: ms higher_is_better: false seo_intro: | - ZetaChain (chain 7000) is an EVM-compatible omnichain L1 enabling native cross-chain assets (BTC, ETH, BNB) without bridges or wrapped tokens. ZETA is the gas token. Five keyless endpoints are probed continuously: dRPC, BlockPI, Thirdweb (7000.rpc.thirdweb.com), All That Node, and Blast API. + ZetaChain (chain 7000) is an EVM-compatible omnichain L1 enabling native cross-chain assets (BTC, ETH, BNB) without bridges or wrapped tokens. ZETA is the gas token. One keyless endpoint is probed continuously: Thirdweb (7000.rpc.thirdweb.com). BlockPI returned 404 and All That Node's DNS is dead. abstract: | Per-chain member of the RPC latency cluster, extended to ZetaChain. We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every no-key public ZetaChain endpoint - that sustains continuous probing, 5 providers at launch, + (`eth_getBlockByNumber`) against the no-key public ZetaChain endpoint + that sustains continuous probing (1 provider: Thirdweb), every 60 seconds, from us-east, eu-west and Singapore. The cross-chain view lives on the parent `rpc-capabilities` benchmark; this page is the ZetaChain-scoped answer with per-region breakdowns as a first-class dimension. @@ -31,16 +31,17 @@ methodology: - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms to 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err`, `stale` (more than 20 blocks behind the cross-provider tip), `timeout`." - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain." - - "Chain scope: every query on this page is pinned to `chain=\"zetachain\"`. Provider coverage at launch: 5 no-key endpoints." + - "Chain scope: every query on this page is pinned to `chain=\"zetachain\"`. Provider coverage: 1 no-key endpoint (Thirdweb). BlockPI returned 404; AllThatNode DNS is dead." findings: - - "{{best_name}} currently leads free ZetaChain RPC at {{best_p50}} (`eth_getBlockByNumber` p50, 24h) across 5 measured providers." + - "Provider coverage is critically limited: only Thirdweb (`7000.rpc.thirdweb.com`) is configured, and its current success rate from our probes is below 1% — the endpoint responds to one-off requests but blocks sustained per-IP probing. Latency figures reflect the rare successful call and do not represent reliable availability. BlockPI and AllThatNode are dead for ZetaChain; no other free keyless endpoint was found operational at audit time." + - "Manual use of `7000.rpc.thirdweb.com` works for individual calls. The 0.5% probe success rate is an artifact of rate-limiting against our fixed Railway probe IPs, not a general outage." faq: - q: "What is the fastest free ZetaChain RPC right now?" - a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 5 no-key providers probed every 60 seconds from us-east, eu-west and Singapore." + a: "{{best_name}} currently leads at {{best_p50}} (`eth_getBlockByNumber` p50 over the last 24h), measured against 1 no-key provider probed every 60 seconds from us-east, eu-west and Singapore." - q: "Which ZetaChain RPCs work without an API key?" - a: "5 endpoints sustain continuous keyless probing: dRPC, BlockPI, Thirdweb, All That Node, and Blast API. Every endpoint was live-verified before inclusion." + a: "1 endpoint sustains continuous keyless probing: Thirdweb (7000.rpc.thirdweb.com). BlockPI returned 404 during the audit; AllThatNode's DNS is dead." - q: "Does the fastest ZetaChain RPC change by region?" a: "Often. The region tabs at the top of the page re-scope every number to a single origin; pick the one closest to where your requests originate." - q: "How is ZetaChain RPC latency measured here?" @@ -62,52 +63,6 @@ dimensions: - { value: sgp, label: Singapore } providers: - - slug: drpc - name: dRPC - tag: dRPC public gateway for ZetaChain mainnet - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to dRPC's no-key ZetaChain endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zetachain"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="zetachain"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="zetachain"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="zetachain"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="zetachain"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="zetachain"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="zetachain"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zetachain"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zetachain", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zetachain", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zetachain", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zetachain", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zetachain", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zetachain", region="sgp"}[1h]) - - - slug: blockpi - name: BlockPI - tag: BlockPI public RPC gateway for ZetaChain - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to BlockPI's no-key ZetaChain endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blockpi", chain="zetachain"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blockpi", chain="zetachain"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blockpi", chain="zetachain"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blockpi", chain="zetachain"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blockpi", chain="zetachain"}) / sum(ocb:rpc_call:rate_24h{provider="blockpi", chain="zetachain"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blockpi", chain="zetachain"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blockpi", chain="zetachain"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blockpi", chain="zetachain", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blockpi", chain="zetachain", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blockpi", chain="zetachain", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blockpi", chain="zetachain", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blockpi", chain="zetachain", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blockpi", chain="zetachain", region="sgp"}[1h]) - - slug: thirdweb name: Thirdweb tag: Thirdweb chain-ID RPC gateway for ZetaChain @@ -131,48 +86,3 @@ providers: p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="thirdweb", chain="zetachain", region="sgp"}) series: avg_over_time(rpc_latency_milliseconds{provider="thirdweb", chain="zetachain", region="sgp"}[1h]) - - slug: allthatnode - name: All That Node - tag: All That Node archive-grade public gateway for ZetaChain - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to All That Node's no-key ZetaChain endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="allthatnode", chain="zetachain"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="allthatnode", chain="zetachain"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="allthatnode", chain="zetachain"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="allthatnode", chain="zetachain"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="allthatnode", chain="zetachain"}) / sum(ocb:rpc_call:rate_24h{provider="allthatnode", chain="zetachain"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="allthatnode", chain="zetachain"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="allthatnode", chain="zetachain"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="allthatnode", chain="zetachain", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="allthatnode", chain="zetachain", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="allthatnode", chain="zetachain", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="allthatnode", chain="zetachain", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="allthatnode", chain="zetachain", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="allthatnode", chain="zetachain", region="sgp"}[1h]) - - - slug: blastapi - name: Blast API - tag: Blast API public gateway for ZetaChain mainnet - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_getBlockByNumber` POST sent every 60s from 3 regions to Blast API's no-key ZetaChain endpoint." - queries: - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="zetachain"}) - p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blastapi", chain="zetachain"}) - p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blastapi", chain="zetachain"}) - mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blastapi", chain="zetachain"}) - success: sum(ocb:rpc_call:ok_rate_24h{provider="blastapi", chain="zetachain"}) / sum(ocb:rpc_call:rate_24h{provider="blastapi", chain="zetachain"}) - sample_size: sum(ocb:rpc_call:increase_24h{provider="blastapi", chain="zetachain"}) - series: avg(avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="zetachain"}[1h])) - regions: - - region: us-east - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="zetachain", region="us-east"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="zetachain", region="us-east"}[1h]) - - region: eu-west - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="zetachain", region="eu-west"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="zetachain", region="eu-west"}[1h]) - - region: ap-southeast - p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blastapi", chain="zetachain", region="sgp"}) - series: avg_over_time(rpc_latency_milliseconds{provider="blastapi", chain="zetachain", region="sgp"}[1h]) diff --git a/harnesses/aggregator-head-lag/Dockerfile b/harnesses/aggregator-head-lag/Dockerfile index 74ca8f0f..777b00d3 100644 --- a/harnesses/aggregator-head-lag/Dockerfile +++ b/harnesses/aggregator-head-lag/Dockerfile @@ -21,9 +21,12 @@ FROM debian:bookworm-slim WORKDIR /app -# Install runtime dependencies +# Install runtime dependencies + Chromium for in-process JWE scraping RUN apt-get update && apt-get install -y \ ca-certificates \ + chromium \ + chromium-sandbox \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* # Copy binary from builder diff --git a/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go b/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go new file mode 100644 index 00000000..f64c3df5 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/scrape-cookie/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +func main() { + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + ) + + allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer cancel() + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + cookies := map[string]string{} + + // Monkey-patch WebSocket to capture connection_init sent to graph.codex.io + // and the server's first response (ack or error/close). + captureScript := ` +window.__wsCaptures = []; +window.__wsResponses = []; +const _WS = window.WebSocket; +window.WebSocket = function(url, protocols) { + const ws = new _WS(url, protocols); + if (url && url.includes('graph.codex.io')) { + const _send = ws.send.bind(ws); + ws.send = function(data) { + try { + const parsed = JSON.parse(data); + if (parsed.type === 'connection_init') { + window.__wsCaptures.push({url: url, payload: parsed.payload, raw: data}); + console.log('WS_CAPTURE:' + JSON.stringify({url, payload: parsed.payload})); + } + } catch(e) {} + return _send(data); + }; + ws.addEventListener('message', function(evt) { + try { + const parsed = JSON.parse(evt.data); + window.__wsResponses.push({url: url, type: parsed.type, raw: evt.data.slice(0, 200)}); + console.log('WS_RESPONSE:' + JSON.stringify({url, type: parsed.type})); + } catch(e) {} + }); + ws.addEventListener('close', function(evt) { + window.__wsResponses.push({url: url, closeCode: evt.code, reason: evt.reason}); + console.log('WS_CLOSE:' + JSON.stringify({url, code: evt.code, reason: evt.reason})); + }); + } + return ws; +}; +window.WebSocket.prototype = _WS.prototype; +window.WebSocket.CONNECTING = _WS.CONNECTING; +window.WebSocket.OPEN = _WS.OPEN; +window.WebSocket.CLOSING = _WS.CLOSING; +window.WebSocket.CLOSED = _WS.CLOSED; +` + + err := chromedp.Run(ctx, + // Inject monkey-patch before page loads + chromedp.ActionFunc(func(ctx context.Context) error { + return chromedp.Evaluate(`void 0`, nil).Do(ctx) // warm up + }), + chromedp.Navigate("about:blank"), + chromedp.Evaluate(captureScript, nil), + chromedp.Navigate("https://www.defined.fi/"), + chromedp.WaitVisible(`body`, chromedp.ByQuery), + // Re-inject on the loaded page (navigate clears scripts) + chromedp.Evaluate(captureScript, nil), + chromedp.Sleep(20*time.Second), + chromedp.ActionFunc(func(ctx context.Context) error { + // Get cookies + cookieParams, err := network.GetCookies().Do(ctx) + if err != nil { + return fmt.Errorf("failed to get cookies: %w", err) + } + for _, cookie := range cookieParams { + cookies[cookie.Name] = cookie.Value + fmt.Fprintf(os.Stderr, "cookie: %s (len=%d)\n", cookie.Name, len(cookie.Value)) + } + return nil + }), + // Capture what the browser sent in connection_init + chromedp.ActionFunc(func(ctx context.Context) error { + var captures []map[string]interface{} + err := chromedp.Evaluate(`window.__wsCaptures || []`, &captures).Do(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "capture eval error: %v\n", err) + return nil + } + if len(captures) == 0 { + fmt.Fprintf(os.Stderr, "\n[WS CAPTURE] No connection_init captured for graph.codex.io\n") + // Try to get captures via console approach + var raw string + chromedp.Evaluate(`JSON.stringify(window.__wsCaptures || [])`, &raw).Do(ctx) + fmt.Fprintf(os.Stderr, "[WS CAPTURE] raw: %s\n", raw) + } + for i, c := range captures { + fmt.Fprintf(os.Stderr, "\n[WS CAPTURE #%d] url=%v\n", i, c["url"]) + payloadBytes, _ := json.MarshalIndent(c["payload"], "", " ") + fmt.Fprintf(os.Stderr, "[WS CAPTURE #%d] connection_init payload:\n%s\n", i, string(payloadBytes)) + fmt.Printf("WS_INIT_PAYLOAD=%s\n", string(payloadBytes)) + } + // Capture server responses (ack / close) + var responses []map[string]interface{} + chromedp.Evaluate(`window.__wsResponses || []`, &responses).Do(ctx) + if len(responses) == 0 { + fmt.Fprintf(os.Stderr, "\n[WS RESPONSE] No server responses captured yet\n") + } + for i, r := range responses { + respBytes, _ := json.MarshalIndent(r, "", " ") + fmt.Fprintf(os.Stderr, "\n[WS RESPONSE #%d]:\n%s\n", i, string(respBytes)) + } + return nil + }), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "chrome error: %v\n", err) + os.Exit(1) + } + + // Output cookies + if attToken, ok := cookies["defined-attestation-token"]; ok { + fmt.Printf("DEFINED_SESSION_COOKIE=%s\n", attToken) + } + if raw, ok := cookies["codex_token"]; ok { + decoded, _ := url.QueryUnescape(raw) + var obj struct { + Token string `json:"token"` + } + if json.Unmarshal([]byte(decoded), &obj) == nil { + fmt.Printf("CODEX_TOKEN=%s\n", obj.Token) + } + } +} diff --git a/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go b/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go new file mode 100644 index 00000000..2033448f --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/codex_scraper.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// in-process JWE refresher: scrapes defined.fi every 5 min via headless Chrome. +// Result is stored here so GetDefinedJWTToken can use it without hitting the Paris box. +var inProcessJWE struct { + sync.RWMutex + token string + mintedAt time.Time +} + +func getInProcessJWE() (string, time.Time) { + inProcessJWE.RLock() + defer inProcessJWE.RUnlock() + return inProcessJWE.token, inProcessJWE.mintedAt +} + +func setInProcessJWE(token string) { + inProcessJWE.Lock() + defer inProcessJWE.Unlock() + inProcessJWE.token = token + inProcessJWE.mintedAt = time.Now() +} + +// scrapeCodexToken runs headless Chrome, navigates to defined.fi, and extracts the codex_token JWE. +func scrapeCodexToken() (string, error) { + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + ) + + // Allow overriding Chrome binary path via env (useful for Alpine/Debian containers). + if chromePath := os.Getenv("CHROME_PATH"); chromePath == "" { + // Try common locations + for _, p := range []string{"/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"} { + if _, err := os.Stat(p); err == nil { + opts = append(opts, chromedp.ExecPath(p)) + break + } + } + } else { + opts = append(opts, chromedp.ExecPath(chromePath)) + } + + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + var cookies []*network.Cookie + err := chromedp.Run(ctx, + chromedp.Navigate("https://www.defined.fi/"), + chromedp.WaitVisible(`body`, chromedp.ByQuery), + chromedp.Sleep(20*time.Second), + chromedp.ActionFunc(func(ctx context.Context) error { + var err error + cookies, err = network.GetCookies().Do(ctx) + return err + }), + ) + if err != nil { + return "", fmt.Errorf("chromedp run failed: %w", err) + } + + for _, c := range cookies { + if c.Name == "codex_token" { + decoded, err := url.QueryUnescape(c.Value) + if err != nil { + return "", fmt.Errorf("url unescape failed: %w", err) + } + var obj struct { + Token string `json:"token"` + } + if err := json.Unmarshal([]byte(decoded), &obj); err == nil && obj.Token != "" { + return obj.Token, nil + } + // Fallback: if the cookie value is the JWE directly + if strings.HasPrefix(decoded, "eyJ") { + return decoded, nil + } + } + } + return "", fmt.Errorf("codex_token cookie not found after page load") +} + +// chromeAvailable returns true if a Chrome/Chromium binary is found on this host. +func chromeAvailable() bool { + for _, p := range []string{ + os.Getenv("CHROME_PATH"), + "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + } { + if p != "" { + if _, err := os.Stat(p); err == nil { + return true + } + } + } + return false +} + +// startInProcessScraper launches a background goroutine that refreshes the JWE every 5 min. +// Call once from main. No-ops silently if Chrome is not installed or repeatedly fails. +func startInProcessScraper(stopChan <-chan struct{}) { + if !chromeAvailable() { + fmt.Println("[CODEX-SCRAPER] Chrome not found — in-process scraper disabled (sidecar will be used)") + return + } + + go func() { + select { + case <-stopChan: + return + case <-time.After(10 * time.Second): + } + + refreshInterval := 5 * time.Minute + consecutiveFails := 0 + const maxFails = 3 + + for { + fmt.Println("[CODEX-SCRAPER] Scraping defined.fi for fresh JWE...") + tok, err := scrapeCodexToken() + if err != nil { + consecutiveFails++ + if consecutiveFails >= maxFails { + fmt.Printf("[CODEX-SCRAPER] %d consecutive failures — disabling scraper (sidecar will be used)\n", maxFails) + return + } + fmt.Printf("[CODEX-SCRAPER] Scrape failed (%d/%d): %v — retrying in 60s\n", consecutiveFails, maxFails, err) + select { + case <-stopChan: + return + case <-time.After(60 * time.Second): + continue + } + } + consecutiveFails = 0 + setInProcessJWE(tok) + fmt.Printf("[CODEX-SCRAPER] Fresh JWE stored (len=%d), next refresh in %v\n", len(tok), refreshInterval) + + select { + case <-stopChan: + return + case <-time.After(refreshInterval): + } + } + }() +} diff --git a/harnesses/aggregator-head-lag/cmd/script/defined_auth.go b/harnesses/aggregator-head-lag/cmd/script/defined_auth.go index 716f05f0..c38dfd56 100644 --- a/harnesses/aggregator-head-lag/cmd/script/defined_auth.go +++ b/harnesses/aggregator-head-lag/cmd/script/defined_auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "sync" "time" @@ -57,8 +58,110 @@ func decodeJWTExpiration(token string) (time.Time, error) { return time.Unix(claims.Exp, 0), nil } -// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired +// tryDirectCodexToken calls /api/codex/token on defined.fi directly using the session cookie. +// No proxy so the JWE is minted from this container's own IP, matching the WS connection IP. +func tryDirectCodexToken(sessionCookie string) (string, error) { + if sessionCookie == "" { + return "", fmt.Errorf("no session cookie") + } + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, + } + req, err := http.NewRequest("GET", "https://www.defined.fi/api/codex/token", nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://www.defined.fi") + req.Header.Set("Referer", "https://www.defined.fi/") + req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("sec-fetch-dest", "empty") + req.Header.Set("sec-fetch-mode", "cors") + req.Header.Set("sec-fetch-site", "same-origin") + req.AddCookie(&http.Cookie{Name: "defined-attestation-token", Value: sessionCookie}) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("status %d: %.100s", resp.StatusCode, string(body)) + } + var parsed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + return parsed.Token, nil + } + return "", fmt.Errorf("no token in response: %.100s", string(body)) +} + +// tryTokenService calls the Paris-box sidecar (DEFINED_TOKEN_SERVICE_URL) for a fresh JWE. +func tryTokenService(baseURL string) (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + for _, path := range []string{"/token", "/jwt", "/"} { + resp, err := client.Get(baseURL + path) + if err != nil { + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + continue + } + var parsed struct{ Token string `json:"token"` } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + return parsed.Token, nil + } + s := strings.TrimSpace(string(body)) + if len(s) > 50 { + return s, nil + } + } + return "", fmt.Errorf("no working path on sidecar") +} + +// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired. +// Priority: CODEX_JWT env var > in-process scraper (fresh, <5min) > direct /api/codex/token > sidecar > inline mint. func GetDefinedJWTToken(sessionCookie string) (string, error) { + if jwt := os.Getenv("CODEX_JWT"); jwt != "" { + return jwt, nil + } + // In-process scraper: headless Chrome running inside this container, freshest possible. + // JWE expires in ~10 min; scraper refreshes every 5 min so token is always <5 min old. + if tok, mintedAt := getInProcessJWE(); tok != "" && time.Since(mintedAt) < 8*time.Minute { + fmt.Printf("[DEFINED-AUTH] Got token from in-process scraper (age=%v, len=%d)\n", time.Since(mintedAt).Round(time.Second), len(tok)) + return tok, nil + } + // utls Chrome fingerprint scraper: visits defined.fi, gets CSRF, POSTs to /api/codex/token. + // Works if this container's IP is not in Vercel's datacenter blocklist. + if tok, err := tryUtlsCodexToken(); err == nil && tok != "" { + fmt.Printf("[DEFINED-AUTH] Got token via utls scraper (len=%d)\n", len(tok)) + return tok, nil + } else { + fmt.Printf("[DEFINED-AUTH] utls scraper failed: %v\n", err) + } + // Direct mint (standard Go TLS, usually blocked by Vercel bot check). + if tok, err := tryDirectCodexToken(sessionCookie); err == nil && tok != "" { + fmt.Printf("[DEFINED-AUTH] Got token via direct /api/codex/token (len=%d)\n", len(tok)) + return tok, nil + } + // Sidecar fallback (Paris box, Mac-push keeps it fresh every 5 min) + if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" { + if tok, err := tryTokenService(svcURL); err == nil && tok != "" { + fmt.Printf("[DEFINED-AUTH] Got token from sidecar (len=%d)\n", len(tok)) + return tok, nil + } + } + globalTokenCache.mu.RLock() // Check if we have a valid cached token @@ -104,49 +207,39 @@ func GetDefinedJWTToken(sessionCookie string) (string, error) { return token, nil } -// generateDefinedJWTToken generates a new JWT token from Defined.fi session cookie -func generateDefinedJWTToken(sessionCookie string) (string, error) { - fmt.Println("[DEFINED-AUTH] Generating new JWT token from Defined.fi (local)...") - fmt.Println("[DEFINED-AUTH] Creating new HTTP client with fresh TCP connection (no keepalive)") - - // Create a new HTTP client with fresh connection for each request. - // CRITICAL: route through HTTP_PROXY/HTTPS_PROXY (webshare rotating proxy) - // so each JWT mint hits a fresh IP. Direct from the container IP gets us - // stuck on Vercel's bot ban (429 loop) when the container restarts often. - transport := &http.Transport{ - DisableKeepAlives: true, - MaxIdleConnsPerHost: 0, - Proxy: http.ProxyFromEnvironment, - } - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: transport, - } +// generateDefinedJWTToken generates a new JWT token via un.defined.fi (old UI, still alive). +// No session cookie or CSRF needed. Falls back to www.defined.fi/api proxy path. +func generateDefinedJWTToken(_ string) (string, error) { + fmt.Println("[DEFINED-AUTH] Generating token via un.defined.fi legacy API...") reqBody := map[string]interface{}{ "operationName": "CreateApiToken", "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", "variables": map[string]interface{}{}, } - bodyBytes, _ := json.Marshal(reqBody) - req, _ := http.NewRequest("POST", "https://www.defined.fi/api", bytes.NewBuffer(bodyBytes)) + transport := &http.Transport{ + DisableKeepAlives: true, + Proxy: http.ProxyFromEnvironment, + } + client := &http.Client{Timeout: 10 * time.Second, Transport: transport} + + req, _ := http.NewRequest("POST", "https://un.defined.fi/api", bytes.NewBuffer(bodyBytes)) req.Header.Set("Accept", "application/json") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://www.defined.fi") - req.Header.Set("Referer", "https://www.defined.fi/") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Origin", "https://un.defined.fi") + req.Header.Set("Referer", "https://un.defined.fi/") req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"`) req.Header.Set("sec-ch-ua-mobile", "?0") req.Header.Set("sec-ch-ua-platform", `"macOS"`) req.Header.Set("sec-fetch-dest", "empty") req.Header.Set("sec-fetch-mode", "cors") req.Header.Set("sec-fetch-site", "same-origin") - req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) - fmt.Println("[DEFINED-AUTH] Sending POST request to https://www.defined.fi/api...") + fmt.Println("[DEFINED-AUTH] POST https://un.defined.fi/api ...") resp, err := client.Do(req) if err != nil { fmt.Printf("[DEFINED-AUTH] ❌ Request failed: %v\n", err) @@ -158,18 +251,16 @@ func generateDefinedJWTToken(sessionCookie string) (string, error) { fmt.Printf("[DEFINED-AUTH] Response status: %d\n", resp.StatusCode) if resp.StatusCode == 429 { - // Parse retry-after header if available retryAfter := resp.Header.Get("Retry-After") fmt.Printf("[DEFINED-AUTH] ⚠ Rate limited! Retry-After: %s\n", retryAfter) - if retryAfter != "" { - return "", fmt.Errorf("rate limited (429), retry after: %s", retryAfter) - } - return "", fmt.Errorf("rate limited (429), too many token requests - will retry later") + return "", fmt.Errorf("rate limited (429)") } if resp.StatusCode != 200 { - fmt.Printf("[DEFINED-AUTH] ❌ Unexpected status %d: %s\n", resp.StatusCode, string(respBody[:min(len(respBody), 100)])) - return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 100)])) + n := len(respBody) + if n > 100 { n = 100 } + fmt.Printf("[DEFINED-AUTH] ❌ Unexpected status %d: %s\n", resp.StatusCode, string(respBody[:n])) + return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody[:n])) } var tokenResp DefinedTokenResponse @@ -183,7 +274,7 @@ func generateDefinedJWTToken(sessionCookie string) (string, error) { return "", fmt.Errorf("no token returned") } - fmt.Printf("[DEFINED-AUTH] ✅ JWT token generated successfully (length: %d)\n", len(tokenResp.Data.CreateApiTokens[0].Token)) + fmt.Printf("[DEFINED-AUTH] ✅ Token generated via un.defined.fi (length: %d)\n", len(tokenResp.Data.CreateApiTokens[0].Token)) return tokenResp.Data.CreateApiTokens[0].Token, nil } diff --git a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go index cef588c4..a912795e 100644 --- a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go +++ b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "log" + "net/http" + "net/url" "strings" "sync" "time" @@ -419,11 +421,19 @@ func connectAndMonitorCodex(config *Config, stopChan <-chan struct{}) error { return fmt.Errorf("failed to get JWT token: %w", err) } - log.Printf("[HEAD-LAG][CODEX] Step 2/4: Creating proxy dialer...") - dialer := getProxyDialerWithSubprotocols([]string{"graphql-transport-ws"}) + log.Printf("[HEAD-LAG][CODEX] Step 2/4: Creating direct dialer (no proxy — IP must match JWE origin)...") + dialer := &websocket.Dialer{ + Subprotocols: []string{"graphql-transport-ws"}, + HandshakeTimeout: 30 * time.Second, + } log.Printf("[HEAD-LAG][CODEX] Step 3/4: Connecting to wss://graph.codex.io/graphql...") - conn, resp, err := dialer.Dial("wss://graph.codex.io/graphql", nil) + cookieVal := url.QueryEscape(`{"token":"` + jwtToken + `"}`) + wsHeaders := http.Header{} + wsHeaders.Set("Cookie", "codex_token="+cookieVal) + wsHeaders.Set("Origin", "https://www.defined.fi") + wsHeaders.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + conn, resp, err := dialer.Dial("wss://graph.codex.io/graphql", wsHeaders) if err != nil { if resp != nil { return fmt.Errorf("dial failed (HTTP %d): %w", resp.StatusCode, err) @@ -439,12 +449,11 @@ func connectAndMonitorCodex(config *Config, stopChan <-chan struct{}) error { log.Printf("[HEAD-LAG][CODEX] Step 3/4: ✅ WebSocket connection established (IP check failed: %v)", err) } - // Connection init with JWT Bearer token - log.Printf("[HEAD-LAG][CODEX] Step 4/4: Sending connection_init with JWT...") + log.Printf("[HEAD-LAG][CODEX] Step 4/4: Sending connection_init (token len=%d)...", len(jwtToken)) initMsg := map[string]interface{}{ "type": "connection_init", "payload": map[string]interface{}{ - "Authorization": fmt.Sprintf("Bearer %s", jwtToken), + "Authorization": "Bearer " + jwtToken, }, } if err := conn.WriteJSON(initMsg); err != nil { diff --git a/harnesses/aggregator-head-lag/cmd/script/main.go b/harnesses/aggregator-head-lag/cmd/script/main.go index 84444379..a545d832 100644 --- a/harnesses/aggregator-head-lag/cmd/script/main.go +++ b/harnesses/aggregator-head-lag/cmd/script/main.go @@ -38,6 +38,11 @@ func main() { var wg sync.WaitGroup stopChan := make(chan struct{}) + // In-process JWE scraper: headless Chrome inside this container scrapes defined.fi + // every 5 min to keep the Codex token fresh. JWE expires in ~10 min, so 5 min gives + // a comfortable margin. Runs only if Chrome is installed (no-op otherwise). + startInProcessScraper(stopChan) + wg.Add(1) go func() { defer wg.Done() diff --git a/harnesses/aggregator-head-lag/cmd/script/scrape_session.go b/harnesses/aggregator-head-lag/cmd/script/scrape_session.go index 4607912c..d88746e6 100644 --- a/harnesses/aggregator-head-lag/cmd/script/scrape_session.go +++ b/harnesses/aggregator-head-lag/cmd/script/scrape_session.go @@ -42,13 +42,13 @@ func ScrapeDefinedSessionCookie() (string, error) { } for _, cookie := range cookieParams { - if cookie.Name == "session" { + if cookie.Name == "defined-attestation-token" { sessionCookie = cookie.Value return nil } } - return fmt.Errorf("session cookie not found") + return fmt.Errorf("defined-attestation-token cookie not found") }), ) diff --git a/harnesses/aggregator-head-lag/cmd/script/utls_codex.go b/harnesses/aggregator-head-lag/cmd/script/utls_codex.go new file mode 100644 index 00000000..a323bb64 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/utls_codex.go @@ -0,0 +1,223 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "time" + + tls "github.com/refraction-networking/utls" +) + +func chromeH1Spec() tls.ClientHelloSpec { + return tls.ClientHelloSpec{ + TLSVersMax: tls.VersionTLS13, + TLSVersMin: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.GREASE_PLACEHOLDER, + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{0x00}, + Extensions: tls.ShuffleChromeTLSExtensions([]tls.TLSExtension{ + &tls.UtlsGREASEExtension{}, + &tls.SNIExtension{}, + &tls.ExtendedMasterSecretExtension{}, + &tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient}, + &tls.SupportedCurvesExtension{[]tls.CurveID{ + tls.GREASE_PLACEHOLDER, tls.X25519, tls.CurveP256, tls.CurveP384, + }}, + &tls.SupportedPointsExtension{SupportedPoints: []byte{0x00}}, + &tls.SessionTicketExtension{}, + &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, + &tls.StatusRequestExtension{}, + &tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{ + tls.ECDSAWithP256AndSHA256, tls.PSSWithSHA256, tls.PKCS1WithSHA256, + tls.ECDSAWithP384AndSHA384, tls.PSSWithSHA384, tls.PKCS1WithSHA384, + tls.PSSWithSHA512, tls.PKCS1WithSHA512, + }}, + &tls.SCTExtension{}, + &tls.KeyShareExtension{[]tls.KeyShare{ + {Group: tls.CurveID(tls.GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: tls.X25519}, + }}, + &tls.PSKKeyExchangeModesExtension{[]uint8{tls.PskModeDHE}}, + &tls.SupportedVersionsExtension{[]uint16{ + tls.GREASE_PLACEHOLDER, tls.VersionTLS13, tls.VersionTLS12, + }}, + &tls.UtlsCompressCertExtension{[]tls.CertCompressionAlgo{tls.CertCompressionBrotli}}, + &tls.UtlsGREASEExtension{}, + &tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle}, + }), + } +} + +func newUTLSClient() *http.Client { + jar, _ := cookiejar.New(nil) + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + Transport: &http.Transport{ + DisableKeepAlives: true, + ForceAttemptHTTP2: false, + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, _ := net.SplitHostPort(addr) + conn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + spec := chromeH1Spec() + uc := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloCustom) + if err := uc.ApplyPreset(&spec); err != nil { + conn.Close() + return nil, err + } + if err := uc.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return uc, nil + }, + }, + } +} + +// tryUtlsLegacyAPI calls un.defined.fi/api (old UI, still alive) with createApiTokens mutation. +// No cookies or CSRF needed — one request, much simpler. Works from residential IPs. +// Try this first since it's a single POST with no page-visit prerequisite. +func tryUtlsLegacyAPI() (string, error) { + client := newUTLSClient() + + body, _ := json.Marshal(map[string]interface{}{ + "operationName": "CreateApiToken", + "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", + "variables": map[string]interface{}{}, + }) + req, _ := http.NewRequest("POST", "https://un.defined.fi/api", bytes.NewBuffer(body)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Origin", "https://un.defined.fi") + req.Header.Set("Referer", "https://un.defined.fi/") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d: %.100s", resp.StatusCode, string(respBody)) + } + + var parsed struct { + Data struct { + CreateApiTokens []struct { + Token string `json:"token"` + } `json:"createApiTokens"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil || len(parsed.Data.CreateApiTokens) == 0 || parsed.Data.CreateApiTokens[0].Token == "" { + return "", fmt.Errorf("no token in response: %.100s", string(respBody)) + } + return parsed.Data.CreateApiTokens[0].Token, nil +} + +// tryUtlsCodexToken uses Chrome TLS fingerprint spoofing to call www.defined.fi/api/codex/token. +// Requires a page visit first to get CSRF cookie. Fallback if tryUtlsLegacyAPI fails. +func tryUtlsCodexToken() (string, error) { + // Try the old un.defined.fi API first — no cookies or CSRF needed. + if tok, err := tryUtlsLegacyAPI(); err == nil && tok != "" { + return tok, nil + } + + client := newUTLSClient() + + req1, _ := http.NewRequest("GET", "https://www.defined.fi/", nil) + req1.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + req1.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req1.Header.Set("Accept-Language", "en-US,en;q=0.9") + req1.Header.Set("Sec-Fetch-Site", "none") + req1.Header.Set("Sec-Fetch-Mode", "navigate") + req1.Header.Set("Sec-Fetch-Dest", "document") + req1.Header.Set("Upgrade-Insecure-Requests", "1") + + resp1, err := client.Do(req1) + if err != nil { + return "", fmt.Errorf("page load failed: %w", err) + } + io.Copy(io.Discard, resp1.Body) + resp1.Body.Close() + + if resp1.StatusCode != 200 { + return "", fmt.Errorf("page load blocked (HTTP %d) — IP blocked by Vercel", resp1.StatusCode) + } + + u, _ := url.Parse("https://www.defined.fi/") + cookies := client.Jar.Cookies(u) + var csrfToken string + for _, c := range cookies { + if c.Name == "csrf-token" { + csrfToken = c.Value + } + } + if csrfToken == "" { + return "", fmt.Errorf("no csrf-token in page cookies") + } + + req2, _ := http.NewRequest("POST", "https://www.defined.fi/api/codex/token", bytes.NewBufferString("{}")) + req2.Header.Set("Accept", "application/json") + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("x-csrf-token", csrfToken) + req2.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req2.Header.Set("Accept-Language", "en-US,en;q=0.9") + req2.Header.Set("Origin", "https://www.defined.fi") + req2.Header.Set("Referer", "https://www.defined.fi/") + req2.Header.Set("Sec-Fetch-Site", "same-origin") + req2.Header.Set("Sec-Fetch-Mode", "cors") + req2.Header.Set("Sec-Fetch-Dest", "empty") + + resp2, err := client.Do(req2) + if err != nil { + return "", fmt.Errorf("codex/token request failed: %w", err) + } + body2, _ := io.ReadAll(resp2.Body) + resp2.Body.Close() + + if resp2.StatusCode != 200 { + return "", fmt.Errorf("codex/token HTTP %d: %.100s", resp2.StatusCode, string(body2)) + } + + var parsed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body2, &parsed); err != nil || parsed.Token == "" { + return "", fmt.Errorf("no token in response: %.100s", string(body2)) + } + + return parsed.Token, nil +} diff --git a/harnesses/aggregator-head-lag/cmd/test-utls/main.go b/harnesses/aggregator-head-lag/cmd/test-utls/main.go new file mode 100644 index 00000000..cbb8adbc --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/test-utls/main.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "time" + + tls "github.com/refraction-networking/utls" +) + +func chromeH1Spec() tls.ClientHelloSpec { + return tls.ClientHelloSpec{ + TLSVersMax: tls.VersionTLS13, + TLSVersMin: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.GREASE_PLACEHOLDER, + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{0x00}, + Extensions: tls.ShuffleChromeTLSExtensions([]tls.TLSExtension{ + &tls.UtlsGREASEExtension{}, + &tls.SNIExtension{}, + &tls.ExtendedMasterSecretExtension{}, + &tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient}, + &tls.SupportedCurvesExtension{[]tls.CurveID{ + tls.GREASE_PLACEHOLDER, tls.X25519, tls.CurveP256, tls.CurveP384, + }}, + &tls.SupportedPointsExtension{SupportedPoints: []byte{0x00}}, + &tls.SessionTicketExtension{}, + &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, + &tls.StatusRequestExtension{}, + &tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{ + tls.ECDSAWithP256AndSHA256, tls.PSSWithSHA256, tls.PKCS1WithSHA256, + tls.ECDSAWithP384AndSHA384, tls.PSSWithSHA384, tls.PKCS1WithSHA384, + tls.PSSWithSHA512, tls.PKCS1WithSHA512, + }}, + &tls.SCTExtension{}, + &tls.KeyShareExtension{[]tls.KeyShare{ + {Group: tls.CurveID(tls.GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: tls.X25519}, + }}, + &tls.PSKKeyExchangeModesExtension{[]uint8{tls.PskModeDHE}}, + &tls.SupportedVersionsExtension{[]uint16{ + tls.GREASE_PLACEHOLDER, tls.VersionTLS13, tls.VersionTLS12, + }}, + &tls.UtlsCompressCertExtension{[]tls.CertCompressionAlgo{tls.CertCompressionBrotli}}, + &tls.UtlsGREASEExtension{}, + &tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle}, + }), + } +} + +func newUTLSClient() *http.Client { + jar, _ := cookiejar.New(nil) + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + Transport: &http.Transport{ + DisableKeepAlives: true, + ForceAttemptHTTP2: false, + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, _ := net.SplitHostPort(addr) + conn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + spec := chromeH1Spec() + uc := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloCustom) + if err := uc.ApplyPreset(&spec); err != nil { + conn.Close() + return nil, err + } + if err := uc.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return uc, nil + }, + }, + } +} + +func main() { + mode := os.Getenv("MODE") + + switch mode { + case "fetch-html": + // Fetch URL via utls, print body to stdout + target := os.Getenv("URL") + if target == "" { + target = "https://www.defined.fi/" + } + client := newUTLSClient() + req, _ := http.NewRequest("GET", target, nil) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + fmt.Fprintf(os.Stderr, "HTTP %d\n", resp.StatusCode) + io.Copy(os.Stdout, resp.Body) + + case "scrape": + // Step 1 only: GET defined.fi, print cookies + client := newUTLSClient() + req, _ := http.NewRequest("GET", "https://www.defined.fi/", nil) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Upgrade-Insecure-Requests", "1") + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + fmt.Fprintf(os.Stderr, "ERROR: HTTP %d\n", resp.StatusCode) + os.Exit(1) + } + u, _ := url.Parse("https://www.defined.fi/") + for _, c := range client.Jar.Cookies(u) { + fmt.Printf("%s=%s\n", c.Name, c.Value) + } + + case "mint-utls": + // Step 2 only via utls: POST /api/codex/token with pre-obtained cookies + attestation := os.Getenv("ATTESTATION") + csrf := os.Getenv("CSRF") + if attestation == "" || csrf == "" { + fmt.Fprintln(os.Stderr, "Need ATTESTATION and CSRF env vars") + os.Exit(1) + } + fmt.Printf("Calling /api/codex/token via utls (Chrome fingerprint)...\n") + client := newUTLSClient() + // Inject cookies into jar + u, _ := url.Parse("https://www.defined.fi/") + client.Jar.SetCookies(u, []*http.Cookie{ + {Name: "defined-attestation-token", Value: attestation}, + {Name: "csrf-token", Value: csrf}, + }) + req, _ := http.NewRequest("POST", "https://www.defined.fi/api/codex/token", bytes.NewBufferString("{}")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-csrf-token", csrf) + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Origin", "https://www.defined.fi") + req.Header.Set("Referer", "https://www.defined.fi/") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("HTTP %d\n", resp.StatusCode) + if resp.StatusCode == 200 { + var parsed struct{ Token string `json:"token"` } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + fmt.Printf("✅ Got JWE (len=%d)\n", len(parsed.Token)) + fmt.Printf("CODEX_TOKEN=%s\n", parsed.Token) + return + } + } + n := len(body) + if n > 200 { + n = 200 + } + fmt.Printf("❌ Body: %s\n", string(body[:n])) + os.Exit(1) + + default: + // Full flow: scrape + mint on this machine + client := newUTLSClient() + req1, _ := http.NewRequest("GET", "https://www.defined.fi/", nil) + req1.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + req1.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req1.Header.Set("Accept-Language", "en-US,en;q=0.9") + req1.Header.Set("Sec-Fetch-Site", "none") + req1.Header.Set("Sec-Fetch-Mode", "navigate") + req1.Header.Set("Sec-Fetch-Dest", "document") + req1.Header.Set("Upgrade-Insecure-Requests", "1") + resp1, err := client.Do(req1) + if err != nil { + fmt.Printf("Page load failed: %v\n", err) + return + } + io.Copy(io.Discard, resp1.Body) + resp1.Body.Close() + fmt.Printf("Page: HTTP %d\n", resp1.StatusCode) + u, _ := url.Parse("https://www.defined.fi/") + cookies := client.Jar.Cookies(u) + var attestation, csrf string + for _, c := range cookies { + switch c.Name { + case "defined-attestation-token": + attestation = c.Value + case "csrf-token": + csrf = c.Value + } + } + fmt.Printf("attestation len=%d, csrf len=%d\n", len(attestation), len(csrf)) + req2, _ := http.NewRequest("POST", "https://www.defined.fi/api/codex/token", bytes.NewBufferString("{}")) + req2.Header.Set("Accept", "application/json") + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("x-csrf-token", csrf) + req2.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req2.Header.Set("Origin", "https://www.defined.fi") + req2.Header.Set("Referer", "https://www.defined.fi/") + req2.Header.Set("Sec-Fetch-Site", "same-origin") + req2.Header.Set("Sec-Fetch-Mode", "cors") + resp2, err := client.Do(req2) + if err != nil { + fmt.Printf("API failed: %v\n", err) + return + } + body, _ := io.ReadAll(resp2.Body) + resp2.Body.Close() + fmt.Printf("API: HTTP %d\n", resp2.StatusCode) + if resp2.StatusCode == 200 { + var parsed struct{ Token string `json:"token"` } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + fmt.Printf("✅ JWE len=%d\n", len(parsed.Token)) + } + } + } +} diff --git a/harnesses/aggregator-head-lag/go.mod b/harnesses/aggregator-head-lag/go.mod index ed17b543..d6ccd878 100644 --- a/harnesses/aggregator-head-lag/go.mod +++ b/harnesses/aggregator-head-lag/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/andybalholm/brotli v1.0.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect @@ -17,12 +18,15 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/harnesses/aggregator-head-lag/go.sum b/harnesses/aggregator-head-lag/go.sum index f594bc89..6379815d 100644 --- a/harnesses/aggregator-head-lag/go.sum +++ b/harnesses/aggregator-head-lag/go.sum @@ -1,3 +1,5 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -47,6 +49,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -55,6 +59,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/harnesses/aggregator-head-lag/scrape-cookie b/harnesses/aggregator-head-lag/scrape-cookie new file mode 100755 index 00000000..585d7baf Binary files /dev/null and b/harnesses/aggregator-head-lag/scrape-cookie differ diff --git a/harnesses/aggregator-head-lag/test-utls b/harnesses/aggregator-head-lag/test-utls new file mode 100755 index 00000000..549c8db9 Binary files /dev/null and b/harnesses/aggregator-head-lag/test-utls differ diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/defined_auth.go b/harnesses/aggregator-latency-benchmark/cmd/script/defined_auth.go index 716f05f0..717e75ad 100644 --- a/harnesses/aggregator-latency-benchmark/cmd/script/defined_auth.go +++ b/harnesses/aggregator-latency-benchmark/cmd/script/defined_auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "sync" "time" @@ -57,8 +58,12 @@ func decodeJWTExpiration(token string) (time.Time, error) { return time.Unix(claims.Exp, 0), nil } -// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired +// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired. +// If CODEX_JWT env var is set, it is returned directly (bypasses session-cookie flow). func GetDefinedJWTToken(sessionCookie string) (string, error) { + if jwt := os.Getenv("CODEX_JWT"); jwt != "" { + return jwt, nil + } globalTokenCache.mu.RLock() // Check if we have a valid cached token @@ -144,7 +149,7 @@ func generateDefinedJWTToken(sessionCookie string) (string, error) { req.Header.Set("sec-fetch-dest", "empty") req.Header.Set("sec-fetch-mode", "cors") req.Header.Set("sec-fetch-site", "same-origin") - req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) + req.AddCookie(&http.Cookie{Name: "defined-attestation-token", Value: sessionCookie}) fmt.Println("[DEFINED-AUTH] Sending POST request to https://www.defined.fi/api...") resp, err := client.Do(req) diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go b/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go index 4607912c..26d3f770 100644 --- a/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go +++ b/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go @@ -42,7 +42,7 @@ func ScrapeDefinedSessionCookie() (string, error) { } for _, cookie := range cookieParams { - if cookie.Name == "session" { + if cookie.Name == "defined-attestation-token" { sessionCookie = cookie.Value return nil } diff --git a/harnesses/app-store-ratings/Dockerfile b/harnesses/app-store-ratings/Dockerfile new file mode 100644 index 00000000..379e2f91 --- /dev/null +++ b/harnesses/app-store-ratings/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +COPY cmd ./cmd +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/app-store-ratings/cmd/script/itunes.go b/harnesses/app-store-ratings/cmd/script/itunes.go new file mode 100644 index 00000000..b9e70f6a --- /dev/null +++ b/harnesses/app-store-ratings/cmd/script/itunes.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type itunesEntry struct { + Rating float64 `json:"averageUserRating"` + ReviewCount int `json:"userRatingCount"` + TrackName string `json:"trackName"` +} + +type itunesResp struct { + ResultCount int `json:"resultCount"` + Results []itunesEntry `json:"results"` +} + +func fetchAppStoreData(appID string) (*itunesEntry, error) { + client := &http.Client{Timeout: 10 * time.Second} + url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%s&country=us", appID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "OpenChainBench/1.0") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d", resp.StatusCode) + } + + var data itunesResp + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + if data.ResultCount == 0 || len(data.Results) == 0 { + return nil, fmt.Errorf("no_results") + } + return &data.Results[0], nil +} diff --git a/harnesses/app-store-ratings/cmd/script/main.go b/harnesses/app-store-ratings/cmd/script/main.go new file mode 100644 index 00000000..8cadd48a --- /dev/null +++ b/harnesses/app-store-ratings/cmd/script/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var apps = []struct { + slug string + id string +}{ + {"pump-fun", "6717572591"}, + {"fomo", "6741115427"}, + {"gmgn", "6745328711"}, + {"moonshot", "6503993131"}, + {"coinbase", "886427730"}, + {"bybit", "1488296980"}, + {"kraken", "1481947260"}, + {"binance-us", "1492670702"}, + {"robinhood", "938003185"}, + {"cryptocom", "1262148500"}, + {"invo", "1601301148"}, +} + +func main() { + initMetrics() + + if err := runOnce(); err != nil { + fmt.Fprintf(os.Stderr, "[app-store] first run: %v\n", err) + } + + go func() { + tick := time.NewTicker(30 * time.Minute) + for range tick.C { + if err := runOnce(); err != nil { + fmt.Fprintf(os.Stderr, "[app-store] poll: %v\n", err) + } + } + }() + + http.Handle("/metrics", promhttp.Handler()) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + fmt.Println("[app-store-ratings] metrics on :2112") + if err := http.ListenAndServe(":2112", nil); err != nil { + fmt.Fprintf(os.Stderr, "server: %v\n", err) + os.Exit(1) + } +} + +func runOnce() error { + for _, app := range apps { + data, err := fetchAppStoreData(app.id) + if err != nil { + fmt.Printf("[app-store] %s: %v\n", app.slug, err) + appStoreHealth.WithLabelValues(app.slug).Set(0) + continue + } + appStoreRating.WithLabelValues(app.slug).Set(data.Rating) + appStoreReviews.WithLabelValues(app.slug).Set(float64(data.ReviewCount)) + appStoreHealth.WithLabelValues(app.slug).Set(1) + fmt.Printf("[app-store] %s: rating=%.4f reviews=%d\n", app.slug, data.Rating, data.ReviewCount) + } + return nil +} diff --git a/harnesses/app-store-ratings/cmd/script/metrics.go b/harnesses/app-store-ratings/cmd/script/metrics.go new file mode 100644 index 00000000..4d1aa5e6 --- /dev/null +++ b/harnesses/app-store-ratings/cmd/script/metrics.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + appStoreRating = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "app_store_rating", + Help: "Apple App Store average user rating (0 to 5).", + }, []string{"app"}) + + appStoreReviews = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "app_store_reviews_total", + Help: "Total number of user ratings in the Apple App Store.", + }, []string{"app"}) + + appStoreHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "app_store_health", + Help: "1 if the iTunes lookup succeeded in the last poll.", + }, []string{"app"}) +) + +func initMetrics() {} diff --git a/harnesses/app-store-ratings/go.mod b/harnesses/app-store-ratings/go.mod new file mode 100644 index 00000000..af2f9da2 --- /dev/null +++ b/harnesses/app-store-ratings/go.mod @@ -0,0 +1,5 @@ +module app-store-ratings + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 diff --git a/harnesses/apps/Dockerfile b/harnesses/apps/Dockerfile new file mode 100644 index 00000000..a3752b65 --- /dev/null +++ b/harnesses/apps/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/collector ./cmd/collector +RUN CGO_ENABLED=0 go build -o /app/materializer ./cmd/materializer +RUN CGO_ENABLED=0 go build -o /app/api ./cmd/api + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /app/collector /app/collector +COPY --from=builder /app/materializer /app/materializer +COPY --from=builder /app/api /app/api +COPY migrations/ /app/migrations/ diff --git a/harnesses/apps/cmd/api/main.go b/harnesses/apps/cmd/api/main.go new file mode 100644 index 00000000..95aecfec --- /dev/null +++ b/harnesses/apps/cmd/api/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + ctx := context.Background() + pool, err := pgxpool.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("apps-api: connect: %v", err) + } + defer pool.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/api/leaderboard", corsJSON(handleLeaderboard(pool))) + + addr := ":2115" + log.Printf("apps-api listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("apps-api: serve: %v", err) + } +} + +type WindowMetrics struct { + Gross float64 `json:"gross"` + Burn float64 `json:"burn"` + LP float64 `json:"lp"` + Spot float64 `json:"spot"` +} + +type ProtocolRow struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Category string `json:"category"` + LatestDay string `json:"latestDay"` + Windows map[string]WindowMetrics `json:"windows"` +} + +type LeaderboardResponse struct { + UpdatedAt string `json:"updatedAt"` + Protocols []ProtocolRow `json:"protocols"` +} + +var deploymentMeta = map[string]struct{ Name, Slug, Category string }{ + "hyperliquid:hypercore": {Name: "Hyperliquid", Slug: "hyperliquid", Category: "perps"}, + "dydx-v4:dydx-chain": {Name: "dYdX", Slug: "dydx", Category: "perps"}, + "gmx-v2:arbitrum": {Name: "GMX v2 (Arbitrum)", Slug: "gmx", Category: "perps"}, + "gmx-v2:avalanche": {Name: "GMX v2 (Avalanche)", Slug: "gmx", Category: "perps"}, + "gains-trade:arbitrum": {Name: "Gains.trade", Slug: "gains-trade", Category: "perps"}, + "drift:solana": {Name: "Drift", Slug: "drift", Category: "perps"}, + "jupiter-perps:solana": {Name: "Jupiter Perps", Slug: "jupiter-perps", Category: "perps"}, + "vertex:arbitrum": {Name: "Vertex", Slug: "vertex", Category: "perps"}, +} + +func handleLeaderboard(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + SELECT + deployment_id, + beneficiary, + component, + SUM(CASE WHEN bucket_start >= now() - INTERVAL '2 days' THEN amount_usd ELSE 0 END) AS h24, + SUM(CASE WHEN bucket_start >= now() - INTERVAL '7 days' THEN amount_usd ELSE 0 END) AS d7, + SUM(CASE WHEN bucket_start >= now() - INTERVAL '30 days' THEN amount_usd ELSE 0 END) AS d30, + SUM(amount_usd) AS all_time, + MAX(bucket_start)::text AS latest_day + FROM fee_facts + WHERE methodology_version = 1 + GROUP BY deployment_id, beneficiary, component + ORDER BY deployment_id, beneficiary, component`, + ) + if err != nil { + log.Printf("leaderboard query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer rows.Close() + + type key struct{ id, beneficiary, component string } + type vals struct{ h24, d7, d30, allTime float64; latestDay string } + data := map[key]vals{} + + for rows.Next() { + var k key + var v vals + if err := rows.Scan(&k.id, &k.beneficiary, &k.component, &v.h24, &v.d7, &v.d30, &v.allTime, &v.latestDay); err != nil { + continue + } + data[k] = v + } + + // Aggregate per deployment into WindowMetrics. + depMap := map[string]*ProtocolRow{} + for k, v := range data { + row, ok := depMap[k.id] + if !ok { + meta := deploymentMeta[k.id] + row = &ProtocolRow{ + ID: k.id, + Name: meta.Name, + Slug: meta.Slug, + Category: meta.Category, + Windows: map[string]WindowMetrics{}, + } + depMap[k.id] = row + } + if v.latestDay > row.LatestDay { + row.LatestDay = v.latestDay + } + addToWindow := func(winKey string, amount float64) { + wm := row.Windows[winKey] + wm.Gross += amount + switch k.beneficiary { + case "burn", "treasury": + wm.Burn += amount + case "lp": + wm.LP += amount + } + if k.component == "spot_fee" { + wm.Spot += amount + } + row.Windows[winKey] = wm + } + addToWindow("24h", v.h24) + addToWindow("7d", v.d7) + addToWindow("30d", v.d30) + addToWindow("allTime", v.allTime) + } + + protocols := make([]ProtocolRow, 0, len(depMap)) + for _, row := range depMap { + protocols = append(protocols, *row) + } + // Sort by 30d gross descending. + for i := 1; i < len(protocols); i++ { + for j := i; j > 0 && protocols[j].Windows["30d"].Gross > protocols[j-1].Windows["30d"].Gross; j-- { + protocols[j], protocols[j-1] = protocols[j-1], protocols[j] + } + } + + resp := LeaderboardResponse{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Protocols: protocols, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } +} + +func corsJSON(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=300") + h(w, r) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} diff --git a/harnesses/apps/cmd/collector/main.go b/harnesses/apps/cmd/collector/main.go new file mode 100644 index 00000000..72d9c4aa --- /dev/null +++ b/harnesses/apps/cmd/collector/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/collect/rest" + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/ledger" + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +func main() { + ctx := context.Background() + + db, err := ledger.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("collector: %v", err) + } + defer db.Close() + + go func() { + http.Handle("/metrics", promhttp.Handler()) + http.ListenAndServe(":2112", nil) + }() + + dydx := rest.NewDyDX() + hl := rest.NewHyperliquid() + gmxArb := rest.NewGMXv2Arbitrum() + gmxAvax := rest.NewGMXv2Avalanche() + drift := rest.NewDeFiLlama("drift-trade") + jupiterPerps := rest.NewDeFiLlama("jupiter-perpetual-exchange") + vertex := rest.NewDeFiLlama("vertex-perps") + + gainsWS := rest.NewGainsTradeWS() + + // WebSocket collector: real-time per-trade fees from gTrade first-party backend. + // Runs as a persistent goroutine; reconnects on any error. + go func() { + for { + out := make(chan spec.FeeEvent, 500) + after := time.Now().UTC().Truncate(24 * time.Hour) + go func() { + defer close(out) + if err := gainsWS.Stream(ctx, "gains-trade:arbitrum", after, out); err != nil && ctx.Err() == nil { + log.Printf("gains-trade WS: %v", err) + } + }() + for e := range out { + if err := db.UpsertEvents(ctx, []spec.FeeEvent{e}); err != nil { + log.Printf("gains-trade WS upsert: %v", err) + } + } + if ctx.Err() != nil { + return + } + time.Sleep(10 * time.Second) + } + }() + + for { + if err := runCollector(ctx, db, dydx, "dydx-v4:dydx-chain"); err != nil { + log.Printf("dydx collector error: %v", err) + } + if err := runCollector(ctx, db, hl, "hyperliquid:hypercore"); err != nil { + log.Printf("hyperliquid collector error: %v", err) + } + if err := runCollector(ctx, db, gmxArb, "gmx-v2:arbitrum"); err != nil { + log.Printf("gmx-v2:arbitrum collector error: %v", err) + } + if err := runCollector(ctx, db, gmxAvax, "gmx-v2:avalanche"); err != nil { + log.Printf("gmx-v2:avalanche collector error: %v", err) + } + if err := runCollector(ctx, db, drift, "drift:solana"); err != nil { + log.Printf("drift collector error: %v", err) + } + if err := runCollector(ctx, db, jupiterPerps, "jupiter-perps:solana"); err != nil { + log.Printf("jupiter-perps collector error: %v", err) + } + if err := runCollector(ctx, db, vertex, "vertex:arbitrum"); err != nil { + log.Printf("vertex collector error: %v", err) + } + time.Sleep(60 * time.Second) + } +} + +func runCollector(ctx context.Context, db *ledger.DB, col spec.Collector, deploymentID string) error { + from, err := db.GetCursor(ctx, deploymentID) + if err != nil { + return err + } + + to := spec.Cursor{ + Height: ^uint64(0), + Ts: time.Now(), + Finalized: true, + } + + out := make(chan spec.FeeEvent, 1000) + collectErr := make(chan error, 1) + + go func() { + defer close(out) + newCursor, err := col.Collect(ctx, deploymentID, from, to, out) + if err != nil { + collectErr <- err + return + } + collectErr <- db.SaveCursor(ctx, deploymentID, newCursor) + }() + + var batch []spec.FeeEvent + for e := range out { + batch = append(batch, e) + if len(batch) >= 500 { + if err := db.UpsertEvents(ctx, batch); err != nil { + return err + } + batch = batch[:0] + } + } + if len(batch) > 0 { + if err := db.UpsertEvents(ctx, batch); err != nil { + return err + } + } + + return <-collectErr +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing required env: %s", key) + } + return v +} diff --git a/harnesses/apps/cmd/materializer/main.go b/harnesses/apps/cmd/materializer/main.go new file mode 100644 index 00000000..cc44a9f4 --- /dev/null +++ b/harnesses/apps/cmd/materializer/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/invariant" + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/ledger" +) + +func main() { + ctx := context.Background() + + db, err := ledger.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("materializer: %v", err) + } + defer db.Close() + + checker := invariant.New(db.Pool()) + + go func() { + http.Handle("/metrics", promhttp.Handler()) + http.ListenAndServe(":2112", nil) + }() + + for { + if err := runMaterialize(ctx, db, checker); err != nil { + log.Printf("materializer error: %v", err) + } + time.Sleep(5 * time.Minute) + } +} + +func runMaterialize(ctx context.Context, db *ledger.DB, checker *invariant.Checker) error { + const mv = 1 + deployments := []string{ + "dydx-v4:dydx-chain", + "hyperliquid:hypercore", + "gmx-v2:arbitrum", + "gmx-v2:avalanche", + "gains-trade:arbitrum", + "drift:solana", + "jupiter-perps:solana", + "vertex:arbitrum", + } + + for _, dep := range deployments { + if err := db.Materialize(ctx, dep, mv); err != nil { + return err + } + if err := checker.CheckAll(ctx, dep, mv); err != nil { + log.Printf("INVARIANT FAILED %s: %v — quarantining", dep, err) + if qErr := db.Quarantine(ctx, dep, err.Error()); qErr != nil { + log.Printf("quarantine write failed: %v", qErr) + } + continue + } + if err := db.RefreshMaterializedView(ctx); err != nil { + return err + } + } + return nil +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing required env: %s", key) + } + return v +} diff --git a/harnesses/apps/docker-compose.apps.yml b/harnesses/apps/docker-compose.apps.yml new file mode 100644 index 00000000..dc6136f0 --- /dev/null +++ b/harnesses/apps/docker-compose.apps.yml @@ -0,0 +1,65 @@ +# Fragment docker-compose à merger dans /opt/ocb/docker-compose.yml sur le VPS. +# Données sur /data (sdb, 200GB) — isolé du disque OS. + +services: + postgres: + image: postgres:16 + container_name: ocb-postgres + restart: unless-stopped + env_file: /run/ocb/.env.apps + volumes: + - /data/postgres:/var/lib/postgresql/data + networks: [web] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ocb -d apps"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + + postgres-exporter: + image: prometheuscommunity/postgres-exporter:latest + container_name: ocb-postgres-exporter + restart: unless-stopped + environment: + DATA_SOURCE_NAME: "postgresql://ocb:${POSTGRES_PASSWORD}@ocb-postgres:5432/apps?sslmode=disable" + networks: [web] + expose: ["9187"] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "5m", max-file: "2" } + + apps-collector: + build: /opt/ocb/harnesses/apps + container_name: ocb-apps-collector + restart: unless-stopped + env_file: /run/ocb/.env.apps + command: ["/app/collector"] + networks: [web] + expose: ["2112"] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "20m", max-file: "5" } + + apps-materializer: + build: /opt/ocb/harnesses/apps + container_name: ocb-apps-materializer + restart: unless-stopped + env_file: /run/ocb/.env.apps + command: ["/app/materializer"] + networks: [web] + expose: ["2112"] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/harnesses/apps/env.apps.example b/harnesses/apps/env.apps.example new file mode 100644 index 00000000..dc248ed6 --- /dev/null +++ b/harnesses/apps/env.apps.example @@ -0,0 +1,32 @@ +# /run/ocb/.env.apps — chiffré avec SOPS avant deploy +# Générer avec : sops encrypt --filename-override secrets/.env.apps.enc env.apps.example + +POSTGRES_USER=ocb +POSTGRES_PASSWORD=CHANGEME +POSTGRES_DB=apps +DATABASE_URL=postgresql://ocb:CHANGEME@ocb-postgres:5432/apps?sslmode=disable + +# dYdX +DYDX_INDEXER_URL=https://indexer.dydx.trade/v4 +DYDX_NODE_GRPC=https://dydx-ops-rpc.kingnodes.com:443 + +# Hyperliquid +HL_API_URL=https://api.hyperliquid.xyz/info +HL_S3_BUCKET=hl-mainnet-node-data +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 + +# GMX +ARBITRUM_RPC_URL= +AVALANCHE_RPC_URL= + +# Pyth Hermes +PYTH_HERMES_URL=https://hermes.pyth.network + +# Prometheus push (si push gateway) +PROM_PUSHGATEWAY_URL=http://ocb-prom:9091 + +# Alerting +QUARANTINE_GITHUB_TOKEN= +QUARANTINE_GITHUB_REPO=ChainBench/OpenChainBench diff --git a/harnesses/apps/go.mod b/harnesses/apps/go.mod new file mode 100644 index 00000000..1dd64ab2 --- /dev/null +++ b/harnesses/apps/go.mod @@ -0,0 +1,27 @@ +module github.com/ChainBench/OpenChainBench/harnesses/apps + +go 1.24.0 + +require ( + github.com/jackc/pgx/v5 v5.7.2 + github.com/prometheus/client_golang v1.20.5 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/apps/go.sum b/harnesses/apps/go.sum new file mode 100644 index 00000000..65512705 --- /dev/null +++ b/harnesses/apps/go.sum @@ -0,0 +1,54 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/apps/internal/collect/rest/defillama_generic.go b/harnesses/apps/internal/collect/rest/defillama_generic.go new file mode 100644 index 00000000..444bb478 --- /dev/null +++ b/harnesses/apps/internal/collect/rest/defillama_generic.go @@ -0,0 +1,189 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// DeFiLlamaCollector fetches daily fees and revenue from the DeFiLlama public +// API for any protocol slug and emits treasury + LP events per day. +// No API key required. +type DeFiLlamaCollector struct { + client *http.Client + slug string +} + +func NewDeFiLlama(slug string) *DeFiLlamaCollector { + return &DeFiLlamaCollector{ + client: &http.Client{Timeout: 30 * time.Second}, + slug: slug, + } +} + +func (c *DeFiLlamaCollector) Name() string { return "defillama:" + c.slug } + +type llamaChartResp struct { + TotalDataChart [][]json.RawMessage `json:"totalDataChart"` +} + +type llamaChartEntry struct { + Ts int64 + Value float64 +} + +func (c *DeFiLlamaCollector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + feesURL := "https://api.llama.fi/summary/fees/" + c.slug + "?dataType=dailyFees" + revURL := "https://api.llama.fi/summary/fees/" + c.slug + "?dataType=dailyRevenue" + + fees, err := c.fetchChart(ctx, feesURL) + if err != nil { + return cursor, fmt.Errorf("%s fees: %w", c.slug, err) + } + + revMap := map[int64]float64{} + revAvailable := true + if revs, err := c.fetchChart(ctx, revURL); err != nil { + revAvailable = false + } else { + for _, e := range revs { + revMap[e.Ts] = e.Value + } + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + + for _, e := range fees { + if e.Value <= 0 { + continue + } + ts := time.Unix(e.Ts, 0).UTC() + if !ts.Before(today) { + continue + } + h := uint64(e.Ts) + if h <= from.Height { + continue + } + + day := ts.Format("2006-01-02") + grossUSD := e.Value + + if !revAvailable || revMap[e.Ts] == 0 { + // Revenue split unknown — emit gross as burn. + micro := int64(math.Round(grossUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:burn:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "burn", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } else { + revUSD := revMap[e.Ts] + if revUSD > grossUSD { + revUSD = grossUSD + } + lpUSD := grossUSD - revUSD + + if revUSD > 0 { + micro := int64(math.Round(revUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:treasury:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "treasury", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + if lpUSD > 0 { + micro := int64(math.Round(lpUSD * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("llama:%s:lp:%s", c.slug, day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "lp", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", micro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + } + + cursor = spec.Cursor{Height: h, Ts: ts, Finalized: true} + } + + return cursor, nil +} + +func (c *DeFiLlamaCollector) fetchChart(ctx context.Context, url string) ([]llamaChartEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var raw llamaChartResp + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + var out []llamaChartEntry + for _, row := range raw.TotalDataChart { + if len(row) < 2 { + continue + } + var ts int64 + if err := json.Unmarshal(row[0], &ts); err != nil { + continue + } + var val float64 + if err := json.Unmarshal(row[1], &val); err != nil { + continue + } + out = append(out, llamaChartEntry{Ts: ts, Value: val}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Ts < out[j].Ts }) + return out, nil +} diff --git a/harnesses/apps/internal/collect/rest/dydx.go b/harnesses/apps/internal/collect/rest/dydx.go new file mode 100644 index 00000000..c470568d --- /dev/null +++ b/harnesses/apps/internal/collect/rest/dydx.go @@ -0,0 +1,188 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strconv" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +const dydxIndexer = "https://indexer.dydx.trade/v4" + +// dYdX v4 effective blended taker fee rate. +// Standard tier: 5bps. VIP tiers (which dominate volume): 2-4bps. +// Empirical blended rate ≈ 3.5bps (large traders ~60% of volume at 2-3bps, +// retail ~40% at 5bps). Using 5bps overstates fees by ~30%. +const dydxTakerFeeBps = 3.5 + +type DyDXCollector struct { + client *http.Client +} + +func NewDyDX() *DyDXCollector { + return &DyDXCollector{client: &http.Client{Timeout: 30 * time.Second}} +} + +func (c *DyDXCollector) Name() string { return "dydx-volume-fees" } + +type dydxCandle struct { + StartedAt string `json:"startedAt"` // "2026-08-09T00:00:00.000Z" + USDVolume string `json:"usdVolume"` +} + +func (c *DyDXCollector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + // Step 1: fetch all markets, keep those with any 24h volume. + markets, err := c.fetchActiveMarkets(ctx) + if err != nil { + return cursor, fmt.Errorf("dydx: markets: %w", err) + } + + // Step 2: fetch 90d of daily candles for each active market. + // Aggregate usdVolume per calendar day (UTC midnight). + dailyVol := map[string]float64{} // "YYYY-MM-DD" -> total USD volume + + for _, ticker := range markets { + candles, err := c.fetchCandles(ctx, ticker, 1000) + if err != nil { + // Non-fatal: skip this market if it fails. + continue + } + for _, candle := range candles { + day := candle.StartedAt[:10] // "YYYY-MM-DD" + vol, err := strconv.ParseFloat(candle.USDVolume, 64) + if err != nil || vol <= 0 { + continue + } + dailyVol[day] += vol + } + } + + // Step 3: sort days and emit fee events. + days := make([]string, 0, len(dailyVol)) + for d := range dailyVol { + days = append(days, d) + } + sort.Strings(days) + + today := time.Now().UTC().Format("2006-01-02") + + for _, day := range days { + if day >= today { + continue // skip incomplete current day + } + + ts, err := time.Parse("2006-01-02", day) + if err != nil { + continue + } + ts = ts.UTC() + h := uint64(ts.Unix()) + + if h <= from.Height { + continue + } + + vol := dailyVol[day] + if vol <= 0 { + continue + } + + // fees = volume × (takerFeeBps / 10000) + feesUSD := vol * dydxTakerFeeBps / 10000.0 + amountMicro := int64(math.Round(feesUSD * 1e6)) + + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("dydx:vol-fee:%s", day), + Ts: ts, + Height: h, + Component: "taker_fee", + Beneficiary: "burn", + Token: "USDC", + AmountRaw: fmt.Sprintf("%d", amountMicro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: "dydx-indexer-volume", + Meta: map[string]string{ + "usd_volume": fmt.Sprintf("%.2f", vol), + "fee_rate_bps": fmt.Sprintf("%.1f", dydxTakerFeeBps), + "note": "volume * 5bps taker fee; maker fee = 0 on dYdX v4", + }, + } + + cursor = spec.Cursor{Height: h + 86400, Ts: ts, Finalized: true} + } + + return cursor, nil +} + +func (c *DyDXCollector) fetchActiveMarkets(ctx context.Context) ([]string, error) { + url := fmt.Sprintf("%s/perpetualMarkets?limit=500", dydxIndexer) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + var result struct { + Markets map[string]json.RawMessage `json:"markets"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + tickers := make([]string, 0, len(result.Markets)) + for ticker := range result.Markets { + tickers = append(tickers, ticker) + } + return tickers, nil +} + +func (c *DyDXCollector) fetchCandles(ctx context.Context, ticker string, days int) ([]dydxCandle, error) { + url := fmt.Sprintf("%s/candles/perpetualMarkets/%s?resolution=1DAY&limit=%d", dydxIndexer, ticker, days) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var result struct { + Candles []dydxCandle `json:"candles"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return result.Candles, nil +} diff --git a/harnesses/apps/internal/collect/rest/gains_trade.go b/harnesses/apps/internal/collect/rest/gains_trade.go new file mode 100644 index 00000000..c56635e5 --- /dev/null +++ b/harnesses/apps/internal/collect/rest/gains_trade.go @@ -0,0 +1,181 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// DeFiLlama public fees API — exposes Gains Network daily fees computed from +// the first-party Dune table (dune.gains.result_g_trade_stats_defi_llama). +// No API key required. Covers Arbitrum, Polygon, Base, MegaETH, ApeChain. +const gainsLlamaFees = "https://api.llama.fi/summary/fees/gains-network?dataType=dailyFees" +const gainsLlamaRevenue = "https://api.llama.fi/summary/fees/gains-network?dataType=dailyRevenue" + +type GainsTradeCollector struct { + client *http.Client +} + +func NewGainsTrade() *GainsTradeCollector { + return &GainsTradeCollector{client: &http.Client{Timeout: 30 * time.Second}} +} + +func (c *GainsTradeCollector) Name() string { return "gains-trade-defillama" } + +// llamaBreakdownEntry is one row from totalDataChartBreakdown. +// [timestamp, {chain: {protocol: amount_usd}}] +type llamaBreakdownEntry struct { + Ts int64 + Chains map[string]map[string]float64 +} + +func (c *GainsTradeCollector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + fees, err := c.fetchBreakdown(ctx, gainsLlamaFees) + if err != nil { + return cursor, fmt.Errorf("gains-trade fees: %w", err) + } + revenues, err := c.fetchBreakdown(ctx, gainsLlamaRevenue) + if err != nil { + return cursor, fmt.Errorf("gains-trade revenue: %w", err) + } + + // Build revenue lookup by timestamp. + revByTs := map[int64]float64{} + for _, e := range revenues { + revByTs[e.Ts] = e.Chains["Arbitrum"]["Gains Network"] + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + // WS collector (gains_trade_ws.go) handles real-time data from this date onwards. + // DeFiLlama only backfills history before the cutoff to avoid double-counting. + wsHandoverDate := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + + for _, e := range fees { + arbFees := e.Chains["Arbitrum"]["Gains Network"] + if arbFees <= 0 { + continue + } + + ts := time.Unix(e.Ts, 0).UTC() + if !ts.Before(today) || !ts.Before(wsHandoverDate) { + continue // skip current day and days covered by WS collector + } + h := uint64(e.Ts) + if h <= from.Height { + continue + } + + arbRev := revByTs[e.Ts] + if arbRev > arbFees { + arbRev = arbFees + } + lpFees := arbFees - arbRev + + // Treasury/token-holders: GNS stakers + governance (DeFiLlama "revenue") + treasuryMicro := int64(math.Round(arbRev * 1e6)) + // LPs: gToken vault + referrals + bots + borrowing (DeFiLlama "supply-side") + lpMicro := int64(math.Round(lpFees * 1e6)) + + day := ts.Format("2006-01-02") + + if treasuryMicro > 0 { + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("gains:arb:treasury:%s", day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "treasury", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", treasuryMicro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + Meta: map[string]string{ + "gross_usd": fmt.Sprintf("%.2f", arbFees), + "source_url": gainsLlamaFees, + }, + } + } + if lpMicro > 0 { + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("gains:arb:lp:%s", day), + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "lp", + Token: "USD", + AmountRaw: fmt.Sprintf("%d", lpMicro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + + cursor = spec.Cursor{Height: h, Ts: ts, Finalized: true} + } + + return cursor, nil +} + +// llamaResp is the shape of the DeFiLlama summary/fees endpoint. +type llamaResp struct { + TotalDataChartBreakdown [][]json.RawMessage `json:"totalDataChartBreakdown"` +} + +func (c *GainsTradeCollector) fetchBreakdown(ctx context.Context, url string) ([]llamaBreakdownEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from DeFiLlama", resp.StatusCode) + } + + var raw llamaResp + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + var out []llamaBreakdownEntry + for _, row := range raw.TotalDataChartBreakdown { + if len(row) < 2 { + continue + } + var ts int64 + if err := json.Unmarshal(row[0], &ts); err != nil { + continue + } + var chains map[string]map[string]float64 + if err := json.Unmarshal(row[1], &chains); err != nil { + continue + } + out = append(out, llamaBreakdownEntry{Ts: ts, Chains: chains}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Ts < out[j].Ts }) + return out, nil +} diff --git a/harnesses/apps/internal/collect/rest/gains_trade_ws.go b/harnesses/apps/internal/collect/rest/gains_trade_ws.go new file mode 100644 index 00000000..2b95e249 --- /dev/null +++ b/harnesses/apps/internal/collect/rest/gains_trade_ws.go @@ -0,0 +1,232 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "math/big" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +const gainsWSS = "wss://backend-arbitrum.gains.trade" +const gainsArbRPC = "https://arb1.arbitrum.io/rpc" + +// topic0 hashes extracted from on-chain receipts (Gains Diamond, Arbitrum) +const ( + topicGovFeeCharged = "0xeb561f0609b402569e8a7e8fe9d4f408b92c96fb83001b2cd78fd55c29bbbac3" + topicGTokenFeeCharged = "0xfe4ab97508a97bb85ad1e2680662e58549e51982d965eed4ef6d7fcd4cc4295f" +) + +// collateral token decimals on Arbitrum (Gains collateralIndex → decimals) +var gainsCollDecimals = map[int]int{ + 1: 6, // USDC.e + 2: 18, // DAI + 3: 6, // USDC (native) + 4: 18, // ETH +} + +// GainsTradeWSCollector streams real-time trade events from the gTrade first-party +// WebSocket and resolves exact fee amounts via eth_getTransactionReceipt. +// arb1.arbitrum.io allows receipt lookups without auth; no API key needed. +type GainsTradeWSCollector struct { + client *http.Client +} + +func NewGainsTradeWS() *GainsTradeWSCollector { + return &GainsTradeWSCollector{client: &http.Client{Timeout: 10 * time.Second}} +} + +type wsTradeMsg struct { + Name string `json:"name"` + Date string `json:"date"` + TX string `json:"tx"` + CollateralIndex string `json:"collateralIndex"` + CollateralPriceUsd float64 `json:"collateralPriceUsd"` +} + +// Stream connects to the gTrade Arbitrum WebSocket and sends fee events for every +// trade that settled after `after`. It returns on connection error; the caller +// is responsible for reconnecting. +func (c *GainsTradeWSCollector) Stream(ctx context.Context, deploymentID string, after time.Time, out chan<- spec.FeeEvent) error { + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, _, err := dialer.DialContext(ctx, gainsWSS, nil) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + + var msg wsTradeMsg + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg.Name != "new-trade-history" || msg.TX == "" { + continue + } + + ts, err := parseGainsDate(msg.Date) + if err != nil || !ts.After(after) { + continue + } + if msg.CollateralPriceUsd <= 0 { + msg.CollateralPriceUsd = 1.0 + } + + collIdx := 3 + if n, err := strconv.Atoi(msg.CollateralIndex); err == nil { + collIdx = n + } + decimals := gainsCollDecimals[collIdx] + if decimals == 0 { + decimals = 6 + } + + govMicro, lpMicro, err := c.fetchFees(ctx, msg.TX, decimals, msg.CollateralPriceUsd) + if err != nil || (govMicro == 0 && lpMicro == 0) { + continue + } + + h := uint64(ts.Unix()) + txKey := strings.ToLower(msg.TX) + + if govMicro > 0 { + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: "gains:arb:treasury:tx:" + txKey, + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "treasury", + Token: "USD", + AmountRaw: strconv.FormatInt(govMicro, 10), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: "gains-trade-ws", + } + } + if lpMicro > 0 { + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: "gains:arb:lp:tx:" + txKey, + Ts: ts, + Height: h, + Component: "position_fee", + Beneficiary: "lp", + Token: "USD", + AmountRaw: strconv.FormatInt(lpMicro, 10), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: "gains-trade-ws", + } + } + } +} + +func parseGainsDate(s string) (time.Time, error) { + for _, layout := range []string{"2006-01-02T15:04:05.999Z", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unparseable date: %s", s) +} + +type arbRPCReq struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []any `json:"params"` + ID int `json:"id"` +} + +type arbRPCResp struct { + Result *struct { + Logs []struct { + Topics []string `json:"topics"` + Data string `json:"data"` + } `json:"logs"` + } `json:"result"` + Error *struct{ Message string } `json:"error"` +} + +func (c *GainsTradeWSCollector) fetchFees(ctx context.Context, txHash string, collDecimals int, collPriceUSD float64) (govMicro, lpMicro int64, err error) { + body, _ := json.Marshal(arbRPCReq{ + JSONRPC: "2.0", + Method: "eth_getTransactionReceipt", + Params: []any{txHash}, + ID: 1, + }) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, gainsArbRPC, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return 0, 0, err + } + defer resp.Body.Close() + + var rpc arbRPCResp + if err := json.NewDecoder(resp.Body).Decode(&rpc); err != nil { + return 0, 0, err + } + if rpc.Error != nil { + return 0, 0, fmt.Errorf("rpc: %s", rpc.Error.Message) + } + if rpc.Result == nil { + return 0, 0, fmt.Errorf("no receipt: %s", txHash) + } + + divisor := new(big.Float).SetFloat64(math.Pow10(collDecimals)) + + for _, log := range rpc.Result.Logs { + if len(log.Topics) == 0 { + continue + } + t0 := log.Topics[0] + if t0 != topicGovFeeCharged && t0 != topicGTokenFeeCharged { + continue + } + rawHex := strings.TrimPrefix(log.Data, "0x") + if len(rawHex) < 64 { + continue + } + rawInt := new(big.Int) + rawInt.SetString(rawHex[:64], 16) + + amtF := new(big.Float).SetInt(rawInt) + amtF.Quo(amtF, divisor) + amtUSD, _ := amtF.Float64() + micro := int64(math.Round(amtUSD * collPriceUSD * 1e6)) + + switch t0 { + case topicGovFeeCharged: + govMicro += micro + case topicGTokenFeeCharged: + lpMicro += micro + } + } + + return govMicro, lpMicro, nil +} diff --git a/harnesses/apps/internal/collect/rest/gmx_v2.go b/harnesses/apps/internal/collect/rest/gmx_v2.go new file mode 100644 index 00000000..8dd82a8d --- /dev/null +++ b/harnesses/apps/internal/collect/rest/gmx_v2.go @@ -0,0 +1,301 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// GMX v2 Subsquid GraphQL endpoints (first-party, GMX-operated). +// Amounts use GMX's internal PRICE_PRECISION (1e30) — store raw with Decimals=30. +const ( + gmxArbitrumSQ = "https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql" + gmxAvaxSQ = "https://gmx.squids.live/gmx-synthetics-avalanche:prod/api/graphql" +) + +// Protocol revenue split per GMX governance (as of 2026): +// +// 63% → GM / GLV LPs (beneficiary = "lp") +// 27% → GMX buyback (beneficiary = "burn") +// 10% → Treasury (beneficiary = "treasury") +// +// Note: staking distributions suspended as of Mar 2026; buyback tokens +// accumulate in treasury. We still book accrual at 37% protocol revenue. +const ( + gmxLPBps = 6300 // 63% + gmxBuybackBps = 2700 // 27% + gmxTreasuryBps = 1000 // 10% +) + +type GMXv2Collector struct { + client *http.Client + endpoint string + chainID string +} + +func NewGMXv2Arbitrum() *GMXv2Collector { + return &GMXv2Collector{ + client: &http.Client{Timeout: 30 * time.Second}, + endpoint: gmxArbitrumSQ, + chainID: "arbitrum", + } +} + +func NewGMXv2Avalanche() *GMXv2Collector { + return &GMXv2Collector{ + client: &http.Client{Timeout: 30 * time.Second}, + endpoint: gmxAvaxSQ, + chainID: "avalanche", + } +} + +func (c *GMXv2Collector) Name() string { + return fmt.Sprintf("gmx-v2-%s-subsquid", c.chainID) +} + +type gmxDayFees struct { + Ts int64 + PosGross string // totalPositionFeeUsd (BigInt, 1e30) + PosForPool string // totalPositionFeeUsdForPool (BigInt, 1e30) + SwapRecv string // totalFeeReceiverUsd from swapFees (BigInt, 1e30) + SwapForPool string // totalFeeUsdForPool from swapFees (BigInt, 1e30) + BorrowPool string // totalBorrowingFeeUsd → all to pool (BigInt, 1e30) +} + +func (c *GMXv2Collector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + // Fetch last 90 days of daily data from Subsquid. + // cursor.Height is the unix timestamp (seconds) of the last processed day. + tsFrom := int(from.Height) + if tsFrom == 0 { + // Default: start 90 days ago. + tsFrom = int(time.Now().AddDate(0, 0, -90).Truncate(24 * time.Hour).Unix()) + } + tsTo := int(to.Ts.Truncate(24 * time.Hour).Unix()) + + days, err := c.fetchDays(ctx, tsFrom, tsTo) + if err != nil { + return cursor, fmt.Errorf("gmx-v2: %w", err) + } + + for _, d := range days { + if uint64(d.Ts) <= from.Height { + continue + } + ts := time.Unix(d.Ts, 0).UTC() + + // Skip current incomplete day. + if ts.Truncate(24 * time.Hour).Equal(time.Now().UTC().Truncate(24 * time.Hour)) { + continue + } + + emitBigInt := func(component, beneficiary, amountRaw string) { + if amountRaw == "" || amountRaw == "0" { + return + } + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("gmx-v2:%s:%d:%s:%s", c.chainID, d.Ts, component, beneficiary), + Ts: ts, + Height: uint64(d.Ts), + Component: component, + Beneficiary: beneficiary, + Token: "USD", + AmountRaw: amountRaw, + Decimals: 30, + Market: "all", + Finality: spec.FinalityFinal, + Source: c.Name(), + } + } + + // Position fees: gross = PosGross, split between LP (posForPool) and protocol (posGross - posForPool). + // Compute posForProtocol = posGross - posForPool in string form for BigInt safety. + posProtocol := bigSubStr(d.PosGross, d.PosForPool) + emitBigInt("position_fee", "lp", d.PosForPool) + emitBigInt("position_fee", "burn", posProtocol) + + // Swap fees: receiver is protocol's cut, pool is LP's cut. + emitBigInt("swap_fee", "burn", d.SwapRecv) + emitBigInt("swap_fee", "lp", d.SwapForPool) + + // Borrowing fees go entirely to the pool (LPs), not to protocol. + emitBigInt("borrow_fee", "lp", d.BorrowPool) + + cursor = spec.Cursor{Height: uint64(d.Ts), Ts: ts, Finalized: true} + } + + return cursor, nil +} + +// fetchDays queries both positionFees and swapFees daily data and merges by timestamp. +func (c *GMXv2Collector) fetchDays(ctx context.Context, tsFrom, tsTo int) ([]gmxDayFees, error) { + // Fetch position fees. + posQuery := fmt.Sprintf(`{ + positionFeesInfoWithPeriods(orderBy:timestamp_ASC, where:{period_eq:"1d",timestamp_gte:%d,timestamp_lt:%d}, limit:500) { + timestamp totalPositionFeeUsd totalPositionFeeUsdForPool totalBorrowingFeeUsd + } + }`, tsFrom, tsTo) + + type posEntry struct { + Timestamp int `json:"timestamp"` + TotalPositionFeeUsd string `json:"totalPositionFeeUsd"` + TotalPositionFeeUsdForPool string `json:"totalPositionFeeUsdForPool"` + TotalBorrowingFeeUsd string `json:"totalBorrowingFeeUsd"` + } + var posResp struct { + Data struct { + Items []posEntry `json:"positionFeesInfoWithPeriods"` + } `json:"data"` + Errors []struct{ Message string } `json:"errors"` + } + if err := c.gqlQuery(ctx, posQuery, &posResp); err != nil { + return nil, fmt.Errorf("position fees: %w", err) + } + if len(posResp.Errors) > 0 { + return nil, fmt.Errorf("position fees gql: %s", posResp.Errors[0].Message) + } + + // Fetch swap fees. + swapQuery := fmt.Sprintf(`{ + swapFeesInfoWithPeriods(orderBy:timestamp_ASC, where:{period_eq:"1d",timestamp_gte:%d,timestamp_lt:%d}, limit:500) { + timestamp totalFeeReceiverUsd totalFeeUsdForPool + } + }`, tsFrom, tsTo) + + type swapEntry struct { + Timestamp int `json:"timestamp"` + TotalFeeReceiverUsd string `json:"totalFeeReceiverUsd"` + TotalFeeUsdForPool string `json:"totalFeeUsdForPool"` + } + var swapResp struct { + Data struct { + Items []swapEntry `json:"swapFeesInfoWithPeriods"` + } `json:"data"` + Errors []struct{ Message string } `json:"errors"` + } + if err := c.gqlQuery(ctx, swapQuery, &swapResp); err != nil { + return nil, fmt.Errorf("swap fees: %w", err) + } + if len(swapResp.Errors) > 0 { + return nil, fmt.Errorf("swap fees gql: %s", swapResp.Errors[0].Message) + } + + // Merge by timestamp. + posByTs := map[int]posEntry{} + for _, p := range posResp.Data.Items { + posByTs[p.Timestamp] = p + } + swapByTs := map[int]swapEntry{} + for _, s := range swapResp.Data.Items { + swapByTs[s.Timestamp] = s + } + + // Collect all unique timestamps. + seen := map[int]bool{} + for ts := range posByTs { + seen[ts] = true + } + for ts := range swapByTs { + seen[ts] = true + } + + var out []gmxDayFees + for ts := range seen { + p := posByTs[ts] + s := swapByTs[ts] + out = append(out, gmxDayFees{ + Ts: int64(ts), + PosGross: orZero(p.TotalPositionFeeUsd), + PosForPool: orZero(p.TotalPositionFeeUsdForPool), + SwapRecv: orZero(s.TotalFeeReceiverUsd), + SwapForPool: orZero(s.TotalFeeUsdForPool), + BorrowPool: orZero(p.TotalBorrowingFeeUsd), + }) + } + + // Sort by timestamp ascending. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Ts < out[j-1].Ts; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func (c *GMXv2Collector) gqlQuery(ctx context.Context, query string, dest interface{}) error { + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(dest) +} + +// bigSubStr subtracts two base-10 integer strings: a - b. +// Uses strconv for small values; falls back to manual subtraction for large BigInt strings. +func bigSubStr(a, b string) string { + // Simple approach: parse both, subtract. + // For 30-decimal GMX values, a and b are ~35 digits — use Go's math/big indirectly + // by doing string arithmetic. Since b <= a always (pool ≤ gross), result is non-negative. + if a == "" || a == "0" { + return "0" + } + if b == "" || b == "0" { + return a + } + // Pad to same length. + for len(a) < len(b) { + a = "0" + a + } + for len(b) < len(a) { + b = "0" + b + } + result := make([]byte, len(a)) + borrow := 0 + for i := len(a) - 1; i >= 0; i-- { + diff := int(a[i]-'0') - int(b[i]-'0') - borrow + if diff < 0 { + diff += 10 + borrow = 1 + } else { + borrow = 0 + } + result[i] = byte('0' + diff) + } + // Trim leading zeros. + s := string(result) + for len(s) > 1 && s[0] == '0' { + s = s[1:] + } + return s +} + +func orZero(s string) string { + if s == "" { + return "0" + } + return s +} diff --git a/harnesses/apps/internal/collect/rest/hyperliquid.go b/harnesses/apps/internal/collect/rest/hyperliquid.go new file mode 100644 index 00000000..dd4d35d3 --- /dev/null +++ b/harnesses/apps/internal/collect/rest/hyperliquid.go @@ -0,0 +1,144 @@ +package rest + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +// fillsAggURL is the hl-fills-agg service running on the SGP node alongside +// the Hyperliquid non-validating node. It reads hourly node_fills_by_block +// files and returns daily fee totals (gross, builder, net) in USDC. +const fillsAggURL = "http://15.235.224.14:2116" + +// fillsEpoch is the earliest date available in the node fills archive. +const fillsEpoch = "20260601" + +type HyperliquidCollector struct { + client *http.Client +} + +func NewHyperliquid() *HyperliquidCollector { + return &HyperliquidCollector{client: &http.Client{Timeout: 60 * time.Second}} +} + +func (c *HyperliquidCollector) Name() string { return "hyperliquid-node-fills" } + +type hlDailySummary struct { + Date string `json:"date"` // "YYYYMMDD" + GrossUSDC float64 `json:"gross_usdc"` // all fees paid (taker + maker) + BuilderUSDC float64 `json:"builder_usdc"` // third-party builder codes cut + NetUSDC float64 `json:"net_usdc"` // gross - builder → goes to AF + HLP + deployers + Fills int64 `json:"fills"` + Hours int `json:"hours"` +} + +func (c *HyperliquidCollector) Collect( + ctx context.Context, + deploymentID string, + from, to spec.Cursor, + out chan<- spec.FeeEvent, +) (spec.Cursor, error) { + cursor := from + + // Determine the start date string. + // cursor.Height encodes the unix timestamp (seconds) of the last processed + // day's midnight UTC. Zero means start from the fills epoch. + var startDate string + if from.Height == 0 { + startDate = fillsEpoch + } else { + startDate = time.Unix(int64(from.Height), 0).UTC().Format("20060102") + } + endDate := to.Ts.UTC().Format("20060102") + + days, err := c.fetchDailySummaries(ctx, startDate, endDate) + if err != nil { + return cursor, fmt.Errorf("hyperliquid: fills-agg: %w", err) + } + + for _, d := range days { + ts, err := time.Parse("20060102", d.Date) + if err != nil { + continue + } + ts = ts.UTC() + + h := uint64(ts.Unix()) + if h < from.Height { + continue + } + + // Skip partial days (< 23 hours) — they're incomplete. + // Today's day will be partial and should not be persisted. + if d.Hours < 23 { + continue + } + + if d.NetUSDC <= 0 { + continue + } + + // net_usdc = all fills fees minus builder codes. + // This is the total amount that flows to AF + HLP + deployers. + // We classify as taker_fee/burn since the vast majority (~97-99%) + // goes to the Assistance Fund (which burns HYPE), per the HL docs. + // The HLP vault share and deployer share are not separable per-fill + // and are small relative to the total. + amountMicro := int64(math.Round(d.NetUSDC * 1e6)) + out <- spec.FeeEvent{ + DeploymentID: deploymentID, + EventKey: fmt.Sprintf("hl:node-fills:%s:net", d.Date), + Ts: ts, + Height: h, + Component: "taker_fee", + Beneficiary: "burn", + Token: "USDC", + AmountRaw: fmt.Sprintf("%d", amountMicro), + Decimals: 6, + Market: "all", + Finality: spec.FinalityFinal, + Source: "hyperliquid-node-fills", + Meta: map[string]string{ + "gross_usdc": fmt.Sprintf("%.2f", d.GrossUSDC), + "builder_usdc": fmt.Sprintf("%.2f", d.BuilderUSDC), + "fills": fmt.Sprintf("%d", d.Fills), + "hours": fmt.Sprintf("%d", d.Hours), + }, + } + + cursor = spec.Cursor{Height: h + 86400, Ts: ts, Finalized: true} + } + + return cursor, nil +} + +func (c *HyperliquidCollector) fetchDailySummaries(ctx context.Context, from, to string) ([]hlDailySummary, error) { + url := fmt.Sprintf("%s/daily?from=%s&to=%s", fillsAggURL, from, to) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "ocb-apps/1.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + var result []hlDailySummary + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return result, nil +} diff --git a/harnesses/apps/internal/invariant/checks.go b/harnesses/apps/internal/invariant/checks.go new file mode 100644 index 00000000..732fdab4 --- /dev/null +++ b/harnesses/apps/internal/invariant/checks.go @@ -0,0 +1,130 @@ +package invariant + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var failures = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "ocb_app_invariant_failures_total", + Help: "Number of accounting invariant failures by deployment and invariant name.", +}, []string{"deployment", "invariant"}) + +type Checker struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Checker { + return &Checker{pool: pool} +} + +// CheckAll runs all invariants for a deployment after a materializer run. +// Returns the first error found, or nil if all pass. +func (c *Checker) CheckAll(ctx context.Context, deploymentID string, methodologyVersion int) error { + if err := c.checkI1GrossSum(ctx, deploymentID, methodologyVersion); err != nil { + failures.WithLabelValues(deploymentID, "I1").Inc() + return fmt.Errorf("I1 (gross sum): %w", err) + } + if err := c.checkI4Additivity(ctx, deploymentID, methodologyVersion); err != nil { + failures.WithLabelValues(deploymentID, "I4").Inc() + return fmt.Errorf("I4 (additivity): %w", err) + } + if err := c.checkI3NoNegatives(ctx, deploymentID); err != nil { + failures.WithLabelValues(deploymentID, "I3").Inc() + return fmt.Errorf("I3 (no negatives): %w", err) + } + return nil +} + +// I1: Σ(beneficiary amounts) == gross_fees — exact, not approximate. +// Any discrepancy reveals a decoder bug we want to see. +func (c *Checker) checkI1GrossSum(ctx context.Context, deploymentID string, mv int) error { + var unallocatedBPS float64 + err := c.pool.QueryRow(ctx, ` + WITH gross AS ( + SELECT SUM(amount_usd) AS total + FROM fee_facts + WHERE deployment_id = $1 + AND methodology_version = $2 + AND component NOT IN ('funding_payment','external_bribe','sequencer_revenue','maker_rebate') + ), + allocated AS ( + SELECT SUM(amount_usd) AS total + FROM fee_facts + WHERE deployment_id = $1 + AND methodology_version = $2 + ) + SELECT CASE WHEN gross.total > 0 + THEN ABS(gross.total - allocated.total) / gross.total * 10000 + ELSE 0 END + FROM gross, allocated`, + deploymentID, mv, + ).Scan(&unallocatedBPS) + if err != nil { + return err + } + if unallocatedBPS > 50 { + return fmt.Errorf("unallocated = %.1f bps (threshold 50 bps)", unallocatedBPS) + } + return nil +} + +// I4: sum of hourly buckets == sum of the full window — exact, not epsilon. +// Tests that [t0,t1) windows compose correctly. +func (c *Checker) checkI4Additivity(ctx context.Context, deploymentID string, mv int) error { + var diff float64 + err := c.pool.QueryRow(ctx, ` + WITH full_window AS ( + SELECT SUM(amount_usd) AS total + FROM fee_facts + WHERE deployment_id = $1 + AND methodology_version = $2 + AND bucket_start >= now() - INTERVAL '24 hours' + AND bucket_start < now() + ), + sum_of_halves AS ( + SELECT + SUM(CASE WHEN bucket_start < now() - INTERVAL '12 hours' THEN amount_usd ELSE 0 END) + + SUM(CASE WHEN bucket_start >= now() - INTERVAL '12 hours' THEN amount_usd ELSE 0 END) + AS total + FROM fee_facts + WHERE deployment_id = $1 + AND methodology_version = $2 + AND bucket_start >= now() - INTERVAL '24 hours' + AND bucket_start < now() + ) + SELECT ABS(COALESCE(full_window.total,0) - COALESCE(sum_of_halves.total,0)) + FROM full_window, sum_of_halves`, + deploymentID, mv, + ).Scan(&diff) + if err != nil { + return err + } + if diff != 0 { + return fmt.Errorf("additivity broken: diff = %f (must be exactly 0)", diff) + } + return nil +} + +// I3: no negative amounts except maker_rebate. +func (c *Checker) checkI3NoNegatives(ctx context.Context, deploymentID string) error { + var count int + err := c.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM fee_events + WHERE deployment_id = $1 + AND amount_raw < 0 + AND component != 'maker_rebate'`, + deploymentID, + ).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("%d events with negative amount_raw (non-rebate)", count) + } + return nil +} diff --git a/harnesses/apps/internal/ledger/ledger.go b/harnesses/apps/internal/ledger/ledger.go new file mode 100644 index 00000000..b8c3c825 --- /dev/null +++ b/harnesses/apps/internal/ledger/ledger.go @@ -0,0 +1,148 @@ +package ledger + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ChainBench/OpenChainBench/harnesses/apps/internal/spec" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, connStr string) (*DB, error) { + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return nil, fmt.Errorf("ledger: connect: %w", err) + } + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { db.pool.Close() } + +// UpsertEvents inserts fee events idempotently. +// A duplicate (deployment_id, event_key, beneficiary) is silently ignored. +func (db *DB) UpsertEvents(ctx context.Context, events []spec.FeeEvent) error { + if len(events) == 0 { + return nil + } + + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("ledger: begin: %w", err) + } + defer tx.Rollback(ctx) + + for _, e := range events { + _, err := tx.Exec(ctx, ` + INSERT INTO fee_events + (deployment_id, event_key, beneficiary, ts, height, + component, token, amount_raw, decimals, market, + finality, source, meta) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8::numeric,$9,$10,$11,$12,$13) + ON CONFLICT (deployment_id, event_key, beneficiary) DO NOTHING`, + e.DeploymentID, e.EventKey, e.Beneficiary, e.Ts, e.Height, + e.Component, e.Token, e.AmountRaw, e.Decimals, e.Market, + string(e.Finality), e.Source, metaToJSON(e.Meta), + ) + if err != nil { + return fmt.Errorf("ledger: upsert event %s: %w", e.EventKey, err) + } + } + + return tx.Commit(ctx) +} + +// GetCursor returns the last known cursor for a deployment. +func (db *DB) GetCursor(ctx context.Context, deploymentID string) (spec.Cursor, error) { + var c spec.Cursor + err := db.pool.QueryRow(ctx, ` + SELECT height, COALESCE(block_hash,''), ts, finalized + FROM collector_cursors WHERE deployment_id = $1`, deploymentID, + ).Scan(&c.Height, &c.BlockHash, &c.Ts, &c.Finalized) + if err != nil { + return spec.Cursor{}, nil // no cursor yet = start from genesis + } + return c, nil +} + +// SaveCursor upserts the cursor after a successful batch. +func (db *DB) SaveCursor(ctx context.Context, deploymentID string, c spec.Cursor) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO collector_cursors (deployment_id, height, block_hash, ts, finalized, updated_at) + VALUES ($1,$2,$3,$4,$5,now()) + ON CONFLICT (deployment_id) DO UPDATE SET + height=EXCLUDED.height, block_hash=EXCLUDED.block_hash, + ts=EXCLUDED.ts, finalized=EXCLUDED.finalized, updated_at=now()`, + deploymentID, c.Height, c.BlockHash, c.Ts, c.Finalized, + ) + return err +} + +// Pool exposes the underlying pool for the invariant checker. +func (db *DB) Pool() *pgxpool.Pool { return db.pool } + +// Materialize computes fee_facts from fee_events for a deployment. +// Prices are stored as 1.0 for USDC (stable_passthrough); extend for non-stable tokens. +func (db *DB) Materialize(ctx context.Context, deploymentID string, mv int) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO fee_facts + (deployment_id, bucket_start, methodology_version, + component, beneficiary, token, + amount_native, price_usd, price_source, price_ts, price_confidence, + amount_usd, event_count, finality) + SELECT + deployment_id, + date_trunc('hour', ts) AS bucket_start, + $2 AS methodology_version, + component, + beneficiary, + token, + SUM(amount_raw::numeric / POW(10, decimals)) AS amount_native, + 1.0 AS price_usd, + 'stable_passthrough' AS price_source, + date_trunc('hour', ts) AS price_ts, + 'high' AS price_confidence, + SUM(amount_raw::numeric / POW(10, decimals)) AS amount_usd, + COUNT(*) AS event_count, + MIN(finality) AS finality + FROM fee_events + WHERE deployment_id = $1 + GROUP BY deployment_id, date_trunc('hour', ts), component, beneficiary, token + ON CONFLICT (deployment_id, bucket_start, methodology_version, component, beneficiary, token) + DO UPDATE SET + amount_native = EXCLUDED.amount_native, + amount_usd = EXCLUDED.amount_usd, + event_count = EXCLUDED.event_count, + finality = EXCLUDED.finality`, + deploymentID, mv, + ) + return err +} + +// Quarantine inserts a quarantine record for a deployment bucket. +func (db *DB) Quarantine(ctx context.Context, deploymentID, reason string) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO quarantine (deployment_id, bucket_start, reason, opened_at) + VALUES ($1, date_trunc('hour', now()), $2, now()) + ON CONFLICT (deployment_id, bucket_start, reason) DO NOTHING`, + deploymentID, reason, + ) + return err +} + +// RefreshMaterializedView refreshes fee_facts_windowed. +func (db *DB) RefreshMaterializedView(ctx context.Context) error { + _, err := db.pool.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY fee_facts_windowed`) + return err +} + +func metaToJSON(m map[string]string) interface{} { + if len(m) == 0 { + return nil + } + return m +} diff --git a/harnesses/apps/internal/spec/types.go b/harnesses/apps/internal/spec/types.go new file mode 100644 index 00000000..9177ee7d --- /dev/null +++ b/harnesses/apps/internal/spec/types.go @@ -0,0 +1,55 @@ +package spec + +import ( + "context" + "time" +) + +// HalfOpenRange enforces [From, To) semantics everywhere. +// No function in this codebase accepts two bare time.Time values for a window. +type HalfOpenRange struct { + From time.Time // inclusive + To time.Time // exclusive +} + +func (r HalfOpenRange) Contains(t time.Time) bool { + return !t.Before(r.From) && t.Before(r.To) +} + +type Finality string + +const ( + FinalityProvisional Finality = "provisional" + FinalityFinal Finality = "final" +) + +// FeeEvent is a raw fact in native units. No USD, no float. +type FeeEvent struct { + DeploymentID string + EventKey string // chain:tx_hash:log_index:sub — idempotence key + Ts time.Time + Height uint64 + Component string + Beneficiary string + Token string + AmountRaw string // NUMERIC(78,0) as string — never float64 + Decimals uint8 + Market string + Finality Finality + Source string + Meta map[string]string +} + +type Cursor struct { + Height uint64 + BlockHash string + Ts time.Time + Finalized bool +} + +// Collector is the only interface collectors implement. +// `to` is EXCLUDED — all windows are [from, to). +type Collector interface { + Name() string + Collect(ctx context.Context, deploymentID string, from, to Cursor, out chan<- FeeEvent) (Cursor, error) +} diff --git a/harnesses/apps/migrations/001_init.sql b/harnesses/apps/migrations/001_init.sql new file mode 100644 index 00000000..fbea9b6a --- /dev/null +++ b/harnesses/apps/migrations/001_init.sql @@ -0,0 +1,181 @@ +-- OpenChainBench /apps — ledger initial +-- Principe : fee_events est immuable et fait autorité. Tout le reste est dérivé +-- et reconstructible. Aucun UPDATE sur fee_events, uniquement des upserts idempotents. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- Ledger brut. Unités natives, aucune conversion, aucun float. +-- --------------------------------------------------------------------------- +CREATE TABLE fee_events ( + deployment_id TEXT NOT NULL, + event_key TEXT NOT NULL, -- chain:tx_hash:log_index:sub + beneficiary TEXT NOT NULL, + ts TIMESTAMPTZ NOT NULL, -- timestamp du bloc, UTC + height BIGINT NOT NULL, + component TEXT NOT NULL, + token TEXT NOT NULL, + amount_raw NUMERIC(78,0) NOT NULL, -- entier natif. JAMAIS de float ici. + decimals SMALLINT NOT NULL, + market TEXT, + finality TEXT NOT NULL DEFAULT 'provisional', + source TEXT NOT NULL, + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + meta JSONB, + -- beneficiary fait partie de la clé : un même log émet souvent plusieurs + -- allocations (feeAmountForPool + feeReceiverAmount dans un PositionFeesCollected). + PRIMARY KEY (deployment_id, event_key, beneficiary), + CONSTRAINT finality_valid CHECK (finality IN ('provisional','final')) +); + +CREATE INDEX fee_events_ts ON fee_events (deployment_id, ts); +CREATE INDEX fee_events_height ON fee_events (deployment_id, height); +CREATE INDEX fee_events_provisional ON fee_events (deployment_id, height) + WHERE finality = 'provisional'; -- pour le rembobinage après reorg + +-- --------------------------------------------------------------------------- +-- Faits dérivés. Jetables : DELETE + recompute est une opération normale. +-- --------------------------------------------------------------------------- +CREATE TABLE fee_facts ( + deployment_id TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, -- bucket horaire, semi-ouvert [start, start+1h) + methodology_version INT NOT NULL, + component TEXT NOT NULL, + beneficiary TEXT NOT NULL, + token TEXT NOT NULL, + amount_native NUMERIC NOT NULL, + -- Le prix utilisé est stocké. Sans lui, un chiffre USD n'est pas reproductible + -- et donc pas auditable — c'est tout l'argument du produit. + price_usd NUMERIC NOT NULL, + price_source TEXT NOT NULL, + price_ts TIMESTAMPTZ NOT NULL, + price_confidence TEXT NOT NULL DEFAULT 'high', + amount_usd NUMERIC NOT NULL, + event_count INT NOT NULL, + finality TEXT NOT NULL, + PRIMARY KEY (deployment_id, bucket_start, methodology_version, + component, beneficiary, token), + CONSTRAINT price_confidence_valid CHECK (price_confidence IN ('high','medium','low')) +); + +CREATE INDEX fee_facts_bucket ON fee_facts (bucket_start, methodology_version); + +-- Témoins. Table séparée : jamais jointe aux faits canoniques, jamais sommée avec eux. +CREATE TABLE fee_facts_witness ( + deployment_id TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + witness_source TEXT NOT NULL, + metric TEXT NOT NULL, + amount_usd NUMERIC NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (deployment_id, bucket_start, witness_source, metric) +); + +-- --------------------------------------------------------------------------- +-- Émissions. tokens_emitted est brut et séparé du prix : n'importe qui doit +-- pouvoir re-pricer avec sa propre source. +-- --------------------------------------------------------------------------- +CREATE TABLE emissions_daily ( + deployment_id TEXT NOT NULL, + day DATE NOT NULL, + token TEXT NOT NULL, + scope TEXT NOT NULL, -- incentives_only | all_inflation + tokens_emitted NUMERIC NOT NULL, + price_usd NUMERIC NOT NULL, + price_min_usd NUMERIC, -- bande d'incertitude si volatilité > 15% + price_max_usd NUMERIC, + price_method TEXT NOT NULL DEFAULT 'daily_twap', + cost_usd NUMERIC NOT NULL, + PRIMARY KEY (deployment_id, day, token, scope) +); + +-- --------------------------------------------------------------------------- +-- Paramètres d'allocation lus on-chain et versionnés dans le temps. +-- C'est la table qui empêche le bug Lido : aucun taux n'est appliqué en dehors +-- de son intervalle de validité observé. +-- --------------------------------------------------------------------------- +CREATE TABLE allocation_params ( + deployment_id TEXT NOT NULL, + param TEXT NOT NULL, + value NUMERIC NOT NULL, + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source TEXT NOT NULL, -- contrat/méthode lue, ou URL de gouvernance + PRIMARY KEY (deployment_id, param, valid_from) +); + +-- Interdit deux valeurs simultanées pour un même paramètre. +CREATE EXTENSION IF NOT EXISTS btree_gist; +ALTER TABLE allocation_params + ADD CONSTRAINT allocation_params_no_overlap + EXCLUDE USING gist ( + deployment_id WITH =, + param WITH =, + tstzrange(valid_from, COALESCE(valid_to, 'infinity'::timestamptz)) WITH && + ); + +-- --------------------------------------------------------------------------- +-- Curseurs et audit +-- --------------------------------------------------------------------------- +CREATE TABLE collector_cursors ( + deployment_id TEXT PRIMARY KEY, + height BIGINT NOT NULL, + block_hash TEXT, -- détection de reorg + ts TIMESTAMPTZ NOT NULL, + finalized BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE methodology_versions ( + slug TEXT NOT NULL, + version INT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + summary TEXT NOT NULL, + diff_json JSONB NOT NULL, -- alimente /apps/changelog + PRIMARY KEY (slug, version) +); + +CREATE TABLE quarantine ( + deployment_id TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + reason TEXT NOT NULL, + detail JSONB, + issue_url TEXT, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + PRIMARY KEY (deployment_id, bucket_start, reason) +); + +-- --------------------------------------------------------------------------- +-- Vue de service du leaderboard. ~40 lignes pour le MVP. +-- Rafraîchie à chaque run du materializer ; la page ne calcule rien. +-- --------------------------------------------------------------------------- +CREATE MATERIALIZED VIEW fee_facts_windowed AS +SELECT + f.deployment_id, + w.window_label, + f.methodology_version, + SUM(f.amount_usd) FILTER (WHERE f.component NOT IN + ('funding_payment','external_bribe','sequencer_revenue','maker_rebate')) AS gross_fees_usd, + SUM(f.amount_usd) FILTER (WHERE f.beneficiary = 'lp') AS lp_revenue_usd, + SUM(f.amount_usd) FILTER (WHERE f.beneficiary IN ('treasury','insurance_fund')) AS protocol_cut_usd, + SUM(f.amount_usd) FILTER (WHERE f.beneficiary IN ('token_holder','staker','burn')) AS token_holder_value_usd, + MIN(f.finality) AS finality, + SUM(f.event_count) AS event_count +FROM fee_facts f +CROSS JOIN LATERAL ( + VALUES ('24h', INTERVAL '24 hours'), + ('7d', INTERVAL '7 days'), + ('30d', INTERVAL '30 days') +) AS w(window_label, span) +-- Fenêtre SEMI-OUVERTE. Le `<` de droite est le correctif du bug de +-- double-comptage à la seconde frontière (DefiLlama issue #8656). +WHERE f.bucket_start >= date_trunc('hour', now()) - w.span + AND f.bucket_start < date_trunc('hour', now()) +GROUP BY f.deployment_id, w.window_label, f.methodology_version; + +CREATE UNIQUE INDEX fee_facts_windowed_pk + ON fee_facts_windowed (deployment_id, window_label, methodology_version); + +COMMIT; diff --git a/harnesses/apps/prom-rules/alert_rules_apps.yml b/harnesses/apps/prom-rules/alert_rules_apps.yml new file mode 100644 index 00000000..34e0b4f0 --- /dev/null +++ b/harnesses/apps/prom-rules/alert_rules_apps.yml @@ -0,0 +1,151 @@ +groups: + - name: apps_infrastructure + rules: + - alert: AppsPostgresDown + expr: pg_up{job="postgres-exporter"} == 0 + for: 2m + labels: + severity: critical + team: ocb + annotations: + summary: "Postgres OCB-Apps est down" + description: "pg_up == 0 depuis 2 minutes. Collectors et materializer sont bloqués." + + - alert: AppsDataDiskWarning + expr: (node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"}) < 0.20 + for: 5m + labels: + severity: warning + team: ocb + annotations: + summary: "/data à plus de 80% de capacité" + description: "Disponible : {{ $value | humanize }}B. Prévoir extension ou nettoyage." + + - alert: AppsDataDiskCritical + expr: (node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"}) < 0.10 + for: 2m + labels: + severity: critical + team: ocb + annotations: + summary: "/data à plus de 90% — Postgres risque de crasher" + description: "Action immédiate requise." + + - alert: AppsPostgresConnSaturation + expr: pg_stat_database_numbackends{datname="apps"} / pg_settings_max_connections > 0.80 + for: 5m + labels: + severity: warning + team: ocb + annotations: + summary: "Postgres : connexions > 80% de max_connections" + + - alert: AppsDBSizeGrowthAnomaly + expr: deriv(pg_database_size_bytes{datname="apps"}[1h]) > 524288000 + for: 10m + labels: + severity: warning + team: ocb + annotations: + summary: "Postgres apps croît de >500MB/h" + description: "Probable bug de replay. Vérifier l'idempotence du collector." + + - name: apps_collectors + rules: + - alert: AppsCollectorLagWarning + expr: time() - ocb_app_data_freshness_seconds > 600 + for: 5m + labels: + severity: warning + team: ocb + annotations: + summary: "Collector /apps en retard > 10min" + description: "Deployment {{ $labels.deployment }} — dernier event il y a {{ $value | humanizeDuration }}." + + - alert: AppsCollectorDead + expr: time() - ocb_app_data_freshness_seconds > 3600 + for: 2m + labels: + severity: critical + team: ocb + annotations: + summary: "Collector /apps mort (> 1h sans données)" + description: "Deployment {{ $labels.deployment }} silencieux depuis {{ $value | humanizeDuration }}." + + - alert: AppsCollectorErrors + expr: rate(ocb_app_collector_errors_total[5m]) > 0.1 + for: 10m + labels: + severity: warning + team: ocb + annotations: + summary: "Erreurs collector /apps" + description: "{{ $labels.deployment }} / {{ $labels.kind }} : {{ $value | humanize }} err/s" + + - alert: AppsMaterializerStale + expr: time() - ocb_app_materializer_last_run_seconds > 1800 + for: 2m + labels: + severity: warning + team: ocb + annotations: + summary: "Materializer /apps n'a pas tourné depuis > 30min" + + - name: apps_data_quality + rules: + - alert: AppsInvariantFailed + expr: ocb_app_invariant_failures_total > 0 + for: 0m + labels: + severity: critical + team: ocb + annotations: + summary: "Invariant comptable cassé sur /apps" + description: >- + {{ $labels.deployment }} — invariant {{ $labels.invariant }}. + I1 (Σ bénéficiaires ≠ gross) ou I4 (additivité sous-fenêtres cassée). + Le chiffre NE DOIT PAS être publié. Quarantaine automatique en cours. + + - alert: AppsQuarantineOpen + expr: ocb_app_quarantined > 0 + for: 24h + labels: + severity: warning + team: ocb + annotations: + summary: "Ligne /apps en quarantaine non résolue > 24h" + description: "Deployment {{ $labels.deployment }} — voir issue GitHub liée." + + - alert: AppsWitnessDivergenceHigh + expr: ocb_app_witness_divergence_ratio > 0.05 + for: 30m + labels: + severity: warning + team: ocb + annotations: + summary: "Divergence témoin > 5% sur /apps" + description: >- + {{ $labels.deployment }} vs {{ $labels.witness }} : {{ $value | humanizePercentage }}. + Déclenchement automatique de la quarantaine si persistant. + + - alert: AppsUnallocatedFeeHigh + expr: ocb_app_unallocated_bps > 50 + for: 10m + labels: + severity: warning + team: ocb + annotations: + summary: "/apps : fees non alloués > 50 bps" + description: "{{ $labels.deployment }} — {{ $value }} bps non alloués. Bug de décodage probable." + + - alert: AppsZeroEventsOnActiveDeployment + expr: rate(ocb_app_collector_events_total[1h]) == 0 + for: 2h + labels: + severity: warning + team: ocb + annotations: + summary: "Zéro event collecté sur un déploiement actif" + description: >- + {{ $labels.deployment }} : aucun event depuis 2h. + Vérifier que les topic0 / hashes sont calculés au runtime et non zéro. diff --git a/harnesses/apps/scripts/hl-fills-agg.py b/harnesses/apps/scripts/hl-fills-agg.py new file mode 100644 index 00000000..009cd412 --- /dev/null +++ b/harnesses/apps/scripts/hl-fills-agg.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +hl-fills-agg: HTTP server exposing daily Hyperliquid fee aggregates. +Reads node fills from /home/ubuntu/hl/data/node_fills_by_block/hourly/{YYYYMMDD}/{H}. +Caches computed days to /home/ubuntu/hl-agg/cache/{YYYYMMDD}.json. + +GET /daily?from=20260601&to=20260809 +-> [{"date":"20260601","gross_usdc":"...", "builder_usdc":"...","net_usdc":"...","fills":N,"hours":24}, ...] +""" + +import json +import os +import sys +import threading +import time +from datetime import date, timedelta +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +FILLS_DIR = "/home/ubuntu/hl/data/node_fills_by_block/hourly" +CACHE_DIR = "/home/ubuntu/hl-agg/cache" +PORT = 2116 + +os.makedirs(CACHE_DIR, exist_ok=True) + +_lock = threading.Lock() + + +def aggregate_day(date_str: str, is_today: bool) -> dict | None: + cache_file = os.path.join(CACHE_DIR, f"{date_str}.json") + if not is_today and os.path.exists(cache_file): + with open(cache_file) as f: + return json.load(f) + + day_dir = os.path.join(FILLS_DIR, date_str) + if not os.path.isdir(day_dir): + return None + + hours = sorted(os.listdir(day_dir)) + gross = 0.0 + builder = 0.0 + fills = 0 + + for h in hours: + path = os.path.join(day_dir, h) + try: + with open(path) as f: + for line in f: + try: + d = json.loads(line) + for item in d.get("events") or []: + fill = item[1] + gross += float(fill.get("fee", 0) or 0) + builder += float(fill.get("builderFee", 0) or 0) + fills += 1 + except Exception: + pass + except OSError: + pass + + result = { + "date": date_str, + "gross_usdc": round(gross, 6), + "builder_usdc": round(builder, 6), + "net_usdc": round(gross - builder, 6), + "fills": fills, + "hours": len(hours), + } + + if not is_today: + tmp = cache_file + ".tmp" + with open(tmp, "w") as f: + json.dump(result, f) + os.replace(tmp, cache_file) + + return result + + +def backfill_worker(): + """Pre-aggregate all past days not yet cached.""" + time.sleep(5) + today = date.today() + d = date(2026, 6, 1) + while d < today: + ds = d.strftime("%Y%m%d") + cache_file = os.path.join(CACHE_DIR, f"{ds}.json") + if not os.path.exists(cache_file): + print(f"backfill: aggregating {ds}", flush=True) + aggregate_day(ds, False) + d += timedelta(days=1) + print("backfill: done", flush=True) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/health": + self._json({"status": "ok"}) + return + if parsed.path != "/daily": + self.send_response(404) + self.end_headers() + return + + qs = parse_qs(parsed.query) + from_str = qs.get("from", ["20260601"])[0] + to_str = qs.get("to", [date.today().strftime("%Y%m%d")])[0] + + try: + from_d = date(int(from_str[:4]), int(from_str[4:6]), int(from_str[6:8])) + to_d = date(int(to_str[:4]), int(to_str[4:6]), int(to_str[6:8])) + except (ValueError, IndexError): + self.send_response(400) + self.end_headers() + return + + today = date.today() + # Only return cached days (or today on-demand). + # Uncached past days are skipped — the backfill thread will cache them. + # This keeps the HTTP response fast regardless of how many uncached days exist. + results = [] + d = from_d + while d <= to_d: + ds = d.strftime("%Y%m%d") + is_today = d == today + cache_file = os.path.join(CACHE_DIR, f"{ds}.json") + if is_today: + r = aggregate_day(ds, True) + elif os.path.exists(cache_file): + with open(cache_file) as f: + r = json.load(f) + else: + d += timedelta(days=1) + continue + if r: + results.append(r) + d += timedelta(days=1) + + self._json(results) + + def _json(self, data): + body = json.dumps(data).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + pass + + +if __name__ == "__main__": + threading.Thread(target=backfill_worker, daemon=True).start() + print(f"hl-fills-agg listening on :{PORT}", flush=True) + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go index afc962b8..e5772f69 100644 --- a/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/squid_bridge.go @@ -169,7 +169,16 @@ func (s *SquidBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU gasUsd += v } - costUsd := inUsd - outUsd + gasUsd + // Prefer fromAmountUSD - toAmountUSD as the all-in cost (captures spread + fees + gas + // as netted by the router). Fall back to explicit fee+gas sum when the USD amounts + // are identical (e.g. stable→stable where spot prices cancel out). + diffUsd := inUsd - outUsd + var costUsd float64 + if diffUsd > 0.001 { + costUsd = diffUsd + } else { + costUsd = bridgeFeeUsd + gasUsd + } if costUsd < 0 { costUsd = 0 } @@ -177,7 +186,7 @@ func (s *SquidBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU if amountUsd > 0 { costPct = (costUsd / amountUsd) * 100 } - slippage := costUsd - bridgeFeeUsd - gasUsd + slippage := diffUsd - bridgeFeeUsd - gasUsd if slippage < 0 { slippage = 0 } diff --git a/harnesses/evm-exec/Dockerfile b/harnesses/evm-exec/Dockerfile new file mode 100644 index 00000000..4058acc2 --- /dev/null +++ b/harnesses/evm-exec/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/collector ./cmd/collector +RUN CGO_ENABLED=0 go build -o /app/materializer ./cmd/materializer + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /app/collector /app/collector +COPY --from=builder /app/materializer /app/materializer +COPY migrations/ /app/migrations/ diff --git a/harnesses/evm-exec/cmd/collector/main.go b/harnesses/evm-exec/cmd/collector/main.go new file mode 100644 index 00000000..b91fc079 --- /dev/null +++ b/harnesses/evm-exec/cmd/collector/main.go @@ -0,0 +1,377 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/platform" + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/source" + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store" +) + +var chainRPC map[string]string + +func main() { + chainRPC = map[string]string{ + "ethereum": envOrDefault("ETH_RPC_URL", "https://eth.drpc.org"), + "bsc": bscRPC(), + "base": envOrDefault("BASE_RPC_URL", "https://base.drpc.org"), + "robinhood": envOrDefault("RH_RPC_URL", "https://rpc.mainnet.chain.robinhood.com"), + } + + ctx := context.Background() + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("collector: %v", err) + } + defer db.Close() + + etherscanKey := mustEnv("ETHERSCAN_API_KEY") + + if err := bootstrap(ctx, db); err != nil { + log.Fatalf("collector: bootstrap: %v", err) + } + log.Printf("collector: ready") + + for { + for plt, chains := range platform.PlatformConfig { + for chain, cfg := range chains { + if err := collectPlatform(ctx, db, etherscanKey, plt, chain, cfg); err != nil { + log.Printf("collector: %s/%s: %v", plt, chain, err) + } + } + } + time.Sleep(10 * time.Minute) + } +} + +// bootstrap seeds cursors at (head - 30d) for each (platform, chain, asset) that has no cursor. +func bootstrap(ctx context.Context, db *store.DB) error { + for plt, chains := range platform.PlatformConfig { + for chain, cfg := range chains { + rpc := chainRPC[chain] + head, err := source.HeadBlock(ctx, rpc) + if err != nil { + return err + } + bpd := platform.BlocksPerDay[chain] + start := uint64(0) + if head > bpd*uint64(cfg.BootstrapDays) { + start = head - bpd*uint64(cfg.BootstrapDays) + } + + for _, token := range cfg.ERC20Tokens { + if err := db.SeedCursorIfAbsent(ctx, chain, plt, token, start); err != nil { + return err + } + } + if cfg.NativeEnabled { + if err := db.SeedCursorIfAbsent(ctx, chain, plt, "native", start); err != nil { + return err + } + } + log.Printf("collector: bootstrap %s/%s start=%d", plt, chain, start) + } + } + return nil +} + +func collectPlatform(ctx context.Context, db *store.DB, etherscanKey, plt, chain string, cfg platform.EVMPlatform) error { + rpc := chainRPC[chain] + toBlock, err := finalityBlock(ctx, rpc, chain) + if err != nil { + return err + } + + for _, token := range cfg.ERC20Tokens { + if err := collectERC20(ctx, db, rpc, plt, chain, cfg.FeeCollector, token, toBlock); err != nil { + log.Printf("collector: %s/%s ERC20 %s...: %v", plt, chain, token[:8], err) + } + } + + if cfg.NativeEnabled { + switch chain { + case "ethereum": + if err := collectNativeEtherscan(ctx, db, etherscanKey, source.ChainIDEthereum, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native ETH: %v", plt, chain, err) + } + case "base": + if err := collectNativeEtherscan(ctx, db, etherscanKey, source.ChainIDBase, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native ETH on Base: %v", plt, chain, err) + } + case "bsc": + if err := collectNativeBSC(ctx, db, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native BNB: %v", plt, chain, err) + } + case "robinhood": + if err := collectNativeBlockscout(ctx, db, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native ETH (Blockscout): %v", plt, chain, err) + } + } + } + return nil +} + +func collectERC20(ctx context.Context, db *store.DB, rpc, plt, chain, collector, token string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, token) + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + if cursor >= toBlock { + return nil + } + decimals := platform.TokenDecimals[token] + + transfers, lastBlock, err := source.GetERC20TransfersAdaptive(ctx, rpc, token, collector, cursor+1, toBlock) + if err != nil { + return err + } + + events := make([]store.EVMEvent, 0, len(transfers)) + for _, t := range transfers { + var bt time.Time + if !t.BlockTime.IsZero() { + bt = t.BlockTime + _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt, + Platform: plt, Asset: token, AmountRaw: t.Amount, + Decimals: decimals, EventKey: source.LogIndexKey(t.LogIndex), + }) + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if lastBlock > cursor { + _ = db.SaveCursor(ctx, chain, plt, token, lastBlock) + } + if len(events) > 0 { + log.Printf("collector: %s/%s ERC20 %d events block=%d", plt, chain, len(events), lastBlock) + } + return nil +} + +func collectNativeEtherscan(ctx context.Context, db *store.DB, apiKey, chainID, plt, chain, collector string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, "native") + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + + internalTxs, lastInt, err := source.GetEtherscanInternalTxs(ctx, apiKey, chainID, collector, cursor) + if err != nil { + return err + } + normalTxs, lastNorm, err := source.GetEtherscanNormalTxs(ctx, apiKey, chainID, collector, cursor) + if err != nil { + return err + } + + rpc := chainRPC[chain] + var events []store.EVMEvent + for _, tx := range append(internalTxs, normalTxs...) { + if tx.BlockNum > toBlock { + continue + } + var bt time.Time + if !tx.BlockTime.IsZero() { + bt = tx.BlockTime + _ = db.CacheBlockTime(ctx, chain, tx.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, tx.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: tx.TxHash, BlockNum: tx.BlockNum, BlockTime: bt, + Platform: plt, Asset: "native", AmountRaw: tx.Amount, + Decimals: 18, EventKey: tx.EventKey, + }) + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if high := max64(lastInt, lastNorm); high > cursor { + _ = db.SaveCursor(ctx, chain, plt, "native", high) + } + if len(events) > 0 { + log.Printf("collector: %s/%s native ETH %d events", plt, chain, len(events)) + } + return nil +} + +func collectNativeBlockscout(ctx context.Context, db *store.DB, plt, chain, collector string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, "native") + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + if cursor >= toBlock { + return nil + } + rpc := chainRPC[chain] + + internalTxs, lastInt, err := source.GetBlockscoutInternalTxs(ctx, source.BlockscoutRobinhood, collector, cursor) + if err != nil { + return err + } + normalTxs, lastNorm, err := source.GetBlockscoutNormalTxs(ctx, source.BlockscoutRobinhood, collector, cursor) + if err != nil { + return err + } + + var events []store.EVMEvent + for _, tx := range append(internalTxs, normalTxs...) { + if tx.BlockNum > toBlock { + continue + } + var bt time.Time + if !tx.BlockTime.IsZero() { + bt = tx.BlockTime + _ = db.CacheBlockTime(ctx, chain, tx.BlockNum, bt) + } else { + bt, err = resolveBlockTime(ctx, db, rpc, chain, tx.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: tx.TxHash, BlockNum: tx.BlockNum, BlockTime: bt, + Platform: plt, Asset: "native", AmountRaw: tx.Amount, + Decimals: 18, EventKey: tx.EventKey, + }) + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if high := max64(lastInt, lastNorm); high > cursor { + _ = db.SaveCursor(ctx, chain, plt, "native", high) + } + if len(events) > 0 { + log.Printf("collector: %s/%s native ETH (Blockscout) %d events block=%d", plt, chain, len(events), max64(lastInt, lastNorm)) + } + return nil +} + +func collectNativeBSC(ctx context.Context, db *store.DB, plt, chain, collector string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, "native") + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + if cursor >= toBlock { + return nil + } + rpc := chainRPC[chain] + + transfers, err := source.GetNativeTransfers(ctx, rpc, collector, cursor+1, toBlock) + if err != nil { + return err + } + + var events []store.EVMEvent + var highBlock uint64 + for _, t := range transfers { + var bt time.Time + if !t.BlockTime.IsZero() { + bt = t.BlockTime + _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt, + Platform: plt, Asset: "native", AmountRaw: t.Amount, + Decimals: 18, EventKey: t.EventKey, + }) + if t.BlockNum > highBlock { + highBlock = t.BlockNum + } + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if highBlock > cursor { + _ = db.SaveCursor(ctx, chain, plt, "native", highBlock) + } + if len(events) > 0 { + log.Printf("collector: %s/%s native BNB %d events block=%d", plt, chain, len(events), highBlock) + } + return nil +} + +func resolveBlockTime(ctx context.Context, db *store.DB, rpc, chain string, blockNum uint64) (time.Time, error) { + if t, ok := db.GetBlockTime(ctx, chain, blockNum); ok { + return t, nil + } + t, err := source.BlockTime(ctx, rpc, blockNum) + if err != nil { + return time.Time{}, err + } + _ = db.CacheBlockTime(ctx, chain, blockNum, t) + return t, nil +} + +func finalityBlock(ctx context.Context, rpc, chain string) (uint64, error) { + switch chain { + case "ethereum", "base": + return source.ResolveBlockTag(ctx, rpc, "finalized") + case "bsc": + head, err := source.HeadBlock(ctx, rpc) + if err != nil { + return 0, err + } + if head > 20 { + return head - 20, nil + } + return 0, nil + default: + return source.HeadBlock(ctx, rpc) + } +} + +func bscRPC() string { + if v := os.Getenv("BSC_RPC_URL"); v != "" { + return v + } + key := os.Getenv("NODEREAL_BSC_KEY") + if key == "" { + log.Fatal("collector: NODEREAL_BSC_KEY or BSC_RPC_URL is required") + } + return "https://bsc-mainnet.nodereal.io/v1/" + key +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} + +func max64(a, b uint64) uint64 { + if a > b { + return a + } + return b +} diff --git a/harnesses/evm-exec/cmd/materializer/main.go b/harnesses/evm-exec/cmd/materializer/main.go new file mode 100644 index 00000000..ece79c69 --- /dev/null +++ b/harnesses/evm-exec/cmd/materializer/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store" +) + +func main() { + ctx := context.Background() + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("materializer: %v", err) + } + defer db.Close() + + for { + if err := db.Materialize(ctx); err != nil { + log.Printf("materializer: %v", err) + } else { + log.Printf("materializer: done") + } + time.Sleep(5 * time.Minute) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} diff --git a/harnesses/evm-exec/docker-compose.evm-exec.yml b/harnesses/evm-exec/docker-compose.evm-exec.yml new file mode 100644 index 00000000..de33a5f0 --- /dev/null +++ b/harnesses/evm-exec/docker-compose.evm-exec.yml @@ -0,0 +1,36 @@ +# Fragment to merge into /opt/ocb/docker-compose.yml on VPS. +# Requires: ocb-postgres (from docker-compose.apps.yml). +# Env: /run/ocb/.env.evm-exec +# DATABASE_URL, ETHERSCAN_API_KEY, NODEREAL_BSC_KEY +# Optional: ETH_RPC_URL (default https://eth.drpc.org) +# BASE_RPC_URL (default https://base.drpc.org) +# BSC_RPC_URL (overrides NODEREAL_BSC_KEY) + +services: + evm-exec-collector: + build: /opt/ocb/harnesses/evm-exec + container_name: ocb-evm-exec-collector + restart: unless-stopped + env_file: /run/ocb/.env.evm-exec + command: ["/app/collector"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "20m", max-file: "5" } + + evm-exec-materializer: + build: /opt/ocb/harnesses/evm-exec + container_name: ocb-evm-exec-materializer + restart: unless-stopped + env_file: /run/ocb/.env.evm-exec + command: ["/app/materializer"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/harnesses/evm-exec/go.mod b/harnesses/evm-exec/go.mod new file mode 100644 index 00000000..437438a5 --- /dev/null +++ b/harnesses/evm-exec/go.mod @@ -0,0 +1,14 @@ +module github.com/ChainBench/OpenChainBench/harnesses/evm-exec + +go 1.24.0 + +require github.com/jackc/pgx/v5 v5.7.2 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/harnesses/evm-exec/go.sum b/harnesses/evm-exec/go.sum new file mode 100644 index 00000000..731b5dfd --- /dev/null +++ b/harnesses/evm-exec/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/evm-exec/internal/platform/platforms.go b/harnesses/evm-exec/internal/platform/platforms.go new file mode 100644 index 00000000..098100b9 --- /dev/null +++ b/harnesses/evm-exec/internal/platform/platforms.go @@ -0,0 +1,199 @@ +package platform + +import ( + "strings" +) + +// EVMPlatform describes what to monitor for one platform on one chain. +type EVMPlatform struct { + FeeCollector string // lowercase 42-char 0x address + NativeEnabled bool // collect native asset (ETH / BNB) + ERC20Tokens []string // lowercase ERC-20 contracts to monitor + BootstrapDays int // how many days back to seed cursor on first run +} + +// USDC contract addresses, lowercase. +const ( + USDC_ETH = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" // 6 decimals + USDC_BSC = "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" // 18 decimals (Binance-Peg) + USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" // 6 decimals + USDG_ROBINHOOD = "0x5fc5360d0400a0fd4f2af552add042d716f1d168" // 6 decimals, Robinhood Chain stablecoin +) + +// TokenDecimals maps known token addresses to their decimal places. +var TokenDecimals = map[string]int{ + USDC_ETH: 6, + USDC_BSC: 18, + USDC_BASE: 6, + USDG_ROBINHOOD: 6, +} + +// NativeSymbol returns the native asset symbol for a chain. +func NativeSymbol(chain string) string { + if chain == "bsc" { + return "BNB" + } + return "ETH" +} + +// BlocksPerDay is the approximate number of blocks produced per day per chain. +var BlocksPerDay = map[string]uint64{ + "ethereum": 7_200, + "bsc": 28_800, + "base": 43_200, + "robinhood": 864_000, // ~10 blocks/second (Arbitrum Orbit) +} + +// PlatformConfig maps platform → chain → configuration. +var PlatformConfig = map[string]map[string]EVMPlatform{ + // pump.fun Terminal (acquired Terminal.fun/Padre Oct 2025) — confirmed via DeFiLlama + "pumpfun": { + "ethereum": { + FeeCollector: "0xa74fa823bc8617fa320a966b3d11b0f722ef09ee", + NativeEnabled: true, + ERC20Tokens: []string{USDC_ETH}, + BootstrapDays: 30, + }, + "bsc": { + FeeCollector: "0x2b0a28a0a9197f8af5d1b8371c048e92dd78b640", + NativeEnabled: true, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + "base": { + FeeCollector: "0x16388de42c5829fd0e88c8eb001ef43bfc93f177", + NativeEnabled: true, // Etherscan V2 chainid=8453, same ETHERSCAN_API_KEY + ERC20Tokens: []string{USDC_BASE}, + BootstrapDays: 30, + }, + }, + // Maestro — confirmed via DeFiLlama fees/maestro adapter, same address all EVM chains + "maestro": { + "ethereum": { + FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397", + NativeEnabled: true, + ERC20Tokens: []string{USDC_ETH}, + BootstrapDays: 30, + }, + "bsc": { + FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397", + NativeEnabled: true, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + "base": { + FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397", + NativeEnabled: true, // Etherscan V2 chainid=8453 + ERC20Tokens: []string{USDC_BASE}, + BootstrapDays: 30, + }, + }, + // Banana Gun — these addresses are routers, not pure fee wallets. + // Native disabled on all chains: internal ETH/BNB = trade principal, not fees. + // USDC inflows are also router throughput; evmKey=null in frontend so nothing is displayed. + // Proper fix: eth_getLogs on topic 0x72015ace… for actual fee events. + "banana-gun": { + "ethereum": { + FeeCollector: "0x3328f7f4a1d1c57c35df56bbf0c9dcafca309c49", + NativeEnabled: false, + ERC20Tokens: []string{USDC_ETH}, + BootstrapDays: 30, + }, + "bsc": { + FeeCollector: "0x461efe0100be0682545972ebfc8b4a13253bd602", + NativeEnabled: false, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + "base": { + FeeCollector: "0x1fba6b0bbae2b74586fba407fb45bd4788b7b130", + NativeEnabled: false, // was incorrectly true; same router logic applies on Base + ERC20Tokens: []string{USDC_BASE}, + BootstrapDays: 30, + }, + }, + "gmgn": { + "ethereum": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, + ERC20Tokens: []string{USDC_ETH}, + BootstrapDays: 30, + }, + "bsc": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + "base": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, // Etherscan V2 chainid=8453 + ERC20Tokens: []string{USDC_BASE}, + BootstrapDays: 30, + }, + }, + // Axiom — confirmed via DeFiLlama fees/axiom adapter (feeReceiver field, start 2026-01-25) + "axiom": { + "bsc": { + FeeCollector: "0xdec29d79e8cdf009d2fa33e0558cb5648481cac3", + NativeEnabled: true, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + }, + // GMGN + Maestro on Robinhood Chain (Arbitrum Orbit L2, chainId=4663) + // Same fee-collector addresses as ETH/BSC; stablecoin is USDG (Robinhood's native stable). + // Native ETH collected via Blockscout (robinhoodchain.blockscout.com/api, txlistinternal). + "gmgn-robinhood": { + "robinhood": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, + ERC20Tokens: []string{USDG_ROBINHOOD}, + BootstrapDays: 30, + }, + }, + "maestro-robinhood": { + "robinhood": { + FeeCollector: "0xb0999731f7c2581844658a9d2ced1be0077b7397", + NativeEnabled: true, + ERC20Tokens: []string{USDG_ROBINHOOD}, + BootstrapDays: 30, + }, + }, +} + +// Coverage returns "full" if native is enabled for this platform+chain, else "stable-only". +func Coverage(platform, chain string) string { + chains, ok := PlatformConfig[platform] + if !ok { + return "stable-only" + } + cfg, ok := chains[chain] + if !ok { + return "stable-only" + } + if cfg.NativeEnabled { + return "full" + } + return "stable-only" +} + +func init() { + for plt, chains := range PlatformConfig { + for chain, cfg := range chains { + mustAddr(cfg.FeeCollector, plt+"/"+chain+"/FeeCollector") + for _, t := range cfg.ERC20Tokens { + mustAddr(t, plt+"/"+chain+"/ERC20Token") + } + } + } + for addr := range TokenDecimals { + mustAddr(addr, "TokenDecimals key") + } +} + +func mustAddr(a, label string) { + if len(a) != 42 || !strings.HasPrefix(a, "0x") || a != strings.ToLower(a) { + panic("invalid address in platform config [" + label + "]: " + a) + } +} diff --git a/harnesses/evm-exec/internal/source/blockscout.go b/harnesses/evm-exec/internal/source/blockscout.go new file mode 100644 index 00000000..06ca775f --- /dev/null +++ b/harnesses/evm-exec/internal/source/blockscout.go @@ -0,0 +1,289 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +// Blockscout Etherscan-compatible API for chains that have a Blockscout explorer +// but are not supported by Etherscan V2. +const ( + BlockscoutRobinhood = "https://robinhoodchain.blockscout.com/api" +) + +// blockscoutTx matches the Blockscout txlistinternal result shape. +type blockscoutTx struct { + TransactionHash string `json:"transactionHash"` + BlockNumber string `json:"blockNumber"` + TimeStamp string `json:"timeStamp"` + Value string `json:"value"` + To string `json:"to"` + IsError string `json:"isError"` + Index string `json:"index"` // trace position within the tx (event key) +} + +// blockscoutNormalTx matches the Blockscout txlist (normal/external tx) result shape. +// Field names differ from txlistinternal: hash vs transactionHash, no index. +type blockscoutNormalTx struct { + Hash string `json:"hash"` + BlockNumber string `json:"blockNumber"` + TimeStamp string `json:"timeStamp"` + Value string `json:"value"` + To string `json:"to"` + IsError string `json:"isError"` +} + +// GetBlockscoutInternalTxs fetches internal ETH transfers to address via Blockscout. +// apiURL is the chain-specific Blockscout API base (e.g. blockscoutRobinhood). +func GetBlockscoutInternalTxs(ctx context.Context, apiURL, address string, startBlock uint64) ([]NativeTx, uint64, error) { + const offset = 1000 + const maxPage = 20 // 20k results max before we advance the window + + addrLower := strings.ToLower(address) + var all []NativeTx + highestBlock := startBlock + curStart := startBlock + + for { + var windowTxs []NativeTx + hitCap := false + + for page := 1; page <= maxPage; page++ { + time.Sleep(300 * time.Millisecond) + + params := url.Values{ + "module": {"account"}, + "action": {"txlistinternal"}, + "address": {address}, + "startblock": {fmt.Sprintf("%d", curStart)}, + "endblock": {"99999999"}, + "page": {fmt.Sprintf("%d", page)}, + "offset": {fmt.Sprintf("%d", offset)}, + "sort": {"asc"}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil) + if err != nil { + return all, highestBlock, err + } + resp, err := httpClient.Do(req) + if err != nil { + return all, highestBlock, fmt.Errorf("blockscout page %d: %w", page, err) + } + + var out struct { + Status string `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + decErr := json.NewDecoder(resp.Body).Decode(&out) + resp.Body.Close() + if decErr != nil { + return all, highestBlock, fmt.Errorf("blockscout decode: %w", decErr) + } + + // Blockscout status "0" = error/empty, "1" = full results, "2" = partial (still valid). + if out.Status == "0" { + break + } + if out.Status != "1" && out.Status != "2" { + var msg string + json.Unmarshal(out.Result, &msg) //nolint:errcheck + return all, highestBlock, fmt.Errorf("blockscout: %s %s", out.Message, msg) + } + + var txs []blockscoutTx + if err := json.Unmarshal(out.Result, &txs); err != nil { + break + } + + for _, tx := range txs { + if tx.IsError == "1" || tx.Value == "" || tx.Value == "0" { + continue + } + if strings.ToLower(tx.To) != addrLower { + continue + } + amt, err := new(big.Int).SetString(tx.Value, 10) + if !err || amt.Sign() <= 0 { + continue + } + blockNum, parseErr := parseDecU64(tx.BlockNumber) + if parseErr != nil { + continue + } + var bt time.Time + if ts, tsErr := parseDecU64(tx.TimeStamp); tsErr == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + ntx := NativeTx{ + TxHash: strings.ToLower(tx.TransactionHash), + BlockNum: blockNum, + BlockTime: bt, + Amount: amt, + EventKey: tx.Index, + } + windowTxs = append(windowTxs, ntx) + if blockNum > highestBlock { + highestBlock = blockNum + } + } + + if len(txs) < offset { + break + } + if page == maxPage { + hitCap = true + break + } + } + + all = append(all, windowTxs...) + + if !hitCap || len(windowTxs) == 0 { + break + } + + var lastBlock uint64 + for _, tx := range windowTxs { + if tx.BlockNum > lastBlock { + lastBlock = tx.BlockNum + } + } + if lastBlock <= curStart { + break + } + curStart = lastBlock + } + + return all, highestBlock, nil +} + +// GetBlockscoutNormalTxs fetches direct ETH transfers (tx.value > 0, to == address) via txlist. +// Complements GetBlockscoutInternalTxs: direct EOA sends don't appear in txlistinternal. +func GetBlockscoutNormalTxs(ctx context.Context, apiURL, address string, startBlock uint64) ([]NativeTx, uint64, error) { + const offset = 1000 + const maxPage = 20 + + addrLower := strings.ToLower(address) + var all []NativeTx + highestBlock := startBlock + curStart := startBlock + + for { + var windowTxs []NativeTx + hitCap := false + + for page := 1; page <= maxPage; page++ { + time.Sleep(300 * time.Millisecond) + + params := url.Values{ + "module": {"account"}, + "action": {"txlist"}, + "address": {address}, + "startblock": {fmt.Sprintf("%d", curStart)}, + "endblock": {"99999999"}, + "page": {fmt.Sprintf("%d", page)}, + "offset": {fmt.Sprintf("%d", offset)}, + "sort": {"asc"}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil) + if err != nil { + return all, highestBlock, err + } + resp, err := httpClient.Do(req) + if err != nil { + return all, highestBlock, fmt.Errorf("blockscout txlist page %d: %w", page, err) + } + + var out struct { + Status string `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + decErr := json.NewDecoder(resp.Body).Decode(&out) + resp.Body.Close() + if decErr != nil { + return all, highestBlock, fmt.Errorf("blockscout txlist decode: %w", decErr) + } + + if out.Status == "0" { + break + } + if out.Status != "1" && out.Status != "2" { + break + } + + var txs []blockscoutNormalTx + if err := json.Unmarshal(out.Result, &txs); err != nil { + break + } + + for _, tx := range txs { + if tx.IsError == "1" || tx.Value == "" || tx.Value == "0" { + continue + } + if strings.ToLower(tx.To) != addrLower { + continue + } + amt, ok := new(big.Int).SetString(tx.Value, 10) + if !ok || amt.Sign() <= 0 { + continue + } + blockNum, parseErr := parseDecU64(tx.BlockNumber) + if parseErr != nil { + continue + } + var bt time.Time + if ts, tsErr := parseDecU64(tx.TimeStamp); tsErr == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + ntx := NativeTx{ + TxHash: strings.ToLower(tx.Hash), + BlockNum: blockNum, + BlockTime: bt, + Amount: amt, + EventKey: "top", // direct tx value, not a trace + } + windowTxs = append(windowTxs, ntx) + if blockNum > highestBlock { + highestBlock = blockNum + } + } + + if len(txs) < offset { + break + } + if page == maxPage { + hitCap = true + break + } + } + + all = append(all, windowTxs...) + + if !hitCap || len(windowTxs) == 0 { + break + } + + var lastBlock uint64 + for _, tx := range windowTxs { + if tx.BlockNum > lastBlock { + lastBlock = tx.BlockNum + } + } + if lastBlock <= curStart { + break + } + curStart = lastBlock + } + + return all, highestBlock, nil +} diff --git a/harnesses/evm-exec/internal/source/blocktime.go b/harnesses/evm-exec/internal/source/blocktime.go new file mode 100644 index 00000000..83386ed5 --- /dev/null +++ b/harnesses/evm-exec/internal/source/blocktime.go @@ -0,0 +1,110 @@ +package source + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +var httpClient = &http.Client{Timeout: 20 * time.Second} + +// HeadBlock returns the current head block number via eth_blockNumber. +func HeadBlock(ctx context.Context, rpcURL string) (uint64, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_blockNumber", "params": []any{}, + }) + var out struct { + Result string `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return 0, err + } + if out.Error != nil { + return 0, fmt.Errorf("eth_blockNumber: %s", out.Error.Message) + } + return ParseHex64(out.Result) +} + +// BlockTime fetches a block's unix timestamp via eth_getBlockByNumber(n, false). +func BlockTime(ctx context.Context, rpcURL string, blockNum uint64) (time.Time, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getBlockByNumber", + "params": []any{fmt.Sprintf("0x%x", blockNum), false}, + }) + var out struct { + Result *struct { + Timestamp string `json:"timestamp"` + } `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return time.Time{}, err + } + if out.Error != nil { + return time.Time{}, fmt.Errorf("eth_getBlockByNumber: %s", out.Error.Message) + } + if out.Result == nil { + return time.Time{}, fmt.Errorf("null block %d", blockNum) + } + ts, err := ParseHex64(out.Result.Timestamp) + if err != nil { + return time.Time{}, err + } + return time.Unix(int64(ts), 0).UTC(), nil +} + +// ResolveBlockTag resolves an eth block tag ("finalized", "latest") to a block number. +func ResolveBlockTag(ctx context.Context, rpcURL, tag string) (uint64, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getBlockByNumber", + "params": []any{tag, false}, + }) + var out struct { + Result *struct { + Number string `json:"number"` + } `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return 0, err + } + if out.Error != nil { + return 0, fmt.Errorf("eth_getBlockByNumber %s: %s", tag, out.Error.Message) + } + if out.Result == nil { + return 0, fmt.Errorf("null block for tag %q", tag) + } + return ParseHex64(out.Result.Number) +} + +// RpcPost marshals body, POSTs to rpcURL with Content-Type: application/json, decodes into dst. +func RpcPost(ctx context.Context, rpcURL string, body []byte, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, rpcURL) + } + return json.NewDecoder(resp.Body).Decode(dst) +} + +// ParseHex64 parses a 0x-prefixed or bare hex string to uint64. +func ParseHex64(s string) (uint64, error) { + return strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64) +} diff --git a/harnesses/evm-exec/internal/source/etherscan.go b/harnesses/evm-exec/internal/source/etherscan.go new file mode 100644 index 00000000..7790bed8 --- /dev/null +++ b/harnesses/evm-exec/internal/source/etherscan.go @@ -0,0 +1,210 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + etherscanV2 = "https://api.etherscan.io/v2/api" + ChainIDEthereum = "1" + ChainIDBase = "8453" // Base via Etherscan V2 (same key as Ethereum, no separate BaseScan key) +) + +// NativeTx is an ETH transfer (internal or normal) to a monitored address. +type NativeTx struct { + TxHash string + BlockNum uint64 + BlockTime time.Time // from Etherscan timeStamp, avoids eth_getBlockByNumber + Amount *big.Int // wei, never divided + EventKey string // traceId for internal txs, "" for normal value txs +} + +type etherscanTx struct { + Hash string `json:"hash"` + BlockNumber string `json:"blockNumber"` + TimeStamp string `json:"timeStamp"` // unix seconds as decimal string + Value string `json:"value"` + To string `json:"to"` + IsError string `json:"isError"` + TraceID string `json:"traceId"` +} + +// GetEtherscanInternalTxs fetches ETH internal transfers to address starting from startBlock. +func GetEtherscanInternalTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64) ([]NativeTx, uint64, error) { + return fetchEtherscanTxs(ctx, apiKey, chainID, address, startBlock, "txlistinternal", func(tx etherscanTx) (NativeTx, bool) { + if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" { + return NativeTx{}, false + } + amt, err := ParseDecAmount(tx.Value) + if err != nil || amt.Sign() <= 0 { + return NativeTx{}, false + } + blockNum, err := parseDecU64(tx.BlockNumber) + if err != nil { + return NativeTx{}, false + } + var bt time.Time + if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: tx.TraceID}, true + }) +} + +// GetEtherscanNormalTxs fetches plain ETH value transfers to address starting from startBlock. +func GetEtherscanNormalTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64) ([]NativeTx, uint64, error) { + addrLower := strings.ToLower(address) + return fetchEtherscanTxs(ctx, apiKey, chainID, address, startBlock, "txlist", func(tx etherscanTx) (NativeTx, bool) { + if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" { + return NativeTx{}, false + } + if strings.ToLower(tx.To) != addrLower { + return NativeTx{}, false + } + amt, err := ParseDecAmount(tx.Value) + if err != nil || amt.Sign() <= 0 { + return NativeTx{}, false + } + blockNum, err := parseDecU64(tx.BlockNumber) + if err != nil { + return NativeTx{}, false + } + var bt time.Time + if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: ""}, true + }) +} + +type filterFn func(etherscanTx) (NativeTx, bool) + +// fetchEtherscanTxs handles Etherscan pagination including the 10k-result cap. +func fetchEtherscanTxs(ctx context.Context, apiKey, chainID, address string, startBlock uint64, action string, filter filterFn) ([]NativeTx, uint64, error) { + const ( + offset = 1000 + maxPage = 10 + endBlock = 99999999 + ) + + var all []NativeTx + highestBlock := startBlock + curStart := startBlock + + for { + var windowTxs []NativeTx + hitCap := false + page := 1 + + for page <= maxPage { + time.Sleep(250 * time.Millisecond) + + batch, err := etherscanPage(ctx, apiKey, chainID, address, action, curStart, endBlock, page, offset) + if err != nil { + return all, highestBlock, fmt.Errorf("etherscan %s page %d: %w", action, page, err) + } + + for _, tx := range batch { + if ntx, ok := filter(tx); ok { + windowTxs = append(windowTxs, ntx) + if ntx.BlockNum > highestBlock { + highestBlock = ntx.BlockNum + } + } + } + + if len(batch) < offset { + break // last page + } + if page == maxPage { + hitCap = true + break + } + page++ + } + + all = append(all, windowTxs...) + + if !hitCap || len(windowTxs) == 0 { + break + } + + // Advance past what we've seen and loop. + var lastBlock uint64 + for _, tx := range windowTxs { + if tx.BlockNum > lastBlock { + lastBlock = tx.BlockNum + } + } + if lastBlock <= curStart { + break + } + curStart = lastBlock + } + + return all, highestBlock, nil +} + +func etherscanPage(ctx context.Context, apiKey, chainID, address, action string, startBlock uint64, endBlock, page, offset int) ([]etherscanTx, error) { + apiURL := etherscanV2 + params := url.Values{ + "module": {"account"}, + "action": {action}, + "address": {address}, + "startblock": {fmt.Sprintf("%d", startBlock)}, + "endblock": {fmt.Sprintf("%d", endBlock)}, + "page": {fmt.Sprintf("%d", page)}, + "offset": {fmt.Sprintf("%d", offset)}, + "sort": {"asc"}, + "apikey": {apiKey}, + } + if chainID != "" { + params.Set("chainid", chainID) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var out struct { + Status string `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("etherscan decode: %w", err) + } + if out.Status == "0" && strings.Contains(out.Message, "No transactions") { + return nil, nil + } + if out.Status != "1" { + var msg string + json.Unmarshal(out.Result, &msg) //nolint:errcheck + return nil, fmt.Errorf("etherscan %s: %s %s", action, out.Message, msg) + } + + var txs []etherscanTx + if err := json.Unmarshal(out.Result, &txs); err != nil { + return nil, fmt.Errorf("etherscan result decode: %w", err) + } + return txs, nil +} + +func parseDecU64(s string) (uint64, error) { + var n uint64 + _, err := fmt.Sscan(s, &n) + return n, err +} diff --git a/harnesses/evm-exec/internal/source/logs.go b/harnesses/evm-exec/internal/source/logs.go new file mode 100644 index 00000000..c1e1f95f --- /dev/null +++ b/harnesses/evm-exec/internal/source/logs.go @@ -0,0 +1,176 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "strings" + "time" +) + +const transferTopic0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +// ERC20Transfer is one token Transfer event emitted to a recipient. +type ERC20Transfer struct { + TxHash string + BlockNum uint64 + LogIndex uint64 + BlockTime time.Time // from blockTimestamp in getLogs response; zero if not present + Amount *big.Int // raw token units (never divided in Go) +} + +// RPCError is a JSON-RPC level error; used to detect range-too-large codes. +type RPCError struct { + Code int + Message string +} + +func (e *RPCError) Error() string { return fmt.Sprintf("RPC %d: %s", e.Code, e.Message) } + +// IsRangeError returns true when the RPC error means the block range is too wide. +func IsRangeError(err error) bool { + rpcErr, ok := err.(*RPCError) + if !ok { + return false + } + // -32005: range too large (Alchemy/Infura), -32602: invalid params (geth), + // -32614: range limit exceeded (base mainnet.base.org) + return rpcErr.Code == -32005 || rpcErr.Code == -32602 || rpcErr.Code == -32614 +} + +// GetERC20Transfers fetches Transfer events from [fromBlock, toBlock] for one window. +func GetERC20Transfers(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, error) { + // topic2: 32-byte left-padded address (12 zero bytes + 20 addr bytes). + topic2 := "0x" + strings.Repeat("0", 24) + strings.TrimPrefix(strings.ToLower(recipient), "0x") + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getLogs", + "params": []any{map[string]any{ + "fromBlock": fmt.Sprintf("0x%x", fromBlock), + "toBlock": fmt.Sprintf("0x%x", toBlock), + "address": tokenContract, + "topics": []any{transferTopic0, nil, topic2}, + }}, + }) + + var out struct { + Result []struct { + TxHash string `json:"transactionHash"` + BlockNumber string `json:"blockNumber"` + BlockTimestamp string `json:"blockTimestamp"` // hex unix seconds; present on Base, absent on ETH/BSC + LogIndex string `json:"logIndex"` + Data string `json:"data"` + Topics []string `json:"topics"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return nil, fmt.Errorf("eth_getLogs: %w", err) + } + if out.Error != nil { + return nil, &RPCError{Code: out.Error.Code, Message: out.Error.Message} + } + + var transfers []ERC20Transfer + for _, log := range out.Result { + if len(log.Data) < 2 || len(log.Topics) < 3 { + continue + } + blockNum, err := ParseHex64(log.BlockNumber) + if err != nil { + continue + } + logIdx, err := ParseHex64(log.LogIndex) + if err != nil { + continue + } + amount, ok := new(big.Int).SetString(strings.TrimPrefix(log.Data, "0x"), 16) + if !ok || amount.Sign() <= 0 { + continue + } + var bt time.Time + if ts, err2 := ParseHex64(log.BlockTimestamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + transfers = append(transfers, ERC20Transfer{ + TxHash: strings.ToLower(log.TxHash), + BlockNum: blockNum, + LogIndex: logIdx, + BlockTime: bt, + Amount: amount, + }) + } + return transfers, nil +} + +// GetERC20TransfersAdaptive fetches all ERC-20 transfers from fromBlock to toBlock +// using adaptive range sizing (halves on range error, grows on success). +func GetERC20TransfersAdaptive(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, uint64, error) { + const maxRange = uint64(10_000) + rangeSize := maxRange + var all []ERC20Transfer + cur := fromBlock + + for cur <= toBlock { + end := cur + rangeSize - 1 + if end > toBlock { + end = toBlock + } + + batch, err := GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end) + if err != nil { + if IsRangeError(err) && rangeSize > 100 { + rangeSize /= 2 + continue + } + select { + case <-ctx.Done(): + return all, cur - 1, ctx.Err() + case <-time.After(3 * time.Second): + } + batch, err = GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end) + if err != nil { + return all, cur - 1, fmt.Errorf("eth_getLogs [%d,%d]: %w", cur, end, err) + } + } + + all = append(all, batch...) + if rangeSize < maxRange { + rangeSize = rangeSize * 125 / 100 + if rangeSize > maxRange { + rangeSize = maxRange + } + } + cur = end + 1 + } + return all, toBlock, nil +} + +// ParseDecAmount parses a decimal-string wei amount (Etherscan format) to *big.Int. +func ParseDecAmount(s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, fmt.Errorf("bad decimal amount: %q", s) + } + return v, nil +} + +// ParseHexAmount parses a 0x-prefixed hex wei amount (NodeReal format) to *big.Int. +func ParseHexAmount(s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16) + if !ok { + return nil, fmt.Errorf("bad hex amount: %q", s) + } + return v, nil +} + +// LogIndexKey returns the event_key string for an ERC-20 log. +func LogIndexKey(logIndex uint64) string { + return strconv.FormatUint(logIndex, 10) +} diff --git a/harnesses/evm-exec/internal/source/nodereal.go b/harnesses/evm-exec/internal/source/nodereal.go new file mode 100644 index 00000000..466a7429 --- /dev/null +++ b/harnesses/evm-exec/internal/source/nodereal.go @@ -0,0 +1,133 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" +) + +// AssetTransfer is one native-asset (BNB/ETH) transfer from NodeReal nr_getAssetTransfers. +type AssetTransfer struct { + TxHash string + BlockNum uint64 + BlockTime time.Time // from NodeReal blockTimeStamp (avoids separate RPC call) + Amount *big.Int // wei, never divided + EventKey string // position index within (tx_hash) group for uniqueness +} + +type nrTransfer struct { + BlockNum string `json:"blockNum"` + Hash string `json:"hash"` + Value string `json:"value"` + Category string `json:"category"` + BlockTimeStamp int64 `json:"blockTimeStamp"` // unix seconds, present when withMetadata:true +} + +// GetNativeTransfers fetches all native asset (BNB) transfers to toAddress in [fromBlock, toBlock] +// using NodeReal nr_getAssetTransfers with full pagination via pageKey. +func GetNativeTransfers(ctx context.Context, rpcURL, toAddress string, fromBlock, toBlock uint64) ([]AssetTransfer, error) { + var allRaw []nrTransfer + pageKey := "" + + for { + params := map[string]any{ + "fromBlock": fmt.Sprintf("0x%x", fromBlock), + "toBlock": fmt.Sprintf("0x%x", toBlock), + "toAddress": toAddress, + "category": []string{"external", "internal"}, + "withMetadata": true, + "excludeZeroValue": true, + "maxCount": "0x3e8", // 1000 + } + if pageKey != "" { + params["pageKey"] = pageKey + } + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "nr_getAssetTransfers", + "params": []any{params}, + }) + + var out struct { + Result *struct { + Transfers []nrTransfer `json:"transfers"` + PageKey string `json:"pageKey"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt*attempt) * time.Second): + } + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + lastErr = err + continue + } + lastErr = nil + break + } + if lastErr != nil { + return nil, fmt.Errorf("nr_getAssetTransfers: %w", lastErr) + } + if out.Error != nil { + return nil, fmt.Errorf("nr_getAssetTransfers RPC %d: %s", out.Error.Code, out.Error.Message) + } + if out.Result == nil { + return nil, fmt.Errorf("nr_getAssetTransfers: null result") + } + + allRaw = append(allRaw, out.Result.Transfers...) + pageKey = out.Result.PageKey + if pageKey == "" { + break + } + } + + // Assign event_key as position within each tx_hash group (handles multicall). + txCount := make(map[string]int, len(allRaw)) + result := make([]AssetTransfer, 0, len(allRaw)) + + for _, t := range allRaw { + if t.Value == "" || t.Value == "0x0" { + continue + } + amt, err := ParseHexAmount(t.Value) + if err != nil || amt.Sign() <= 0 { + continue + } + blockNum, err := ParseHex64(t.BlockNum) + if err != nil { + continue + } + hash := strings.ToLower(t.Hash) + idx := txCount[hash] + txCount[hash]++ + + var blockTime time.Time + if t.BlockTimeStamp > 0 { + blockTime = time.Unix(t.BlockTimeStamp, 0).UTC() + } + + result = append(result, AssetTransfer{ + TxHash: hash, + BlockNum: blockNum, + BlockTime: blockTime, + Amount: amt, + EventKey: fmt.Sprintf("%d", idx), + }) + } + return result, nil +} diff --git a/harnesses/evm-exec/internal/store/store.go b/harnesses/evm-exec/internal/store/store.go new file mode 100644 index 00000000..35ad6595 --- /dev/null +++ b/harnesses/evm-exec/internal/store/store.go @@ -0,0 +1,199 @@ +package store + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, connStr string) (*DB, error) { + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { db.pool.Close() } + +// EVMEvent is one fee inflow to a platform's fee collector. +type EVMEvent struct { + Chain string + TxHash string + BlockNum uint64 + BlockTime time.Time + Platform string + Asset string // "native" or token contract (lowercase) + AmountRaw *big.Int // wei or raw token units; never divided in Go + Decimals int + EventKey string // log_index / traceId / transfer index / "" +} + +// UpsertEvent inserts an EVM fee event; skips silently on (chain,tx_hash,asset,event_key) conflict. +func (db *DB) UpsertEvent(ctx context.Context, e EVMEvent) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_events + (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key) + VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9) + ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`, + e.Chain, e.TxHash, e.BlockNum, e.BlockTime, + e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey, + ) + if err != nil { + return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err) + } + return nil +} + +// UpsertEvents bulk-upserts in a single transaction. +func (db *DB) UpsertEvents(ctx context.Context, events []EVMEvent) error { + if len(events) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + + for _, e := range events { + _, err := tx.Exec(ctx, ` + INSERT INTO evm_exec_events + (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key) + VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9) + ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`, + e.Chain, e.TxHash, e.BlockNum, e.BlockTime, + e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey, + ) + if err != nil { + return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err) + } + } + return tx.Commit(ctx) +} + +// GetCursor returns the last processed block for (chain, platform, asset). +// Returns (0, false, nil) when no cursor exists yet. +func (db *DB) GetCursor(ctx context.Context, chain, platform, asset string) (uint64, bool, error) { + var lastBlock uint64 + err := db.pool.QueryRow(ctx, + `SELECT last_block FROM evm_exec_cursors WHERE chain=$1 AND platform=$2 AND asset=$3`, + chain, platform, asset, + ).Scan(&lastBlock) + if err != nil { + return 0, false, nil + } + return lastBlock, true, nil +} + +// SaveCursor upserts the last processed block. +func (db *DB) SaveCursor(ctx context.Context, chain, platform, asset string, lastBlock uint64) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at) + VALUES ($1,$2,$3,$4,now()) + ON CONFLICT (chain, platform, asset) DO UPDATE SET + last_block = EXCLUDED.last_block, updated_at = now()`, + chain, platform, asset, lastBlock, + ) + return err +} + +// SeedCursorIfAbsent inserts a bootstrap cursor only when none exists (DO NOTHING on conflict). +func (db *DB) SeedCursorIfAbsent(ctx context.Context, chain, platform, asset string, bootstrapBlock uint64) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at) + VALUES ($1,$2,$3,$4,now()) + ON CONFLICT (chain, platform, asset) DO NOTHING`, + chain, platform, asset, bootstrapBlock, + ) + return err +} + +// GetBlockTime returns a cached block timestamp, if present. +func (db *DB) GetBlockTime(ctx context.Context, chain string, blockNum uint64) (time.Time, bool) { + var t time.Time + err := db.pool.QueryRow(ctx, + `SELECT block_time FROM evm_block_times WHERE chain=$1 AND block_num=$2`, + chain, blockNum, + ).Scan(&t) + return t, err == nil +} + +// CacheBlockTime writes a block timestamp to the local cache table. +func (db *DB) CacheBlockTime(ctx context.Context, chain string, blockNum uint64, t time.Time) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_block_times (chain, block_num, block_time) + VALUES ($1,$2,$3) + ON CONFLICT (chain, block_num) DO NOTHING`, + chain, blockNum, t, + ) + return err +} + +// Materialize recomputes hourly revenue facts from the last 31 days of events. +func (db *DB) Materialize(ctx context.Context) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_facts + (chain, platform, bucket_start, revenue_stable, revenue_native, native_symbol, updated_at) + SELECT + chain, + platform, + date_trunc('hour', block_time) AS bucket_start, + COALESCE(SUM(amount_raw::numeric / (10::numeric ^ decimals)) FILTER (WHERE asset <> 'native'), 0), + COALESCE(SUM(amount_raw::numeric / (10::numeric ^ decimals)) FILTER (WHERE asset = 'native'), 0), + CASE WHEN chain = 'bsc' THEN 'BNB' ELSE 'ETH' END, + now() + FROM evm_exec_events + WHERE block_time > now() - INTERVAL '31 days' + GROUP BY 1, 2, 3 + ON CONFLICT (chain, platform, bucket_start) DO UPDATE SET + revenue_stable = EXCLUDED.revenue_stable, + revenue_native = EXCLUDED.revenue_native, + native_symbol = EXCLUDED.native_symbol, + updated_at = now()`, + ) + return err +} + +// RevenueRow is one (chain, platform) 24h revenue aggregate. +type RevenueRow struct { + Chain string + Platform string + RevenueStable float64 + RevenueNative float64 + NativeSymbol string +} + +// GetRevenue24h returns aggregated revenue for the last 24h per (chain, platform). +func (db *DB) GetRevenue24h(ctx context.Context) ([]RevenueRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT chain, platform, + COALESCE(SUM(revenue_stable), 0), + COALESCE(SUM(revenue_native), 0), + COALESCE(MAX(native_symbol), '') + FROM evm_exec_facts + WHERE bucket_start >= now() - INTERVAL '24 hours' + GROUP BY chain, platform`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []RevenueRow + for rows.Next() { + var r RevenueRow + if err := rows.Scan(&r.Chain, &r.Platform, &r.RevenueStable, &r.RevenueNative, &r.NativeSymbol); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/harnesses/evm-exec/migrations/004_evm_exec.sql b/harnesses/evm-exec/migrations/004_evm_exec.sql new file mode 100644 index 00000000..ac7c879c --- /dev/null +++ b/harnesses/evm-exec/migrations/004_evm_exec.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS evm_exec_events ( + id BIGSERIAL PRIMARY KEY, + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + block_num BIGINT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + asset TEXT NOT NULL, + amount_raw NUMERIC(78,0) NOT NULL, + decimals INT NOT NULL, + -- intra-tx discriminator: log_index for ERC-20, traceId for Etherscan internal, + -- position index for NodeReal transfers, "" for plain value txs. + event_key TEXT NOT NULL, + UNIQUE (chain, tx_hash, asset, event_key) +); + +CREATE INDEX IF NOT EXISTS idx_evm_events_lookup + ON evm_exec_events (platform, chain, block_time DESC); + +CREATE TABLE IF NOT EXISTS evm_block_times ( + chain TEXT NOT NULL, + block_num BIGINT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + PRIMARY KEY (chain, block_num) +); + +-- No DEFAULT on last_block: the collector seeds from bootstrap on first run. +CREATE TABLE IF NOT EXISTS evm_exec_cursors ( + chain TEXT NOT NULL, + platform TEXT NOT NULL, + asset TEXT NOT NULL, + last_block BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (chain, platform, asset) +); + +CREATE TABLE IF NOT EXISTS evm_exec_facts ( + chain TEXT NOT NULL, + platform TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + revenue_stable NUMERIC NOT NULL DEFAULT 0, + revenue_native NUMERIC NOT NULL DEFAULT 0, + native_symbol TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (chain, platform, bucket_start) +); diff --git a/harnesses/memecoin-platforms/.env.example b/harnesses/memecoin-platforms/.env.example new file mode 100644 index 00000000..dbb65e2d --- /dev/null +++ b/harnesses/memecoin-platforms/.env.example @@ -0,0 +1,3 @@ +DUNE_API_KEY=your_dune_api_key +DUNE_QUERY_ID= # leave empty on first run; harness prints the ID and exits +MOBULA_API_KEY=your_mobula_api_key diff --git a/harnesses/memecoin-platforms/Dockerfile b/harnesses/memecoin-platforms/Dockerfile new file mode 100644 index 00000000..0f7bd539 --- /dev/null +++ b/harnesses/memecoin-platforms/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /memecoin-platforms ./cmd/monitor + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=builder /memecoin-platforms . + +EXPOSE 2112 + +CMD ["./memecoin-platforms"] diff --git a/harnesses/memecoin-platforms/cmd/monitor/defillama.go b/harnesses/memecoin-platforms/cmd/monitor/defillama.go new file mode 100644 index 00000000..32fa0d1c --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/defillama.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type defillamaClient struct { + http *http.Client +} + +func newDefillamaClient() *defillamaClient { + return &defillamaClient{http: &http.Client{Timeout: 15 * time.Second}} +} + +// fetchFomoFees returns Fomo's 24h Solana fees in USD from DeFiLlama. +// Fomo combines on-chain USDC inflows + off-chain relay fees via a private +// Dune table (dune.tryfomo.fomo_relay_fees) that only DeFiLlama can read. +func (c *defillamaClient) fetchFomoFees() (float64, error) { + req, err := http.NewRequest(http.MethodGet, "https://api.llama.fi/summary/fees/fomo", nil) + if err != nil { + return 0, err + } + resp, err := c.http.Do(req) + if err != nil { + return 0, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, err + } + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("status %d", resp.StatusCode) + } + var out struct { + ChainBreakdown map[string]struct { + Total24h float64 `json:"total24h"` + } `json:"chainBreakdown"` + } + if err := json.Unmarshal(body, &out); err != nil { + return 0, fmt.Errorf("parse: %w", err) + } + if bd, ok := out.ChainBreakdown["Solana"]; ok { + return bd.Total24h, nil + } + return 0, nil +} diff --git a/harnesses/memecoin-platforms/cmd/monitor/dune.go b/harnesses/memecoin-platforms/cmd/monitor/dune.go new file mode 100644 index 00000000..853eb815 --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/dune.go @@ -0,0 +1,292 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// feesSQL is the Dune Trino SQL embedded here for auto-create. +// Mirrors harnesses/memecoin-platforms/queries/platform_fees.sql. +const feesSQL = ` +WITH + +wallet_platform AS ( + SELECT address, platform FROM (VALUES + ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'), + ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'), + ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'), + ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'), + ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'), + ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'), + ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'), + ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'), + ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'), + ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'), + ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'), + ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'), + ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'), + ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'), + ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'), + ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'), + ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'), + ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'), + ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'), + ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'), + ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'), + ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'), + ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'), + ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'), + ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'), + ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'), + ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'), + ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'), + ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'), + ('HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq','fomo'), + ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'), + ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'), + ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'), + ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'), + ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'), + ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'), + ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'), + ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'), + ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro'), + ('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM','pump-fun'), + ('62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV','pump-fun'), + ('FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz','pump-fun'), + ('7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX','pump-fun'), + ('AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY','pump-fun'), + ('9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz','pump-fun'), + ('G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP','pump-fun'), + ('7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ','pump-fun'), + ('GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS','pump-fun') + ) AS t(address, platform) +), + +native_sol AS ( + SELECT wp.platform, a.tx_id, CAST(a.post_balance - a.pre_balance AS DOUBLE) / 1e9 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.address = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address IS NULL + AND a.post_balance > a.pre_balance +), + +usdc_inflows AS ( + SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e6 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.token_balance_owner = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + AND a.post_token_balance > a.pre_token_balance +), + +wsol_inflows AS ( + SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e9 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.token_balance_owner = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address = 'So11111111111111111111111111111111111111112' + AND a.post_token_balance > a.pre_token_balance +), + +combined AS ( + SELECT platform, 0.0 AS usdc_inflow, 0.0 AS wsol_inflow, inflow AS sol_inflow, block_time FROM native_sol + UNION ALL + SELECT platform, inflow, 0.0, 0.0, block_time FROM usdc_inflows + UNION ALL + SELECT platform, 0.0, inflow, 0.0, block_time FROM wsol_inflows +), + +fee_tx_ids AS ( + SELECT platform, tx_id FROM native_sol + UNION + SELECT platform, tx_id FROM usdc_inflows + UNION + SELECT platform, tx_id FROM wsol_inflows +), + +fee_paying_vol AS ( + SELECT sub.platform, SUM(sub.max_swap) AS fee_paying_volume_usd_24h + FROM ( + SELECT ft.platform, ft.tx_id, MAX(COALESCE(ABS(t.amount_usd), 0)) AS max_swap + FROM fee_tx_ids ft + JOIN dex_solana.trades t ON t.tx_id = ft.tx_id + WHERE t.block_time >= NOW() - INTERVAL '1' DAY + GROUP BY ft.platform, ft.tx_id + ) sub + GROUP BY sub.platform +) + +SELECT + fees.platform, + fees.usdc_fees_24h, + fees.wsol_fees_24h, + fees.sol_fees_24h, + fees.data_freshness, + COALESCE(fpv.fee_paying_volume_usd_24h, 0) AS fee_paying_volume_usd_24h +FROM ( + SELECT + platform, + COALESCE(SUM(usdc_inflow), 0) AS usdc_fees_24h, + COALESCE(SUM(wsol_inflow), 0) AS wsol_fees_24h, + COALESCE(SUM(sol_inflow), 0) AS sol_fees_24h, + MAX(block_time) AS data_freshness + FROM combined + GROUP BY platform +) fees +LEFT JOIN fee_paying_vol fpv ON fpv.platform = fees.platform +ORDER BY fees.platform +` + +const duneBase = "https://api.dune.com/api/v1" + +type duneClient struct { + apiKey string + http *http.Client +} + +type duneRow struct { + Platform string `json:"platform"` + USDCFees24h float64 `json:"usdc_fees_24h"` + WSOLFees24h float64 `json:"wsol_fees_24h"` + SOLFees24h float64 `json:"sol_fees_24h"` + DataFreshness string `json:"data_freshness"` + FeePayingVolumeUSD24h float64 `json:"fee_paying_volume_usd_24h"` +} + +func isNoExecutionError(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.HasPrefix(s, "HTTP 40") && strings.Contains(s, "No execution") +} + +func newDuneClient(apiKey string) *duneClient { + return &duneClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (d *duneClient) req(method, path string, body any) (*http.Response, error) { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, duneBase+path, r) + if err != nil { + return nil, err + } + req.Header.Set("X-Dune-Api-Key", d.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return d.http.Do(req) +} + +func (d *duneClient) createQuery() (string, error) { + payload := map[string]any{ + "name": "OCB: Memecoin Platform Fees 24h", + "query_sql": feesSQL, + "is_private": false, + "parameters": []any{}, + } + resp, err := d.req("POST", "/query", payload) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, b) + } + var out struct { + QueryID int `json:"query_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return fmt.Sprintf("%d", out.QueryID), nil +} + +func (d *duneClient) updateQuery(queryID string) error { + payload := map[string]any{ + "query_sql": feesSQL, + } + resp, err := d.req("PATCH", "/query/"+queryID, payload) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, b) + } + return nil +} + +func (d *duneClient) execute(queryID string) (string, error) { + resp, err := d.req("POST", "/query/"+queryID+"/execute", map[string]any{}) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, b) + } + var out struct { + ExecutionID string `json:"execution_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ExecutionID, nil +} + +func (d *duneClient) executionState(execID string) (string, error) { + resp, err := d.req("GET", "/execution/"+execID+"/status", nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + var out struct { + State string `json:"state"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.State, nil +} + +func (d *duneClient) latestResult(queryID string) ([]duneRow, error) { + resp, err := d.req("GET", "/query/"+queryID+"/results", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, b) + } + var out struct { + Result struct { + Rows []duneRow `json:"rows"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out.Result.Rows, nil +} diff --git a/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go b/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go new file mode 100644 index 00000000..7c947c40 --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/lighthouse.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const lighthouseURL = "https://api.mobula.io/api/2/market/lighthouse" + +type lighthouseClient struct { + apiKey string + http *http.Client +} + +func newLighthouseClient(apiKey string) *lighthouseClient { + return &lighthouseClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// fetchVolumes returns platform name -> volume_usd_24h from the Mobula lighthouse. +func (c *lighthouseClient) fetchVolumes() (map[string]float64, error) { + req, err := http.NewRequest(http.MethodGet, lighthouseURL, nil) + if err != nil { + return nil, fmt.Errorf("build req: %w", err) + } + req.Header.Set("Authorization", c.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("status %d: %.200s", resp.StatusCode, body) + } + + type entry struct { + Name string `json:"name"` + VolumeUSD struct { + H24 float64 `json:"24h"` + } `json:"volumeUSD"` + } + var out struct { + Data struct { + ByPlatform []entry `json:"byPlatform"` + ByLaunchpad []entry `json:"byLaunchpad"` + } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + + volumes := make(map[string]float64) + for _, p := range out.Data.ByPlatform { + if p.Name == "" { + continue + } + volumes[strings.ToLower(p.Name)] = p.VolumeUSD.H24 + } + // pump.fun bonding curve is under byLaunchpad as "PumpFun" + for _, p := range out.Data.ByLaunchpad { + key := strings.ToLower(p.Name) + if key == "pumpfun" { + volumes["pump-fun"] = p.VolumeUSD.H24 + } + } + return volumes, nil +} diff --git a/harnesses/memecoin-platforms/cmd/monitor/main.go b/harnesses/memecoin-platforms/cmd/monitor/main.go new file mode 100644 index 00000000..c266ce8c --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/main.go @@ -0,0 +1,236 @@ +// memecoin-platforms -- Bench 203 +// +// Computes per-platform take rates for Solana memecoin trading bots by combining: +// - Dune Analytics: fee wallet inflows (USDC + WSOL + native SOL) -> fees_usd_24h +// - Mobula lighthouse: platform trading volume -> volume_usd_24h +// - take_rate = fees_usd_24h / volume_usd_24h * 100 +// +// Required env vars: +// +// DUNE_API_KEY - Dune Analytics API key +// DUNE_QUERY_ID - Dune query ID (leave empty on first run; harness prints it and exits) +// MOBULA_API_KEY - Mobula API key +// +// Metrics on :2112/metrics: +// +// memecoin_platform_fee_rate_pct{platform} +// memecoin_platform_fees_usd_24h{platform} +// memecoin_platform_volume_usd_24h{platform} +// memecoin_platform_health +// memecoin_last_poll_timestamp_seconds +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + fetchInterval = 15 * time.Minute + refreshInterval = 6 * time.Hour + solPriceRefresh = 5 * time.Minute +) + +func main() { + fmt.Println("=== memecoin-platforms harness ===") + fmt.Println("OpenChainBench Bench 203 -- Memecoin platform fee rates via Dune + Mobula lighthouse.") + + duneKey := os.Getenv("DUNE_API_KEY") + if duneKey == "" { + fmt.Fprintln(os.Stderr, "[fatal] DUNE_API_KEY not set") + os.Exit(1) + } + mobulaKey := os.Getenv("MOBULA_API_KEY") + if mobulaKey == "" { + fmt.Fprintln(os.Stderr, "[fatal] MOBULA_API_KEY not set") + os.Exit(1) + } + + queryID := os.Getenv("DUNE_QUERY_ID") + dune := newDuneClient(duneKey) + + if queryID == "" { + fmt.Println("[init] DUNE_QUERY_ID not set, creating Dune query...") + id, err := dune.createQuery() + if err != nil { + fmt.Fprintf(os.Stderr, "[fatal] failed to create Dune query: %v\n", err) + os.Exit(1) + } + fmt.Printf("[init] Dune query created: %s\n", id) + fmt.Printf("[init] Set DUNE_QUERY_ID=%s and restart the harness.\n", id) + os.Exit(0) + } + + lighthouse := newLighthouseClient(mobulaKey) + llama := newDefillamaClient() + + startSolPriceRefresher(solPriceRefresh) + + go func() { + if err := startMetricsServer(":2112"); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + // Sync Dune query SQL to the embedded feesSQL constant, then trigger a fresh execution. + // This ensures new SQL columns (e.g. fee_paying_volume_usd_24h) are picked up automatically. + if err := dune.updateQuery(queryID); err != nil { + fmt.Printf("[init] Dune query update failed (continuing with cached SQL): %v\n", err) + } else { + fmt.Println("[init] Dune query SQL synced") + } + + // On startup: fetch cached Dune result + current lighthouse data immediately, + // then trigger a fresh Dune execution in the background. + runPoll(dune, lighthouse, llama, queryID) + go func() { + execID, err := dune.execute(queryID) + if err != nil { + fmt.Printf("[refresh] execute failed: %v\n", err) + return + } + pollUntilDone(dune, lighthouse, llama, queryID, execID) + }() + + fetchTick := time.NewTicker(fetchInterval) + refreshTick := time.NewTicker(refreshInterval) + defer fetchTick.Stop() + defer refreshTick.Stop() + + for { + select { + case <-sig: + fmt.Println("[shutdown] received signal") + return + case <-fetchTick.C: + runPoll(dune, lighthouse, llama, queryID) + case <-refreshTick.C: + execID, err := dune.execute(queryID) + if err != nil { + fmt.Printf("[refresh] execute failed: %v\n", err) + continue + } + go pollUntilDone(dune, lighthouse, llama, queryID, execID) + } + } +} + +func runPoll(dune *duneClient, lh *lighthouseClient, llama *defillamaClient, queryID string) { + rows, err := dune.latestResult(queryID) + if err != nil { + if isNoExecutionError(err) { + // After a SQL update, Dune creates a new version with no execution yet. + // The background goroutine will execute and call runPoll when done. + fmt.Printf("[fetch] dune: no execution for current query version, waiting for background refresh\n") + } else { + fmt.Printf("[fetch] dune failed: %v\n", err) + platformHealth.Set(0) + } + return + } + + volumes, err := lh.fetchVolumes() + if err != nil { + fmt.Printf("[fetch] lighthouse failed: %v\n", err) + platformHealth.Set(0) + return + } + + solUSD := getSolPrice() + + // Aggregate rows per platform (Dune may return multiple rows). + type totals struct { + feesUSD float64 + feePayingVolumeUSD float64 + } + byPlatform := make(map[string]*totals) + var latestBlockTime string + for _, r := range rows { + if r.Platform == "" { + continue + } + t, ok := byPlatform[r.Platform] + if !ok { + t = &totals{} + byPlatform[r.Platform] = t + } + t.feesUSD += r.USDCFees24h + r.WSOLFees24h*solUSD + r.SOLFees24h*solUSD + t.feePayingVolumeUSD += r.FeePayingVolumeUSD24h + if r.DataFreshness > latestBlockTime { + latestBlockTime = r.DataFreshness + } + } + if latestBlockTime != "" { + s := latestBlockTime + if len(s) > 19 { + s = s[:19] + } + for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} { + if ts, err := time.Parse(layout, s); err == nil { + duneDataFreshness.Set(float64(ts.Unix())) + break + } + } + } + + // Fomo fees: on-chain USDC sweeps + off-chain relay fees only accessible + // via DeFiLlama (private Dune table dune.tryfomo.fomo_relay_fees). + fomoFees, err := llama.fetchFomoFees() + if err != nil { + fmt.Printf("[fetch] fomo/defillama failed: %v\n", err) + } else if fomoFees > 0 { + byPlatform["fomo"] = &totals{feesUSD: fomoFees} + } + + for platform, t := range byPlatform { + mobulaVol := volumes[platform] + feesUSD24h.WithLabelValues(platform).Set(t.feesUSD) + volumeUSD24h.WithLabelValues(platform).Set(mobulaVol) + feePayingVolumeUSD24h.WithLabelValues(platform).Set(t.feePayingVolumeUSD) + + rate := 0.0 + if mobulaVol > 0 { + rate = t.feesUSD / mobulaVol * 100 + } + feeRatePct.WithLabelValues(platform).Set(rate) + + if t.feePayingVolumeUSD > 0 { + feePayingRatePct.WithLabelValues(platform).Set(t.feesUSD / t.feePayingVolumeUSD * 100) + if mobulaVol > 0 { + coveragePct.WithLabelValues(platform).Set(t.feePayingVolumeUSD / mobulaVol * 100) + } + } + } + + lastPollTime.SetToCurrentTime() + platformHealth.Set(1) + fmt.Printf("[fetch] updated %d platform(s), sol=$%.2f\n", len(byPlatform), solUSD) +} + +func pollUntilDone(dune *duneClient, lh *lighthouseClient, llama *defillamaClient, queryID, execID string) { + for range 30 { + time.Sleep(30 * time.Second) + state, err := dune.executionState(execID) + if err != nil { + fmt.Printf("[poll] state check failed: %v\n", err) + return + } + switch state { + case "QUERY_STATE_COMPLETED": + fmt.Printf("[poll] execution %s complete\n", execID) + runPoll(dune, lh, llama, queryID) + return + case "QUERY_STATE_FAILED", "QUERY_STATE_CANCELLED", "QUERY_STATE_EXPIRED": + fmt.Printf("[poll] execution %s ended with state %s\n", execID, state) + return + } + } + fmt.Printf("[poll] execution %s timed out waiting\n", execID) +} diff --git a/harnesses/memecoin-platforms/cmd/monitor/metrics.go b/harnesses/memecoin-platforms/cmd/monitor/metrics.go new file mode 100644 index 00000000..87225b2e --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/metrics.go @@ -0,0 +1,73 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + feeRatePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_fee_rate_pct", + Help: "Take rate using Mobula total volume: fees_usd_24h / mobula_volume_usd_24h * 100 (lower bound; denominator includes non-fee trades)", + }, []string{"platform"}) + + feePayingRatePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_fee_paying_rate_pct", + Help: "Take rate using fee-paying volume only: fees_usd_24h / fee_paying_volume_usd_24h * 100 (comparable across platforms)", + }, []string{"platform"}) + + coveragePct = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_coverage_pct", + Help: "Fee-paying volume / Mobula total volume * 100: fraction of attributed volume that generated a fee inflow", + }, []string{"platform"}) + + feesUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_fees_usd_24h", + Help: "Raw USD fees collected by this platform in the last 24h (Dune)", + }, []string{"platform"}) + + volumeUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_volume_usd_24h", + Help: "Trading volume in USD in the last 24h (Mobula lighthouse, total attributed volume)", + }, []string{"platform"}) + + feePayingVolumeUSD24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "memecoin_platform_fee_paying_volume_usd_24h", + Help: "Volume of trades that generated a fee inflow in the last 24h (Dune: largest swap per fee-receiving tx)", + }, []string{"platform"}) + + platformHealth = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "memecoin_platform_health", + Help: "1.0 when both Dune and lighthouse data are fresh", + }) + + lastPollTime = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "memecoin_last_poll_timestamp_seconds", + Help: "Unix timestamp of the last successful poll", + }) + + duneDataFreshness = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "memecoin_dune_data_freshness_seconds", + Help: "Unix timestamp of the most recent block_time seen in the Dune query results. Gap from now() indicates solana.account_activity table lag.", + }) +) + +func init() { + prometheus.MustRegister( + feeRatePct, feePayingRatePct, coveragePct, + feesUSD24h, volumeUSD24h, feePayingVolumeUSD24h, + platformHealth, lastPollTime, duneDataFreshness, + ) +} + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/memecoin-platforms/cmd/monitor/solprice.go b/harnesses/memecoin-platforms/cmd/monitor/solprice.go new file mode 100644 index 00000000..2a057a73 --- /dev/null +++ b/harnesses/memecoin-platforms/cmd/monitor/solprice.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" + "time" + "math" +) + +const coingeckoURL = "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd" + +// solPriceBits stores the current SOL/USD price as a uint64 (math.Float64bits). +var solPriceBits atomic.Uint64 + +func init() { + setSolPrice(150.0) +} + +func setSolPrice(usd float64) { + solPriceBits.Store(math.Float64bits(usd)) +} + +func getSolPrice() float64 { + return math.Float64frombits(solPriceBits.Load()) +} + +func updateSolPrice(client *http.Client) { + price, err := fetchSolPrice(client) + if err != nil { + fmt.Printf("[solprice] fetch failed: %v (keeping %.2f)\n", err, getSolPrice()) + return + } + setSolPrice(price) + fmt.Printf("[solprice] updated: $%.2f\n", price) +} + +func fetchSolPrice(client *http.Client) (float64, error) { + req, err := http.NewRequest(http.MethodGet, coingeckoURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("coingecko: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return 0, fmt.Errorf("read: %w", err) + } + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("status %d: %.100s", resp.StatusCode, body) + } + + var out struct { + Solana struct { + USD float64 `json:"usd"` + } `json:"solana"` + } + if err := json.Unmarshal(body, &out); err != nil { + return 0, fmt.Errorf("parse: %w", err) + } + if out.Solana.USD <= 0 { + return 0, fmt.Errorf("invalid price: %v", out.Solana.USD) + } + return out.Solana.USD, nil +} + +func startSolPriceRefresher(interval time.Duration) { + client := &http.Client{Timeout: 10 * time.Second} + updateSolPrice(client) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + updateSolPrice(client) + } + }() +} diff --git a/harnesses/memecoin-platforms/go.mod b/harnesses/memecoin-platforms/go.mod new file mode 100644 index 00000000..f7eb7f34 --- /dev/null +++ b/harnesses/memecoin-platforms/go.mod @@ -0,0 +1,19 @@ +module memecoin-platforms + +go 1.24.0 + +toolchain go1.24.4 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/harnesses/memecoin-platforms/go.sum b/harnesses/memecoin-platforms/go.sum new file mode 100644 index 00000000..309d820f --- /dev/null +++ b/harnesses/memecoin-platforms/go.sum @@ -0,0 +1,24 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/harnesses/memecoin-platforms/queries/platform_fees.sql b/harnesses/memecoin-platforms/queries/platform_fees.sql new file mode 100644 index 00000000..9f440be3 --- /dev/null +++ b/harnesses/memecoin-platforms/queries/platform_fees.sql @@ -0,0 +1,144 @@ +-- OCB Bench 203: Memecoin Platform Fees (Dune Trino SQL) +-- Monitors known fee wallets for native SOL, USDC, and WSOL inflows. +-- Key: native SOL uses address column, SPL tokens use token_balance_owner column. +-- +-- Columns: platform, usdc_fees_24h, wsol_fees_24h, sol_fees_24h, data_freshness, +-- fee_paying_volume_usd_24h +-- fee_paying_volume_usd_24h = sum of the largest swap (amount_usd) in each tx that +-- generated a fee inflow; provides a self-consistent denominator so take_rate is +-- comparable across platforms (vs. Mobula total volume which includes non-fee trades). +-- data_freshness = MAX(block_time) across all matched rows; alerts if table lags +-- and the nominal 24h window silently shrinks (e.g. table only refreshed 20h ago). + +WITH + +wallet_platform AS ( + SELECT address, platform FROM (VALUES + ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'), + ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'), + ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'), + ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'), + ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'), + ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'), + ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'), + ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'), + ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'), + ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'), + ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'), + ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'), + ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'), + ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'), + ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'), + ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'), + ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'), + ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'), + ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'), + ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'), + ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'), + ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'), + ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'), + ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'), + ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'), + ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'), + ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'), + ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'), + ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'), + ('HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq','fomo'), + ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'), + ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'), + ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'), + ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'), + ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'), + ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'), + ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'), + ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'), + ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro'), + ('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM','pump-fun'), + ('62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV','pump-fun'), + ('FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz','pump-fun'), + ('7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX','pump-fun'), + ('AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY','pump-fun'), + ('9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz','pump-fun'), + ('G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP','pump-fun'), + ('7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ','pump-fun'), + ('GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS','pump-fun') + ) AS t(address, platform) +), + +native_sol AS ( + SELECT wp.platform, a.tx_id, CAST(a.post_balance - a.pre_balance AS DOUBLE) / 1e9 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.address = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address IS NULL + AND a.post_balance > a.pre_balance +), + +usdc_inflows AS ( + SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e6 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.token_balance_owner = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + AND a.post_token_balance > a.pre_token_balance +), + +wsol_inflows AS ( + SELECT wp.platform, a.tx_id, GREATEST(a.post_token_balance - a.pre_token_balance, 0) / 1e9 AS inflow, a.block_time + FROM solana.account_activity a + JOIN wallet_platform wp ON a.token_balance_owner = wp.address + WHERE a.block_time >= NOW() - INTERVAL '1' DAY + AND a.token_mint_address = 'So11111111111111111111111111111111111111112' + AND a.post_token_balance > a.pre_token_balance +), + +combined AS ( + SELECT platform, 0.0 AS usdc_inflow, 0.0 AS wsol_inflow, inflow AS sol_inflow, block_time FROM native_sol + UNION ALL + SELECT platform, inflow, 0.0, 0.0, block_time FROM usdc_inflows + UNION ALL + SELECT platform, 0.0, inflow, 0.0, block_time FROM wsol_inflows +), + +-- All (platform, tx_id) pairs where a fee inflow was received (deduplicated) +fee_tx_ids AS ( + SELECT platform, tx_id FROM native_sol + UNION + SELECT platform, tx_id FROM usdc_inflows + UNION + SELECT platform, tx_id FROM wsol_inflows +), + +-- For each fee-generating tx, take the largest swap trade (max amount_usd per tx) +-- to avoid double-counting multi-hop swaps in the same transaction. +fee_paying_vol AS ( + SELECT sub.platform, SUM(sub.max_swap) AS fee_paying_volume_usd_24h + FROM ( + SELECT ft.platform, ft.tx_id, MAX(COALESCE(ABS(t.amount_usd), 0)) AS max_swap + FROM fee_tx_ids ft + JOIN dex_solana.trades t ON t.tx_id = ft.tx_id + WHERE t.block_time >= NOW() - INTERVAL '1' DAY + GROUP BY ft.platform, ft.tx_id + ) sub + GROUP BY sub.platform +) + +SELECT + fees.platform, + fees.usdc_fees_24h, + fees.wsol_fees_24h, + fees.sol_fees_24h, + fees.data_freshness, + COALESCE(fpv.fee_paying_volume_usd_24h, 0) AS fee_paying_volume_usd_24h +FROM ( + SELECT + platform, + COALESCE(SUM(usdc_inflow), 0) AS usdc_fees_24h, + COALESCE(SUM(wsol_inflow), 0) AS wsol_fees_24h, + COALESCE(SUM(sol_inflow), 0) AS sol_fees_24h, + MAX(block_time) AS data_freshness + FROM combined + GROUP BY platform +) fees +LEFT JOIN fee_paying_vol fpv ON fpv.platform = fees.platform +ORDER BY fees.platform diff --git a/harnesses/metadata-coverage/cmd/script/defined_auth.go b/harnesses/metadata-coverage/cmd/script/defined_auth.go index 716f05f0..40ca7c73 100644 --- a/harnesses/metadata-coverage/cmd/script/defined_auth.go +++ b/harnesses/metadata-coverage/cmd/script/defined_auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "sync" "time" @@ -57,8 +58,39 @@ func decodeJWTExpiration(token string) (time.Time, error) { return time.Unix(claims.Exp, 0), nil } -// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired +// tryTokenService fetches a fresh JWE from the Paris-box sidecar. +func tryTokenService(baseURL string) (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + for _, path := range []string{"/token", "/jwt", "/"} { + resp, err := client.Get(baseURL + path) + if err != nil { + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + continue + } + var parsed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + return parsed.Token, nil + } + s := strings.TrimSpace(string(body)) + if len(s) > 50 { + return s, nil + } + } + return "", fmt.Errorf("no working path on sidecar") +} + +// GetDefinedJWTToken returns a cached JWT token or generates a new one if expired. +// If CODEX_JWT env var is set, it is returned directly (bypasses session-cookie flow). func GetDefinedJWTToken(sessionCookie string) (string, error) { + if jwt := os.Getenv("CODEX_JWT"); jwt != "" { + return jwt, nil + } globalTokenCache.mu.RLock() // Check if we have a valid cached token @@ -79,6 +111,17 @@ func GetDefinedJWTToken(sessionCookie string) (string, error) { return globalTokenCache.token, nil } + // Sidecar (Paris box, fresh every 8 min via Tor+utls, bypasses Kasada) + if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" { + if tok, err := tryTokenService(svcURL); err == nil && tok != "" { + fmt.Printf("[DEFINED-AUTH] Got token from sidecar (len=%d)\n", len(tok)) + globalTokenCache.token = tok + globalTokenCache.expiresAt = time.Now().Add(8 * time.Minute) + globalTokenCache.lastRefresh = time.Now() + return tok, nil + } + } + // Generate new token token, err := generateDefinedJWTToken(sessionCookie) if err != nil { @@ -144,7 +187,7 @@ func generateDefinedJWTToken(sessionCookie string) (string, error) { req.Header.Set("sec-fetch-dest", "empty") req.Header.Set("sec-fetch-mode", "cors") req.Header.Set("sec-fetch-site", "same-origin") - req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) + req.AddCookie(&http.Cookie{Name: "defined-attestation-token", Value: sessionCookie}) fmt.Println("[DEFINED-AUTH] Sending POST request to https://www.defined.fi/api...") resp, err := client.Do(req) diff --git a/harnesses/metadata-coverage/cmd/script/scrape_session.go b/harnesses/metadata-coverage/cmd/script/scrape_session.go index 4607912c..26d3f770 100644 --- a/harnesses/metadata-coverage/cmd/script/scrape_session.go +++ b/harnesses/metadata-coverage/cmd/script/scrape_session.go @@ -42,7 +42,7 @@ func ScrapeDefinedSessionCookie() (string, error) { } for _, cookie := range cookieParams { - if cookie.Name == "session" { + if cookie.Name == "defined-attestation-token" { sessionCookie = cookie.Value return nil } diff --git a/harnesses/mev-protect-rpc/cmd/script/probe.go b/harnesses/mev-protect-rpc/cmd/script/probe.go index 25920a4d..0afd3358 100644 --- a/harnesses/mev-protect-rpc/cmd/script/probe.go +++ b/harnesses/mev-protect-rpc/cmd/script/probe.go @@ -192,7 +192,7 @@ func call(ctx context.Context, client *http.Client, url, method string, params [ start := time.Now() resp, err := client.Do(req) - lat := float64(time.Since(start).Milliseconds()) + lat := float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil { return lat, "canceled" diff --git a/harnesses/network-coverage/cmd/script/codex.go b/harnesses/network-coverage/cmd/script/codex.go index db31dde1..60db0b81 100644 --- a/harnesses/network-coverage/cmd/script/codex.go +++ b/harnesses/network-coverage/cmd/script/codex.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "strconv" "time" ) @@ -99,7 +100,11 @@ func fetchCodex(cfg *Config) ProviderResult { // getCodexJWT mints a Defined.fi JWT from the session cookie, or pulls a // pre-minted one from a sidecar URL if DEFINED_TOKEN_SERVICE_URL is set. +// If CODEX_JWT env var is set, it is returned directly (bypasses all other flows). func getCodexJWT(cfg *Config) (string, error) { + if jwt := os.Getenv("CODEX_JWT"); jwt != "" { + return jwt, nil + } // If a sidecar token service is configured, try it first. On any error, // silently fall back to inline mint — the sidecar isn't authoritative. if cfg.DefinedTokenURL != "" { @@ -127,7 +132,7 @@ func getCodexJWT(cfg *Config) (string, error) { req.Header.Set("Accept", "application/json") req.Header.Set("Origin", "https://www.defined.fi") req.Header.Set("Referer", "https://www.defined.fi/") - req.Header.Set("Cookie", "session-token="+cfg.CodexSessionCookie) + req.AddCookie(&http.Cookie{Name: "defined-attestation-token", Value: cfg.CodexSessionCookie}) resp, err := client.Do(req) if err != nil { diff --git a/harnesses/perp-protocol-longevity/cmd/script/defillama.go b/harnesses/perp-protocol-longevity/cmd/script/defillama.go index e54b5962..c07becb2 100644 --- a/harnesses/perp-protocol-longevity/cmd/script/defillama.go +++ b/harnesses/perp-protocol-longevity/cmd/script/defillama.go @@ -65,6 +65,9 @@ func enrichFromDefiLlama(reg []venueRecord) []venueRecord { if t.Before(reg[i].Launched) { continue } + if h.Amount <= 0 { + continue // null/zero amount = no confirmed financial loss + } if h.ReturnedFunds != nil && *h.ReturnedFunds >= h.Amount { continue } diff --git a/harnesses/pm-rate-limits/cmd/script/codex_auth.go b/harnesses/pm-rate-limits/cmd/script/codex_auth.go index b06ed1eb..2b170cd7 100644 --- a/harnesses/pm-rate-limits/cmd/script/codex_auth.go +++ b/harnesses/pm-rate-limits/cmd/script/codex_auth.go @@ -14,6 +14,7 @@ import ( "io" "log" "net/http" + "os" "strings" "sync" "time" @@ -59,7 +60,11 @@ func decodeJWTExpiration(token string) (time.Time, error) { // GetCodexJWT returns a cached short-lived JWT, minting a new one when the // cached one is within 1h of expiry. +// If CODEX_JWT env var is set, it is returned directly (bypasses session-cookie flow). func GetCodexJWT(sessionCookie string) (string, error) { + if jwt := os.Getenv("CODEX_JWT"); jwt != "" { + return jwt, nil + } codexTokenCache.mu.RLock() if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { t := codexTokenCache.token @@ -73,6 +78,18 @@ func GetCodexJWT(sessionCookie string) (string, error) { if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { return codexTokenCache.token, nil } + + // Sidecar (Paris box, fresh every 8 min via Tor+utls, bypasses Kasada) + if svcURL := os.Getenv("DEFINED_TOKEN_SERVICE_URL"); svcURL != "" { + if tok, err := tryTokenService(svcURL); err == nil && tok != "" { + log.Printf("[codex-auth] Got token from sidecar (len=%d)", len(tok)) + codexTokenCache.token = tok + codexTokenCache.expiresAt = time.Now().Add(8 * time.Minute) + codexTokenCache.lastRefresh = time.Now() + return tok, nil + } + } + tok, err := mintCodexJWT(sessionCookie) if err != nil { return "", err @@ -96,6 +113,32 @@ func InvalidateCodexJWT() { log.Printf("[codex-auth] JWT cache invalidated") } +func tryTokenService(baseURL string) (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + for _, path := range []string{"/token", "/jwt", "/"} { + resp, err := client.Get(baseURL + path) + if err != nil { + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + continue + } + var parsed struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Token != "" { + return parsed.Token, nil + } + s := strings.TrimSpace(string(body)) + if len(s) > 50 { + return s, nil + } + } + return "", fmt.Errorf("no working path on sidecar") +} + func mintCodexJWT(sessionCookie string) (string, error) { // CRITICAL: route through HTTP_PROXY / HTTPS_PROXY (Webshare). Direct // Railway IPs get stuck in Vercel's bot-ban loop after a few mints. @@ -120,7 +163,7 @@ func mintCodexJWT(sessionCookie string) (string, error) { req.Header.Set("sec-fetch-dest", "empty") req.Header.Set("sec-fetch-mode", "cors") req.Header.Set("sec-fetch-site", "same-origin") - req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) + req.AddCookie(&http.Cookie{Name: "defined-attestation-token", Value: sessionCookie}) resp, err := client.Do(req) if err != nil { diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index ab02e508..8075eccd 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -224,7 +224,7 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_MEGAETH_TENDERLY", "https://megaeth.gateway.tenderly.co")}, }, }, - // ─── Ethereum mainnet (9 providers) ──────────────────────── + // ─── Ethereum mainnet (8 providers) ──────────────────────── { Slug: "ethereum", Name: "Ethereum", @@ -233,12 +233,9 @@ func chains() []Chain { {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_ETHEREUM_DRPC", "https://eth.drpc.org")}, {Slug: "meowrpc", Name: "MeowRPC", URL: envDefault("RPC_URL_ETHEREUM_MEOWRPC", "https://eth.meowrpc.com")}, {Slug: "flashbots", Name: "Flashbots Protect", URL: envDefault("RPC_URL_ETHEREUM_FLASHBOTS", "https://rpc.flashbots.net")}, - {Slug: "cloudflare", Name: "Cloudflare", URL: envDefault("RPC_URL_ETHEREUM_CLOUDFLARE", "https://cloudflare-eth.com")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_ETHEREUM_TENDERLY", "https://gateway.tenderly.co/public/mainnet")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ETHEREUM_NODIES", "https://eth-pokt.nodies.app")}, {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ETHEREUM_LAVA", "https://eth1.lava.build")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ETHEREUM_BLASTAPI", "https://eth-mainnet.public.blastapi.io")}, - {Slug: "gatewayfm", Name: "Gateway.fm", URL: envDefault("RPC_URL_ETHEREUM_GATEWAYFM", "https://rpc.eth.gateway.fm")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_ETHEREUM_BLOXROUTE", "https://eth.rpc.blxrbdn.com")}, }, }, @@ -516,22 +513,21 @@ func chains() []Chain { // wallet + TronWeb/EVM bridge integrates against. Providers // live-verified keyless via eth_blockNumber during launch // audit: TronGrid (api.trongrid.io/jsonrpc — Tron Foundation - // official), dRPC and PublicNode/Allnodes. Excluded by that - // sweep: Ankr (API key required despite public branding), - // Chainstack + NOWNodes + GetBlock + Tatum + BlockPI (all - // require API key on TRON JSON-RPC path), OnFinality + Blast - // API + AllThatNode (no public TRON JSON-RPC gateway). The - // TRON JSON-RPC provider market is materially smaller than - // EVM — most TRON infra vendors expose only the native TRON - // REST API keyless. Native REST API surface is out of scope - // for this cluster; a `tron-rest` bench would need a + // official), PublicNode/Allnodes. Excluded: dRPC (method not + // available on TRON JSON-RPC path), Ankr (API key required + // despite public branding), Chainstack + NOWNodes + GetBlock + + // Tatum + BlockPI (all require API key on TRON JSON-RPC path), + // OnFinality + Blast API + AllThatNode (no public TRON JSON-RPC + // gateway). The TRON JSON-RPC provider market is materially + // smaller than EVM — most TRON infra vendors expose only the + // native TRON REST API keyless. Native REST API surface is out + // of scope for this cluster; a `tron-rest` bench would need a // chain-specific probe. { Slug: "tron", Name: "TRON", Providers: []Provider{ {Slug: "trongrid", Name: "TronGrid", URL: envDefault("RPC_URL_TRON_TRONGRID", "https://api.trongrid.io/jsonrpc")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_TRON_DRPC", "https://tron.drpc.org")}, {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_TRON_PUBLICNODE", "https://tron.publicnode.com/jsonrpc")}, }, }, @@ -552,9 +548,9 @@ func chains() []Chain { }, // ─── Kaia (bench 098) — added 2026-07-24. EVM L1 formed by // Klaytn + Finschia merger. Providers live-verified keyless: - // kaia-official (public-en.node.kaia.io), drpc, thirdweb. - // Excluded: BlockPI (`Apikey not found`), Nodies (paid tier), - // Alchemy public (KAIA_MAINNET not enabled), AllThatNode + + // kaia-official (public-en.node.kaia.io). Excluded: dRPC (500 + // paid plan only), BlockPI (`Apikey not found`), Nodies (paid + // tier), Alchemy public (KAIA_MAINNET not enabled), AllThatNode + // OnFinality + Chainstack + Tatum + NowNodes (DNS-fail or key // required). { @@ -562,7 +558,6 @@ func chains() []Chain { Name: "Kaia", Providers: []Provider{ {Slug: "kaia-official", Name: "Kaia Foundation", URL: envDefault("RPC_URL_KAIA_OFFICIAL", "https://public-en.node.kaia.io")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_KAIA_DRPC", "https://klaytn.drpc.org")}, }, }, // ─── Ink (bench 099) — added 2026-07-24. OP Stack rollup @@ -599,24 +594,18 @@ func chains() []Chain { }, // ─── Sei EVM (bench 108) — added 2026-07-26. Cosmos SDK L1 with // EVM parallel-execution layer (chain 1329). Native EVM JSON-RPC. - // The 2026-07-03 sweep excluded Sei because drpc cached - // eth_blockNumber leaving only 2 clean providers; re-audit on - // 2026-07-26 confirms dRPC no longer caches, and Thirdweb + - // Stakeme.pro have come online, restoring 4 clean keyless - // providers. Providers live-verified: sei-official - // (evm-rpc.sei-apis.com), drpc, thirdweb, stakeme. Excluded: - // PublicNode (no sei-evm subdomain), Tenderly public (no - // sei route), BlockPI (Apikey required), MeowRPC (defunct), - // Basement Nodes / Node75 / AllThatNode (host unresolvable - // or key-gated). Sei's dual Cosmos + EVM stack means this bench - // scopes strictly to the EVM side; the Cosmos JSON-RPC would - // need a Kind:"cosmos" entry as we did for Osmosis. + // Providers live-verified: sei-official (evm-rpc.sei-apis.com), + // thirdweb, stakeme. Excluded: dRPC (500 persistent internal error + // after 3 consecutive attempts), PublicNode (no sei-evm subdomain), + // Tenderly public (no sei route), BlockPI (Apikey required), + // MeowRPC (defunct), Basement Nodes / Node75 / AllThatNode + // (host unresolvable or key-gated). Sei's dual Cosmos + EVM stack + // means this bench scopes strictly to the EVM side. { Slug: "sei", Name: "Sei EVM", Providers: []Provider{ {Slug: "sei-official", Name: "Sei Labs", URL: envDefault("RPC_URL_SEI_OFFICIAL", "https://evm-rpc.sei-apis.com")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_SEI_DRPC", "https://sei.drpc.org")}, {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_SEI_THIRDWEB", "https://sei.rpc.thirdweb.com")}, {Slug: "stakeme", Name: "Stakeme", URL: envDefault("RPC_URL_SEI_STAKEME", "https://sei-evm-rpc.stakeme.pro")}, }, @@ -641,9 +630,10 @@ func chains() []Chain { }, // ─── Ronin (bench 110) — added 2026-07-26. EVM gaming L1 // (chain 2020) operated by Sky Mavis, primary home of Axie - // Infinity + Pixels + a broader gaming ecosystem. 4 clean + // Infinity + Pixels + a broader gaming ecosystem. 3 clean // keyless providers live-verified: ronin-official - // (api.roninchain.com), drpc, tenderly, thirdweb. Excluded: + // (api.roninchain.com), tenderly, thirdweb. Excluded: + // dRPC (500 persistent internal error after 3 attempts), // Ronin Tech secondary (DNS-fail), PublicNode (no ronin // subdomain), lgns.net (DNS-fail). { @@ -651,7 +641,6 @@ func chains() []Chain { Name: "Ronin", Providers: []Provider{ {Slug: "ronin-official", Name: "Sky Mavis", URL: envDefault("RPC_URL_RONIN_OFFICIAL", "https://api.roninchain.com/rpc")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_RONIN_DRPC", "https://ronin.drpc.org")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_RONIN_TENDERLY", "https://ronin.gateway.tenderly.co")}, {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_RONIN_THIRDWEB", "https://ronin.rpc.thirdweb.com")}, }, @@ -857,11 +846,12 @@ func chains() []Chain { {Slug: "morph-quicknode", Name: "QuickNode", URL: envDefault("RPC_URL_MORPH_QUICKNODE", "https://rpc-quicknode.morph.network")}, }, }, - // 2026-08-03 audit. Moonriver Kusama parachain (chain 1285). 4 clean + // 2026-08-03 audit. Moonriver Kusama parachain (chain 1285). 3 clean // keyless providers: moonriver-official (rpc.api.moonriver.moonbeam.network), // publicnode (moonriver-rpc.publicnode.com), onfinality - // (moonriver.api.onfinality.io/public), unitedbloc (moonriver.unitedbloc.com). - // Excluded: Ankr (key required for moonriver), drpc (no moonriver route). + // (moonriver.api.onfinality.io/public). Excluded: UnitedBloc + // (moonriver.unitedbloc.com DNS dead), Ankr (key required for + // moonriver), drpc (no moonriver route). { Slug: "moonriver", Name: "Moonriver", @@ -869,7 +859,6 @@ func chains() []Chain { {Slug: "moonriver-official", Name: "Moonbeam Foundation", URL: envDefault("RPC_URL_MOONRIVER_OFFICIAL", "https://rpc.api.moonriver.moonbeam.network")}, {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_MOONRIVER_PUBLICNODE", "https://moonriver-rpc.publicnode.com")}, {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_MOONRIVER_ONFINALITY", "https://moonriver.api.onfinality.io/public")}, - {Slug: "unitedbloc", Name: "UnitedBloc", URL: envDefault("RPC_URL_MOONRIVER_UNITEDBLOC", "https://moonriver.unitedbloc.com")}, }, }, // 2026-08-03 audit. Hemi BTC+ETH hybrid OP Stack L2 (chain 43111). 3 clean @@ -987,29 +976,24 @@ func chains() []Chain { {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_FILECOIN_ANKR", "https://rpc.ankr.com/filecoin")}, }, }, - // 2026-08-04 wave-5. Canto EVM L1 (chain 7700). 4 keyless providers: - // canto-official (canto.gravitychain.io), drpc (canto.drpc.org), - // publicnode (canto-evm.publicnode.com), ankr (rpc.ankr.com/canto). + // 2026-08-04 wave-5. Canto EVM L1 (chain 7700). 1 keyless provider: + // canto-official (canto.gravitychain.io). Excluded: dRPC (404), + // PublicNode (404), Ankr (403 API key required). { Slug: "canto", Name: "Canto", Providers: []Provider{ {Slug: "canto-official", Name: "Canto Official", URL: envDefault("RPC_URL_CANTO_OFFICIAL", "https://canto.gravitychain.io/")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_CANTO_DRPC", "https://canto.drpc.org")}, - {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_CANTO_PUBLICNODE", "https://canto-evm.publicnode.com")}, - {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_CANTO_ANKR", "https://rpc.ankr.com/canto")}, }, }, - // 2026-08-04 wave-5. Aurora EVM on NEAR Protocol (chain 1313161554). 3 keyless - // providers: aurora-official (mainnet.aurora.dev), drpc (aurora.drpc.org), - // ankr (rpc.ankr.com/aurora). + // 2026-08-04 wave-5. Aurora EVM on NEAR Protocol (chain 1313161554). 1 keyless + // provider: aurora-official (mainnet.aurora.dev). Excluded: Ankr (403 API key + // required), dRPC (403 API key required). { Slug: "aurora", Name: "Aurora", Providers: []Provider{ {Slug: "aurora-official", Name: "Aurora Labs", URL: envDefault("RPC_URL_AURORA_OFFICIAL", "https://mainnet.aurora.dev")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_AURORA_DRPC", "https://aurora.drpc.org")}, - {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_AURORA_ANKR", "https://rpc.ankr.com/aurora")}, }, }, // 2026-08-04 wave-5. Bitlayer Bitcoin L2 EVM (chain 200901). 3 keyless @@ -1070,25 +1054,24 @@ func chains() []Chain { {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_BOBA_DRPC", "https://boba-eth.drpc.org")}, }, }, - // XDC Network — Enterprise EVM L1 (chain 50). 5 keyless providers. + // XDC Network — Enterprise EVM L1 (chain 50). 4 keyless providers. + // Excluded: dRPC (400 paid plan only). { Slug: "xdc", Name: "XDC Network", Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_XDC_PUBLICNODE", "https://rpc.xdc.org")}, {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_XDC_ANKR", "https://rpc.ankr.com/xdc")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_XDC_DRPC", "https://xdc.drpc.org")}, {Slug: "xdc-erpc", Name: "XDC eRPC", URL: envDefault("RPC_URL_XDC_ERPC", "https://erpc.xinfin.network")}, {Slug: "xdc-org", Name: "XDC.org", URL: envDefault("RPC_URL_XDC_ORG", "https://rpc.xdc.org")}, }, }, - // Astar — Polkadot EVM parachain (chain 592). 4 keyless providers. + // Astar — Polkadot EVM parachain (chain 592). 2 keyless providers. + // Excluded: PublicNode (404), BlastAPI (DNS dead). { Slug: "astar", Name: "Astar", Providers: []Provider{ - {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_ASTAR_PUBLICNODE", "https://astar-rpc.publicnode.com")}, - {Slug: "blastapi", Name: "BlastAPI", URL: envDefault("RPC_URL_ASTAR_BLASTAPI", "https://astar.public.blastapi.io")}, {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_ASTAR_ONFINALITY", "https://astar.api.onfinality.io/public")}, {Slug: "1rpc", Name: "1RPC", URL: envDefault("RPC_URL_ASTAR_1RPC", "https://1rpc.io/astr")}, }, @@ -1339,19 +1322,17 @@ func chains() []Chain { {Slug: "stavr", Name: "STAVR", URL: envDefault("RPC_URL_COMDEX_STAVR", "https://comdex.rpc.m.stavr.tech:443")}, }, }, - // Fantom — EVM (chain 250). 5 keyless providers. + // Fantom — EVM (chain 250). 3 keyless providers. { Slug: "fantom", Name: "Fantom", Providers: []Provider{ {Slug: "fantom-official", Name: "Fantom Foundation", URL: envDefault("RPC_URL_FANTOM_OFFICIAL", "https://rpcapi.fantom.network")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_FANTOM_DRPC", "https://fantom.drpc.org")}, - {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_FANTOM_ANKR", "https://rpc.ankr.com/fantom")}, {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_FANTOM_THIRDWEB", "https://250.rpc.thirdweb.com")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_FANTOM_BLASTAPI", "https://fantom-mainnet.public.blastapi.io")}, }, }, - // Kusama — Polkadot canary relay chain. 6 keyless providers. + // Kusama — Polkadot canary relay chain. 3 keyless providers. { Slug: "kusama", Name: "Kusama", @@ -1360,37 +1341,20 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_KUSAMA_PUBLICNODE", "https://kusama-rpc.publicnode.com")}, {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_KUSAMA_ONFINALITY", "https://kusama.api.onfinality.io/public")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_KUSAMA_DRPC", "https://kusama.drpc.org")}, - {Slug: "ibp", Name: "IBP Network", URL: envDefault("RPC_URL_KUSAMA_IBP", "https://rpc.ibp.network/kusama")}, - {Slug: "dwellir", Name: "Dwellir", URL: envDefault("RPC_URL_KUSAMA_DWELLIR", "https://kusama-rpc.dwellir.com")}, - {Slug: "1rpc", Name: "1RPC", URL: envDefault("RPC_URL_KUSAMA_1RPC", "https://1rpc.io/ksm")}, }, }, - // Hydration — Polkadot DeFi parachain (HydraDX). 5 keyless providers. - { - Slug: "hydration", - Name: "Hydration", - Kind: "polkadot", - Providers: []Provider{ - {Slug: "hydration-official", Name: "Hydration Foundation", URL: envDefault("RPC_URL_HYDRATION_OFFICIAL", "https://rpc.hydration.cloud")}, - {Slug: "dwellir", Name: "Dwellir", URL: envDefault("RPC_URL_HYDRATION_DWELLIR", "https://hydradx-rpc.dwellir.com")}, - {Slug: "helikon", Name: "Helikon", URL: envDefault("RPC_URL_HYDRATION_HELIKON", "https://rpc.helikon.io/hydradx")}, - {Slug: "dotters-network", Name: "Dotters Network", URL: envDefault("RPC_URL_HYDRATION_DOTTERS", "https://hydradx.dotters.network")}, - {Slug: "parachains-network", Name: "Parachains.network", URL: envDefault("RPC_URL_HYDRATION_PARACHAINS", "https://rpc.parachains.network/hydradx")}, - }, - }, - // ZetaChain — EVM omnichain L1 (chain 7000). 5 keyless providers. + // Hydration — Polkadot DeFi parachain (HydraDX). All providers dead + // (DNS dead or HTTP 400). Chain suspended from active probing. + // ZetaChain — EVM omnichain L1 (chain 7000). 1 keyless provider. + // Excluded: BlockPI (404), AllThatNode (DNS dead). { Slug: "zetachain", Name: "ZetaChain", Providers: []Provider{ - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_ZETACHAIN_DRPC", "https://zetachain.drpc.org")}, - {Slug: "blockpi", Name: "BlockPi", URL: envDefault("RPC_URL_ZETACHAIN_BLOCKPI", "https://zetachain.blockpi.network/v1/rpc/public")}, {Slug: "thirdweb", Name: "Thirdweb", URL: envDefault("RPC_URL_ZETACHAIN_THIRDWEB", "https://7000.rpc.thirdweb.com")}, - {Slug: "allthatnode", Name: "AllThatNode", URL: envDefault("RPC_URL_ZETACHAIN_ATN", "https://zetachain-mainnet.rpc.public.allthatnode.com")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ZETACHAIN_BLASTAPI", "https://zetachain-mainnet.public.blastapi.io")}, }, }, - // HAQQ — EVM Islamic finance L1 (chain 11235). 4 keyless providers. + // HAQQ — EVM Islamic finance L1 (chain 11235). 3 keyless providers. { Slug: "haqq", Name: "HAQQ", @@ -1398,21 +1362,18 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_HAQQ_PUBLICNODE", "https://haqq-evm-rpc.publicnode.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_HAQQ_DRPC", "https://haqq.drpc.org")}, {Slug: "haqq-official", Name: "HAQQ Foundation", URL: envDefault("RPC_URL_HAQQ_OFFICIAL", "https://rpc.eth.haqq.network")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_HAQQ_BLASTAPI", "https://haqq-network.public.blastapi.io")}, }, }, - // Etherlink — Tezos EVM L2 (chain 42793). 4 keyless providers. + // Etherlink — Tezos EVM L2 (chain 42793). 2 keyless providers. { Slug: "etherlink", Name: "Etherlink", Providers: []Provider{ {Slug: "etherlink-official", Name: "Etherlink Foundation", URL: envDefault("RPC_URL_ETHERLINK_OFFICIAL", "https://node.mainnet.etherlink.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_ETHERLINK_DRPC", "https://etherlink.drpc.org")}, - {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_ETHERLINK_ANKR", "https://rpc.ankr.com/etherlink")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ETHERLINK_BLASTAPI", "https://etherlink-mainnet.public.blastapi.io")}, }, }, - // Chiliz — Sports fan token EVM (chain 88888). 4 keyless providers. + // Chiliz — Sports fan token EVM (chain 88888). 3 keyless providers. { Slug: "chiliz", Name: "Chiliz", @@ -1420,39 +1381,33 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_CHILIZ_PUBLICNODE", "https://chiliz-rpc.publicnode.com")}, {Slug: "ankr", Name: "Ankr", URL: envDefault("RPC_URL_CHILIZ_ANKR", "https://rpc.ankr.com/chiliz")}, {Slug: "chiliz-official", Name: "Chiliz Foundation", URL: envDefault("RPC_URL_CHILIZ_OFFICIAL", "https://rpc.chiliz.com")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_CHILIZ_DRPC", "https://chiliz.drpc.org")}, }, }, - // WEMIX — Korean web3 gaming EVM (chain 1111). 4 keyless providers. + // WEMIX — Korean web3 gaming EVM (chain 1111). 2 keyless providers. { Slug: "wemix", Name: "WEMIX", Providers: []Provider{ - {Slug: "wemix-official", Name: "WEMIX Foundation", URL: envDefault("RPC_URL_WEMIX_OFFICIAL", "https://api.wemix.com")}, + {Slug: "wemix-official", Name: "WeMade", URL: envDefault("RPC_URL_WEMIX_OFFICIAL", "https://api.wemix.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_WEMIX_DRPC", "https://wemix.drpc.org")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_WEMIX_BLASTAPI", "https://wemix-mainnet.public.blastapi.io")}, - {Slug: "1rpc", Name: "1RPC", URL: envDefault("RPC_URL_WEMIX_1RPC", "https://1rpc.io/wemix")}, }, }, - // Songbird — Flare canary network (chain 19). 4 keyless providers. + // Songbird — Flare canary network (chain 19). 1 keyless provider. + // Excluded: ftso-au (DNS dead). { Slug: "songbird", Name: "Songbird", Providers: []Provider{ {Slug: "flare-official", Name: "Flare Foundation", URL: envDefault("RPC_URL_SONGBIRD_FLARE", "https://songbird-api.flare.network/ext/C/rpc")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_SONGBIRD_DRPC", "https://songbird.drpc.org")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_SONGBIRD_BLASTAPI", "https://songbird-mainnet.public.blastapi.io")}, - {Slug: "ftso-au", Name: "FTSO AU", URL: envDefault("RPC_URL_SONGBIRD_FTSOAU", "https://songbird.rpc.ftso.au")}, }, }, - // Cronos zkEVM — zkSync-stack Cronos L2 (chain 388). 3 keyless providers. + // Cronos zkEVM — zkSync-stack Cronos L2 (chain 388). 2 keyless providers. { Slug: "cronos-zkevm", Name: "Cronos zkEVM", Providers: []Provider{ {Slug: "cronos-zkevm-official", Name: "Crypto.com Foundation", URL: envDefault("RPC_URL_CRONOS_ZKEVM_OFFICIAL", "https://mainnet.zkevm.cronos.org")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_CRONOS_ZKEVM_DRPC", "https://cronos-zkevm.drpc.org")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_CRONOS_ZKEVM_BLASTAPI", "https://cronos-zkevm-mainnet.public.blastapi.io")}, }, }, // Ethereum Classic — Original Ethereum chain (chain 61). 3 keyless providers. @@ -1465,14 +1420,13 @@ func chains() []Chain { {Slug: "etcmc", Name: "ETCMC", URL: envDefault("RPC_URL_ETC_ETCMC", "https://etcmc.rpc.nz")}, }, }, - // Telos EVM — High-performance EVM (chain 40). 3 keyless providers. + // Telos EVM — High-performance EVM (chain 40). 1 keyless provider. + // Excluded: telos-official (404). { Slug: "telos", Name: "Telos", Providers: []Provider{ - {Slug: "telos-official", Name: "Telos Foundation", URL: envDefault("RPC_URL_TELOS_OFFICIAL", "https://mainnet.telos.net/evm")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_TELOS_DRPC", "https://telos.drpc.org")}, - {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_TELOS_BLASTAPI", "https://telos-mainnet.public.blastapi.io")}, }, }, // PulseChain — Full-state Ethereum fork (chain 369). 3 keyless providers. @@ -1485,17 +1439,16 @@ func chains() []Chain { {Slug: "g4mm4", Name: "G4MM4", URL: envDefault("RPC_URL_PULSECHAIN_G4MM4", "https://rpc-pulsechain.g4mm4.io")}, }, }, - // Warden Protocol — Intent-based EVM L1. 3 keyless providers. + // Warden Protocol — Intent-based EVM L1. 2 keyless providers. { Slug: "warden", Name: "Warden Protocol", Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_WARDEN_PUBLICNODE", "https://warden-evm-rpc.publicnode.com")}, - {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_WARDEN_DRPC", "https://warden.drpc.org")}, {Slug: "warden-official", Name: "Warden Foundation", URL: envDefault("RPC_URL_WARDEN_OFFICIAL", "https://evm.wardenprotocol.org")}, }, }, - // Oraichain — AI-focused Cosmos SDK chain. 3 keyless providers. + // Oraichain — AI-focused Cosmos SDK chain. 2 keyless providers. { Slug: "oraichain", Name: "Oraichain", @@ -1503,17 +1456,15 @@ func chains() []Chain { Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_ORAICHAIN_PUBLICNODE", "https://oraichain-rpc.publicnode.com")}, {Slug: "orai-official", Name: "Orai Foundation", URL: envDefault("RPC_URL_ORAICHAIN_OFFICIAL", "https://rpc.orai.io")}, - {Slug: "lavenderfive", Name: "LavenderFive", URL: envDefault("RPC_URL_ORAICHAIN_LAVENDERFIVE", "https://oraichain-rpc.lavenderfive.com")}, }, }, - // Peaq Network — DePIN Polkadot parachain. 3 keyless providers. + // Peaq Network — DePIN Polkadot parachain. 2 keyless providers. { Slug: "peaq", Name: "Peaq Network", Kind: "polkadot", Providers: []Provider{ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_PEAQ_PUBLICNODE", "https://peaq-rpc.publicnode.com")}, - {Slug: "peaq-official", Name: "Peaq Foundation", URL: envDefault("RPC_URL_PEAQ_OFFICIAL", "https://rpc.peaq.network")}, {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_PEAQ_ONFINALITY", "https://peaq.api.onfinality.io/public")}, }, }, diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index 3856911e..b27502c5 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -122,7 +122,7 @@ func callLatestBlock(ctx context.Context, url string) (block uint64, hash string start := time.Now() resp, err := client.Do(req) - latencyMs = float64(time.Since(start).Milliseconds()) + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { @@ -309,7 +309,7 @@ func callLatestSlot(ctx context.Context, url string) (slot uint64, result string start := time.Now() resp, err := client.Do(req) - latencyMs = float64(time.Since(start).Milliseconds()) + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { @@ -375,7 +375,7 @@ func callSubstrateHeader(ctx context.Context, url string) (block uint64, hash st start := time.Now() resp, err := client.Do(req) - latencyMs = float64(time.Since(start).Milliseconds()) + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { @@ -449,7 +449,7 @@ func callCosmosStatus(ctx context.Context, url string) (block uint64, hash strin start := time.Now() resp, err := client.Do(req) - latencyMs = float64(time.Since(start).Milliseconds()) + latencyMs = float64(time.Since(start).Nanoseconds()) / 1e6 if err != nil { if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") { diff --git a/harnesses/solana-dex-volume/Dockerfile b/harnesses/solana-dex-volume/Dockerfile new file mode 100644 index 00000000..c620d261 --- /dev/null +++ b/harnesses/solana-dex-volume/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +COPY cmd ./cmd +RUN go mod download && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/solana-dex-volume/cmd/script/main.go b/harnesses/solana-dex-volume/cmd/script/main.go new file mode 100644 index 00000000..e04d2049 --- /dev/null +++ b/harnesses/solana-dex-volume/cmd/script/main.go @@ -0,0 +1,175 @@ +// solana-dex-volume -- Bench 205 +// +// Polls the DeFiLlama DEX API every 30 minutes for each tracked platform +// and exposes per-platform 24h/7d volume and protocol revenue as Prometheus gauges. +// +// No API key required. Endpoint: https://api.llama.fi/summary/dexs/{slug} +// +// Metrics on :2112/metrics: +// +// defillama_dex_volume_24h_usd{platform} +// defillama_dex_volume_7d_usd{platform} +// defillama_dex_fees_24h_usd{platform} (protocol revenue, ?dataType=dailyRevenue) +// defillama_dex_fees_7d_usd{platform} +// defillama_dex_take_rate{platform} (fees24h / volume24h) +// defillama_dex_health{platform} +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + pollInterval = 30 * time.Minute + baseURL = "https://api.llama.fi" +) + +var platforms = []struct { + slug string + label string +}{ + {"pump.fun", "pump-fun"}, + {"gmgn", "gmgn"}, + {"axiom", "axiom"}, + {"fomo-wallet", "fomo"}, + {"trojan", "trojan"}, + {"photon", "photon"}, + {"bullx", "bullx"}, +} + +var ( + volume24h = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_volume_24h_usd", + Help: "24h DEX trading volume in USD from DeFiLlama", + }, []string{"platform"}) + + volume7d = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_volume_7d_usd", + Help: "7-day DEX trading volume in USD from DeFiLlama", + }, []string{"platform"}) + + fees24h = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_fees_24h_usd", + Help: "24h protocol revenue in USD from DeFiLlama (dailyRevenue dataType)", + }, []string{"platform"}) + + fees7d = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_fees_7d_usd", + Help: "7-day protocol revenue in USD from DeFiLlama (dailyRevenue dataType)", + }, []string{"platform"}) + + takeRate = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_take_rate", + Help: "Protocol revenue as fraction of trading volume (fees24h / volume24h)", + }, []string{"platform"}) + + health = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "defillama_dex_health", + Help: "1 if last DeFiLlama fetch succeeded, 0 otherwise", + }, []string{"platform"}) +) + +type llamaResponse struct { + Total24h float64 `json:"total24h"` + Total7d float64 `json:"total7d"` +} + +func fetch(endpoint, slug, query string) (llamaResponse, error) { + url := fmt.Sprintf("%s/summary/%s/%s", baseURL, endpoint, slug) + if query != "" { + url += "?" + query + } + resp, err := http.Get(url) + if err != nil { + return llamaResponse{}, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return llamaResponse{}, fmt.Errorf("HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return llamaResponse{}, err + } + var r llamaResponse + if err := json.Unmarshal(body, &r); err != nil { + return llamaResponse{}, err + } + return r, nil +} + +func runOnce() { + for _, p := range platforms { + vol, err := fetch("dexs", p.slug, "") + if err != nil { + fmt.Printf("[poll] volume %s: %v\n", p.slug, err) + health.WithLabelValues(p.label).Set(0) + continue + } + + rev, err := fetch("fees", p.slug, "dataType=dailyRevenue") + if err != nil { + fmt.Printf("[poll] fees %s: %v (volume ok)\n", p.slug, err) + } + + volume24h.WithLabelValues(p.label).Set(vol.Total24h) + volume7d.WithLabelValues(p.label).Set(vol.Total7d) + fees24h.WithLabelValues(p.label).Set(rev.Total24h) + fees7d.WithLabelValues(p.label).Set(rev.Total7d) + if vol.Total24h > 0 { + takeRate.WithLabelValues(p.label).Set(rev.Total24h / vol.Total24h) + } + health.WithLabelValues(p.label).Set(1) + fmt.Printf("[poll] %s: vol24=$%.0f vol7d=$%.0f rev24=$%.0f rate=%.4f\n", + p.label, vol.Total24h, vol.Total7d, rev.Total24h, rev.Total24h/max(vol.Total24h, 1)) + } +} + +func max(a, b float64) float64 { + if a > b { + return a + } + return b +} + +func main() { + fmt.Println("=== solana-dex-volume harness (Bench 205) ===") + + go func() { + http.Handle("/metrics", promhttp.Handler()) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + fmt.Println("[srv] metrics on :2112") + if err := http.ListenAndServe(":2112", nil); err != nil { + fmt.Fprintf(os.Stderr, "[fatal] %v\n", err) + os.Exit(1) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + runOnce() + + tick := time.NewTicker(pollInterval) + defer tick.Stop() + + for { + select { + case <-sig: + return + case <-tick.C: + runOnce() + } + } +} diff --git a/harnesses/solana-dex-volume/go.mod b/harnesses/solana-dex-volume/go.mod new file mode 100644 index 00000000..8655e4bf --- /dev/null +++ b/harnesses/solana-dex-volume/go.mod @@ -0,0 +1,18 @@ +module solana-dex-volume + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/solana-dex-volume/go.sum b/harnesses/solana-dex-volume/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/solana-dex-volume/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-exec/Dockerfile b/harnesses/solana-exec/Dockerfile new file mode 100644 index 00000000..a3752b65 --- /dev/null +++ b/harnesses/solana-exec/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/collector ./cmd/collector +RUN CGO_ENABLED=0 go build -o /app/materializer ./cmd/materializer +RUN CGO_ENABLED=0 go build -o /app/api ./cmd/api + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /app/collector /app/collector +COPY --from=builder /app/materializer /app/materializer +COPY --from=builder /app/api /app/api +COPY migrations/ /app/migrations/ diff --git a/harnesses/solana-exec/cmd/api/main.go b/harnesses/solana-exec/cmd/api/main.go new file mode 100644 index 00000000..79c3853a --- /dev/null +++ b/harnesses/solana-exec/cmd/api/main.go @@ -0,0 +1,539 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + ctx := context.Background() + pool, err := pgxpool.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("exec-api: connect: %v", err) + } + defer pool.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/api/exec-leaderboard", corsJSON(handleExecLeaderboard(pool))) + mux.HandleFunc("/api/evm-revenue", corsJSON(handleEVMRevenue(pool))) + mux.HandleFunc("/metrics", handleMetrics(pool)) + + addr := ":2116" + log.Printf("exec-api listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("exec-api: serve: %v", err) + } +} + +// WindowStats holds aggregated execution metrics for one time window. +type WindowStats struct { + TxCount int64 `json:"txCount"` + AvgPriorityFeeLamports float64 `json:"avgPriorityFeeLamports"` + P50CUPriceMicro float64 `json:"p50CUPriceMicro"` + P95CUPriceMicro float64 `json:"p95CUPriceMicro"` + AvgPlatformFeeLamports float64 `json:"avgPlatformFeeLamports"` + SumPlatformFeeLamports int64 `json:"sumPlatformFeeLamports"` + JitoRate float64 `json:"jitoRate"` + AvgCUConsumed float64 `json:"avgCUConsumed"` +} + +type PlatformRow struct { + Platform string `json:"platform"` + LatestBkt string `json:"latestBucket"` + Windows map[string]WindowStats `json:"windows"` +} + +type ExecLeaderboardResponse struct { + UpdatedAt string `json:"updatedAt"` + Platforms []PlatformRow `json:"platforms"` +} + +func handleExecLeaderboard(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + WITH cu AS ( + SELECT platform, + percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '24 hours') AS h24_p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '24 hours') AS h24_p95, + percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '7 days') AS d7_p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '7 days') AS d7_p95, + percentile_cont(0.5) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '30 days') AS d30_p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY cu_price_micro) + FILTER (WHERE block_time >= now() - INTERVAL '30 days') AS d30_p95 + FROM solana_exec_cu_samples + GROUP BY platform + ) + SELECT + f.platform, + -- weighted averages: SUM(avg×count)/SUM(count) avoids skewing by small off-peak buckets + SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_prio, + MAX(c.h24_p50) AS h24_p50, + MAX(c.h24_p95) AS h24_p95, + SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_pfee, + COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_sum_pfee, + SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_jito, + SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours'), 0) AS h24_cu, + SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '24 hours') AS h24_count, + + SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_prio, + MAX(c.d7_p50) AS d7_p50, + MAX(c.d7_p95) AS d7_p95, + SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_pfee, + COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_sum_pfee, + SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_jito, + SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days'), 0) AS d7_cu, + SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '7 days') AS d7_count, + + SUM(f.avg_priority_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_prio, + MAX(c.d30_p50) AS d30_p50, + MAX(c.d30_p95) AS d30_p95, + SUM(f.avg_platform_fee_lamports * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_pfee, + COALESCE(SUM(f.sum_platform_fee_lamports) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_sum_pfee, + SUM(f.jito_rate * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_jito, + SUM(f.avg_cu_consumed * f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') + / NULLIF(SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days'), 0) AS d30_cu, + SUM(f.tx_count) FILTER (WHERE f.bucket_start >= now() - INTERVAL '30 days') AS d30_count, + + MAX(f.bucket_start)::text AS latest_bucket + FROM solana_exec_facts f + LEFT JOIN cu c USING (platform) + WHERE f.bucket_start >= now() - INTERVAL '31 days' + GROUP BY f.platform`, + ) + if err != nil { + log.Printf("exec-api: query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer rows.Close() + + var platforms []PlatformRow + for rows.Next() { + var plt, latestBkt string + var h24Prio, h24P50, h24P95, h24Pfee, h24Jito, h24Cu *float64 + var h24SumPfee int64 + var h24Count *int64 + var d7Prio, d7P50, d7P95, d7Pfee, d7Jito, d7Cu *float64 + var d7SumPfee int64 + var d7Count *int64 + var d30Prio, d30P50, d30P95, d30Pfee, d30Jito, d30Cu *float64 + var d30SumPfee int64 + var d30Count *int64 + + if err := rows.Scan( + &plt, + &h24Prio, &h24P50, &h24P95, &h24Pfee, &h24SumPfee, &h24Jito, &h24Cu, &h24Count, + &d7Prio, &d7P50, &d7P95, &d7Pfee, &d7SumPfee, &d7Jito, &d7Cu, &d7Count, + &d30Prio, &d30P50, &d30P95, &d30Pfee, &d30SumPfee, &d30Jito, &d30Cu, &d30Count, + &latestBkt, + ); err != nil { + log.Printf("exec-api: scan: %v", err) + continue + } + + p := PlatformRow{ + Platform: plt, + LatestBkt: latestBkt, + Windows: map[string]WindowStats{ + "24h": buildWindow(h24Prio, h24P50, h24P95, h24Pfee, h24SumPfee, h24Jito, h24Cu, h24Count), + "7d": buildWindow(d7Prio, d7P50, d7P95, d7Pfee, d7SumPfee, d7Jito, d7Cu, d7Count), + "30d": buildWindow(d30Prio, d30P50, d30P95, d30Pfee, d30SumPfee, d30Jito, d30Cu, d30Count), + }, + } + platforms = append(platforms, p) + } + + // Sort by 30d tx count descending. + sort.Slice(platforms, func(i, j int) bool { + return platforms[i].Windows["30d"].TxCount > platforms[j].Windows["30d"].TxCount + }) + + resp := ExecLeaderboardResponse{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Platforms: platforms, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } +} + +func buildWindow(prio, p50, p95, pfee *float64, sumPfee int64, jito, cu *float64, count *int64) WindowStats { + ws := WindowStats{} + if count != nil { + ws.TxCount = *count + } + if prio != nil { + ws.AvgPriorityFeeLamports = *prio + } + if p50 != nil { + ws.P50CUPriceMicro = *p50 + } + if p95 != nil { + ws.P95CUPriceMicro = *p95 + } + if pfee != nil { + ws.AvgPlatformFeeLamports = *pfee + } + ws.SumPlatformFeeLamports = sumPfee + if jito != nil { + ws.JitoRate = *jito + } + if cu != nil { + ws.AvgCUConsumed = *cu + } + return ws +} + +func corsJSON(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=300") + h(w, r) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} + +// ---- Prometheus /metrics endpoint ---- + +type latestBucketRow struct { + platform string + jitoRate float64 + p50CUPriceMicro float64 + p95CUPriceMicro float64 + avgPriorityFeeLamports float64 + avgPlatformFeeLamports float64 + txCount int64 +} + +func handleMetrics(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Latest hourly bucket per platform. + latestRows, err := pool.Query(ctx, ` + SELECT DISTINCT ON (platform) + platform, jito_rate, p50_cu_price_micro, p95_cu_price_micro, + avg_priority_fee_lamports, avg_platform_fee_lamports, tx_count + FROM solana_exec_facts + ORDER BY platform, bucket_start DESC`) + if err != nil { + log.Printf("metrics: latest query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer latestRows.Close() + + var latest []latestBucketRow + for latestRows.Next() { + var row latestBucketRow + if err := latestRows.Scan( + &row.platform, + &row.jitoRate, + &row.p50CUPriceMicro, + &row.p95CUPriceMicro, + &row.avgPriorityFeeLamports, + &row.avgPlatformFeeLamports, + &row.txCount, + ); err != nil { + log.Printf("metrics: scan: %v", err) + continue + } + latest = append(latest, row) + } + latestRows.Close() + + // 24h tx counts. + countRows, err := pool.Query(ctx, ` + SELECT platform, SUM(tx_count) AS count_24h + FROM solana_exec_facts + WHERE bucket_start >= now() - INTERVAL '24 hours' + GROUP BY platform`) + if err != nil { + log.Printf("metrics: count query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer countRows.Close() + + counts24h := make(map[string]int64) + for countRows.Next() { + var plt string + var cnt int64 + if err := countRows.Scan(&plt, &cnt); err != nil { + continue + } + counts24h[plt] = cnt + } + countRows.Close() + + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + + fmt.Fprint(w, "# HELP solana_exec_jito_rate Fraction of transactions with a Jito tip, latest hourly bucket\n") + fmt.Fprint(w, "# TYPE solana_exec_jito_rate gauge\n") + for _, row := range latest { + fmt.Fprintf(w, "solana_exec_jito_rate{platform=%q} %g\n", row.platform, row.jitoRate) + } + + fmt.Fprint(w, "# HELP solana_exec_cu_price_p50_micro Median compute unit price microlamports/CU, latest hourly bucket\n") + fmt.Fprint(w, "# TYPE solana_exec_cu_price_p50_micro gauge\n") + for _, row := range latest { + fmt.Fprintf(w, "solana_exec_cu_price_p50_micro{platform=%q} %g\n", row.platform, row.p50CUPriceMicro) + } + + fmt.Fprint(w, "# HELP solana_exec_cu_price_p95_micro p95 compute unit price microlamports/CU, latest hourly bucket\n") + fmt.Fprint(w, "# TYPE solana_exec_cu_price_p95_micro gauge\n") + for _, row := range latest { + fmt.Fprintf(w, "solana_exec_cu_price_p95_micro{platform=%q} %g\n", row.platform, row.p95CUPriceMicro) + } + + fmt.Fprint(w, "# HELP solana_exec_priority_fee_lamports Average priority fee in lamports, latest hourly bucket\n") + fmt.Fprint(w, "# TYPE solana_exec_priority_fee_lamports gauge\n") + for _, row := range latest { + fmt.Fprintf(w, "solana_exec_priority_fee_lamports{platform=%q} %g\n", row.platform, row.avgPriorityFeeLamports) + } + + fmt.Fprint(w, "# HELP solana_exec_platform_fee_lamports Average platform fee in lamports, latest hourly bucket\n") + fmt.Fprint(w, "# TYPE solana_exec_platform_fee_lamports gauge\n") + for _, row := range latest { + fmt.Fprintf(w, "solana_exec_platform_fee_lamports{platform=%q} %g\n", row.platform, row.avgPlatformFeeLamports) + } + + fmt.Fprint(w, "# HELP solana_exec_tx_count_24h Total transaction count over the last 24 hours\n") + fmt.Fprint(w, "# TYPE solana_exec_tx_count_24h gauge\n") + for plt, cnt := range counts24h { + fmt.Fprintf(w, "solana_exec_tx_count_24h{platform=%q} %d\n", plt, cnt) + } + } +} + +// ---- EVM revenue endpoint ---- + +// evmCoverage is the static coverage map derived from the evm-exec platform config. +// "full" = native + stable; "stable-only" = only ERC-20 USDC/USDG tracked. +// Mirrors platform.Coverage() in evm-exec/internal/platform/platforms.go. +var evmCoverage = map[string]map[string]string{ + "pumpfun": {"ethereum": "full", "bsc": "full", "base": "full"}, + "gmgn": {"ethereum": "full", "bsc": "full", "base": "full"}, + "maestro": {"ethereum": "full", "bsc": "full", "base": "full"}, + "axiom": {"bsc": "full"}, + "gmgn-robinhood": {"robinhood": "full"}, + "maestro-robinhood": {"robinhood": "full"}, + // banana-gun: evmKey=null in frontend config; EVM data intentionally not displayed + // (router addresses receive trade principal, not fees; pending eth_getLogs on topic 0x72015ace…) +} + +// coinGeckoIDs maps chain name → CoinGecko asset ID for native price lookup. +var coinGeckoIDs = map[string]string{ + "ethereum": "ethereum", + "bsc": "binancecoin", + "base": "ethereum", + "robinhood": "ethereum", +} + +// nativeSymbols maps chain → native asset ticker. +var nativeSymbols = map[string]string{ + "ethereum": "ETH", + "bsc": "BNB", + "base": "ETH", + "robinhood": "ETH", +} + +type priceCache struct { + mu sync.Mutex + prices map[string]float64 // coingecko id → USD price + fetchedAt time.Time +} + +var globalPrices = &priceCache{prices: make(map[string]float64)} + +func (p *priceCache) get(ctx context.Context) map[string]float64 { + p.mu.Lock() + defer p.mu.Unlock() + if time.Since(p.fetchedAt) < 15*time.Minute && len(p.prices) > 0 { + out := make(map[string]float64, len(p.prices)) + for k, v := range p.prices { + out[k] = v + } + return out + } + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,binancecoin&vs_currencies=usd", nil) + if err != nil { + return nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var raw map[string]map[string]float64 + if err := json.Unmarshal(body, &raw); err != nil { + return nil + } + for id, vs := range raw { + if usd, ok := vs["usd"]; ok { + p.prices[id] = usd + } + } + p.fetchedAt = time.Now() + out := make(map[string]float64, len(p.prices)) + for k, v := range p.prices { + out[k] = v + } + return out +} + +type NativeRevenue struct { + Symbol string `json:"symbol"` + Amount float64 `json:"amount"` + USD *float64 `json:"usd"` +} + +type EVMChainRevenue struct { + Stable24h float64 `json:"stable24h"` + Stable7d float64 `json:"stable7d"` + Stable30d float64 `json:"stable30d"` + Native *NativeRevenue `json:"native"` // 24h only; historical prices not stored + Coverage string `json:"coverage"` +} + +type EVMPlatformRow struct { + Platform string `json:"platform"` + Chains map[string]EVMChainRevenue `json:"chains"` +} + +type EVMRevenueResponse struct { + UpdatedAt string `json:"updatedAt"` + Platforms []EVMPlatformRow `json:"platforms"` +} + +func handleEVMRevenue(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + SELECT chain, platform, + COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0), + COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '7 days'), 0), + COALESCE(SUM(revenue_stable) FILTER (WHERE bucket_start >= now() - INTERVAL '30 days'), 0), + COALESCE(SUM(revenue_native) FILTER (WHERE bucket_start >= now() - INTERVAL '24 hours'), 0), + COALESCE(MAX(native_symbol), '') + FROM evm_exec_facts + WHERE bucket_start >= now() - INTERVAL '30 days' + GROUP BY chain, platform`) + if err != nil { + log.Printf("evm-revenue: query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer rows.Close() + + prices := globalPrices.get(r.Context()) + + // Group by platform. + byPlatform := make(map[string]map[string]EVMChainRevenue) + for rows.Next() { + var chain, plt, nativeSym string + var stable24h, stable7d, stable30d, native float64 + if err := rows.Scan(&chain, &plt, &stable24h, &stable7d, &stable30d, &native, &nativeSym); err != nil { + continue + } + if byPlatform[plt] == nil { + byPlatform[plt] = make(map[string]EVMChainRevenue) + } + + coverage := "stable-only" + if m, ok := evmCoverage[plt]; ok { + if c, ok := m[chain]; ok { + coverage = c + } + } + + var nat *NativeRevenue + if native > 0 || coverage == "full" { + sym := nativeSym + if sym == "" { + sym = nativeSymbols[chain] + } + nr := &NativeRevenue{Symbol: sym, Amount: native} + if cgID := coinGeckoIDs[chain]; cgID != "" && prices != nil { + if price, ok := prices[cgID]; ok { + usd := native * price + nr.USD = &usd + } + } + nat = nr + } + + byPlatform[plt][chain] = EVMChainRevenue{ + Stable24h: stable24h, + Stable7d: stable7d, + Stable30d: stable30d, + Native: nat, + Coverage: coverage, + } + } + if err := rows.Err(); err != nil { + log.Printf("evm-revenue: rows: %v", err) + } + + var platforms []EVMPlatformRow + for plt, chains := range byPlatform { + platforms = append(platforms, EVMPlatformRow{Platform: plt, Chains: chains}) + } + sort.Slice(platforms, func(i, j int) bool { + return platforms[i].Platform < platforms[j].Platform + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(EVMRevenueResponse{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Platforms: platforms, + }) + } +} diff --git a/harnesses/solana-exec/cmd/collector/main.go b/harnesses/solana-exec/cmd/collector/main.go new file mode 100644 index 00000000..56cf537a --- /dev/null +++ b/harnesses/solana-exec/cmd/collector/main.go @@ -0,0 +1,308 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/helius" + "github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/platform" + "github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/store" +) + +// baseFeePerSig is the fixed Solana base fee per signature in lamports. +const baseFeePerSig = 5000 + +func main() { + ctx := context.Background() + + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("collector: %v", err) + } + defer db.Close() + + rpcURL := os.Getenv("SOLANA_RPC_URL") + if rpcURL == "" { + rpcURL = "https://api.mainnet-beta.solana.com" + } + h := helius.New(rpcURL) + + log.Printf("collector: monitoring %d platforms", len(platform.FeeAccounts)) + + for { + for plt, feeAccounts := range platform.FeeAccounts { + if err := collect(ctx, db, h, plt, feeAccounts); err != nil { + log.Printf("collector: %s: %v", plt, err) + } + } + time.Sleep(10 * time.Minute) + } +} + +type acctData struct { + cursorKey string + cursor store.Cursor + sigs []helius.SigEntry // newest-first from RPC +} + +func collect(ctx context.Context, db *store.DB, h *helius.Client, plt string, feeAccounts []string) error { + // SPL token mint for fee detection; empty = SOL (native lamports). + feeToken := platform.FeeToken[plt] + + // feeSet is used to detect fee transfers in NativeTransfers / postTokenBalances.owner. + // For SPL-token platforms (e.g. FOMO), FeeAccounts holds the ATA (for sig discovery) + // but postTokenBalances.owner is the wallet — so use FeeOwners when present. + feeOwners := platform.FeeOwners[plt] + if feeOwners == nil { + feeOwners = feeAccounts + } + feeSet := make(map[string]bool, len(feeOwners)) + for _, fa := range feeOwners { + feeSet[fa] = true + } + + // Paginate each fee account independently with its own cursor so a crash + // between accounts doesn't cause re-processing of already-committed data. + perAcct := make([]acctData, 0, len(feeAccounts)) + + for _, feeAccount := range feeAccounts { + cursorKey := plt + ":" + feeAccount + cursor, err := db.GetCursor(ctx, cursorKey) + if err != nil { + return fmt.Errorf("get cursor %s: %w", feeAccount, err) + } + + const sigLimit = 1000 + const maxPages = 300 // cap backfill at 300k sigs per account (~20 days for busy wallets) + var sigs []helius.SigEntry + before := "" + for page := 0; page < maxPages; page++ { + batch, err := h.GetSignaturesForAddress(ctx, feeAccount, sigLimit, cursor.LastSig, before) + if err != nil { + return fmt.Errorf("get sigs %s (before=%s): %w", feeAccount, before, err) + } + sigs = append(sigs, batch...) + if len(batch) < sigLimit { + break + } + before = batch[len(batch)-1].Signature + time.Sleep(100 * time.Millisecond) + } + if len(sigs) > sigLimit { + log.Printf("collector: %s: %s: paginated %d sigs", plt, feeAccount, len(sigs)) + } + + perAcct = append(perAcct, acctData{cursorKey: cursorKey, cursor: cursor, sigs: sigs}) + } + + // Merge sigs from all accounts, de-duplicate by signature. + // A single tx can touch multiple fee accounts of the same platform (rare but possible). + seen := make(map[string]struct{}) + var allSigs []helius.SigEntry + for _, acct := range perAcct { + for _, s := range acct.sigs { + if _, dup := seen[s.Signature]; dup { + continue + } + seen[s.Signature] = struct{}{} + allSigs = append(allSigs, s) + } + } + + if len(allSigs) == 0 { + return nil + } + + // Count raw hourly volume from the merged+deduped sig set so a tx touching + // multiple fee accounts of the same platform is counted exactly once. + // from_cursor is a compound of all account cursors for idempotency on retry. + fromCursor := compoundCursor(perAcct) + hourBuckets := make(map[time.Time]int64) + for _, s := range allSigs { + if s.Err != nil || s.BlockTime == 0 { + continue + } + bucket := time.Unix(s.BlockTime, 0).UTC().Truncate(time.Hour) + hourBuckets[bucket]++ + } + if err := db.UpsertRawCounts(ctx, plt, fromCursor, hourBuckets); err != nil { + return fmt.Errorf("upsert raw counts: %w", err) + } + + // Pre-filter failed txs; only successful trades generate platform fees. + var sigStrs []string + for _, s := range allSigs { + if s.Err == nil { + sigStrs = append(sigStrs, s.Signature) + } + } + + // All sigs failed: still save cursors so we don't re-scan next poll. + if len(sigStrs) == 0 { + return saveCursors(ctx, db, perAcct) + } + + // Sort by slot descending before striding so the sample is temporally uniform + // across multi-account platforms (e.g. Axiom 20 accounts — merge order is arbitrary). + sort.Slice(allSigs, func(i, j int) bool { return allSigs[i].Slot > allSigs[j].Slot }) + + // Rebuild sigStrs from the sorted order after filtering failed txs. + sigStrs = sigStrs[:0] + for _, s := range allSigs { + if s.Err == nil { + sigStrs = append(sigStrs, s.Signature) + } + } + + // Cap enhanced API at 100 sigs; stride-sample across the full window so the + // sample represents the whole poll period, not just the most recent transactions. + if len(sigStrs) > 100 { + step := len(sigStrs) / 100 + sampled := make([]string, 0, 100) + for i := 0; i < len(sigStrs) && len(sampled) < 100; i += step { + sampled = append(sampled, sigStrs[i]) + } + sigStrs = sampled + } + + txs, err := h.GetEnhancedTransactions(ctx, sigStrs) + if err != nil { + return fmt.Errorf("get enhanced txs: %w", err) + } + + events := make([]store.ExecEvent, 0, len(txs)) + for _, tx := range txs { + if tx.TransactionError != nil || tx.Timestamp == 0 { + continue + } + + priorityFee := tx.Fee - baseFeePerSig + if priorityFee < 0 { + priorityFee = 0 + } + + var platformFeeLamports, jitoTipLamports int64 + isJito := false + + if feeToken == "" { + // SOL-fee platform: detect via native SOL transfers. + // Skip any tx where a fee wallet is also a sender — these are internal sweeps + // (e.g. Axiom consolidating 20 wallets) that would inflate the average fee. + hasFeeWalletSender := false + for _, sender := range tx.SenderAccounts { + if feeSet[sender] { + hasFeeWalletSender = true + break + } + } + if !hasFeeWalletSender { + for _, xfer := range tx.NativeTransfers { + if feeSet[xfer.ToUserAccount] { + platformFeeLamports += xfer.Amount + } + } + } + for _, xfer := range tx.NativeTransfers { + if platform.JitoTipAccounts[xfer.ToUserAccount] { + jitoTipLamports += xfer.Amount + isJito = true + } + } + } else { + // SPL-token-fee platform (e.g. FOMO uses USDC): detect via token transfers. + // platformFeeLamports stores raw token units (1 USDC = 1_000_000). + for _, xfer := range tx.TokenTransfers { + if feeSet[xfer.ToOwner] && xfer.Mint == feeToken { + platformFeeLamports += xfer.Amount + } + } + // Jito detection is SOL-based regardless of fee token. + for _, xfer := range tx.NativeTransfers { + if platform.JitoTipAccounts[xfer.ToUserAccount] { + jitoTipLamports += xfer.Amount + isJito = true + } + } + } + + // CU price comes directly from SetComputeUnitPrice (disc 0x03), already in + // micro-lamports per CU — no division needed. Only set when the instruction + // is present; 0 means base fee only, not a zero-price priority tx. + cuPriceMicro := tx.CUPriceDeclared + + events = append(events, store.ExecEvent{ + Sig: tx.Signature, + Platform: plt, + Slot: tx.Slot, + BlockTime: time.Unix(tx.Timestamp, 0).UTC(), + TotalFeeLamports: tx.Fee, + PriorityFeeLamports: priorityFee, + PlatformFeeLamports: platformFeeLamports, + IsJitoBundle: isJito, + JitoTipLamports: jitoTipLamports, + CUConsumed: tx.ComputeUnitsConsumed, + CUPriceMicro: cuPriceMicro, + }) + } + + if err := db.UpsertEvents(ctx, events); err != nil { + return fmt.Errorf("upsert: %w", err) + } + + cuSamples := make([]store.CUSample, 0, len(events)) + for _, e := range events { + if e.CUPriceMicro > 0 { + cuSamples = append(cuSamples, store.CUSample{Sig: e.Sig, BlockTime: e.BlockTime, CUPriceMicro: e.CUPriceMicro}) + } + } + if err := db.InsertCUSamples(ctx, plt, cuSamples); err != nil { + return fmt.Errorf("insert cu samples: %w", err) + } + + if err := saveCursors(ctx, db, perAcct); err != nil { + return err + } + + log.Printf("collector: %s: ingested %d events", plt, len(events)) + return nil +} + +// compoundCursor returns a deterministic string that encodes the cursor state for +// all accounts in this poll, used as the from_cursor idempotency key in raw_counts. +func compoundCursor(perAcct []acctData) string { + if len(perAcct) == 0 { + return "" + } + parts := make([]string, 0, len(perAcct)) + for _, a := range perAcct { + parts = append(parts, a.cursorKey+"="+a.cursor.LastSig) + } + return strings.Join(parts, ",") +} + +// saveCursors advances each fee account's cursor to its newest observed sig. +func saveCursors(ctx context.Context, db *store.DB, perAcct []acctData) error { + for _, acct := range perAcct { + if len(acct.sigs) == 0 { + continue + } + newest := acct.sigs[0] // RPC returns newest-first + if err := db.SaveCursor(ctx, acct.cursorKey, newest.Signature, newest.Slot); err != nil { + return fmt.Errorf("save cursor %s: %w", acct.cursorKey, err) + } + } + return nil +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} diff --git a/harnesses/solana-exec/cmd/materializer/main.go b/harnesses/solana-exec/cmd/materializer/main.go new file mode 100644 index 00000000..63e0c462 --- /dev/null +++ b/harnesses/solana-exec/cmd/materializer/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/platform" + "github.com/ChainBench/OpenChainBench/harnesses/solana-exec/internal/store" +) + +func main() { + ctx := context.Background() + + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("materializer: %v", err) + } + defer db.Close() + + for { + for plt := range platform.FeeAccounts { + if err := db.Materialize(ctx, plt); err != nil { + log.Printf("materializer: %s: %v", plt, err) + } else { + log.Printf("materializer: %s: done", plt) + } + } + if err := db.PurgeCUSamples(ctx); err != nil { + log.Printf("materializer: purge cu samples: %v", err) + } + if err := db.PurgeEvents(ctx); err != nil { + log.Printf("materializer: purge events: %v", err) + } + time.Sleep(5 * time.Minute) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} diff --git a/harnesses/solana-exec/docker-compose.solana-exec.yml b/harnesses/solana-exec/docker-compose.solana-exec.yml new file mode 100644 index 00000000..7a956401 --- /dev/null +++ b/harnesses/solana-exec/docker-compose.solana-exec.yml @@ -0,0 +1,47 @@ +# Fragment to merge into /opt/ocb/docker-compose.yml on VPS. +# Requires: ocb-postgres (from docker-compose.apps.yml). +# Optional: SOLANA_RPC_URL (defaults to api.mainnet-beta.solana.com). + +services: + solana-exec-collector: + build: /opt/ocb/harnesses/solana-exec + container_name: ocb-solana-exec-collector + restart: unless-stopped + env_file: /run/ocb/.env.solana-exec + command: ["/app/collector"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "20m", max-file: "5" } + + solana-exec-materializer: + build: /opt/ocb/harnesses/solana-exec + container_name: ocb-solana-exec-materializer + restart: unless-stopped + env_file: /run/ocb/.env.solana-exec + command: ["/app/materializer"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + + solana-exec-api: + build: /opt/ocb/harnesses/solana-exec + container_name: ocb-solana-exec-api + restart: unless-stopped + env_file: /run/ocb/.env.solana-exec + command: ["/app/api"] + networks: [web] + expose: ["2116"] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/harnesses/solana-exec/env.solana-exec.example b/harnesses/solana-exec/env.solana-exec.example new file mode 100644 index 00000000..d341a264 --- /dev/null +++ b/harnesses/solana-exec/env.solana-exec.example @@ -0,0 +1,10 @@ +# /run/ocb/.env.solana-exec — encrypt with SOPS before deploy +# Shares the same Postgres instance as apps harness. + +POSTGRES_USER=ocb +POSTGRES_PASSWORD=CHANGEME +POSTGRES_DB=apps +DATABASE_URL=postgresql://ocb:CHANGEME@ocb-postgres:5432/apps?sslmode=disable + +# Helius API key — https://helius.dev (free tier works for start) +HELIUS_API_KEY=CHANGEME diff --git a/harnesses/solana-exec/go.mod b/harnesses/solana-exec/go.mod new file mode 100644 index 00000000..868e901f --- /dev/null +++ b/harnesses/solana-exec/go.mod @@ -0,0 +1,14 @@ +module github.com/ChainBench/OpenChainBench/harnesses/solana-exec + +go 1.24.0 + +require github.com/jackc/pgx/v5 v5.7.2 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/harnesses/solana-exec/go.sum b/harnesses/solana-exec/go.sum new file mode 100644 index 00000000..731b5dfd --- /dev/null +++ b/harnesses/solana-exec/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-exec/internal/helius/client.go b/harnesses/solana-exec/internal/helius/client.go new file mode 100644 index 00000000..58b0743a --- /dev/null +++ b/harnesses/solana-exec/internal/helius/client.go @@ -0,0 +1,475 @@ +package helius + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Client wraps the Solana JSON-RPC API. +// All calls use standard Solana JSON-RPC — no third-party credits required. +type Client struct { + httpClient *http.Client + rpcURL string +} + +// New creates a client using the given Solana RPC endpoint. +func New(rpcURL string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + rpcURL: rpcURL, + } +} + +// NewWithRPC kept for call-site compat; apiKey is ignored. +func NewWithRPC(_, rpcURL string) *Client { + return New(rpcURL) +} + +// SigEntry is one result from getSignaturesForAddress. +type SigEntry struct { + Signature string `json:"signature"` + Slot uint64 `json:"slot"` + BlockTime int64 `json:"blockTime"` // unix seconds, may be 0 + Err any `json:"err"` // nil = success +} + +// GetSignaturesForAddress fetches up to `limit` finalized signatures for +// `address`, newest-first. `until` is the exclusive upper bound (cursor); +// `before` is the exclusive lower bound for pagination (pass "" for first page). +func (c *Client) GetSignaturesForAddress(ctx context.Context, address string, limit int, until, before string) ([]SigEntry, error) { + params := map[string]any{ + "limit": limit, + "commitment": "finalized", + } + if until != "" { + params["until"] = until + } + if before != "" { + params["before"] = before + } + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignaturesForAddress", + "params": []any{address, params}, + }) + + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt*attempt) * time.Second): + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + if resp.StatusCode == http.StatusTooManyRequests { + resp.Body.Close() + lastErr = fmt.Errorf("RPC HTTP 429") + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("RPC HTTP %d", resp.StatusCode) + } + + var out struct { + Result []SigEntry `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("RPC decode: %w", err) + } + resp.Body.Close() + if out.Error != nil { + return nil, fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message) + } + return out.Result, nil + } + return nil, fmt.Errorf("RPC: %w (after 3 attempts)", lastErr) +} + +// NativeTransfer is a SOL transfer derived from pre/post balance diff. +type NativeTransfer struct { + FromUserAccount string + ToUserAccount string + Amount int64 // lamports +} + +// TokenTransfer is an SPL token transfer derived from pre/post token balance diff. +// Amount is in the token's raw units (e.g. 1 USDC = 1_000_000). +type TokenTransfer struct { + ToOwner string // wallet that owns the destination token account + Mint string // SPL token mint address + Amount int64 // raw token units (positive = received) +} + +// EnhancedTx is a parsed Solana transaction. +type EnhancedTx struct { + Signature string + Slot uint64 + Timestamp int64 // unix seconds + Fee int64 // total fee lamports + FeePayer string + NativeTransfers []NativeTransfer + TokenTransfers []TokenTransfer + TransactionError any + ComputeUnitsConsumed int64 + // CULimit from SetComputeUnitLimit (disc 0x02). 0 = instruction absent. + CULimit int64 + // CUPriceDeclared is the micro-lamports/CU from SetComputeUnitPrice (disc 0x03). + // This is the authoritative price — use it directly, no division needed. + // 0 = no priority fee set (base fee only). + CUPriceDeclared int64 + // SenderAccounts is the set of accounts whose SOL balance decreased in this tx. + // Used by the collector to detect internal fee-wallet sweeps and skip them. + SenderAccounts []string +} + +const b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// decodeBase58 decodes a base58-encoded Solana instruction data string to bytes. +func decodeBase58(s string) []byte { + var n big.Int + for _, c := range s { + idx := strings.IndexRune(b58Alphabet, c) + if idx < 0 { + return nil + } + n.Mul(&n, big.NewInt(58)) + n.Add(&n, big.NewInt(int64(idx))) + } + decoded := n.Bytes() + leading := 0 + for _, c := range s { + if c != '1' { + break + } + leading++ + } + out := make([]byte, leading+len(decoded)) + copy(out[leading:], decoded) + return out +} + +const computeBudgetProgram = "ComputeBudget111111111111111111111111111111" + +// GetEnhancedTransactions fetches and parses up to 100 transactions in parallel +// using standard Solana JSON-RPC getTransaction — no third-party credits needed. +// NativeTransfers and TokenTransfers are derived from pre/post balance diffs. +func (c *Client) GetEnhancedTransactions(ctx context.Context, sigs []string) ([]EnhancedTx, error) { + if len(sigs) == 0 { + return nil, nil + } + if len(sigs) > 100 { + return nil, fmt.Errorf("batch max 100 sigs, got %d", len(sigs)) + } + + type result struct { + tx EnhancedTx + ok bool + } + results := make([]result, len(sigs)) + + sem := make(chan struct{}, 10) + var wg sync.WaitGroup + for i, sig := range sigs { + i, sig := i, sig + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + tx, err := c.getTransaction(ctx, sig) + if err != nil { + return + } + results[i] = result{tx: tx, ok: true} + }() + } + wg.Wait() + + txs := make([]EnhancedTx, 0, len(sigs)) + for _, r := range results { + if r.ok { + txs = append(txs, r.tx) + } + } + return txs, nil +} + +// accountKey handles both legacy (bare string) and v0 (object with pubkey field) formats. +type accountKey struct{ Pubkey string } + +func (a *accountKey) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + a.Pubkey = s + return nil + } + var obj struct { + Pubkey string `json:"pubkey"` + } + if err := json.Unmarshal(b, &obj); err != nil { + return err + } + a.Pubkey = obj.Pubkey + return nil +} + +func (c *Client) getTransaction(ctx context.Context, sig string) (EnhancedTx, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getTransaction", + "params": []any{sig, map[string]any{ + "encoding": "json", + "commitment": "finalized", + "maxSupportedTransactionVersion": 0, + }}, + }) + + type tokenBalanceEntry struct { + AccountIndex int `json:"accountIndex"` + Mint string `json:"mint"` + Owner string `json:"owner"` + UITokenAmount struct { + Amount string `json:"amount"` // raw integer as string + } `json:"uiTokenAmount"` + } + + var out struct { + Result *struct { + Slot uint64 `json:"slot"` + BlockTime *int64 `json:"blockTime"` + Transaction struct { + Message struct { + AccountKeys []accountKey `json:"accountKeys"` + Instructions []struct { + ProgramIDIndex int `json:"programIdIndex"` + Data string `json:"data"` // base58-encoded + } `json:"instructions"` + } `json:"message"` + Signatures []string `json:"signatures"` + } `json:"transaction"` + Meta *struct { + Err any `json:"err"` + Fee int64 `json:"fee"` + PreBalances []int64 `json:"preBalances"` + PostBalances []int64 `json:"postBalances"` + ComputeUnitsConsumed *int64 `json:"computeUnitsConsumed"` + PreTokenBalances []tokenBalanceEntry `json:"preTokenBalances"` + PostTokenBalances []tokenBalanceEntry `json:"postTokenBalances"` + LoadedAddresses *struct { + Writable []string `json:"writable"` + Readonly []string `json:"readonly"` + } `json:"loadedAddresses"` + } `json:"meta"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-ctx.Done(): + return EnhancedTx{}, ctx.Err() + case <-time.After(time.Duration(attempt*attempt) * time.Second): + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body)) + if err != nil { + return EnhancedTx{}, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + if resp.StatusCode == http.StatusTooManyRequests { + resp.Body.Close() + lastErr = fmt.Errorf("getTransaction HTTP 429") + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return EnhancedTx{}, fmt.Errorf("getTransaction HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + resp.Body.Close() + lastErr = fmt.Errorf("decode: %w", err) + continue + } + resp.Body.Close() + lastErr = nil + break + } + if lastErr != nil { + return EnhancedTx{}, fmt.Errorf("getTransaction: %w (after 3 attempts)", lastErr) + } + if out.Error != nil { + return EnhancedTx{}, fmt.Errorf("RPC error %d: %s", out.Error.Code, out.Error.Message) + } + if out.Result == nil || out.Result.Meta == nil { + return EnhancedTx{}, fmt.Errorf("null result for %s", sig) + } + + r := out.Result + m := r.Meta + + // Full account key list: static keys + v0 loaded addresses (writable then readonly). + accounts := make([]accountKey, len(r.Transaction.Message.AccountKeys)) + copy(accounts, r.Transaction.Message.AccountKeys) + if m.LoadedAddresses != nil { + for _, addr := range m.LoadedAddresses.Writable { + accounts = append(accounts, accountKey{Pubkey: addr}) + } + for _, addr := range m.LoadedAddresses.Readonly { + accounts = append(accounts, accountKey{Pubkey: addr}) + } + } + + // NativeTransfers: accounts whose SOL balance increased received SOL. + // SenderAccounts: accounts whose SOL balance decreased (used for sweep detection). + var nativeTransfers []NativeTransfer + var senderAccounts []string + for i, key := range accounts { + if i >= len(m.PreBalances) || i >= len(m.PostBalances) { + break + } + diff := m.PostBalances[i] - m.PreBalances[i] + if diff > 0 { + nativeTransfers = append(nativeTransfers, NativeTransfer{ToUserAccount: key.Pubkey, Amount: diff}) + } else if diff < 0 { + senderAccounts = append(senderAccounts, key.Pubkey) + } + } + + // TokenTransfers: net SPL token gain per (owner, mint). + // Sum pre and post separately so wallets with multiple ATAs for the same mint + // are handled correctly — the diff is the net change, not a per-account delta. + type ownerMint struct{ owner, mint string } + preTokenAmt := make(map[ownerMint]int64, len(m.PreTokenBalances)) + for _, tb := range m.PreTokenBalances { + if tb.Owner == "" { + continue + } + amt, _ := strconv.ParseInt(tb.UITokenAmount.Amount, 10, 64) + preTokenAmt[ownerMint{tb.Owner, tb.Mint}] += amt + } + postTokenAmt := make(map[ownerMint]int64, len(m.PostTokenBalances)) + for _, tb := range m.PostTokenBalances { + if tb.Owner == "" { + continue // owner field absent on very old txs + } + amt, _ := strconv.ParseInt(tb.UITokenAmount.Amount, 10, 64) + postTokenAmt[ownerMint{tb.Owner, tb.Mint}] += amt + } + var tokenTransfers []TokenTransfer + for key, postAmt := range postTokenAmt { + diff := postAmt - preTokenAmt[key] + if diff > 0 { + tokenTransfers = append(tokenTransfers, TokenTransfer{ + ToOwner: key.owner, + Mint: key.mint, + Amount: diff, + }) + } + } + + var cuConsumed int64 + if m.ComputeUnitsConsumed != nil { + cuConsumed = *m.ComputeUnitsConsumed + } + + // Parse ComputeBudget instructions for CU price and limit. + // Discriminator enum (borsh u8): + // 0x02 = SetComputeUnitLimit payload: u32 LE [1:5] + // 0x03 = SetComputeUnitPrice payload: u64 LE [1:9] (micro-lamports per CU) + var cuLimit, cuPriceDeclared int64 + for _, ix := range r.Transaction.Message.Instructions { + if ix.ProgramIDIndex < 0 || ix.ProgramIDIndex >= len(accounts) { + continue + } + if accounts[ix.ProgramIDIndex].Pubkey != computeBudgetProgram { + continue + } + data := decodeBase58(ix.Data) + if len(data) == 0 { + continue + } + switch data[0] { + case 2: + if len(data) >= 5 { + cuLimit = int64(binary.LittleEndian.Uint32(data[1:5])) + } + case 3: + if len(data) >= 9 { + cuPriceDeclared = int64(binary.LittleEndian.Uint64(data[1:9])) + } + } + } + + var feePayer string + if len(accounts) > 0 { + feePayer = accounts[0].Pubkey + } + + var blockTime int64 + if r.BlockTime != nil { + blockTime = *r.BlockTime + } + + var sig0 string + if len(r.Transaction.Signatures) > 0 { + sig0 = r.Transaction.Signatures[0] + } + + return EnhancedTx{ + Signature: sig0, + Slot: r.Slot, + Timestamp: blockTime, + Fee: m.Fee, + FeePayer: feePayer, + NativeTransfers: nativeTransfers, + TokenTransfers: tokenTransfers, + TransactionError: m.Err, + ComputeUnitsConsumed: cuConsumed, + CULimit: cuLimit, + CUPriceDeclared: cuPriceDeclared, + SenderAccounts: senderAccounts, + }, nil +} diff --git a/harnesses/solana-exec/internal/platform/platforms.go b/harnesses/solana-exec/internal/platform/platforms.go new file mode 100644 index 00000000..a1ec8b7a --- /dev/null +++ b/harnesses/solana-exec/internal/platform/platforms.go @@ -0,0 +1,117 @@ +package platform + +// FeeAccounts maps platform name → its on-chain fee-receiver accounts. +// Multi-account platforms (GMGN, Axiom) list all wallets; the collector +// paginates each independently and aggregates before sampling. +// Sources: DefiLlama dimension-adapters, verified 2026-08. +var FeeAccounts = map[string][]string{ + // 8 bonding-curve fee recipients (round-robin) + Mayhem wallet. + // Sources: DeFiLlama fees/pumpdotfun adapter (master, 2026-08). + "pump.fun": { + "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM", + "62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV", + "FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz", + "7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX", + "AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY", + "9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz", + "G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP", + "7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ", + "GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS", // Mayhem mode + }, + "photon": { + "AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH", + }, + "bullx": { + "9RYJ3qr5eU5xAooqVcbmdeusjcViL5Nkiq7Gske3tiKq", // current (post 2024-11-16) + "F4hJ3Ee3c5UuaorKAMfELBjYCjiiLH75haZTKqTywRP3", // legacy + }, + "gmgn": { + // Nine pure fee-collector wallets verified on-chain (2026-06). + // No transfers among themselves → positive SOL diff = fee, no double-count. + "BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT", + "7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA", + "HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3", + "ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy", + "DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9", + "3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK", + "DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx", + "dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ", + "6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn", + }, + "axiom": { + "7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj", + "4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S", + "CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ", + "AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW", + "7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws", + "9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP", + "DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy", + "4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd", + "76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf", + "H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK", + "8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm", + "4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz", + "8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb", + "86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG", + "DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM", + "5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB", + "DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF", + "Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9", + "846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8", + "5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg", + }, + "fomo": { + // FOMO collects fees in USDC. The fee wallet's USDC ATA is monitored for sigs + // (getSignaturesForAddress on the wallet returns 0 overlap with ATA — confirmed + // live on 2026-08). Fee detection uses FeeOwners below (the wallet address) + // because postTokenBalances.owner = wallet, not ATA. + "HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq", // USDC ATA of fee wallet + }, + // Trojan — confirmed via DeFiLlama dexs/trojan/index.ts (master, 2026-08) + "trojan": { + "9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco", // primary bot fee wallet + "92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH", // terminal + "2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ", + "65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH", + "BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp", + "8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn", + }, + // Banana Gun — confirmed via DeFiLlama fees/banana-gun-trading.ts (master, 2026-08) + "banana-gun": { + "47hEzz83VFR23rLTEeVm9A7eFzjJwjvdupPPmX3cePqF", + "4BBNEVRgrxVKv9f7pMNE788XM1tt379X9vNjpDH2KCL7", + "8r2hZoDfk5hDWJ1sDujAi2Qr45ZyZw5EQxAXiMZWLKh2", + }, + // Maestro — confirmed via DeFiLlama fees/maestro.ts (master, 2026-08) + "maestro": { + "MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36", + "FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN", + }, +} + +// FeeToken maps platform name → SPL token mint used for platform fee collection. +// Absent (empty string) = SOL (native lamports). Set for USDC-fee platforms. +// USDC mint: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v +var FeeToken = map[string]string{ + "fomo": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", +} + +// FeeOwners maps platform → wallet addresses to match against postTokenBalances.owner +// for SPL-token fee detection. Only needed when FeeAccounts holds ATA addresses +// (which differ from the wallet). If absent, feeSet falls back to FeeAccounts. +var FeeOwners = map[string][]string{ + "fomo": {"R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX"}, +} + +// JitoTipAccounts is the authoritative set of 8 Jito tip wallets. +// Verified against solana-tx-landing harness wallets.go (2026-08). +var JitoTipAccounts = map[string]bool{ + "96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5": true, + "HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe": true, + "Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY": true, + "ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49": true, + "DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh": true, + "ADuUkR4vqLUMWXxW9gh6D6L8pivKeVBBjNo7XZQshxw3": true, + "DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL": true, + "3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT": true, +} diff --git a/harnesses/solana-exec/internal/store/store.go b/harnesses/solana-exec/internal/store/store.go new file mode 100644 index 00000000..6185da79 --- /dev/null +++ b/harnesses/solana-exec/internal/store/store.go @@ -0,0 +1,217 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, connStr string) (*DB, error) { + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { db.pool.Close() } + +func (db *DB) Pool() *pgxpool.Pool { return db.pool } + +// ExecEvent is a parsed Solana transaction from a monitored platform. +type ExecEvent struct { + Sig string + Platform string + Slot uint64 + BlockTime time.Time + TotalFeeLamports int64 + PriorityFeeLamports int64 + PlatformFeeLamports int64 + IsJitoBundle bool + JitoTipLamports int64 + CUConsumed int64 + CUPriceMicro int64 +} + +// UpsertEvents inserts exec events idempotently; existing sigs are skipped. +func (db *DB) UpsertEvents(ctx context.Context, events []ExecEvent) error { + if len(events) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + + for _, e := range events { + _, err := tx.Exec(ctx, ` + INSERT INTO solana_exec_events + (sig, platform, slot, block_time, + total_fee_lamports, priority_fee_lamports, platform_fee_lamports, + is_jito_bundle, jito_tip_lamports, cu_consumed, cu_price_micro) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (sig) DO NOTHING`, + e.Sig, e.Platform, e.Slot, e.BlockTime, + e.TotalFeeLamports, e.PriorityFeeLamports, e.PlatformFeeLamports, + e.IsJitoBundle, e.JitoTipLamports, e.CUConsumed, e.CUPriceMicro, + ) + if err != nil { + return fmt.Errorf("store: upsert %s: %w", e.Sig, err) + } + } + return tx.Commit(ctx) +} + +// Cursor holds the last processed signature for a fee account. +type Cursor struct { + LastSig string + Slot uint64 +} + +func (db *DB) GetCursor(ctx context.Context, platform string) (Cursor, error) { + var c Cursor + err := db.pool.QueryRow(ctx, + `SELECT last_sig, slot FROM solana_exec_cursors WHERE platform = $1`, platform, + ).Scan(&c.LastSig, &c.Slot) + if err != nil { + return Cursor{}, nil // no cursor yet = start from recent + } + return c, nil +} + +func (db *DB) SaveCursor(ctx context.Context, platform, lastSig string, slot uint64) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO solana_exec_cursors (platform, last_sig, slot, updated_at) + VALUES ($1,$2,$3,now()) + ON CONFLICT (platform) DO UPDATE SET + last_sig=EXCLUDED.last_sig, slot=EXCLUDED.slot, updated_at=now()`, + platform, lastSig, slot, + ) + return err +} + +// UpsertRawCounts stores hourly successful-tx counts from raw sig pagination. +// fromCursor is cursor.LastSig at poll start — makes each row idempotent: +// same (platform, bucket_start, fromCursor) on retry → DO NOTHING, no double-count. +func (db *DB) UpsertRawCounts(ctx context.Context, platform, fromCursor string, counts map[time.Time]int64) error { + if len(counts) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + for bucket, count := range counts { + _, err := tx.Exec(ctx, ` + INSERT INTO solana_exec_raw_counts (platform, bucket_start, from_cursor, success_count, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (platform, bucket_start, from_cursor) DO NOTHING`, + platform, bucket, fromCursor, count, + ) + if err != nil { + return fmt.Errorf("store: upsert raw count %v: %w", bucket, err) + } + } + return tx.Commit(ctx) +} + +// CUSample is a single compute-unit price observation from the enhanced API sample. +type CUSample struct { + Sig string + BlockTime time.Time + CUPriceMicro int64 +} + +// InsertCUSamples stores CU price samples idempotently keyed on (platform, sig). +func (db *DB) InsertCUSamples(ctx context.Context, platform string, samples []CUSample) error { + if len(samples) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + for _, s := range samples { + _, err := tx.Exec(ctx, + `INSERT INTO solana_exec_cu_samples (platform, sig, block_time, cu_price_micro) + VALUES ($1,$2,$3,$4) + ON CONFLICT (platform, sig) DO NOTHING`, + platform, s.Sig, s.BlockTime, s.CUPriceMicro, + ) + if err != nil { + return fmt.Errorf("store: insert cu sample %s: %w", s.Sig, err) + } + } + return tx.Commit(ctx) +} + +// PurgeCUSamples deletes samples older than 35 days to bound table growth. +func (db *DB) PurgeCUSamples(ctx context.Context) error { + _, err := db.pool.Exec(ctx, `DELETE FROM solana_exec_cu_samples WHERE block_time < now() - INTERVAL '35 days'`) + return err +} + +// PurgeEvents deletes sampled events older than 90 days. +func (db *DB) PurgeEvents(ctx context.Context) error { + _, err := db.pool.Exec(ctx, `DELETE FROM solana_exec_events WHERE block_time < now() - INTERVAL '90 days'`) + return err +} + +// Materialize recomputes hourly facts for the given platform. +// tx_count comes from solana_exec_raw_counts (full volume); fee metrics come +// from the sampled solana_exec_events (representative quality metrics). +func (db *DB) Materialize(ctx context.Context, platform string) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO solana_exec_facts + (platform, bucket_start, tx_count, + avg_priority_fee_lamports, p50_cu_price_micro, p95_cu_price_micro, + avg_platform_fee_lamports, sum_platform_fee_lamports, jito_rate, avg_cu_consumed, computed_at) + SELECT + e.platform, + date_trunc('hour', e.block_time) AS bucket_start, + COALESCE(r.success_count, COUNT(*)) AS tx_count, + AVG(e.priority_fee_lamports) AS avg_priority_fee_lamports, + COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p50_cu_price_micro, + COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.cu_price_micro) FILTER (WHERE e.cu_price_micro > 0), 0) AS p95_cu_price_micro, + AVG(e.platform_fee_lamports) AS avg_platform_fee_lamports, + -- Scale sampled fee sum by raw/sampled ratio so the total reflects full volume. + -- Without scaling, sum would be ~1/N of reality for high-volume platforms. + CASE + WHEN r.success_count IS NOT NULL AND COUNT(*) > 0 + THEN (SUM(e.platform_fee_lamports)::numeric * r.success_count / COUNT(*))::bigint + ELSE SUM(e.platform_fee_lamports) + END AS sum_platform_fee_lamports, + AVG(CASE WHEN e.is_jito_bundle THEN 1.0 ELSE 0.0 END) AS jito_rate, + AVG(e.cu_consumed) AS avg_cu_consumed, + now() + FROM solana_exec_events e + LEFT JOIN ( + SELECT platform, bucket_start, SUM(success_count) AS success_count + FROM solana_exec_raw_counts + GROUP BY platform, bucket_start + ) r ON r.platform = e.platform AND r.bucket_start = date_trunc('hour', e.block_time) + WHERE e.platform = $1 + GROUP BY e.platform, date_trunc('hour', e.block_time), r.success_count + ON CONFLICT (platform, bucket_start) DO UPDATE SET + tx_count = EXCLUDED.tx_count, + avg_priority_fee_lamports = EXCLUDED.avg_priority_fee_lamports, + p50_cu_price_micro = EXCLUDED.p50_cu_price_micro, + p95_cu_price_micro = EXCLUDED.p95_cu_price_micro, + avg_platform_fee_lamports = EXCLUDED.avg_platform_fee_lamports, + sum_platform_fee_lamports = EXCLUDED.sum_platform_fee_lamports, + jito_rate = EXCLUDED.jito_rate, + avg_cu_consumed = EXCLUDED.avg_cu_consumed, + computed_at = now()`, + platform, + ) + return err +} diff --git a/harnesses/solana-exec/migrations/001_init.sql b/harnesses/solana-exec/migrations/001_init.sql new file mode 100644 index 00000000..840ee08b --- /dev/null +++ b/harnesses/solana-exec/migrations/001_init.sql @@ -0,0 +1,49 @@ +BEGIN; + +-- Raw events. One row per transaction that touched a monitored platform fee account. +-- Immutable: only upsert, never update. sig is globally unique on Solana. +CREATE TABLE IF NOT EXISTS solana_exec_events ( + sig TEXT PRIMARY KEY, + platform TEXT NOT NULL, + slot BIGINT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + -- fee = base + priority. Base ≈ 5000 lamports × num_signers. + total_fee_lamports BIGINT NOT NULL DEFAULT 0, + priority_fee_lamports BIGINT NOT NULL DEFAULT 0, + -- SOL transferred to the platform's fee account in this tx. + platform_fee_lamports BIGINT NOT NULL DEFAULT 0, + is_jito_bundle BOOLEAN NOT NULL DEFAULT false, + jito_tip_lamports BIGINT NOT NULL DEFAULT 0, + cu_consumed BIGINT NOT NULL DEFAULT 0, + -- microlamports per CU (from ComputeBudget instruction if present) + cu_price_micro BIGINT NOT NULL DEFAULT 0, + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS solana_exec_events_platform_bt + ON solana_exec_events (platform, block_time DESC); + +-- Derived hourly facts. Recomputable from solana_exec_events at any time. +CREATE TABLE IF NOT EXISTS solana_exec_facts ( + platform TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, -- truncated to 1h + tx_count INT NOT NULL DEFAULT 0, + avg_priority_fee_lamports DOUBLE PRECISION NOT NULL DEFAULT 0, + p50_cu_price_micro DOUBLE PRECISION NOT NULL DEFAULT 0, + p95_cu_price_micro DOUBLE PRECISION NOT NULL DEFAULT 0, + avg_platform_fee_lamports DOUBLE PRECISION NOT NULL DEFAULT 0, + jito_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + avg_cu_consumed DOUBLE PRECISION NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (platform, bucket_start) +); + +-- Cursor per platform: tracks last-seen signature to enable incremental polling. +CREATE TABLE IF NOT EXISTS solana_exec_cursors ( + platform TEXT PRIMARY KEY, + last_sig TEXT NOT NULL, + slot BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMIT; diff --git a/harnesses/solana-exec/migrations/002_raw_counts.sql b/harnesses/solana-exec/migrations/002_raw_counts.sql new file mode 100644 index 00000000..bf8f199a --- /dev/null +++ b/harnesses/solana-exec/migrations/002_raw_counts.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Hourly raw successful-tx counts derived from getSignaturesForAddress pagination. +-- Counted at zero extra Helius credit cost (no enhanced API needed). +-- Separate from solana_exec_events which only holds fee-quality sampled rows. +CREATE TABLE IF NOT EXISTS solana_exec_raw_counts ( + platform TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + success_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (platform, bucket_start) +); + +-- Replace tx_count (INT) in facts with BIGINT to hold real volumes. +ALTER TABLE solana_exec_facts + ALTER COLUMN tx_count TYPE BIGINT; + +COMMIT; diff --git a/harnesses/solana-exec/migrations/003_cu_samples.sql b/harnesses/solana-exec/migrations/003_cu_samples.sql new file mode 100644 index 00000000..38d89388 --- /dev/null +++ b/harnesses/solana-exec/migrations/003_cu_samples.sql @@ -0,0 +1,16 @@ +BEGIN; + +-- Raw CU price samples from the 100-sig enhanced API poll each cycle. +-- Gives real percentile_cont on actual data points instead of AVG(hourly_p50). +-- Retention: 35 days (purged by materializer). +CREATE TABLE IF NOT EXISTS solana_exec_cu_samples ( + id BIGSERIAL PRIMARY KEY, + platform TEXT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + cu_price_micro BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS solana_exec_cu_samples_platform_time + ON solana_exec_cu_samples (platform, block_time); + +COMMIT; diff --git a/harnesses/solana-exec/migrations/003_multi_platform.sql b/harnesses/solana-exec/migrations/003_multi_platform.sql new file mode 100644 index 00000000..bf5528ee --- /dev/null +++ b/harnesses/solana-exec/migrations/003_multi_platform.sql @@ -0,0 +1,29 @@ +BEGIN; + +-- Add from_cursor to raw_counts for idempotency: same poll window = same from_cursor = DO NOTHING on retry. +-- Drop and recreate: table is ephemeral (rolling window, recomputable) so truncate is safe. +TRUNCATE solana_exec_raw_counts; +ALTER TABLE solana_exec_raw_counts + ADD COLUMN IF NOT EXISTS from_cursor TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_exec_raw_counts + DROP CONSTRAINT IF EXISTS solana_exec_raw_counts_pkey; +ALTER TABLE solana_exec_raw_counts + ADD PRIMARY KEY (platform, bucket_start, from_cursor); + +-- Per-account cursors: key was platform, now platform:feeAccount. +-- Existing single-account cursors (key = platform name) stay valid for pump.fun. +-- No migration needed; old key will not match new compound keys — polls restart from tip for new accounts. + +-- CU price samples for rolling percentile metrics (35-day retention). +CREATE TABLE IF NOT EXISTS solana_exec_cu_samples ( + platform TEXT NOT NULL, + sig TEXT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + cu_price_micro BIGINT NOT NULL, + PRIMARY KEY (platform, sig) +); + +CREATE INDEX IF NOT EXISTS solana_exec_cu_samples_bt + ON solana_exec_cu_samples (platform, block_time DESC); + +COMMIT; diff --git a/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql b/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql new file mode 100644 index 00000000..66ab942a --- /dev/null +++ b/harnesses/solana-exec/migrations/004_raw_counts_idempotent.sql @@ -0,0 +1,17 @@ +BEGIN; + +-- Make raw_counts idempotent on crash/restart. +-- Problem: UpsertRawCounts accumulated (success_count + EXCLUDED.success_count). +-- On a crash between UpsertRawCounts and SaveCursor, the next boot re-processes +-- the same sigs and double-counts. Fix: key by (platform, bucket_start, from_cursor) +-- so each poll's contribution is a distinct row. ON CONFLICT DO NOTHING = fully idempotent. + +ALTER TABLE solana_exec_raw_counts DROP CONSTRAINT solana_exec_raw_counts_pkey; + +-- from_cursor = cursor.LastSig at the start of the poll that produced this row. +-- Existing rows get '' which is safe — they predate this schema change and won't be re-inserted. +ALTER TABLE solana_exec_raw_counts ADD COLUMN IF NOT EXISTS from_cursor TEXT NOT NULL DEFAULT ''; + +ALTER TABLE solana_exec_raw_counts ADD PRIMARY KEY (platform, bucket_start, from_cursor); + +COMMIT; diff --git a/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql b/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql new file mode 100644 index 00000000..2594ba92 --- /dev/null +++ b/harnesses/solana-exec/migrations/005_events_purge_and_cu_sig.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Bound events table growth: materializer will purge rows older than 90 days. +-- Facts table already holds all aggregated metrics; raw events are only needed +-- for re-materialization within the rolling window. +-- No schema change needed — purge is done in application code. + +-- Make cu_samples idempotent on crash/restart by keying on (platform, sig). +-- Without this, a crash between UpsertEvents and SaveCursor causes the next +-- boot to re-call InsertCUSamples for the same 100 sigs, duplicating them +-- and slightly skewing percentiles. +ALTER TABLE solana_exec_cu_samples ADD COLUMN IF NOT EXISTS sig TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS solana_exec_cu_samples_platform_sig + ON solana_exec_cu_samples (platform, sig) + WHERE sig IS NOT NULL; + +COMMIT; diff --git a/harnesses/solana-exec/migrations/006_sum_platform_fee.sql b/harnesses/solana-exec/migrations/006_sum_platform_fee.sql new file mode 100644 index 00000000..65218cc6 --- /dev/null +++ b/harnesses/solana-exec/migrations/006_sum_platform_fee.sql @@ -0,0 +1,8 @@ +BEGIN; + +-- Exact per-bucket fee sum, populated by Materialize(). +-- Replaces the extrapolation txCount × avg_platform_fee when events are complete. +ALTER TABLE solana_exec_facts + ADD COLUMN IF NOT EXISTS sum_platform_fee_lamports BIGINT NOT NULL DEFAULT 0; + +COMMIT; diff --git a/harnesses/solana-launchpad-wars/Dockerfile b/harnesses/solana-launchpad-wars/Dockerfile new file mode 100644 index 00000000..379e2f91 --- /dev/null +++ b/harnesses/solana-launchpad-wars/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +COPY cmd ./cmd +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/solana-launchpad-wars/cmd/script/main.go b/harnesses/solana-launchpad-wars/cmd/script/main.go new file mode 100644 index 00000000..a7307869 --- /dev/null +++ b/harnesses/solana-launchpad-wars/cmd/script/main.go @@ -0,0 +1,71 @@ +// solana-launchpad-wars -- Bench 200 +// +// Polls the Mobula lighthouse endpoint every 15 minutes and exposes +// per-launchpad 24h volume, fees and trade counts as Prometheus gauges. +// +// Metrics on :2112/metrics: +// +// solana_launchpad_volume_24h_usd{launchpad} +// solana_launchpad_fees_24h_usd{launchpad} +// solana_launchpad_trades_24h{launchpad} +// solana_launchpad_health{launchpad} +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +const pollInterval = 15 * time.Minute + +func main() { + fmt.Println("=== solana-launchpad-wars harness ===") + fmt.Println("OpenChainBench Bench 200 -- Solana launchpad leaderboard via Mobula lighthouse.") + + apiKey := os.Getenv("MOBULA_API_KEY") + if apiKey == "" { + fmt.Fprintln(os.Stderr, "[fatal] MOBULA_API_KEY not set") + os.Exit(1) + } + + client := newMobulaClient(apiKey) + + go func() { + if err := startMetricsServer(":2112"); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + runOnce(client) + + tick := time.NewTicker(pollInterval) + defer tick.Stop() + + for { + select { + case <-sig: + fmt.Println("[shutdown] received signal") + return + case <-tick.C: + runOnce(client) + } + } +} + +func runOnce(c *mobulaClient) { + data, err := c.fetchLighthouse() + if err != nil { + fmt.Printf("[poll] lighthouse fetch failed: %v\n", err) + return + } + publishLighthouse(data) + fmt.Printf("[poll] updated: %d launchpads, %d platforms\n", + len(data.ByLaunchpad), len(data.ByPlatform)) +} diff --git a/harnesses/solana-launchpad-wars/cmd/script/metrics.go b/harnesses/solana-launchpad-wars/cmd/script/metrics.go new file mode 100644 index 00000000..54cb19cb --- /dev/null +++ b/harnesses/solana-launchpad-wars/cmd/script/metrics.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/http" + "regexp" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + launchpadVol24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_launchpad_volume_24h_usd", + Help: "24h trading volume in USD (Mobula lighthouse byLaunchpad).", + }, []string{"launchpad"}) + + launchpadFees24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_launchpad_fees_24h_usd", + Help: "24h protocol fees in USD (Mobula lighthouse byLaunchpad).", + }, []string{"launchpad"}) + + launchpadTrades24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_launchpad_trades_24h", + Help: "24h trade count (Mobula lighthouse byLaunchpad).", + }, []string{"launchpad"}) + + launchpadHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_launchpad_health", + Help: "1 if this launchpad returned data in the last lighthouse poll.", + }, []string{"launchpad"}) + + platformVol24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_volume_24h_usd", + Help: "24h trading volume in USD routed through this Solana trading platform.", + }, []string{"platform"}) + + platformFees24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_fees_24h_usd", + Help: "24h platform fees in USD collected by this Solana trading platform.", + }, []string{"platform"}) + + platformTrades24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_trades_24h", + Help: "24h trade count attributed to this Solana trading platform (Mobula lighthouse).", + }, []string{"platform"}) + + platformHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_health", + Help: "1 if this platform returned data in the last lighthouse poll.", + }, []string{"platform"}) +) + +func init() { + prometheus.MustRegister( + launchpadVol24h, + launchpadFees24h, + launchpadTrades24h, + launchpadHealth, + platformVol24h, + platformFees24h, + platformTrades24h, + platformHealth, + ) +} + +var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(name string) string { + s := strings.ToLower(name) + s = nonAlnum.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func publishLighthouse(data *lighthouseData) { + for _, e := range data.ByLaunchpad { + slug := slugify(e.Name) + launchpadVol24h.WithLabelValues(slug).Set(e.VolumeUSD.H24) + launchpadFees24h.WithLabelValues(slug).Set(e.FeesPaidUSD.H24) + launchpadTrades24h.WithLabelValues(slug).Set(e.Trades.H24) + launchpadHealth.WithLabelValues(slug).Set(1) + // pump.fun trades via its own frontend are in byLaunchpad; publish as platform + // so bench 201 can compare it against GMGN, Axiom, Fomo etc. (same attribution logic). + if slug == "pumpfun" { + platformVol24h.WithLabelValues("pump-fun").Set(e.VolumeUSD.H24) + platformFees24h.WithLabelValues("pump-fun").Set(e.FeesPaidUSD.H24) + platformTrades24h.WithLabelValues("pump-fun").Set(e.Trades.H24) + platformHealth.WithLabelValues("pump-fun").Set(1) + } + } + for _, e := range data.ByPlatform { + slug := slugify(e.Name) + platformVol24h.WithLabelValues(slug).Set(e.VolumeUSD.H24) + platformFees24h.WithLabelValues(slug).Set(e.FeesPaidUSD.H24) + platformTrades24h.WithLabelValues(slug).Set(e.Trades.H24) + platformHealth.WithLabelValues(slug).Set(1) + } +} + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/solana-launchpad-wars/cmd/script/mobula.go b/harnesses/solana-launchpad-wars/cmd/script/mobula.go new file mode 100644 index 00000000..a458aa5b --- /dev/null +++ b/harnesses/solana-launchpad-wars/cmd/script/mobula.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const lighthouseURL = "https://api.mobula.io/api/2/market/lighthouse" + +type mobulaClient struct { + apiKey string + http *http.Client +} + +func newMobulaClient(apiKey string) *mobulaClient { + return &mobulaClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +type windowValues struct { + Min15 float64 `json:"15min"` + H1 float64 `json:"1h"` + H6 float64 `json:"6h"` + H24 float64 `json:"24h"` +} + +type lighthouseEntry struct { + Name string `json:"name"` + VolumeUSD windowValues `json:"volumeUSD"` + FeesPaidUSD windowValues `json:"feesPaidUSD"` + Trades windowValues `json:"trades"` +} + +type lighthouseData struct { + ByLaunchpad []lighthouseEntry `json:"byLaunchpad"` + ByPlatform []lighthouseEntry `json:"byPlatform"` +} + +func (c *mobulaClient) fetchLighthouse() (*lighthouseData, error) { + req, err := http.NewRequest(http.MethodGet, lighthouseURL, nil) + if err != nil { + return nil, fmt.Errorf("build req: %w", err) + } + req.Header.Set("Authorization", c.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("status %d: %.200s", resp.StatusCode, body) + } + + var out struct { + Data lighthouseData `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + return &out.Data, nil +} diff --git a/harnesses/solana-launchpad-wars/go.mod b/harnesses/solana-launchpad-wars/go.mod new file mode 100644 index 00000000..6a1707dd --- /dev/null +++ b/harnesses/solana-launchpad-wars/go.mod @@ -0,0 +1,18 @@ +module solana-launchpad-wars + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/solana-launchpad-wars/go.sum b/harnesses/solana-launchpad-wars/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/solana-launchpad-wars/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-unique-traders/Dockerfile b/harnesses/solana-unique-traders/Dockerfile new file mode 100644 index 00000000..f88e4a0d --- /dev/null +++ b/harnesses/solana-unique-traders/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +COPY cmd ./cmd +COPY queries ./queries +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/solana-unique-traders/cmd/script/dune.go b/harnesses/solana-unique-traders/cmd/script/dune.go new file mode 100644 index 00000000..7fadc36f --- /dev/null +++ b/harnesses/solana-unique-traders/cmd/script/dune.go @@ -0,0 +1,196 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// querySQL counts unique fee-touching transactions per Solana trading platform. +// Source: solana.account_activity filtered to known fee wallet addresses. +// Count distinct tx_ids per platform as a proxy for daily active traders. +// pump_fun_solana.trades (Dune Spellbook) is unavailable on our API tier. +// Since pump.fun cut bonding-curve trading fees to 0% on 2026-08-07, +// its count reflects only transactions generating graduation/creator fees. +const querySQL = ` +WITH fee_wallets AS ( + SELECT address, platform FROM (VALUES + ('CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM','pump-fun'), + ('62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV','pump-fun'), + ('FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz','pump-fun'), + ('7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX','pump-fun'), + ('AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY','pump-fun'), + ('9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz','pump-fun'), + ('G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP','pump-fun'), + ('7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ','pump-fun'), + ('GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS','pump-fun'), + ('BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT','gmgn'), + ('7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA','gmgn'), + ('HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3','gmgn'), + ('ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy','gmgn'), + ('DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9','gmgn'), + ('3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK','gmgn'), + ('DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx','gmgn'), + ('dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ','gmgn'), + ('6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn','gmgn'), + ('7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj','axiom'), + ('4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S','axiom'), + ('CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ','axiom'), + ('AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW','axiom'), + ('7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws','axiom'), + ('9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP','axiom'), + ('DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy','axiom'), + ('4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd','axiom'), + ('76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf','axiom'), + ('H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK','axiom'), + ('8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm','axiom'), + ('4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz','axiom'), + ('8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb','axiom'), + ('86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG','axiom'), + ('DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM','axiom'), + ('5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB','axiom'), + ('DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF','axiom'), + ('Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9','axiom'), + ('846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8','axiom'), + ('5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg','axiom'), + ('HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq','fomo'), + ('9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco','trojan'), + ('92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH','trojan'), + ('2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ','trojan'), + ('65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH','trojan'), + ('BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp','trojan'), + ('8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn','trojan'), + ('AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH','photon'), + ('MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36','maestro'), + ('FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN','maestro') + ) AS t(address, platform) +) +SELECT fw.platform, COUNT(DISTINCT a.tx_id) AS unique_traders_24h +FROM solana.account_activity a +JOIN fee_wallets fw ON a.address = fw.address +WHERE a.block_time >= NOW() - INTERVAL '1' DAY +GROUP BY fw.platform +ORDER BY unique_traders_24h DESC +` + +const duneBase = "https://api.dune.com/api/v1" + +type duneClient struct { + apiKey string + http *http.Client +} + +type duneRow struct { + Platform string `json:"platform"` + UniqueTraders24h float64 `json:"unique_traders_24h"` +} + +func newDuneClient(apiKey string) *duneClient { + return &duneClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (d *duneClient) req(method, path string, body any) (*http.Response, error) { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, duneBase+path, r) + if err != nil { + return nil, err + } + req.Header.Set("X-Dune-Api-Key", d.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return d.http.Do(req) +} + +func (d *duneClient) createQuery() (string, error) { + payload := map[string]any{ + "name": "OCB: Solana Unique Traders 24h by Platform", + "query_sql": querySQL, + "is_private": false, + "parameters": []any{}, + } + resp, err := d.req("POST", "/query", payload) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + var out struct { + QueryID int `json:"query_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return fmt.Sprintf("%d", out.QueryID), nil +} + +func (d *duneClient) execute(queryID string) (string, error) { + resp, err := d.req("POST", "/query/"+queryID+"/execute", map[string]any{}) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + var out struct { + ExecutionID string `json:"execution_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.ExecutionID, nil +} + +func (d *duneClient) executionState(execID string) (string, error) { + resp, err := d.req("GET", "/execution/"+execID+"/status", nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + var out struct { + State string `json:"state"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.State, nil +} + +func (d *duneClient) latestResult(queryID string) ([]duneRow, error) { + resp, err := d.req("GET", "/query/"+queryID+"/results", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + var out struct { + Result struct { + Rows []duneRow `json:"rows"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out.Result.Rows, nil +} diff --git a/harnesses/solana-unique-traders/cmd/script/main.go b/harnesses/solana-unique-traders/cmd/script/main.go new file mode 100644 index 00000000..793cee93 --- /dev/null +++ b/harnesses/solana-unique-traders/cmd/script/main.go @@ -0,0 +1,128 @@ +// solana-unique-traders -- Bench 207 +// +// Fetches the Dune query result (unique daily traders per Solana platform) +// and exposes it as Prometheus gauges. Triggers a fresh Dune execution every +// hour; serves the latest cached result every 15 minutes in between. +// +// Required env vars: +// DUNE_API_KEY - Dune Analytics API key +// DUNE_QUERY_ID - ID of the saved Dune query (see queries/unique_traders.sql) +// If not set, the harness creates the query automatically and exits. +// +// Metrics on :2112/metrics: +// solana_platform_unique_traders_24h{platform} +// solana_platform_trader_health{platform} +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + fetchInterval = 15 * time.Minute + refreshInterval = 1 * time.Hour +) + +func main() { + fmt.Println("=== solana-unique-traders harness ===") + fmt.Println("OpenChainBench Bench 207 -- Unique daily traders by Solana platform via Dune.") + + apiKey := os.Getenv("DUNE_API_KEY") + if apiKey == "" { + fmt.Fprintln(os.Stderr, "[fatal] DUNE_API_KEY not set") + os.Exit(1) + } + + queryID := os.Getenv("DUNE_QUERY_ID") + client := newDuneClient(apiKey) + + if queryID == "" { + fmt.Println("[init] DUNE_QUERY_ID not set, creating Dune query...") + id, err := client.createQuery() + if err != nil { + fmt.Fprintf(os.Stderr, "[fatal] failed to create Dune query: %v\n", err) + os.Exit(1) + } + fmt.Printf("[init] Dune query created: %s\n", id) + fmt.Printf("[init] Set DUNE_QUERY_ID=%s and restart the harness.\n", id) + os.Exit(0) + } + + go func() { + if err := startMetricsServer(":2112"); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + // On startup: fetch latest cached result immediately, then trigger a fresh execution. + runFetch(client, queryID) + go func() { + execID, err := client.execute(queryID) + if err != nil { + fmt.Printf("[refresh] execute failed: %v\n", err) + return + } + pollUntilDone(client, queryID, execID) + }() + + fetchTick := time.NewTicker(fetchInterval) + refreshTick := time.NewTicker(refreshInterval) + defer fetchTick.Stop() + defer refreshTick.Stop() + + for { + select { + case <-sig: + fmt.Println("[shutdown] received signal") + return + case <-fetchTick.C: + runFetch(client, queryID) + case <-refreshTick.C: + execID, err := client.execute(queryID) + if err != nil { + fmt.Printf("[refresh] execute failed: %v\n", err) + continue + } + go pollUntilDone(client, queryID, execID) + } + } +} + +func runFetch(c *duneClient, queryID string) { + rows, err := c.latestResult(queryID) + if err != nil { + fmt.Printf("[fetch] failed: %v\n", err) + return + } + publishRows(rows) + fmt.Printf("[fetch] updated %d platform(s)\n", len(rows)) +} + +func pollUntilDone(c *duneClient, queryID, execID string) { + for range 30 { + time.Sleep(30 * time.Second) + state, err := c.executionState(execID) + if err != nil { + fmt.Printf("[poll] state check failed: %v\n", err) + return + } + switch state { + case "QUERY_STATE_COMPLETED": + fmt.Printf("[poll] execution %s complete\n", execID) + runFetch(c, queryID) + return + case "QUERY_STATE_FAILED", "QUERY_STATE_CANCELLED", "QUERY_STATE_EXPIRED": + fmt.Printf("[poll] execution %s ended with state %s\n", execID, state) + return + } + } + fmt.Printf("[poll] execution %s timed out waiting\n", execID) +} diff --git a/harnesses/solana-unique-traders/cmd/script/metrics.go b/harnesses/solana-unique-traders/cmd/script/metrics.go new file mode 100644 index 00000000..86b285a8 --- /dev/null +++ b/harnesses/solana-unique-traders/cmd/script/metrics.go @@ -0,0 +1,44 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + uniqueTraders24h = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_unique_traders_24h", + Help: "Unique wallet addresses that traded through this Solana platform in the last 24h (Dune Analytics, fee-wallet attribution).", + }, []string{"platform"}) + + traderHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "solana_platform_trader_health", + Help: "1 if this platform returned unique-trader data in the last Dune poll.", + }, []string{"platform"}) +) + +func init() { + prometheus.MustRegister(uniqueTraders24h, traderHealth) +} + +func publishRows(rows []duneRow) { + for _, r := range rows { + if r.Platform == "" { + continue + } + uniqueTraders24h.WithLabelValues(r.Platform).Set(r.UniqueTraders24h) + traderHealth.WithLabelValues(r.Platform).Set(1) + } +} + +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/solana-unique-traders/go.mod b/harnesses/solana-unique-traders/go.mod new file mode 100644 index 00000000..d5e7f65d --- /dev/null +++ b/harnesses/solana-unique-traders/go.mod @@ -0,0 +1,18 @@ +module solana-unique-traders + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/solana-unique-traders/go.sum b/harnesses/solana-unique-traders/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/solana-unique-traders/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-unique-traders/queries/unique_traders.sql b/harnesses/solana-unique-traders/queries/unique_traders.sql new file mode 100644 index 00000000..da82fafb --- /dev/null +++ b/harnesses/solana-unique-traders/queries/unique_traders.sql @@ -0,0 +1,143 @@ +-- Unique daily traders by Solana trading platform +-- Source: pump_fun_solana.trades (pump.fun) + solana.account_activity joined with solana.transactions (all others) +-- Attribution: fee wallet presence in transaction account keys +-- Window: rolling 24 hours +-- Used by: OpenChainBench Bench 207 (solana-unique-traders) + +WITH pump_fun AS ( + SELECT + 'pump-fun' AS platform, + COUNT(DISTINCT trader_id) AS unique_traders_24h + FROM pump_fun_solana.trades + WHERE block_time >= NOW() - INTERVAL '1' DAY +), + +fee_activity AS ( + SELECT + CASE + WHEN address IN ( + 'BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT', + '7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA', + 'HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3', + 'ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy', + 'DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9', + '3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK', + 'DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx', + 'dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ', + '6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn' + ) THEN 'gmgn' + WHEN address IN ( + '7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj', + '4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S', + 'CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ', + 'AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW', + '7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws', + '9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP', + 'DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy', + '4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd', + '76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf', + 'H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK', + '8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm', + '4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz', + '8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb', + '86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG', + 'DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM', + '5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB', + 'DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF', + 'Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9', + '846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8', + '5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg' + ) THEN 'axiom' + WHEN address IN ( + 'HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq' + ) THEN 'fomo' + WHEN address IN ( + '9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco', + '92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH', + '2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ', + '65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH', + 'BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp', + '8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn' + ) THEN 'trojan' + WHEN address IN ( + 'AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH' + ) THEN 'photon' + WHEN address IN ( + 'MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36', + 'FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN' + ) THEN 'maestro' + END AS platform, + tx_id + FROM solana.account_activity + WHERE block_time >= NOW() - INTERVAL '1' DAY + AND address IN ( + -- GMGN (9 wallets) + 'BB5dnY55FXS1e1NXqZDwCzgdYJdMCj3B92PU6Q5Fb6DT', + '7sHXjs1j7sDJGVSMSPjD1b4v3FD6uRSvRWfhRdfv5BiA', + 'HeZVpHj9jLwTVtMMbzQRf6mLtFPkWNSg11o68qrbUBa3', + 'ByRRgnZenY6W2sddo1VJzX9o4sMU4gPDUkcmgrpGBxRy', + 'DXfkEGoo6WFsdL7x6gLZ7r6Hw2S6HrtrAQVPWYx2A1s9', + '3t9EKmRiAUcQUYzTZpNojzeGP1KBAVEEbDNmy6wECQpK', + 'DymeoWc5WLNiQBaoLuxrxDnDRvLgGZ1QGsEoCAM7Jsrx', + 'dBhdrmwBkRa66XxBuAK4WZeZnsZ6bHeHCCLXa3a8bTJ', + '6TxjC5wJzuuZgTtnTMipwwULEbMPx5JPW3QwWkdTGnrn', + -- AXIOM (20 wallets) + '7LCZckF6XXGQ1hDY6HFXBKWAtiUgL9QY5vj1C4Bn1Qjj', + '4V65jvcDG9DSQioUVqVPiUcUY9v6sb6HKtMnsxSKEz5S', + 'CeA3sPZfWWToFEBmw5n1Y93tnV66Vmp8LacLzsVprgxZ', + 'AaG6of1gbj1pbDumvbSiTuJhRCRkkUNaWVxijSbWvTJW', + '7oi1L8U9MRu5zDz5syFahsiLUric47LzvJBQX6r827ws', + '9kPrgLggBJ69tx1czYAbp7fezuUmL337BsqQTKETUEhP', + 'DKyUs1xXMDy8Z11zNsLnUg3dy9HZf6hYZidB6WodcaGy', + '4FobGn5ZWYquoJkxMzh2VUAWvV36xMgxQ3M7uG1pGGhd', + '76sxKrPtgoJHDJvxwFHqb3cAXWfRHFLe3VpKcLCAHSEf', + 'H2cDR3EkJjtTKDQKk8SJS48du9mhsdzQhy8xJx5UMqQK', + '8m5GkL7nVy95G4YVUbs79z873oVKqg2afgKRmqxsiiRm', + '4kuG6NsAFJNwqEkac8GFDMMheCGKUPEbaRVHHyFHSwWz', + '8vFGAKdwpn4hk7kc1cBgfWZzpyW3MEMDATDzVZhddeQb', + '86Vh4XGLW2b6nvWbRyDs4ScgMXbuvRCHT7WbUT3RFxKG', + 'DZfEurFKFtSbdWZsKSDTqpqsQgvXxmESpvRtXkAdgLwM', + '5L2QKqDn5ukJSWGyqR4RPvFvwnBabKWqAqMzH4heaQNB', + 'DYVeNgXGLAhZdeLMMYnCw1nPnMxkBN7fJnNpHmizTrrF', + 'Hbj6XdxX6eV4nfbYTseysibp4zZJtVRRPn2J3BhGRuK9', + '846ah7iBSu9ApuCyEhA5xpnjHHX7d4QJKetWLbwzmJZ8', + '5BqYhuD4q1YD3DMAYkc1FeTu9vqQVYYdfBAmkZjamyZg', + -- FOMO (1 wallet, USDC ATA) + 'HrTf9CzXR1dRH4Sof5QrpmGWwpwAf3qZzwCsEjQpXcSq', + -- TROJAN (6 wallets) + '9yMwSPk9mrXSN7yDHUuZurAh1sjbJsfpUqjZ7SvVtdco', + '92Med3qeK7duC5iiYsHX38H2f2twJfRsSx93oNrza2VH', + '2jwHNxavSoMZMEDbT1eV9PcPt5dDcayCqM6MkgaPpmWQ', + '65gDv7pZQCZELsNpNYSFEBtNFpWZAbxmRFB6BGMqFkHH', + 'BWgb8wR1FEGiu1jCDSKuHKf752W27b4iN6SvoNCiK4qp', + '8jgg7moFJkHyTtAv9M6RBSPMp2oXeXhuiUMKW8YbYCWn', + -- PHOTON (1 wallet) + 'AVUCZyuT35YSuj4RH7fwiyPu82Djn2Hfg7y2ND2XcnZH', + -- MAESTRO (2 wallets) + 'MaestroUL88UBnZr3wfoN7hqmNWFi3ZYCGqZoJJHE36', + 'FRMxAnZgkW58zbYcE7Bxqsg99VWpJh6sMP5xLzAWNabN' + ) +), + +fee_with_signer AS ( + SELECT + fa.platform, + t.fee_payer + FROM fee_activity fa + JOIN solana.transactions t ON t.id = fa.tx_id + WHERE t.success = true + AND fa.platform IS NOT NULL +), + +fee_counts AS ( + SELECT + platform, + COUNT(DISTINCT fee_payer) AS unique_traders_24h + FROM fee_with_signer + GROUP BY platform +) + +SELECT platform, unique_traders_24h FROM pump_fun +UNION ALL +SELECT platform, unique_traders_24h FROM fee_counts +ORDER BY unique_traders_24h DESC diff --git a/harnesses/ws-head-latency/cmd/script/aggregator.go b/harnesses/ws-head-latency/cmd/script/aggregator.go index 87e384bc..89a4a029 100644 --- a/harnesses/ws-head-latency/cmd/script/aggregator.go +++ b/harnesses/ws-head-latency/cmd/script/aggregator.go @@ -147,7 +147,7 @@ func (a *aggregator) settle(height int64) { } } for _, p := range providers { - lagMs := float64(e.arrivals[p].Sub(t0).Milliseconds()) + lagMs := float64(e.arrivals[p].Sub(t0).Nanoseconds()) / 1e6 headLagHist.WithLabelValues(p, a.chain).Observe(lagMs) } headWins.WithLabelValues(winner, a.chain).Inc() diff --git a/next.config.ts b/next.config.ts index cf1c571b..3e70417c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -262,6 +262,11 @@ const nextConfig: NextConfig = { destination: "/benchmarks/pm-resolution-delay/:path*", permanent: true, }, + { + source: "/apps/exec", + destination: "/apps", + permanent: true, + }, ...chainRedirects, ...rpcClusterRedirects, ]; diff --git a/public/logos/banana-gun.png b/public/logos/banana-gun.png new file mode 100644 index 00000000..740eca24 Binary files /dev/null and b/public/logos/banana-gun.png differ diff --git a/public/logos/binance-us.jpg b/public/logos/binance-us.jpg new file mode 100644 index 00000000..6b24fa2b Binary files /dev/null and b/public/logos/binance-us.jpg differ diff --git a/public/logos/binance-us.svg b/public/logos/binance-us.svg new file mode 100644 index 00000000..dfe4c478 --- /dev/null +++ b/public/logos/binance-us.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/bullx.png b/public/logos/bullx.png new file mode 100644 index 00000000..a822fee4 Binary files /dev/null and b/public/logos/bullx.png differ diff --git a/public/logos/bybit.jpg b/public/logos/bybit.jpg index de2bba1d..4ea8413e 100644 Binary files a/public/logos/bybit.jpg and b/public/logos/bybit.jpg differ diff --git a/public/logos/coinbase.jpg b/public/logos/coinbase.jpg new file mode 100644 index 00000000..287d5fb4 Binary files /dev/null and b/public/logos/coinbase.jpg differ diff --git a/public/logos/cryptocom.jpg b/public/logos/cryptocom.jpg new file mode 100644 index 00000000..9212c9ff Binary files /dev/null and b/public/logos/cryptocom.jpg differ diff --git a/public/logos/cryptocom.svg b/public/logos/cryptocom.svg new file mode 100644 index 00000000..0d8d2454 --- /dev/null +++ b/public/logos/cryptocom.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/logos/dex-screener.png b/public/logos/dex-screener.png new file mode 100644 index 00000000..a86efa35 Binary files /dev/null and b/public/logos/dex-screener.png differ diff --git a/public/logos/gmgn.jpg b/public/logos/gmgn.jpg new file mode 100644 index 00000000..079d613c Binary files /dev/null and b/public/logos/gmgn.jpg differ diff --git a/public/logos/kraken.jpg b/public/logos/kraken.jpg new file mode 100644 index 00000000..f00da4ff Binary files /dev/null and b/public/logos/kraken.jpg differ diff --git a/public/logos/maestro.jpg b/public/logos/maestro.jpg new file mode 100644 index 00000000..070bff5e Binary files /dev/null and b/public/logos/maestro.jpg differ diff --git a/public/logos/moonshot.svg b/public/logos/moonshot.svg new file mode 100644 index 00000000..6e67b8ab --- /dev/null +++ b/public/logos/moonshot.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/phantom.svg b/public/logos/phantom.svg new file mode 100644 index 00000000..9cc90aa9 --- /dev/null +++ b/public/logos/phantom.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/public/logos/photon.webp b/public/logos/photon.webp new file mode 100644 index 00000000..38238ce0 Binary files /dev/null and b/public/logos/photon.webp differ diff --git a/public/logos/pump-fun.jpg b/public/logos/pump-fun.jpg new file mode 100644 index 00000000..710d65cc Binary files /dev/null and b/public/logos/pump-fun.jpg differ diff --git a/public/logos/pumpfun.svg b/public/logos/pumpfun.svg new file mode 100644 index 00000000..c264f37f --- /dev/null +++ b/public/logos/pumpfun.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logos/trojan.jpg b/public/logos/trojan.jpg new file mode 100644 index 00000000..72f4d9bd Binary files /dev/null and b/public/logos/trojan.jpg differ diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index a7b35462..05dfd25f 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { Pill } from "@/components/pill"; import { TimeSeriesChart } from "@/components/time-series-chart"; -import { LedgerTable } from "@/components/ledger-table"; import { CountLeaderboard } from "@/components/count-leaderboard"; import { fmtUnit, unitSuffix, fmtValue } from "@/lib/format"; import { computeFieldStats } from "@/lib/stats"; @@ -23,6 +22,7 @@ import { isRegion } from "@/lib/brand"; import { PERP_VENUE_META, benchRowsForVenue } from "@/lib/perp-venue-context"; import { fetchPerpCohort } from "@/lib/perp-stats"; import { PerpVenueBenchCards } from "@/components/perp-venue-bench-cards"; +import { LedgerTable } from "@/components/ledger-table"; export const dynamic = "force-dynamic"; @@ -50,13 +50,36 @@ export async function generateMetadata({ const title = alt.seo_title ?? `${alt.target_product} alternatives. live benchmark · OpenChainBench`; - const description = alt.seo_description ?? alt.intro.slice(0, 200); + const description = capDescription( + alt.seo_description ?? alt.intro, + 158, + ); const url = `${SITE.url}/alternatives/${alt.slug}`; + const ogImage = `${SITE.url}/api/og/${alt.benchmark}`; + // Noindex when the underlying bench has no live data yet — same rule as + // /benchmarks/[slug]. Avoids indexing a "Not measured yet" page that + // undercuts the bench canonical once data arrives. + const isDraft = alt.bench.status === "draft" || alt.status === "draft"; return { title, description, alternates: { canonical: url }, - openGraph: { title, description, type: "article", url }, + ...(isDraft ? { robots: { index: false, follow: true } } : {}), + openGraph: { + title, + description, + type: "article", + url, + siteName: SITE.name, + images: [ogImage], + }, + twitter: { + card: "summary_large_image", + site: SITE.twitter, + title, + description, + images: [ogImage], + }, }; } @@ -268,8 +291,8 @@ export default async function AlternativePage({ )} {/* Top alternatives cards - explicit list with internal links to - product pages. The bench leaderboard below carries the full data; - this section frames the answer for skim-readers and search. */} + product pages. Excludes the target product itself so every card + is a genuine alternative, not a restatement of the subject. */} {topAlternatives.length > 0 && (
@@ -312,20 +335,24 @@ export default async function AlternativePage({
)} - {/* Reuse bench rendering. count vs latency split, same as the - /benchmarks/[slug] page. */} + {/* Count benches: leaderboard is the primary view; no latency stats. */} {!isDraft && bench.unit === "count" && ( - <> +
-
- Product ledger - -
- +
)} + {/* Latency / rate / USD benches: full ranked table + trend chart. + LedgerTable gives readers the complete ranked list on this page + (better search-intent match than a CTA-only pattern) while + /benchmarks/ remains canonical for the interactive bar chart, + distribution view, and per-chain drilldowns. */} {!isDraft && bench.unit !== "count" && ( <> +
+ +
+
{bench.metric} · last 24 hours - -
- Product ledger · sorted by p50 - -
)} -

- Same data as{" "} - - /benchmarks/{bench.slug} + {/* Link to canonical benchmark for interactive views not present here: + ranked bar chart, distribution, per-chain drilldowns, chart export. */} + {!isDraft && ( +

+

+ Interactive bar chart, distribution view, per-chain drilldowns and + chart export on the benchmark page. +

+ + Open benchmark + + +
+ )} + +

+ Measurements sourced from{" "} + + {bench.title} - {alt.chain ? ` (filtered to ${alt.chain})` : ""}, refreshed every minute. - OpenChainBench is community-run; methodology is open. + {alt.chain ? ` (filtered to ${alt.chain})` : ""}. Methodology and + raw data on the benchmark page.

); } - diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index 28626486..cdd7fe68 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { loadAnswer, loadAllAnswers } from "@/lib/answers"; +import { getProviderSlugs } from "@/lib/providers"; import { renderTemplate } from "@/lib/bench-template"; import { cleanLeftoverTokens, @@ -155,8 +156,14 @@ export default async function AnswerPage({ // Top results from the referenced bench, mirroring the alternatives // top-N section: surfaces the answer visually for skim readers + gives // crawlers concrete provider names + numbers in the SSR HTML. + // Only include results whose slug has a matching /products/ page — some + // benches (e.g. pm-resolution-delay) use non-provider slugs like + // "politics" or "all-markets" as result labels, which would otherwise + // generate 404 links. + const validProviderSlugs = new Set(await getProviderSlugs()); const sortedResults = [...bench.results] .filter((r) => !isRegion(r.slug)) + .filter((r) => validProviderSlugs.has(r.slug)) .filter((r) => r.ms.p50 !== 0 || r.ms.p99 !== 0) .sort((a, b) => bench.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50, diff --git a/src/app/api/bench/hyperliquid-frontends/history/route.test.ts b/src/app/api/bench/hyperliquid-frontends/history/route.test.ts index f17eb47b..fc95439f 100644 --- a/src/app/api/bench/hyperliquid-frontends/history/route.test.ts +++ b/src/app/api/bench/hyperliquid-frontends/history/route.test.ts @@ -211,7 +211,7 @@ describe("GET /api/bench/hyperliquid-frontends/history", () => { source: "archive", rows: [ { - slug: "0xphantom", + slug: "phantom", name: "Phantom", volume_usd: 9_000_000, fees_usd: 90_000, @@ -219,7 +219,7 @@ describe("GET /api/bench/hyperliquid-frontends/history", () => { rank: 1, }, { - slug: "0xmetamask", + slug: "metamask", volume_usd: 4_500_000, rank: 2, }, diff --git a/src/app/api/internal/codex-token/route.ts b/src/app/api/internal/codex-token/route.ts new file mode 100644 index 00000000..c921b979 --- /dev/null +++ b/src/app/api/internal/codex-token/route.ts @@ -0,0 +1,68 @@ +export const runtime = 'edge'; + +const SECRET = process.env.INTERNAL_RELAY_SECRET ?? ''; + +export async function GET(request: Request) { + const auth = request.headers.get('x-relay-secret'); + if (SECRET && auth !== SECRET) { + return new Response('Forbidden', { status: 403 }); + } + + const body = JSON.stringify({ + operationName: 'CreateApiToken', + query: 'mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }', + variables: {}, + }); + + const resp = await fetch('https://un.defined.fi/api', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Origin: 'https://un.defined.fi', + Referer: 'https://un.defined.fi/', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9', + 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"macOS"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + }, + body, + }); + + const status = resp.status; + const text = await resp.text(); + + if (status !== 200) { + return new Response(JSON.stringify({ error: `upstream ${status}`, body: text.slice(0, 200) }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let parsed: { data?: { createApiTokens?: { token: string }[] } }; + try { + parsed = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: 'parse error', body: text.slice(0, 200) }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const token = parsed?.data?.createApiTokens?.[0]?.token; + if (!token) { + return new Response(JSON.stringify({ error: 'no token', body: text.slice(0, 300) }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(token, { + headers: { 'Content-Type': 'text/plain' }, + }); +} diff --git a/src/app/apps/page.tsx b/src/app/apps/page.tsx new file mode 100644 index 00000000..c7b580ca --- /dev/null +++ b/src/app/apps/page.tsx @@ -0,0 +1,201 @@ +import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; +import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; +import { SITE } from "@/data/site"; +import { fetchEVMRevenue } from "@/lib/evm-exec"; +import { fetchExecLeaderboard } from "@/lib/solana-exec"; +import { fetchSolPrice } from "@/lib/sol-price"; +import { fetchFOMORelayFees } from "@/lib/dune"; +import { TRADING_APPS } from "@/lib/trading-apps-config"; +import { fetchDeFiLlamaData } from "@/lib/defillama"; +import { TradingAppsLeaderboard, type UnifiedAppRow } from "@/components/trading-apps-leaderboard"; +import { RevenueSummary } from "@/components/revenue-summary"; +import Link from "next/link"; + +const DESCRIPTION = + "Protocol fees collected by trading apps and meme trading terminals — Solana, Ethereum, BSC. On-chain data, updated every 5 min."; + +export const metadata: Metadata = pageMetadata({ + path: "/apps", + title: "Trading App Revenue — pump.fun, Axiom, GMGN, BullX | OpenChainBench", + description: DESCRIPTION, +}); + +export const revalidate = 300; + +const WINDOWS = ["24h", "7d", "30d"] as const; + +export default async function AppsHubPage() { + const dlSlugMap = Object.fromEntries( + TRADING_APPS.filter((a) => a.defillamaSlug).map((a) => [a.id, a.defillamaSlug!]) + ); + + const [evmData, solanaData, solPrice, fomoRelay, dlData] = await Promise.all([ + fetchEVMRevenue(), + fetchExecLeaderboard(), + fetchSolPrice(), + fetchFOMORelayFees(), + fetchDeFiLlamaData(dlSlugMap), + ]); + + // Market share = each platform's DL 24h fees / cohort total (active only) + const activeDlTotal = TRADING_APPS.filter((a) => !a.inactive && a.defillamaSlug) + .reduce((sum, a) => sum + (dlData.get(a.id)?.total24h ?? 0), 0); + + const evmByPlatform = new Map( + (evmData?.platforms ?? []).map((p) => [p.platform, p]) + ); + + const solanaByPlatform = new Map( + (solanaData?.platforms ?? []).map((p) => [p.platform, p]) + ); + + const rows: UnifiedAppRow[] = TRADING_APPS.map((meta) => { + const evmRow = meta.evmKey ? evmByPlatform.get(meta.evmKey) : undefined; + const solRow = meta.solanaKey ? solanaByPlatform.get(meta.solanaKey) : undefined; + + const rhRow = meta.robinhoodKey ? evmByPlatform.get(meta.robinhoodKey) : undefined; + + const ethChain = evmRow?.chains["ethereum"]; + const bscChain = evmRow?.chains["bsc"]; + const baseChain = evmRow?.chains["base"]; + const rhChain = rhRow?.chains["robinhood"]; + + const windows: UnifiedAppRow["windows"] = {}; + + for (const w of WINDOWS) { + let solanaFees: number | null = null; + if (solRow) { + const wData = solRow.windows[w]; + if (wData) { + if (meta.solanaFeeIsUSDC) { + // Fees collected in USDC (6 dec): raw units / 1e6 = USD directly + solanaFees = wData.sumPlatformFeeLamports / 1e6; + } else if (solPrice !== null) { + solanaFees = wData.sumPlatformFeeLamports / 1e9 * solPrice; + } + } + } + + // FOMO: supplement on-chain fees with off-chain relay fees from Dune + // (relay accounts for ~96% of FOMO's actual revenue) + if (meta.id === "fomo" && fomoRelay) { + const relayFee = w === "24h" ? fomoRelay.fees24h : w === "7d" ? fomoRelay.fees7d : fomoRelay.fees30d; + solanaFees = (solanaFees ?? 0) + relayFee; + } + + // EVM: 24h adds native ETH/BNB in USD; 7d/30d stable only (no historical prices). + const evmStable = (chain: typeof ethChain) => + !chain ? null : w === "24h" ? chain.stable24h : w === "7d" ? chain.stable7d : chain.stable30d; + const evmNative = (chain: typeof ethChain) => + w === "24h" && chain ? (chain.native?.usd ?? 0) : 0; + + const ethFees = ethChain ? evmStable(ethChain)! + evmNative(ethChain) : null; + const bscFees = bscChain ? evmStable(bscChain)! + evmNative(bscChain) : null; + const baseFees = baseChain ? evmStable(baseChain)! + evmNative(baseChain) : null; + const rhFees = rhChain ? evmStable(rhChain)! + evmNative(rhChain) : null; + const evmTotal = (ethFees ?? 0) + (bscFees ?? 0) + (baseFees ?? 0) + (rhFees ?? 0); + + windows[w] = { + solana: solanaFees, + ethereum: ethFees, + bsc: bscFees, + base: baseFees, + robinhood: rhFees, + total: (solanaFees ?? 0) + evmTotal, + }; + } + + const dl = dlData.get(meta.id) ?? null; + const marketSharePct = dl && activeDlTotal > 0 && !meta.inactive + ? (dl.total24h / activeDlTotal) * 100 + : null; + + return { + meta, + windows, + stableOnly: { + ethereum: ethChain?.coverage === "stable-only", + bsc: bscChain?.coverage === "stable-only", + base: baseChain?.coverage === "stable-only", + robinhood: rhChain?.coverage === "stable-only", + }, + dl, + marketSharePct, + }; + }); + + const updatedAt = evmData?.updatedAt ?? solanaData?.updatedAt ?? null; + + const breadcrumb = { + "@context": "https://schema.org", + ...buildBreadcrumbJsonLd([ + { name: "Home", item: SITE.url }, + { name: "Apps", item: `${SITE.url}/apps` }, + ]), + }; + + return ( +
+