From 6f114efec8fe8a984481284c6cf9920df8d4c1e8 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 16 Jul 2026 17:00:24 +0200 Subject: [PATCH 1/3] site: provider_notes chip on ranking rows + rpc-capabilities copy fixes - validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat - New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render) - Chip renders only when the note is set, no visual change to any other bench - rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ - rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST) --- benchmarks/rpc-capabilities.yml | 46 ++++++++++++++++++--------------- benchmarks/validator-yield.yml | 5 ++++ src/components/ledger-table.tsx | 5 ++++ src/lib/materialize/load.ts | 1 + src/lib/spec-schema.ts | 14 ++++++++++ src/lib/spec.ts | 5 ++++ src/types/benchmark.ts | 7 +++++ 7 files changed, 62 insertions(+), 21 deletions(-) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index cff135e2..cee5e02a 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -2,10 +2,10 @@ slug: rpc-capabilities number: "010" -title: Fastest free public RPC for Ethereum, BNB, Polygon and 7 more EVM chains +title: Fastest free public RPC for Ethereum, BNB, Polygon and 23 more chains (plus Solana and Polkadot) seo_title: "Fastest free public RPC 2026" seo_description: "Fastest free public RPC live on eth_getBlockByNumber p50. dRPC, PublicNode, Tenderly, Nodies, Lava ranked." -subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 10 EVM chains, audited every 60 seconds. +subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 26 chains (24 EVM plus Solana and Polkadot), audited every 60 seconds. category: RPCs status: live metric: RPC latency @@ -13,13 +13,14 @@ unit: ms higher_is_better: false disclaimer: > - Latency is conditional on success: the harness times only calls that - return a valid, fresh block, so an endpoint failing most calls can - still post a fast p50 from its rare successes. Treat rows below 50 - percent success as degraded. Providers whose probes stop succeeding - are pinned below the table as unresponsive. On the All chains tab - each number is the mean of per chain, per region 24h p50s; single - chain providers are measured on their one chain only. + Latency is conditional on success: the harness times only calls + returning a valid fresh block, so a mostly-failing endpoint can + post a fast p50. Rows below 50 percent success are degraded; + unresponsive probes pin below the table. The All chains aggregate + favours single-chain foundation endpoints (Base, Binance, + Arbitrum, Optimism, Avalanche officials) since they rank on one + chain only; use a chain tab for honest comparison. Dead endpoints + (cloudflare-eth POST returns -32603) are excluded. seo_intro: | This benchmark answers the question every developer reaching for a @@ -28,14 +29,15 @@ seo_intro: | chain my product runs on. Alchemy and Infura own the keyed-tier market; the free public side of the question lives elsewhere, in the RPC services dapps hit when a contributor's free quota is the - budget. We probe `eth_getBlockByNumber` every 60 seconds against 13 - audited providers across 10 EVM chains. PublicNode, dRPC, - MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network, - Flashbots Protect, Cloudflare and 5 chain-official foundation - endpoints (Base, Binance BSC, Avalanche, Arbitrum, Optimism). - Chains covered. Ethereum (8 providers), Arbitrum (7), Base (5), - Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea + Scroll + Mantle (3 - each). The headline question is per chain, not cross-chain. Five + budget. We probe `eth_getBlockByNumber` every 60 seconds against + audited providers across 26 chains (24 EVM plus Solana and + Polkadot). PublicNode, dRPC, MeowRPC, Tenderly Gateway, Nodies + (POKT), Lava Network, Flashbots Protect, bloXroute, OnFinality + and chain-official foundation endpoints (Base, Binance BSC, + Avalanche, Arbitrum, Optimism, Sonic, Gnosis, Celo, Blast, Taiko, + Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, + Monad, MegaETH, Parity for Polkadot, Solana Labs). + Chain footprint spans the L1/L2 majors down to long-tail chains. The headline question is per chain, not cross-chain. Five providers are single-chain by design (the foundation endpoints above), so a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. @@ -53,8 +55,10 @@ seo_intro: | abstract: | We measure the round-trip latency of a single, identical RPC call - (`eth_getBlockByNumber`) against every major no-key Ethereum-compatible - public RPC endpoint, across 10 EVM chains. The + (`eth_getBlockByNumber` for EVM chains, `getSlot` for Solana, + `chain_getHeader` for Polkadot) against every major no-key public + RPC endpoint, across 26 chains (24 EVM plus Solana and Polkadot). + The harness emits three metric families from one binary because they share the same (provider × chain) client matrix and same 15 s scrape interval: (a) `rpc_latency_milliseconds` for the speed @@ -78,7 +82,7 @@ methodology: - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err` (status ≠ 200 or transport failure), `jsonrpc_err` (HTTP 200 with an `error` field, the Cloudflare-eth trap), `stale` (returned block more than 20 behind the cross-provider tip for that chain), `timeout`. Counter `rpc_call_total{result}` powers the reliability leaderboard." - "Success gating: because failed probes record no latency, a mostly failing endpoint can post a fast p50 from its rare successes. The ledger pairs every latency figure with a Success column computed as ok calls over total calls in the same 24h window; read rows below 50 percent success as degraded. Providers with under 5 percent success lose their latency series to Prometheus staleness and are pinned below the table as unresponsive instead of being ranked." - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head, depth) for `depth` in {300, 7200, 216_000, 1_296_000, 5_000_000}. Gauge `rpc_archive_depth_supported{depth}` is 1 when the response is non-pruned, 0 otherwise. The 300/7200 thresholds cover Geth's default pruned-cap range; 216k ≈ 1 month; 1.3M ≈ 6 months; 5M ≈ genesis-era full archive." - - "Chain coverage: 10 EVM chains. Ethereum (8 providers), Arbitrum (7), Base (5), Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea (3), Scroll (3), Mantle (3). Provider matrix per chain is documented in `miniapps/rpc-capabilities/cmd/script/config.go`." + - "Chain coverage: 26 chains (24 EVM plus Solana and Polkadot). EVM majors: Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle. EVM long-tail: Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH. Non-EVM: Solana (getSlot probe), Polkadot (chain_getHeader probe). Provider matrix per chain is documented in `harnesses/rpc-capabilities/cmd/script/config.go`." - "Excluded: 1RPC (delisted 2026-07-09, under 13% success even at two chains and one probe per minute), Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Blink, formerly Merkle (no public eth_getBlockByNumber) + Lava and MeowRPC outside ETH/Arb excluded. Every (provider, chain) was live-verified no-key before inclusion." findings: @@ -95,7 +99,7 @@ faq: - q: "What is the fastest free public RPC right now?" a: "The question is per chain because several providers are single-chain by design (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) and a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at {{best_p50:chain:base}}, BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}}. The leaderboard re-sorts every 60 seconds against fresh Prometheus samples averaged across us-east, eu-west and sgp probes. Switch the chain tab to see the leader on the network your product runs on." - q: "Which public RPCs work without an API key in 2026?" - a: "13 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)." + a: "Audited providers across 26 chains (24 EVM plus Solana and Polkadot). Universal multi-chain: PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only), Flashbots Protect, Lava Network (ETH + Arbitrum + Sonic + Solana no-key). Chain-official foundation RPCs: Arbitrum, Optimism, Base, Avalanche, BNB, Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH, Parity for Polkadot, Solana Labs, plus regional gateways like OnFinality and bloXroute. Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated), cloudflare-eth (POST returns -32603)." - q: "Does Cloudflare still have a free Ethereum RPC that works?" a: "Partially, and the trend is the wrong way. Cloudflare's public Ethereum gateway (cloudflare-eth) switched to a permissioned mode for many JSON-RPC methods. The endpoint still answers HTTP 200 quickly, but the body is increasingly a JSON-RPC error (`-32046 Cannot fulfill request`) rather than a block. This benchmark catches the trap by classifying a call as `ok` only when the HTTP status is 200 AND the JSON-RPC body has a usable `result` field, so Cloudflare's real success rate is visible in the Success column instead of hiding behind fast error responses. Latency without reliability is a misleading ranking signal for a public RPC." - q: "Why can an RPC with a low success rate still show a fast latency?" diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 93ed2e5c..cf714536 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -15,6 +15,11 @@ higher_is_better: true disclaimer: | Honest scope. (1) `net_yield = gross_apr × uptime`. NOT "yield net of MEV", Stakewiz `total_apy` already folds Jito MEV into gross, MEV is INCLUDED. `mev_share_bps` exists for transparency, not subtraction. (2) Solana median is dragged by ~42 vals at 0% APY (commission 100% or zero leader slots). (3) Hyperliquid: centralised sequencer, no separate MEV layer, ~30 vals only. (4) Ethereum: one network-average consensus-layer APR from the beacon spec formula, execution tips and MEV excluded. +provider_notes: + solana: "MEV inc." + ethereum: "MEV excl." + hyperliquid: "no MEV" + seo_intro: | This benchmark answers the question every staker asks when choosing a chain or a validator. what is the live net yield I diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index b98c53de..f06ed03f 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -760,6 +760,11 @@ function Row({ {r.name} )} + {benchmark.providerNotes?.[r.slug] && !isMuted && ( + + {benchmark.providerNotes[r.slug]} + + )} {r.tag && !isMuted && ( {r.tag} diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 8c19ed90..6abd6d1d 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -144,6 +144,7 @@ export function buildEditorial( source: spec.source, dimensions: spec.dimensions, ledgerColumns: spec.ledger_columns, + providerNotes: spec.provider_notes, }; } diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index 75535ac0..2aa96b9f 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -357,6 +357,20 @@ export const SpecSchema = z }) .optional(), + /** + * Optional per-provider chip labels rendered next to the provider + * name in the ranking row. Keys are provider slugs, values are the + * short chip text (max ~20 chars so it fits inline without wrapping). + * Used for scan-time context on comparisons that would otherwise read + * as apples-to-oranges (e.g. validator-yield: Solana includes MEV, + * Ethereum excludes it, Hyperliquid has no MEV layer). Only providers + * with a note render a chip; benches without provider_notes render + * unchanged. + */ + provider_notes: z + .record(slug, z.string().min(1).max(30).refine(noAiDashes, noAiDashesMsg)) + .optional(), + /* Optional single PromQL returning one instant sample per * (provider[, chain][, region]) — e.g. * avg by (provider, chain, region) (quantile_over_time(0.50, m[24h])) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 86cce92b..2acbb703 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -124,6 +124,11 @@ function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { // and the bench page filters without waiting on the materialise // worker to rewrite the snapshot. dimensions: spec.dimensions ?? stored.dimensions, + // provider_notes is a YAML editorial declaration: drives the + // per-provider chip rendered next to the name in the ranking row. + // Overlay so newly added notes surface immediately without waiting + // on the materialize worker to rewrite the snapshot. + providerNotes: spec.provider_notes ?? stored.providerNotes, // expected_n is a YAML editorial declaration too: drives the // sample-health badge logic on the page + the citable APIs. A // freshly added/edited value must take effect immediately, before diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 47d29a00..d61cff87 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -252,6 +252,13 @@ export type Benchmark = { * read as the global chain leader). Cells make the honest claim * ("#1 on BNB Chain from Singapore") computable. */ cellRanks?: Record; + /** Optional per-provider chip labels keyed by provider slug. When set, + * the ranking row renders a small pill next to the provider name so + * scan-readers see context on comparisons that would otherwise be + * apples-to-oranges (validator-yield: Solana "MEV inc.", Ethereum + * "MEV excl.", Hyperliquid "no MEV"). Absent on benches whose spec + * does not declare `provider_notes` — UI renders unchanged. */ + providerNotes?: Record; findings: string[]; methodology: string[]; source: string; From c7d7bd02a36f69eacdc6cb55755f78abffc04504 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 16 Jul 2026 17:00:37 +0200 Subject: [PATCH 2/3] perp-fees harness: add Aster + edgeX venues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API) - edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0) - Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers - Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume) --- harnesses/perp-fees/cmd/script/aster.go | 102 +++++++++++++++++++ harnesses/perp-fees/cmd/script/config.go | 10 ++ harnesses/perp-fees/cmd/script/edgex.go | 123 +++++++++++++++++++++++ harnesses/perp-fees/cmd/script/main.go | 4 + 4 files changed, 239 insertions(+) create mode 100644 harnesses/perp-fees/cmd/script/aster.go create mode 100644 harnesses/perp-fees/cmd/script/edgex.go diff --git a/harnesses/perp-fees/cmd/script/aster.go b/harnesses/perp-fees/cmd/script/aster.go new file mode 100644 index 00000000..0480ed83 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/aster.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Aster (BSC-based perp DEX, Binance-style REST). Public no-auth REST for +// the depth endpoint; symbol convention is uppercase USDT-quoted +// (ETHUSDT, BTCUSDT, SOLUSDT). The taker fee is not exposed by any public +// endpoint: the documented base rate (5 bps, docs.asterdex.com) is used +// and disclosed in the spec formula. +// TODO: switch to fee schedule API when Aster ships one. + +const asterBase = "https://fapi.asterdex.com" + +// Documented base taker rate (5 bps = 0.0005). Revisit if Aster ships a +// public fees endpoint. +const asterTakerBps = 5.0 + +type asterDepth struct { + Bids [][]string `json:"bids"` // [price, qty] + Asks [][]string `json:"asks"` +} + +func fetchAster(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + + symbol := v.Asset + "USDT" + s.TakerFeeBps = asterTakerBps + + var book asterDepth + url := fmt.Sprintf("%s/fapi/v1/depth?symbol=%s&limit=100", asterBase, symbol) + if err := asterGet(client, url, &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Bids) == 0 || len(book.Asks) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Bids[0]) < 2 || len(book.Asks[0]) < 2 { + s.Err = "malformed_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(book.Bids[0][0], 64) + bestAsk, _ := strconv.ParseFloat(book.Asks[0][0], 64) + mid := (bestBid + bestAsk) / 2 + if mid <= 0 { + s.Err = "bad_mid" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(book.Asks)) + for _, a := range book.Asks { + if len(a) < 2 { + continue + } + px, _ := strconv.ParseFloat(a[0], 64) + sz, _ := strconv.ParseFloat(a[1], 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func asterGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +} diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go index aa2bc52f..89f01d78 100644 --- a/harnesses/perp-fees/cmd/script/config.go +++ b/harnesses/perp-fees/cmd/script/config.go @@ -49,6 +49,16 @@ func loadConfig() *Config { // Extended's taker fee is documented (2.5 bps), not API-exposed; // disclosed in the spec formula. {slug: "extended", display: "Extended", assets: []string{"ETH", "BTC", "SOL"}}, + // Aster (BSC-based, Binance-style REST). Public depth endpoint + // used for the walk; taker fee (5 bps, docs.asterdex.com) is + // hardcoded because no fee schedule API is published — disclosed + // in the spec formula. + {slug: "aster", display: "Aster", assets: []string{"ETH", "BTC", "SOL"}}, + // edgeX (offchain CLOB perp DEX). Public depth endpoint keyed by + // contractId (map hardcoded after a one-time getMetaData lookup); + // taker fee (3.8 bps, edgex-1.gitbook.io) hardcoded because no + // public fee schedule API — disclosed in the spec formula. + {slug: "edgex", display: "edgeX", assets: []string{"ETH", "BTC", "SOL"}}, } venues := make([]VenueConfig, 0, len(defs)*3) diff --git a/harnesses/perp-fees/cmd/script/edgex.go b/harnesses/perp-fees/cmd/script/edgex.go new file mode 100644 index 00000000..1b29b527 --- /dev/null +++ b/harnesses/perp-fees/cmd/script/edgex.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// edgeX (offchain CLOB perp DEX). Public no-auth REST. Contract IDs are +// resolved via GET /api/v1/public/meta/getMetaData; hardcoded here after a +// one-time lookup (verified via getMetaData on 2026-07-16). The taker fee +// is not exposed by any public endpoint: the documented base rate +// (3.8 bps, edgex-1.gitbook.io) is used and disclosed in the spec formula. +// TODO: switch to fee schedule API when edgeX ships one. + +const edgexBase = "https://pro.edgex.exchange" + +// Documented base taker rate (3.8 bps = 0.00038). Revisit if edgeX ships a +// public fees endpoint. +const edgexTakerBps = 3.8 + +// Asset → contractId map. Verified via getMetaData on 2026-07-16. If edgeX +// re-numbers contracts, update these IDs (or wire in a dynamic lookup). +var edgexContractIDs = map[string]string{ + "ETH": "10000002", + "BTC": "10000001", + "SOL": "10000003", +} + +type edgexDepthResp struct { + Code string `json:"code"` + Data []struct { + Bids []struct { + Price string `json:"price"` + Size string `json:"size"` + } `json:"bids"` + Asks []struct { + Price string `json:"price"` + Size string `json:"size"` + } `json:"asks"` + } `json:"data"` + Msg string `json:"msg"` +} + +func fetchEdgex(v VenueConfig) PerpSample { + s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)} + start := time.Now() + client := &http.Client{Timeout: 8 * time.Second} + + contractID, ok := edgexContractIDs[v.Asset] + if !ok { + s.Err = "asset_not_mapped" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.TakerFeeBps = edgexTakerBps + + var book edgexDepthResp + url := fmt.Sprintf("%s/api/v1/public/quote/getDepth?contractId=%s&level=100", edgexBase, contractID) + if err := edgexGet(client, url, &book); err != nil { + s.Err = fmt.Sprintf("orderbook: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + if len(book.Data) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + d := book.Data[0] + if len(d.Bids) == 0 || len(d.Asks) == 0 { + s.Err = "empty_orderbook" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + bestBid, _ := strconv.ParseFloat(d.Bids[0].Price, 64) + bestAsk, _ := strconv.ParseFloat(d.Asks[0].Price, 64) + mid := (bestBid + bestAsk) / 2 + if mid <= 0 { + s.Err = "bad_mid" + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.MidPrice = mid + + levels := make([]bookLevel, 0, len(d.Asks)) + for _, a := range d.Asks { + px, _ := strconv.ParseFloat(a.Price, 64) + sz, _ := strconv.ParseFloat(a.Size, 64) + levels = append(levels, bookLevel{Px: px, Sz: sz}) + } + effective, err := walkBookForNotional(levels, v.NotionalUSD) + if err != nil { + s.Err = fmt.Sprintf("walk: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s + } + s.SpreadBps = (effective - mid) / mid * 10000 + s.AllInBps = s.TakerFeeBps + s.SpreadBps + applyBookTiers(&s, levels, mid) + + s.FetchLatencyMs = time.Since(start).Milliseconds() + return s +} + +func edgexGet(client *http.Client, url string, out any) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return json.Unmarshal(body, out) +} diff --git a/harnesses/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go index 6026812d..9cbbf998 100644 --- a/harnesses/perp-fees/cmd/script/main.go +++ b/harnesses/perp-fees/cmd/script/main.go @@ -105,6 +105,10 @@ func fetchOne(v VenueConfig, cfg *Config) PerpSample { return fetchParadex(v) case "extended": return fetchExtended(v) + case "aster": + return fetchAster(v) + case "edgex": + return fetchEdgex(v) default: return PerpSample{Venue: v.Slug, Asset: v.Asset, Err: "unsupported_venue"} } From 466fc53483c19280b6c6fd5aa1f936148b8088c9 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 16 Jul 2026 17:00:48 +0200 Subject: [PATCH 3/3] validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains. - Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io. - Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs. - Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission). - Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100). Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code. --- .../validator-yield/cmd/script/avalanche.go | 269 ++++++++++++++ .../validator-yield/cmd/script/cardano.go | 345 ++++++++++++++++++ .../validator-yield/cmd/script/cosmos.go | 270 ++++++++++++++ harnesses/validator-yield/cmd/script/main.go | 6 +- harnesses/validator-yield/cmd/script/sui.go | 249 +++++++++++++ 5 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 harnesses/validator-yield/cmd/script/avalanche.go create mode 100644 harnesses/validator-yield/cmd/script/cardano.go create mode 100644 harnesses/validator-yield/cmd/script/cosmos.go create mode 100644 harnesses/validator-yield/cmd/script/sui.go diff --git a/harnesses/validator-yield/cmd/script/avalanche.go b/harnesses/validator-yield/cmd/script/avalanche.go new file mode 100644 index 00000000..80812d4b --- /dev/null +++ b/harnesses/validator-yield/cmd/script/avalanche.go @@ -0,0 +1,269 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "time" +) + +const ( + avalancheChain = "avalanche" + avalancheValidatorsTag = "avax_platform_getCurrentValidators" + coingeckoAvaxSourceTag = "coingecko_avax" + + // Cap: Avalanche has ~1500 primary-network validators. Top 100 by + // stake covers the vast majority of delegated AVAX and keeps + // Prometheus cardinality bounded. + avalancheTopN = 100 + + // Avalanche P-Chain base staking reward is a documented ~8.5% APY + // for a validator delegating for a full year at 100% uptime. The + // P-Chain API doesn't publish a per-validator realised APR — the + // number is deterministic given (stake, duration, uptime), and + // duration doesn't materially move for long-running delegators. + // v1 shortcut: use the documented base and adjust per-validator by + // uptime × (1 - delegationFee/100). Real per-validator numbers vary + // by ±20 bps due to duration-bonus differences; acceptable for a + // network-comparison bench. + avalancheBaseAPR = 0.085 +) + +var ( + avalanchePChainURL = envDefault("VAL_ECON_AVAX_PCHAIN_URL", "https://api.avax.network/ext/bc/P") + coingeckoAvaxURL = envDefault("VAL_ECON_COINGECKO_AVAX_URL", "https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd") +) + +// avaxRPCEnvelope wraps the JSON-RPC 2.0 response used by the P-Chain +// `platform` API. +type avaxRPCEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// avaxValidator mirrors the shape returned by +// platform.getCurrentValidators. Numeric fields come back as strings. +// +// - stakeAmount: nAVAX (1 AVAX = 1e9 nAVAX) +// - delegationFee: percentage string, e.g. "2.0000" = 2% +// - uptime: fractional string in [0,1] on newer avalanchego, +// older nodes returned percent [0,100] — normalise both. +type avaxValidator struct { + NodeID string `json:"nodeID"` + StakeAmount string `json:"stakeAmount"` + Weight string `json:"weight"` // some responses use weight instead of stakeAmount + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + DelegationFee string `json:"delegationFee"` + Uptime string `json:"uptime"` + Connected bool `json:"connected"` + Signer *struct { + PublicKey string `json:"publicKey"` + } `json:"signer"` + Delegators any `json:"delegators"` // ignored — variable shape, blobs cause parse fights +} + +type avaxCurrentValidatorsResult struct { + Validators []avaxValidator `json:"validators"` +} + +func avaxPChainRPC(ctx context.Context, client *http.Client, method string, params any, out any) error { + if params == nil { + params = map[string]any{} + } + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, avalanchePChainURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("avax pchain %s http %d: %s", method, resp.StatusCode, string(raw)) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var env avaxRPCEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("avax pchain %s envelope parse: %w", method, err) + } + if env.Error != nil { + return fmt.Errorf("avax pchain %s error %d: %s", method, env.Error.Code, env.Error.Message) + } + if err := json.Unmarshal(env.Result, out); err != nil { + return fmt.Errorf("avax pchain %s result parse: %w", method, err) + } + return nil +} + +func fetchAvalancheValidators(ctx context.Context, client *http.Client) ([]avaxValidator, error) { + var out avaxCurrentValidatorsResult + // Empty params returns primary network validators, all subnets excluded. + err := avaxPChainRPC(ctx, client, "platform.getCurrentValidators", map[string]any{}, &out) + return out.Validators, err +} + +func fetchAvalanchePrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAvaxURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko avax http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko avax parse: %w", err) + } + v, ok := payload["avalanche-2"] + if !ok { + return 0, fmt.Errorf("coingecko missing avalanche-2 key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid avax price %f", price) + } + return price, nil +} + +// parseAvaxUptime normalises the uptime field returned by +// platform.getCurrentValidators. Newer nodes report it as a fractional +// value in [0,1] ("0.998"); older nodes returned a percent ("99.8"). +// If the parsed value is >1 we treat it as a percent, else a fraction. +func parseAvaxUptime(s string) float64 { + if s == "" { + return 100.0 + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 100.0 + } + if v <= 1.0 { + v *= 100 + } + if v < 0 { + v = 0 + } + if v > 100 { + v = 100 + } + return v +} + +// scrapeAvalanche publishes per-validator gauges for the Avalanche +// primary network (P-Chain). Yield is computed from the documented base +// APR adjusted by per-validator uptime and delegation fee. +// +// stakeAmount / weight — we prefer stakeAmount but some node builds +// return weight instead; fall back if needed. +func scrapeAvalanche(ctx context.Context, client *http.Client) { + validators, err := fetchAvalancheValidators(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(avalancheChain, avalancheValidatorsTag).Inc() + fmt.Printf("[avalanche] getCurrentValidators err: %v\n", err) + return + } + + avaxPrice, err := fetchAvalanchePrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(avalancheChain, coingeckoAvaxSourceTag).Inc() + fmt.Printf("[avalanche] coingecko err (stake_usd will be skipped): %v\n", err) + avaxPrice = 0 + } + + // Sort by stake desc, cap top-N. + stakeOf := func(v avaxValidator) float64 { + if s := parseFloat(v.StakeAmount); s > 0 { + return s + } + return parseFloat(v.Weight) + } + sort.Slice(validators, func(i, j int) bool { + return stakeOf(validators[i]) > stakeOf(validators[j]) + }) + if len(validators) > avalancheTopN { + validators = validators[:avalancheTopN] + } + + const nAvaxPerAvax = 1e9 + grossBps := pctToBps(avalancheBaseAPR) + + netYields := make([]float64, 0, len(validators)) + for _, v := range validators { + id := v.NodeID + // Avalanche doesn't include a human name in the P-Chain + // response — the nodeID is the operator identity. + name := shortenName(id) + + uptimePct := parseAvaxUptime(v.Uptime) + delegationFeePct := parseFloat(v.DelegationFee) // 0..100 + + commissionBps := delegationFeePct * 100 + if commissionBps > 10000 { + commissionBps = 10000 + } + + netBps := grossBps * (uptimePct / 100) * (1 - delegationFeePct/100) + + stakeNAvax := stakeOf(v) + stakeAvax := stakeNAvax / nAvaxPerAvax + stakeUSD := stakeAvax * avaxPrice + + validatorNetYieldBps.WithLabelValues(avalancheChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(avalancheChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(avalancheChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(avalancheChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(avalancheChain, id, name).Set(uptimePct) + if avaxPrice > 0 { + validatorStakeUSD.WithLabelValues(avalancheChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(avalancheChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(avalancheChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(avalancheChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(avalancheChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[avalanche] published %d validators, median net yield %.0f bps, AVAX=$%.4f\n", + len(netYields), medianBps(netYields), avaxPrice) +} diff --git a/harnesses/validator-yield/cmd/script/cardano.go b/harnesses/validator-yield/cmd/script/cardano.go new file mode 100644 index 00000000..ec36aebb --- /dev/null +++ b/harnesses/validator-yield/cmd/script/cardano.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +const ( + cardanoChain = "cardano" + koiosPoolListTag = "koios_pool_list" + koiosPoolInfoTag = "koios_pool_info" + koiosEpochParamsTag = "koios_epoch_params" + coingeckoAdaSourceTag = "coingecko_ada" + + // Top-N cap: Cardano has ~3000 registered stake pools but the tail + // past position 50 holds a rounding-error share of total stake. Same + // rationale as the Solana top-200 cap — bounds Prometheus label + // cardinality without losing ranking information for this bench. + cardanoTopN = 50 +) + +// koiosPoolListEntry mirrors GET /pool_list. The bech32 pool ID is the +// stable identifier used as the label; ticker is nullable when the pool +// hasn't registered SMASH metadata. +type koiosPoolListEntry struct { + PoolIDBech32 string `json:"pool_id_bech32"` + PoolIDHex string `json:"pool_id_hex"` + Ticker *string `json:"ticker"` + MetaURL *string `json:"meta_url"` + PoolStatus string `json:"pool_status"` + ActiveStake *string `json:"active_stake"` // lovelace, stringified +} + +// koiosPoolInfo mirrors the POST /pool_info response. Only the fields +// we consume are declared. `margin` is Cardano's commission fraction +// [0,1]; `active_stake` and `live_pledge` are stringified lovelace +// (1 ADA = 1e6 lovelace). +type koiosPoolInfo struct { + PoolIDBech32 string `json:"pool_id_bech32"` + Ticker *string `json:"meta_json_ticker"` // some deployments nest under meta_json + Margin float64 `json:"margin"` + FixedCost string `json:"fixed_cost"` + Pledge string `json:"pledge"` + LivePledge string `json:"live_pledge"` + ActiveStake string `json:"active_stake"` + MetaJSON *struct { + Ticker string `json:"ticker"` + Name string `json:"name"` + } `json:"meta_json"` +} + +// koiosEpochParams — we only need monetary_expansion (rho) and +// treasury_growth_rate (tau) to approximate the network-wide gross APR +// pre-commission. +type koiosEpochParams struct { + EpochNo int64 `json:"epoch_no"` + MonetaryExpansion float64 `json:"monetary_expansion"` + TreasuryGrowthRate float64 `json:"treasury_growth_rate"` +} + +var ( + koiosBaseURL = envDefault("VAL_ECON_KOIOS_URL", "https://api.koios.rest/api/v1") + coingeckoCardanoURL = envDefault("VAL_ECON_COINGECKO_ADA_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=usd") +) + +// Cardano's real yield formula is famously nuanced (pledge influence +// factor a0, saturation curve k, block-production luck). For v1 we +// approximate: +// +// gross_network_apr ≈ rho × (1 - tau) +// gross_pool_apr ≈ gross_network_apr (all pools share the pot pro-rata) +// net_pool_apr ≈ gross_pool_apr × (1 - margin) +// +// This ignores the a0/k/saturation effects; empirically it lands +// within ~50-100 bps of Cardano's ADAPools/pooltool.io figures for +// non-saturated pools and is honest enough for a network-comparison +// bench. If Koios's epoch_params fetch fails we fall back to +// cardanoFallbackAPR (a documented approximation of ~5%). +const cardanoFallbackAPR = 0.05 + +func fetchKoiosPoolList(ctx context.Context, client *http.Client) ([]koiosPoolListEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/pool_list?pool_status=eq.registered", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("koios pool_list http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out []koiosPoolListEntry + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("koios pool_list parse: %w", err) + } + return out, nil +} + +func fetchKoiosPoolInfo(ctx context.Context, client *http.Client, ids []string) ([]koiosPoolInfo, error) { + body, err := json.Marshal(map[string]any{"_pool_bech32_ids": ids}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, koiosBaseURL+"/pool_info", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("koios pool_info http %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out []koiosPoolInfo + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("koios pool_info parse: %w", err) + } + return out, nil +} + +func fetchKoiosEpochParams(ctx context.Context, client *http.Client) (koiosEpochParams, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/epoch_params?limit=1", nil) + if err != nil { + return koiosEpochParams{}, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return koiosEpochParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params http %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return koiosEpochParams{}, err + } + var out []koiosEpochParams + if err := json.Unmarshal(raw, &out); err != nil { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params parse: %w", err) + } + if len(out) == 0 { + return koiosEpochParams{}, fmt.Errorf("koios epoch_params: empty response") + } + return out[0], nil +} + +func fetchCardanoPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoCardanoURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko ada http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko ada parse: %w", err) + } + v, ok := payload["cardano"] + if !ok { + return 0, fmt.Errorf("coingecko missing cardano key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid ada price %f", price) + } + return price, nil +} + +// scrapeCardano fetches the top-N stake pools from Koios, applies a +// simple rho×(1-tau)/pool_margin approximation, and publishes the +// standard OCB validator gauges under chain="cardano". +// +// Uptime is set to 100% for every pool: Cardano stake pools don't have +// a canonical uptime metric in the same sense as Solana/Hyperliquid +// (block-production luck varies by chance, not by operator downtime). +func scrapeCardano(ctx context.Context, client *http.Client) { + pools, err := fetchKoiosPoolList(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolListTag).Inc() + fmt.Printf("[cardano] koios pool_list err: %v\n", err) + return + } + + // Sort descending by active_stake (parsed as lovelace) and cap. + sort.Slice(pools, func(i, j int) bool { + var a, b float64 + if pools[i].ActiveStake != nil { + a = parseFloat(*pools[i].ActiveStake) + } + if pools[j].ActiveStake != nil { + b = parseFloat(*pools[j].ActiveStake) + } + return a > b + }) + if len(pools) > cardanoTopN { + pools = pools[:cardanoTopN] + } + + // Batch pool_info in a single POST — Koios accepts up to ~200 IDs. + ids := make([]string, 0, len(pools)) + for _, p := range pools { + ids = append(ids, p.PoolIDBech32) + } + infos, err := fetchKoiosPoolInfo(ctx, client, ids) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolInfoTag).Inc() + fmt.Printf("[cardano] koios pool_info err: %v\n", err) + return + } + infoIdx := make(map[string]koiosPoolInfo, len(infos)) + for _, info := range infos { + infoIdx[info.PoolIDBech32] = info + } + + // Network-wide gross APR from monetary params — fall back to a + // documented static approximation if Koios flakes here. + networkAPR := cardanoFallbackAPR + params, err := fetchKoiosEpochParams(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosEpochParamsTag).Inc() + fmt.Printf("[cardano] koios epoch_params err (using %.1f%% fallback): %v\n", cardanoFallbackAPR*100, err) + } else if params.MonetaryExpansion > 0 { + // rho × (1 - tau). Cardano distributes rho of the reserve per + // epoch; tau goes to treasury before pools split the rest. + // Annualisation: rho is already per-epoch × 73 epochs/year ≈ + // per-year in Koios semantics for this field. + networkAPR = params.MonetaryExpansion * (1 - params.TreasuryGrowthRate) + } + + adaPrice, err := fetchCardanoPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cardanoChain, coingeckoAdaSourceTag).Inc() + fmt.Printf("[cardano] coingecko err (stake_usd will be skipped): %v\n", err) + adaPrice = 0 + } + + const lovelacePerAda = 1e6 + + netYields := make([]float64, 0, len(pools)) + for _, p := range pools { + info, ok := infoIdx[p.PoolIDBech32] + if !ok { + continue + } + name := shortenName(cardanoPoolLabel(p, info)) + id := p.PoolIDBech32 + + grossBps := pctToBps(networkAPR) + commissionBps := info.Margin * 10000 + if commissionBps > 10000 { + commissionBps = 10000 + } + netAPR := networkAPR * (1 - info.Margin) + netBps := pctToBps(netAPR) + + // Uptime: no canonical Cardano equivalent — assume 100%. + uptimePct := 100.0 + + activeStakeLovelace := parseFloat(info.ActiveStake) + if activeStakeLovelace == 0 && p.ActiveStake != nil { + activeStakeLovelace = parseFloat(*p.ActiveStake) + } + stakeADA := activeStakeLovelace / lovelacePerAda + stakeUSD := stakeADA * adaPrice + + validatorNetYieldBps.WithLabelValues(cardanoChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(cardanoChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(cardanoChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(cardanoChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(cardanoChain, id, name).Set(uptimePct) + if adaPrice > 0 { + validatorStakeUSD.WithLabelValues(cardanoChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(cardanoChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(cardanoChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(cardanoChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(cardanoChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[cardano] published %d pools, median net yield %.0f bps, ADA=$%.4f\n", + len(netYields), medianBps(netYields), adaPrice) +} + +// cardanoPoolLabel picks the best human name we can find: meta_json +// ticker/name if present, else the list-endpoint ticker, else a +// truncated bech32. Never returns empty (shortenName upgrades that +// to "(unknown)"). +func cardanoPoolLabel(p koiosPoolListEntry, info koiosPoolInfo) string { + if info.MetaJSON != nil { + if info.MetaJSON.Ticker != "" { + return info.MetaJSON.Ticker + } + if info.MetaJSON.Name != "" { + return info.MetaJSON.Name + } + } + if info.Ticker != nil && *info.Ticker != "" { + return *info.Ticker + } + if p.Ticker != nil && *p.Ticker != "" { + return *p.Ticker + } + if len(p.PoolIDBech32) > 16 { + return p.PoolIDBech32[:16] + } + return p.PoolIDBech32 +} diff --git a/harnesses/validator-yield/cmd/script/cosmos.go b/harnesses/validator-yield/cmd/script/cosmos.go new file mode 100644 index 00000000..b7099333 --- /dev/null +++ b/harnesses/validator-yield/cmd/script/cosmos.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +const ( + cosmosChain = "cosmos-hub" + cosmosInflationTag = "cosmos_lcd_inflation" + cosmosPoolTag = "cosmos_lcd_pool" + cosmosValidatorsTag = "cosmos_lcd_validators" + coingeckoAtomSourceTag = "coingecko_atom" + + // Cosmos Hub community_tax has been ~2% for years; hard-coding + // avoids a third LCD round-trip per scrape. If governance changes + // it materially, update here (grep this constant). + cosmosCommunityTax = 0.02 + + // Cap: Cosmos Hub has ~180 active validators (top 180 in bond + // order). We publish all of them — the tail is small enough to + // not blow up cardinality. + cosmosTopN = 200 +) + +var ( + cosmosLCDBase = envDefault("VAL_ECON_COSMOS_LCD_URL", "https://cosmos-rest.publicnode.com") + coingeckoAtomURL = envDefault("VAL_ECON_COINGECKO_ATOM_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cosmos&vs_currencies=usd") +) + +// cosmosInflationResponse mirrors /cosmos/mint/v1beta1/inflation. +// `inflation` is a decimal string in [0,1] (0.1 = 10%). +type cosmosInflationResponse struct { + Inflation string `json:"inflation"` +} + +// cosmosPoolResponse mirrors /cosmos/staking/v1beta1/pool. Token amounts +// are stringified integer uatom (1 ATOM = 1e6 uatom). +type cosmosPoolResponse struct { + Pool struct { + BondedTokens string `json:"bonded_tokens"` + NotBondedTokens string `json:"not_bonded_tokens"` + } `json:"pool"` +} + +// cosmosValidator mirrors items in /cosmos/staking/v1beta1/validators. +type cosmosValidator struct { + OperatorAddress string `json:"operator_address"` + Status string `json:"status"` + Tokens string `json:"tokens"` + Description struct { + Moniker string `json:"moniker"` + Identity string `json:"identity"` + } `json:"description"` + Commission struct { + CommissionRates struct { + Rate string `json:"rate"` + MaxRate string `json:"max_rate"` + MaxChangeRate string `json:"max_change_rate"` + } `json:"commission_rates"` + } `json:"commission"` + Jailed bool `json:"jailed"` +} + +type cosmosValidatorsResponse struct { + Validators []cosmosValidator `json:"validators"` + Pagination struct { + NextKey *string `json:"next_key"` + Total string `json:"total"` + } `json:"pagination"` +} + +func lcdGet(ctx context.Context, client *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("lcd http %d: %s", resp.StatusCode, url) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +func fetchCosmosInflation(ctx context.Context, client *http.Client) (float64, error) { + var out cosmosInflationResponse + if err := lcdGet(ctx, client, cosmosLCDBase+"/cosmos/mint/v1beta1/inflation", &out); err != nil { + return 0, err + } + v := parseFloat(out.Inflation) + if v <= 0 || v > 1 { + return 0, fmt.Errorf("cosmos inflation implausible: %s", out.Inflation) + } + return v, nil +} + +func fetchCosmosPool(ctx context.Context, client *http.Client) (bonded, notBonded float64, err error) { + var out cosmosPoolResponse + if err = lcdGet(ctx, client, cosmosLCDBase+"/cosmos/staking/v1beta1/pool", &out); err != nil { + return 0, 0, err + } + bonded = parseFloat(out.Pool.BondedTokens) + notBonded = parseFloat(out.Pool.NotBondedTokens) + if bonded <= 0 { + return 0, 0, fmt.Errorf("cosmos pool: bonded_tokens=0") + } + return bonded, notBonded, nil +} + +func fetchCosmosValidators(ctx context.Context, client *http.Client) ([]cosmosValidator, error) { + url := cosmosLCDBase + "/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=250" + var out cosmosValidatorsResponse + if err := lcdGet(ctx, client, url, &out); err != nil { + return nil, err + } + return out.Validators, nil +} + +func fetchCosmosPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAtomURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko atom http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko atom parse: %w", err) + } + v, ok := payload["cosmos"] + if !ok { + return 0, fmt.Errorf("coingecko missing cosmos key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid atom price %f", price) + } + return price, nil +} + +// scrapeCosmos computes Cosmos Hub validator yield from LCD REST: +// +// network_apr = inflation × (1 - community_tax) / bonded_ratio +// net_apr = network_apr × (1 - commission_rate) +// +// Uptime is set to 100% — Cosmos Hub does surface missed_blocks via +// /cosmos/slashing/v1beta1/signing_infos but we skip that in v1 to +// keep the scrape to a single validators-pagination call. +func scrapeCosmos(ctx context.Context, client *http.Client) { + inflation, err := fetchCosmosInflation(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosInflationTag).Inc() + fmt.Printf("[cosmos-hub] inflation err: %v\n", err) + return + } + + bonded, notBonded, err := fetchCosmosPool(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosPoolTag).Inc() + fmt.Printf("[cosmos-hub] pool err: %v\n", err) + return + } + bondedRatio := bonded / (bonded + notBonded) + if bondedRatio <= 0 || bondedRatio > 1 { + fmt.Printf("[cosmos-hub] implausible bonded_ratio %.4f, aborting\n", bondedRatio) + return + } + + validators, err := fetchCosmosValidators(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosValidatorsTag).Inc() + fmt.Printf("[cosmos-hub] validators err: %v\n", err) + return + } + + atomPrice, err := fetchCosmosPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(cosmosChain, coingeckoAtomSourceTag).Inc() + fmt.Printf("[cosmos-hub] coingecko err (stake_usd will be skipped): %v\n", err) + atomPrice = 0 + } + + // Sort by tokens desc, cap top-N. + sort.Slice(validators, func(i, j int) bool { + return parseFloat(validators[i].Tokens) > parseFloat(validators[j].Tokens) + }) + if len(validators) > cosmosTopN { + validators = validators[:cosmosTopN] + } + + networkAPR := inflation * (1 - cosmosCommunityTax) / bondedRatio + grossBps := pctToBps(networkAPR) + + const uatomPerAtom = 1e6 + + netYields := make([]float64, 0, len(validators)) + for _, v := range validators { + name := shortenName(v.Description.Moniker) + id := v.OperatorAddress + + commissionRate := parseFloat(v.Commission.CommissionRates.Rate) // 0..1 + if commissionRate < 0 { + commissionRate = 0 + } + if commissionRate > 1 { + commissionRate = 1 + } + commissionBps := commissionRate * 10000 + + uptimePct := 100.0 + netBps := grossBps * (1 - commissionRate) * (uptimePct / 100) + if v.Jailed { + netBps = 0 + } + + stakeAtom := parseFloat(v.Tokens) / uatomPerAtom + stakeUSD := stakeAtom * atomPrice + + jailed := 0.0 + if v.Jailed { + jailed = 1 + } + + validatorNetYieldBps.WithLabelValues(cosmosChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(cosmosChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(cosmosChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(cosmosChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(cosmosChain, id, name).Set(uptimePct) + if atomPrice > 0 { + validatorStakeUSD.WithLabelValues(cosmosChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(cosmosChain, id, name).Set(jailed) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(cosmosChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(cosmosChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(cosmosChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[cosmos-hub] published %d validators, median net yield %.0f bps, inflation=%.2f%%, bonded=%.1f%%, ATOM=$%.4f\n", + len(netYields), medianBps(netYields), inflation*100, bondedRatio*100, atomPrice) +} diff --git a/harnesses/validator-yield/cmd/script/main.go b/harnesses/validator-yield/cmd/script/main.go index 8f25c6c5..c056e1bf 100644 --- a/harnesses/validator-yield/cmd/script/main.go +++ b/harnesses/validator-yield/cmd/script/main.go @@ -19,7 +19,7 @@ func main() { installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Validator Economics Harness ===") fmt.Println("OpenChainBench bench #026 — net yield = gross APR + MEV − downtime") - fmt.Println("Scope: Solana + Hyperliquid + Ethereum (consensus-layer, network avg)") + fmt.Println("Scope: Solana + Hyperliquid + Ethereum + Cardano + Sui + Cosmos Hub + Avalanche") fmt.Println() go func() { @@ -41,6 +41,10 @@ func main() { go runChainScraper(ctx, client, "solana", scrapeSolana) go runChainScraper(ctx, client, "hyperliquid", scrapeHyperliquid) go runChainScraper(ctx, client, "ethereum", scrapeEthereum) + go runChainScraper(ctx, client, "cardano", scrapeCardano) + go runChainScraper(ctx, client, "sui", scrapeSui) + go runChainScraper(ctx, client, "cosmos-hub", scrapeCosmos) + go runChainScraper(ctx, client, "avalanche", scrapeAvalanche) sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) diff --git a/harnesses/validator-yield/cmd/script/sui.go b/harnesses/validator-yield/cmd/script/sui.go new file mode 100644 index 00000000..6158e860 --- /dev/null +++ b/harnesses/validator-yield/cmd/script/sui.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const ( + suiChain = "sui" + suiApysSourceTag = "sui_getValidatorsApy" + suiSystemSourceTag = "sui_getLatestSystemState" + coingeckoSuiSourceTag = "coingecko_sui" +) + +// Sui exposes a single JSON-RPC endpoint per fullnode. The public +// mainnet endpoint has no key requirement and no strict rate limit for +// low-QPS pollers. +var ( + suiRPCURL = envDefault("VAL_ECON_SUI_RPC_URL", "https://fullnode.mainnet.sui.io:443") + coingeckoSuiURL = envDefault("VAL_ECON_COINGECKO_SUI_URL", "https://api.coingecko.com/api/v3/simple/price?ids=sui&vs_currencies=usd") +) + +// suiRPCEnvelope is the standard JSON-RPC 2.0 wrapper. +type suiRPCEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// suiValidatorApy is one entry in `suix_getValidatorsApy.result.apys`. +// `apy` is a decimal fraction (0.0234 = 2.34%) already net of the +// validator's commission per Sui docs. +type suiValidatorApy struct { + Address string `json:"address"` + APY float64 `json:"apy"` + Epoch int64 `json:"epoch"` +} + +type suiValidatorsApyResponse struct { + Epoch string `json:"epoch"` + APYs []suiValidatorApy `json:"apys"` +} + +// suiActiveValidator mirrors the fields we care about from the +// `activeValidators` array in `suix_getLatestSuiSystemState`. All +// numeric-like fields come back as strings. +type suiActiveValidator struct { + SuiAddress string `json:"suiAddress"` + Name string `json:"name"` + Description string `json:"description"` + CommissionRate string `json:"commissionRate"` // bps, e.g. "200" = 2% + StakingPoolSuiBal string `json:"stakingPoolSuiBalance"` // MIST (1 SUI = 1e9 MIST) + NextEpochStake string `json:"nextEpochStake"` +} + +type suiSystemStateResponse struct { + Epoch string `json:"epoch"` + ActiveValidators []suiActiveValidator `json:"activeValidators"` +} + +func suiRPC(ctx context.Context, client *http.Client, method string, out any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": []any{}, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, suiRPCURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("sui rpc %s http %d: %s", method, resp.StatusCode, string(raw)) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var env suiRPCEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("sui rpc %s envelope parse: %w", method, err) + } + if env.Error != nil { + return fmt.Errorf("sui rpc %s error %d: %s", method, env.Error.Code, env.Error.Message) + } + if err := json.Unmarshal(env.Result, out); err != nil { + return fmt.Errorf("sui rpc %s result parse: %w", method, err) + } + return nil +} + +func fetchSuiValidatorsAPY(ctx context.Context, client *http.Client) (suiValidatorsApyResponse, error) { + var out suiValidatorsApyResponse + err := suiRPC(ctx, client, "suix_getValidatorsApy", &out) + return out, err +} + +func fetchSuiSystemState(ctx context.Context, client *http.Client) (suiSystemStateResponse, error) { + var out suiSystemStateResponse + err := suiRPC(ctx, client, "suix_getLatestSuiSystemState", &out) + return out, err +} + +func fetchSuiPrice(ctx context.Context, client *http.Client) (float64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoSuiURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-harness/1.0") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("coingecko sui http %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var payload map[string]map[string]float64 + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("coingecko sui parse: %w", err) + } + v, ok := payload["sui"] + if !ok { + return 0, fmt.Errorf("coingecko missing sui key") + } + price := v["usd"] + if price <= 0 { + return 0, fmt.Errorf("coingecko invalid sui price %f", price) + } + return price, nil +} + +// scrapeSui publishes per-validator yield gauges for Sui. The apy field +// from `suix_getValidatorsApy` is already net of commission per Sui +// docs, so we use it as both gross (before uptime adjustment) and net +// (after uptime, which we can't measure per-validator without the +// per-epoch checkpoint history — we assume 100% for v1). +// +// MEV: Sui rewards are pure staking rewards (protocol emission), no +// MEV component surfaces to validators today. +func scrapeSui(ctx context.Context, client *http.Client) { + apy, err := fetchSuiValidatorsAPY(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, suiApysSourceTag).Inc() + fmt.Printf("[sui] getValidatorsApy err: %v\n", err) + return + } + + state, err := fetchSuiSystemState(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, suiSystemSourceTag).Inc() + fmt.Printf("[sui] getLatestSuiSystemState err (metadata only, continuing): %v\n", err) + } + + // Index active validators by address for enrichment. + valMeta := make(map[string]suiActiveValidator, len(state.ActiveValidators)) + for _, v := range state.ActiveValidators { + valMeta[v.SuiAddress] = v + } + + suiPrice, err := fetchSuiPrice(ctx, client) + if err != nil { + scrapeErrorsTotal.WithLabelValues(suiChain, coingeckoSuiSourceTag).Inc() + fmt.Printf("[sui] coingecko err (stake_usd will be skipped): %v\n", err) + suiPrice = 0 + } + + const mistPerSui = 1e9 + + netYields := make([]float64, 0, len(apy.APYs)) + for _, entry := range apy.APYs { + meta := valMeta[entry.Address] + name := shortenName(meta.Name) + if name == "(unknown)" { + // Fall back to a truncated address so operators are + // distinguishable in Grafana even when metadata is missing. + if len(entry.Address) > 16 { + name = shortenName(entry.Address[:16]) + } else { + name = shortenName(entry.Address) + } + } + id := entry.Address + + grossBps := pctToBps(entry.APY) + // Uptime — no direct field; assume 100%. + uptimePct := 100.0 + netBps := grossBps * (uptimePct / 100) + + // Commission comes back as basis points already (e.g. "200" = 2%). + commissionBps, _ := strconv.ParseFloat(meta.CommissionRate, 64) + if commissionBps < 0 { + commissionBps = 0 + } + if commissionBps > 10000 { + commissionBps = 10000 + } + + stakeMist, _ := strconv.ParseFloat(meta.StakingPoolSuiBal, 64) + stakeSUI := stakeMist / mistPerSui + stakeUSD := stakeSUI * suiPrice + + validatorNetYieldBps.WithLabelValues(suiChain, id, name).Set(netBps) + validatorGrossYieldBps.WithLabelValues(suiChain, id, name).Set(grossBps) + validatorMevShareBps.WithLabelValues(suiChain, id, name).Set(0) + validatorCommissionBps.WithLabelValues(suiChain, id, name).Set(commissionBps) + validatorUptimePct.WithLabelValues(suiChain, id, name).Set(uptimePct) + if suiPrice > 0 { + validatorStakeUSD.WithLabelValues(suiChain, id, name).Set(stakeUSD) + } + validatorJailed.WithLabelValues(suiChain, id, name).Set(0) + + netYields = append(netYields, netBps) + } + + chainTotalValidators.WithLabelValues(suiChain).Set(float64(len(netYields))) + chainMedianNetYieldBps.WithLabelValues(suiChain).Set(medianBps(netYields)) + lastScrapeTimestamp.WithLabelValues(suiChain).Set(float64(time.Now().Unix())) + + fmt.Printf("[sui] published %d validators, median net yield %.0f bps, SUI=$%.4f\n", + len(netYields), medianBps(netYields), suiPrice) +}