diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml index e015946a..82dedfed 100644 --- a/benchmarks/network-coverage.yml +++ b/benchmarks/network-coverage.yml @@ -32,9 +32,9 @@ seo_intro: | abstract: | We benchmark how many networks each major onchain data provider lists in its public "supported networks" endpoint. The harness fetches the - official listing every six hours from five providers (GeckoTerminal, - Codex, Mobula, CoinPaprika and Dune via Sim API), deduplicates by - chain id and counts. Mainnet only, + official listing every six hours from six providers (GeckoTerminal, + Codex, Mobula, CoinPaprika, CoinStats and Dune via Sim API), + deduplicates by chain id and counts. Mainnet only, testnets are excluded because providers list them inconsistently and the comparison is meant to reflect what a builder can integrate against in production. Coverage breadth is one dimension of a data provider's @@ -47,6 +47,7 @@ methodology: - "Codex: GraphQL `getNetworks` query at https://graph.codex.io/graphql with an official API key." - "Mobula: GET /api/1/blockchains with an Authorization API key." - "CoinPaprika: GET /v1/contracts (no auth). Lists platforms supported for contract lookup." + - "CoinStats: GET /wallet/blockchains with X-API-KEY." - "Dune (via Sim API): GET https://api.sim.dune.com/v1/evm/supported-chains (no auth). EVM only, mainnets filtered via the `mainnet` tag." - "Cadence: full refresh every 6 hours." - "Counting: a provider's network is counted once per unique chain id; mainnet only." @@ -81,7 +82,7 @@ faq: # Real metrics exposed by the network-coverage harness: # networks_supported_total{provider="geckoterminal"|"codex"|"mobula"| -# "coinpaprika"|"dune"} +# "coinpaprika"|"coinstats"|"dune"} # -> gauge, the unique-chain count from the latest successful refresh. # network_supported{provider, chain_id, slug, name} -> gauge (1 per # network; useful for diff queries on the site if we add a @@ -140,6 +141,19 @@ providers: sample_size: networks_supported_total{provider="coinpaprika"} series: networks_supported_total{provider="coinpaprika"} + - slug: coinstats + name: CoinStats + tag: Portfolio + market data + formula: "Count of blockchains returned by CoinStats's `/wallet/blockchains` endpoint, refreshed every 6 hours." + queries: + p50: networks_supported_total{provider="coinstats"} + p90: networks_supported_total{provider="coinstats"} + p99: networks_supported_total{provider="coinstats"} + mean: networks_supported_total{provider="coinstats"} + success: clamp_max(networks_supported_total{provider="coinstats"} > bool 0, 1) + sample_size: networks_supported_total{provider="coinstats"} + series: networks_supported_total{provider="coinstats"} + - slug: dune name: Dune tag: Onchain analytics + Sim API diff --git a/harnesses/network-coverage/cmd/script/coinstats.go b/harnesses/network-coverage/cmd/script/coinstats.go new file mode 100644 index 00000000..344c6ee7 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/coinstats.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const coinstatsBlockchainsURL = "https://openapiv1.coinstats.app/wallet/blockchains" + +// CoinStats /wallet/blockchains returns a flat JSON array of: +// {connectionId, name, icon, chain} +// `connectionId` is the slug (e.g. "binancesmartchain"), `chain` is the +// category enum (e.g. "binance_smart"), `name` is the human label. +type coinstatsBlockchain struct { + ConnectionID string `json:"connectionId"` + Name string `json:"name"` + Chain string `json:"chain"` +} + +func fetchCoinStats(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "coinstats"} + if cfg.CoinStatsAPIKey == "" { + res.Err = "missing_api_key" + return res + } + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", coinstatsBlockchainsURL, nil) + req.Header.Set("X-API-KEY", cfg.CoinStatsAPIKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var arr []coinstatsBlockchain + if err := json.Unmarshal(body, &arr); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, b := range arr { + res.Networks = append(res.Networks, Network{ + ChainID: b.Chain, + Slug: b.ConnectionID, + Name: b.Name, + }) + } + return res +} diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go index 1bbdc805..83d27ab8 100644 --- a/harnesses/network-coverage/cmd/script/config.go +++ b/harnesses/network-coverage/cmd/script/config.go @@ -12,6 +12,7 @@ type Config struct { CodexAPIKey string // official Codex Bearer (preferred — no mint, no proxy) CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie DefinedTokenURL string // optional: pre-minted JWT sidecar + CoinStatsAPIKey string SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits HTTPProxy string RefreshInterval time.Duration @@ -24,6 +25,7 @@ func loadConfig() *Config { CodexAPIKey: os.Getenv("CODEX_API_KEY"), CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"), DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"), + CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"), SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"), HTTPProxy: os.Getenv("HTTP_PROXY"), RefreshInterval: 6 * time.Hour, @@ -45,8 +47,8 @@ func loadConfig() *Config { } else if c.CodexSessionCookie != "" { codexAuth = "cookie+mint" } - fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, sim_dune_key=%v\n", + fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, coinstats_key=%v, sim_dune_key=%v\n", c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth, - c.SimDuneAPIKey != "") + c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "") return c } diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go index cb8f157c..b8c544c4 100644 --- a/harnesses/network-coverage/cmd/script/main.go +++ b/harnesses/network-coverage/cmd/script/main.go @@ -73,11 +73,7 @@ func fetchAll(cfg *Config) { {"codex", fetchCodex}, {"coinpaprika", fetchCoinPaprika}, {"dune", fetchSimDune}, - // coinstats dropped 2026-07-16: the free tier exhausts credits - // well below the harness cadence, so /wallet/blockchains returns - // HTTP 406 "Credits limit reached" on every fetch and never - // publishes a networks count. Re-add when a paid key is - // provisioned; the removed adapter is one revert away in git. + {"coinstats", fetchCoinStats}, } var wg sync.WaitGroup