From bf62750a72d4823b5f9cb5f2b5e24523376388e9 Mon Sep 17 00:00:00 2001 From: JunJie Date: Thu, 4 Jun 2026 15:20:18 +0800 Subject: [PATCH 01/14] feat: add chain analytics commands --- src/api/chain.rs | 663 ++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 11 +- src/cli/chain.rs | 36 +++ src/cli/mod.rs | 3 + src/commands/chain.rs | 59 ++++ src/commands/mod.rs | 4 + src/models/chain.rs | 310 ++++++++++++++++++++ src/models/mod.rs | 1 + 8 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 src/api/chain.rs create mode 100644 src/cli/chain.rs create mode 100644 src/commands/chain.rs create mode 100644 src/models/chain.rs diff --git a/src/api/chain.rs b/src/api/chain.rs new file mode 100644 index 0000000..dbdfbf2 --- /dev/null +++ b/src/api/chain.rs @@ -0,0 +1,663 @@ +use reqwest::Client; +use serde::Deserialize; +use serde_json::Value; + +use crate::api::send_retrying; +use crate::error::{ChainError, Result}; +use crate::models::chain::{ + ChainFlows, ChainFlowsSources, ChainInfo, ChainInfoSources, ChainProtocolEntry, ChainProtocols, + ChainProtocolsSources, ChainStablecoins, ChainStablecoinsSources, StablecoinType, +}; + +#[derive(Clone)] +pub struct ChainClient { + client: Client, + defillama_url: String, + coingecko_url: String, + coingecko_key: Option, +} + +// ── DefiLlama response types ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct DefiLlamaChain { + name: String, + #[serde(rename = "tokenSymbol")] + token_symbol: Option, + tvl: Option, + #[serde(rename = "chainId", deserialize_with = "deserialize_optional_u64_from_string_or_int")] + chain_id: Option, +} + +/// DefiLlama returns `chainId` as either a JSON number or a quoted string. +/// Accept both so deserialization doesn't fail on chains like "11235". +fn deserialize_optional_u64_from_string_or_int<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = Option; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a u64, a string-encoded u64, or null") + } + + fn visit_none(self) -> std::result::Result { + Ok(None) + } + + fn visit_unit(self) -> std::result::Result { + Ok(None) + } + + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(Some(v)) + } + + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(Some(v as u64)) + } + + fn visit_f64(self, v: f64) -> std::result::Result { + Ok(Some(v as u64)) + } + + fn visit_str(self, v: &str) -> std::result::Result { + match v.parse::() { + Ok(n) => Ok(Some(n)), + Err(_) => Err(de::Error::invalid_value( + de::Unexpected::Str(v), + &"a numeric string", + )), + } + } + } + + deserializer.deserialize_any(Visitor) +} + +#[derive(Debug, Deserialize)] +struct DefiLlamaProtocol { + name: String, + #[allow(dead_code)] + slug: String, + category: Option, + chains: Option>, + tvl: Option, + chain_tvls: Option>, + #[allow(dead_code)] + #[serde(rename = "change_1d")] + change_1d: Option, + #[allow(dead_code)] + #[serde(rename = "change_7d")] + change_7d: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct DefiLlamaStablecoin { + name: String, + symbol: String, + #[allow(dead_code)] + chain: Option, + chains: Option>, + circulating: Option, + #[allow(dead_code)] + peggedUSD: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct DefiLlamaStablecoinCirculating { + peggedUSD: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct DefiLlamaStablecoinsResponse { + peggedAssets: Option>, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoSimplePrice { + #[serde(flatten)] + prices: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoPriceEntry { + usd: Option, +} + +impl ChainClient { + pub fn new( + client: Client, + defillama_url: &str, + coingecko_url: &str, + coingecko_key: Option, + ) -> Self { + Self { + client, + defillama_url: defillama_url.trim_end_matches('/').to_string(), + coingecko_url: coingecko_url.trim_end_matches('/').to_string(), + coingecko_key, + } + } + + pub async fn chain_info(&self, chain: &str) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + let matched = chains.iter().find(|c| c.name == resolved).unwrap(); + let chain_id = matched.chain_id; + let native_token = matched.token_symbol.clone(); + let tvl = matched.tvl; + let sources_base = "defillama:chains".to_string(); + + // Fetch native token price, fees, and active users concurrently + let price_fut = async { + if let Some(ref token) = native_token { + self.fetch_native_price(&resolved, token).await + } else { + None + } + }; + let fees_fut = self.fetch_chain_fees(&resolved); + let active_fut = self.fetch_chain_active_users(&resolved); + + let (native_price, fees_result, active_result) = + tokio::join!(price_fut, fees_fut, active_fut); + + let fees_24h = fees_result.unwrap_or(None); + let active_addresses = active_result.unwrap_or(None); + + let price_source = if native_price.is_some() { + Some("coingecko".to_string()) + } else { + None + }; + let fees_source = if fees_24h.is_some() { + Some("defillama:fees".to_string()) + } else { + None + }; + let active_source = if active_addresses.is_some() { + Some("defillama:activeUsers".to_string()) + } else { + None + }; + + Ok(ChainInfo { + chain: resolved, + chain_id, + native_token, + native_price, + tvl, + active_addresses, + tx_count_24h: None, + fees_24h, + throughput: None, + sources: ChainInfoSources { + chain: Some(sources_base.clone()), + chain_id: Some(sources_base.clone()), + native_token: Some(sources_base.clone()), + native_price: price_source, + tvl: Some(sources_base), + active_addresses: active_source, + tx_count_24h: None, + fees_24h: fees_source, + throughput: None, + }, + }) + } + + pub async fn chain_flows(&self, chain: &str) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + // Flow data is limited with public APIs - return structure with available data + Ok(ChainFlows { + chain: resolved, + net_flow_usd: None, + inflow_usd: None, + outflow_usd: None, + bridge_flow: Vec::new(), + cex_flow: Vec::new(), + stablecoin_flow: Vec::new(), + sources: ChainFlowsSources::default(), + }) + } + + pub async fn chain_stablecoins(&self, chain: &str) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + let stablecoins = self.fetch_defillama_stablecoins(&resolved).await?; + + let total_supply: f64 = stablecoins.iter().map(|s| s.supply).sum(); + + let stablecoin_types: Vec = stablecoins + .iter() + .map(|s| StablecoinType { + name: s.name.clone(), + supply: s.supply, + share_pct: if total_supply > 0.0 { + (s.supply / total_supply) * 100.0 + } else { + 0.0 + }, + }) + .collect(); + + let supply_source = if stablecoin_types.is_empty() { + None + } else { + Some("defillama:stablecoins".to_string()) + }; + + Ok(ChainStablecoins { + chain: resolved, + stablecoin_supply: if total_supply > 0.0 { + Some(total_supply) + } else { + None + }, + stablecoin_types, + stablecoin_flow_24h: None, + sources: ChainStablecoinsSources { + stablecoin_supply: supply_source.clone(), + stablecoin_types: supply_source, + stablecoin_flow_24h: None, + }, + }) + } + + pub async fn chain_protocols(&self, chain: &str, limit: u32) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + let all_protocols = self.fetch_defillama_protocols().await?; + + // Filter protocols by chain and sort by TVL descending + let mut protocols: Vec = all_protocols + .iter() + .filter(|p| { + p.chains + .as_ref() + .map(|chains| chains.iter().any(|c| c.eq_ignore_ascii_case(&resolved))) + .unwrap_or(false) + }) + .map(|p| { + let chain_tvl = p + .chain_tvls + .as_ref() + .and_then(|tvls| tvls.get(&resolved).copied()) + .or(p.tvl); + ChainProtocolEntry { + name: p.name.clone(), + tvl: chain_tvl, + revenue: None, + users: None, + category: p.category.clone(), + } + }) + .collect(); + + protocols.sort_by(|a, b| { + b.tvl + .unwrap_or(0.0) + .partial_cmp(&a.tvl.unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let total = protocols.len(); + let limit = limit as usize; + protocols.truncate(limit); + + Ok(ChainProtocols { + chain: resolved, + protocols, + sources: ChainProtocolsSources { + protocols: Some(format!( + "defillama:protocols ({} of {})", + limit.min(total), + total + )), + }, + }) + } + + // ── internal fetchers ──────────────────────────────────────────────────── + + async fn fetch_defillama_chains(&self) -> Result> { + let url = format!("{}/chains", self.defillama_url); + let req = self.client.get(&url); + let body = send_retrying(req, "defillama.chains") + .await? + .error_for_status() + .map_err(ChainError::Http)? + .text() + .await + .map_err(ChainError::Http)?; + + serde_json::from_str(&body).map_err(|e| { + ChainError::Config(format!( + "DefiLlama chains response could not be parsed: {e}. Body snippet: {}", + snippet(&body) + )) + }) + } + + async fn fetch_defillama_protocols(&self) -> Result> { + let url = format!("{}/protocols", self.defillama_url); + let req = self.client.get(&url); + let body = send_retrying(req, "defillama.protocols") + .await? + .error_for_status() + .map_err(ChainError::Http)? + .text() + .await + .map_err(ChainError::Http)?; + + serde_json::from_str(&body).map_err(|e| { + ChainError::Config(format!( + "DefiLlama protocols response could not be parsed: {e}. Body snippet: {}", + snippet(&body) + )) + }) + } + + async fn fetch_defillama_stablecoins( + &self, + chain_name: &str, + ) -> Result> { + let url = format!("{}/stablecoins", self.defillama_url); + let req = self.client.get(&url).query(&[("includePrices", "false")]); + let body = send_retrying(req, "defillama.stablecoins") + .await? + .error_for_status() + .map_err(ChainError::Http)? + .text() + .await + .map_err(ChainError::Http)?; + + let resp: DefiLlamaStablecoinsResponse = serde_json::from_str(&body).map_err(|e| { + ChainError::Config(format!( + "DefiLlama stablecoins response could not be parsed: {e}. Body snippet: {}", + snippet(&body) + )) + })?; + + let assets = resp.peggedAssets.unwrap_or_default(); + + Ok(assets + .into_iter() + .filter_map(|asset| { + // Check if this stablecoin exists on the target chain + let chains = asset.chains.unwrap_or_default(); + if !chains.iter().any(|c| c.eq_ignore_ascii_case(chain_name)) { + return None; + } + + let supply = asset + .circulating + .as_ref() + .and_then(|c| c.peggedUSD) + .unwrap_or(0.0); + + if supply <= 0.0 { + return None; + } + + Some(SimpleStablecoin { + name: format!("{} ({})", asset.name, asset.symbol), + supply, + }) + }) + .collect()) + } + + async fn fetch_native_price(&self, chain_name: &str, _token_symbol: &str) -> Option { + // Map chain name to CoinGecko ID for the native token + let coingecko_id = chain_to_coingecko_id(chain_name)?; + + let url = format!("{}/simple/price", self.coingecko_url); + let mut req = self + .client + .get(&url) + .query(&[("ids", coingecko_id), ("vs_currencies", "usd")]); + if let Some(ref key) = self.coingecko_key { + req = req.header("x-cg-demo-api-key", key); + } + + let body = send_retrying(req, "coingecko.simple_price") + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + + let resp: CoinGeckoSimplePrice = serde_json::from_str(&body).ok()?; + resp.prices.get(coingecko_id)?.usd + } + + /// Fetch 24h fees for a chain from DefiLlama `/summary/fees/{chain}`. + async fn fetch_chain_fees(&self, chain_name: &str) -> Result> { + let url = format!("{}/summary/fees/{}", self.defillama_url, chain_name); + let req = self + .client + .get(&url) + .query(&[("dataType", "dailyFees")]); + let body = match send_retrying(req, "defillama.chain_fees").await { + Ok(resp) => match resp.error_for_status() { + Ok(r) => r.text().await.map_err(ChainError::Http)?, + Err(_) => return Ok(None), + }, + Err(_) => return Ok(None), + }; + + let value: Value = serde_json::from_str(&body).map_err(|e| { + ChainError::Config(format!( + "DefiLlama chain fees response could not be parsed: {e}. Body snippet: {}", + snippet(&body) + )) + })?; + + Ok(value.get("total24h").and_then(|v| v.as_f64())) + } + + /// Fetch active users for a chain from DefiLlama. + async fn fetch_chain_active_users(&self, chain_name: &str) -> Result> { + let url = format!("{}/overview/activeUsers", self.defillama_url); + let req = self + .client + .get(&url) + .query(&[("chain", chain_name)]); + let body = match send_retrying(req, "defillama.chain_active_users").await { + Ok(resp) => match resp.error_for_status() { + Ok(r) => r.text().await.map_err(ChainError::Http)?, + Err(_) => return Ok(None), + }, + Err(_) => return Ok(None), + }; + + let value: Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + + // Try to extract total active users for the chain + // Response may have a `total24h` or `users24h` field + let count = value + .get("total24h") + .or_else(|| value.get("users24h")) + .and_then(|v| v.as_f64()) + .map(|v| v as u64); + + Ok(count) + } +} + +struct SimpleStablecoin { + name: String, + supply: f64, +} + +/// Resolve user input (chain ID, abbreviation, alias, or name) to the +/// canonical DefiLlama chain name. Matching order: +/// 1. Numeric chain ID (e.g. "1", "8453") +/// 2. Well-known abbreviation/alias (e.g. "eth", "arb", "op") +/// 3. Exact DefiLlama name (case-insensitive) +/// 4. DefiLlama name with spaces removed (e.g. "bnbchain" → "BNB Chain") +fn resolve_chain(input: &str, chains: &[DefiLlamaChain]) -> Result { + let needle = input.trim().to_lowercase(); + + // 1. Numeric chain ID + if let Ok(chain_id) = needle.parse::() { + if let Some(c) = chains.iter().find(|c| c.chain_id == Some(chain_id)) { + return Ok(c.name.clone()); + } + } + + // 2. Well-known abbreviation/alias → canonical DefiLlama name + if let Some(canonical) = chain_alias_to_name(&needle) { + // Verify it exists in the DefiLlama list + if chains.iter().any(|c| c.name.eq_ignore_ascii_case(canonical)) { + return Ok(canonical.to_string()); + } + } + + // 3. Exact DefiLlama name (case-insensitive) + if let Some(c) = chains.iter().find(|c| c.name.to_lowercase() == needle) { + return Ok(c.name.clone()); + } + + // 4. DefiLlama name with spaces removed + let needle_nospace = needle.replace(' ', ""); + if let Some(c) = chains + .iter() + .find(|c| c.name.to_lowercase().replace(' ', "") == needle_nospace) + { + return Ok(c.name.clone()); + } + + Err(ChainError::Config(format!( + "Chain '{}' not found. Use a chain name (ethereum), ID (1), or abbreviation (eth)", + input + ))) +} + +/// Map common abbreviations and aliases to DefiLlama canonical chain names. +fn chain_alias_to_name(alias: &str) -> Option<&'static str> { + match alias { + // Ethereum + "eth" | "ethereum" => Some("Ethereum"), + // BNB Chain + "bsc" | "bnb" | "binance" | "bnbchain" | "bnbsmartchain" => Some("BNB Chain"), + // Polygon + "matic" | "polygon" | "polygonpos" => Some("Polygon"), + // Arbitrum + "arb" | "arbitrum" | "arbitrumone" => Some("Arbitrum"), + // Optimism + "op" | "optimism" => Some("Optimism"), + // Avalanche + "avax" | "avalanche" => Some("Avalanche"), + // Base + "base" => Some("Base"), + // Linea + "linea" => Some("Linea"), + // Scroll + "scrl" | "scroll" => Some("Scroll"), + // Mantle + "mnt" | "mantle" => Some("Mantle"), + // Aurora + "aurora" => Some("Aurora"), + // Blast + "blast" => Some("Blast"), + // zkSync + "zksync" | "era" => Some("zkSync Era"), + // Starknet + "starknet" | "strk" => Some("Starknet"), + // Solana (non-EVM but commonly queried) + "sol" | "solana" => Some("Solana"), + // Tron + "trx" | "tron" => Some("Tron"), + // Bitcoin + "btc" | "bitcoin" => Some("Bitcoin"), + // Cronos + "cro" | "cronos" => Some("Cronos"), + // Gnosis / xDai + "gnosis" | "xdai" => Some("Gnosis"), + // Fantom + "ftm" | "fantom" => Some("Fantom"), + // Celo + "celo" => Some("Celo"), + // Moonbeam + "glmr" | "moonbeam" => Some("Moonbeam"), + // Kava + "kava" => Some("Kava"), + // Manta + "manta" => Some("Manta Pacific"), + // Conflux + "cfx" | "conflux" | "confluxespace" => Some("Conflux"), + // OKX Chain + "okt" | "okc" | "okxchain" => Some("OKXChain"), + // Taiko + "taiko" => Some("Taiko"), + // Plume + "plume" => Some("Plume"), + // Sonic + "sonic" | "s" => Some("Sonic"), + // Sei + "sei" => Some("Sei"), + // Mode + "mode" => Some("Mode"), + // Bob + "bob" => Some("BOB"), + // Abstract + "abstract" => Some("Abstract"), + // Berachain + "bera" | "berachain" => Some("Berachain"), + // Hyperliquid + "hyper" | "hyperliquid" => Some("Hyperliquid"), + _ => None, + } +} + +fn chain_to_coingecko_id(chain_name: &str) -> Option<&'static str> { + match chain_name.to_lowercase().as_str() { + "ethereum" => Some("ethereum"), + "bsc" | "binance" | "bnb chain" | "bnb smart chain" => Some("binancecoin"), + "polygon" | "polygon pos" | "matic network" => Some("matic-network"), + "arbitrum" | "arbitrum one" => Some("ethereum"), // ETH on L2 + "optimism" => Some("ethereum"), // ETH on L2 + "avalanche" | "avax" => Some("avalanche-2"), + "base" => Some("ethereum"), // ETH on L2 + "linea" => Some("ethereum"), // ETH on L2 + "scroll" => Some("ethereum"), // ETH on L2 + "mantle" => Some("mantle"), + "aurora" => Some("ethereum"), // ETH on L2 + _ => None, + } +} + +fn snippet(body: &str) -> String { + const MAX: usize = 400; + if body.len() <= MAX { + body.to_string() + } else { + let mut end = MAX; + while !body.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &body[..end]) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 0012444..9f5daef 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,4 @@ +mod chain; mod debank; mod defillama; mod dodo; @@ -6,6 +7,7 @@ mod goldrush; mod token_metadata; mod zerion; +pub use chain::ChainClient; pub use debank::{debank_chain_to_id, DebankAssetRecord, DebankClient}; pub use defillama::DefillamaClient; pub use dodo::DodoClient; @@ -89,6 +91,7 @@ pub struct ApiClients { pub goldrush: GoldrushClient, pub dune: DuneClient, pub defillama: DefillamaClient, + pub chain: ChainClient, } impl ApiClients { @@ -134,7 +137,13 @@ impl ApiClients { &config.goldrush_api_key, ), dune: DuneClient::new(client.clone(), &config.dune_api_url, &config.dune_api_key), - defillama: DefillamaClient::new(client, crate::config::DEFAULT_DEFILLAMA_API_URL), + defillama: DefillamaClient::new(client.clone(), crate::config::DEFAULT_DEFILLAMA_API_URL), + chain: ChainClient::new( + client, + crate::config::DEFAULT_DEFILLAMA_API_URL, + &config.coingecko_api_url, + config.coingecko_api_key.clone(), + ), }) } } diff --git a/src/cli/chain.rs b/src/cli/chain.rs new file mode 100644 index 0000000..1d09709 --- /dev/null +++ b/src/cli/chain.rs @@ -0,0 +1,36 @@ +use clap::Args; +use clap::Subcommand; + +#[derive(Args)] +pub struct ChainCmd { + #[command(subcommand)] + pub action: ChainAction, +} + +#[derive(Subcommand)] +pub enum ChainAction { + /// Chain overview (chain ID, native token, TVL, native price) + Info(ChainArgs), + /// Chain fund flows (bridge, CEX, stablecoin flows) + Flows(ChainArgs), + /// Chain stablecoin supply and distribution + Stablecoins(ChainArgs), + /// Top protocols on the chain by TVL + Protocols(ChainProtocolsArgs), +} + +#[derive(Args)] +pub struct ChainArgs { + /// Chain name (e.g. ethereum, base, arbitrum, bsc) + pub chain: String, +} + +#[derive(Args)] +pub struct ChainProtocolsArgs { + /// Chain name (e.g. ethereum, base, arbitrum, bsc) + pub chain: String, + + /// Maximum number of protocols to return (default 20, max 100) + #[arg(long, default_value_t = 20)] + pub limit: u32, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6a567e3..346fc98 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod chain; pub mod config; pub mod protocol; pub mod risk; @@ -67,6 +68,8 @@ pub enum Commands { Wallet(wallet::WalletCmd), /// Risk analysis (token, wallet, approval) Risk(risk::RiskCmd), + /// Chain analytics (info, flows, stablecoins, protocols) + Chain(chain::ChainCmd), /// Manage API keys and configuration Config(config::ConfigCmd), } diff --git a/src/commands/chain.rs b/src/commands/chain.rs new file mode 100644 index 0000000..966608c --- /dev/null +++ b/src/commands/chain.rs @@ -0,0 +1,59 @@ +use std::process::ExitCode; + +use crate::api::ApiClients; +use crate::cli::chain::{ChainAction, ChainCmd}; +use crate::config::AppConfig; +use crate::error::Result; +use crate::models::chain::{ChainFlows, ChainInfo, ChainProtocols, ChainStablecoins}; +use crate::output::{OutputContext, OutputMode}; +use crate::store::QuoteStore; + +pub async fn handle( + cmd: ChainCmd, + config: &AppConfig, + _store: &QuoteStore, + api: &ApiClients, + output_mode: OutputMode, +) -> Result { + match cmd.action { + ChainAction::Info(args) => { + let data = api.chain.chain_info(&args.chain).await; + Ok(crate::output::print_output::( + data, + "chain.info", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + ChainAction::Flows(args) => { + let data = api.chain.chain_flows(&args.chain).await; + Ok(crate::output::print_output::( + data, + "chain.flows", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + ChainAction::Stablecoins(args) => { + let data = api.chain.chain_stablecoins(&args.chain).await; + Ok(crate::output::print_output::( + data, + "chain.stablecoins", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + ChainAction::Protocols(args) => { + let data = api + .chain + .chain_protocols(&args.chain, args.limit) + .await; + Ok(crate::output::print_output::( + data, + "chain.protocols", + output_mode, + OutputContext::new(config.chain_id, false), + )) + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ef750d0..61b6aed 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ use crate::error::Result; use crate::output::OutputMode; use crate::store::QuoteStore; +pub mod chain; pub mod config; pub mod protocol; pub mod risk; @@ -35,6 +36,9 @@ pub async fn dispatch( wallet::handle(cmd, &config, &store, &api_clients, output_mode).await } Commands::Risk(cmd) => risk::handle(cmd, &config, &store, &api_clients, output_mode).await, + Commands::Chain(cmd) => { + chain::handle(cmd, &config, &store, &api_clients, output_mode).await + } Commands::Config(cmd) => config::handle(cmd, &config, output_mode).await, } } diff --git a/src/models/chain.rs b/src/models/chain.rs new file mode 100644 index 0000000..78dec00 --- /dev/null +++ b/src/models/chain.rs @@ -0,0 +1,310 @@ +use serde::{Deserialize, Serialize}; + +// ── chain info ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainInfo { + pub chain: String, + pub chain_id: Option, + pub native_token: Option, + pub native_price: Option, + pub tvl: Option, + pub active_addresses: Option, + pub tx_count_24h: Option, + pub fees_24h: Option, + pub throughput: Option, + pub sources: ChainInfoSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChainInfoSources { + pub chain: Option, + pub chain_id: Option, + pub native_token: Option, + pub native_price: Option, + pub tvl: Option, + pub active_addresses: Option, + pub tx_count_24h: Option, + pub fees_24h: Option, + pub throughput: Option, +} + +// ── chain flows ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainFlows { + pub chain: String, + pub net_flow_usd: Option, + pub inflow_usd: Option, + pub outflow_usd: Option, + pub bridge_flow: Vec, + pub cex_flow: Vec, + pub stablecoin_flow: Vec, + pub sources: ChainFlowsSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChainFlowsSources { + pub net_flow_usd: Option, + pub inflow_usd: Option, + pub outflow_usd: Option, + pub bridge_flow: Option, + pub cex_flow: Option, + pub stablecoin_flow: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowEntry { + pub name: String, + pub flow_usd: f64, +} + +// ── chain stablecoins ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainStablecoins { + pub chain: String, + pub stablecoin_supply: Option, + pub stablecoin_types: Vec, + pub stablecoin_flow_24h: Option, + pub sources: ChainStablecoinsSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChainStablecoinsSources { + pub stablecoin_supply: Option, + pub stablecoin_types: Option, + pub stablecoin_flow_24h: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StablecoinType { + pub name: String, + pub supply: f64, + pub share_pct: f64, +} + +// ── chain protocols ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainProtocols { + pub chain: String, + pub protocols: Vec, + pub sources: ChainProtocolsSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChainProtocolsSources { + pub protocols: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainProtocolEntry { + pub name: String, + pub tvl: Option, + pub revenue: Option, + pub users: Option, + pub category: Option, +} + +// ── TableRenderable ────────────────────────────────────────────────────────── + +impl crate::output::TableRenderable for ChainInfo { + fn render_table(&self) { + use comfy_table::Table; + + let mut table = Table::new(); + table.set_header(vec!["Field", "Value", "Source"]); + table.add_row(vec![ + "Chain", + &self.chain, + self.sources.chain.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Chain ID", + &self + .chain_id + .map(|v| v.to_string()) + .unwrap_or_else(|| "N/A".to_string()), + self.sources.chain_id.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Native Token", + self.native_token.as_deref().unwrap_or("N/A"), + self.sources.native_token.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Native Price (USD)", + &format_optional_usd(self.native_price), + self.sources.native_price.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "TVL (USD)", + &format_optional_usd(self.tvl), + self.sources.tvl.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Active Addresses", + &self + .active_addresses + .map(|v| v.to_string()) + .unwrap_or_else(|| "N/A".to_string()), + self.sources.active_addresses.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "TX Count 24h", + &self + .tx_count_24h + .map(|v| v.to_string()) + .unwrap_or_else(|| "N/A".to_string()), + self.sources.tx_count_24h.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Fees 24h (USD)", + &format_optional_usd(self.fees_24h), + self.sources.fees_24h.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Throughput", + &self + .throughput + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "N/A".to_string()), + self.sources.throughput.as_deref().unwrap_or(""), + ]); + println!("{}", table); + } +} + +impl crate::output::TableRenderable for ChainFlows { + fn render_table(&self) { + use comfy_table::Table; + + let mut table = Table::new(); + table.set_header(vec!["Field", "Value", "Source"]); + table.add_row(vec!["Chain", &self.chain, ""]); + table.add_row(vec![ + "Net Flow (USD)", + &format_optional_usd(self.net_flow_usd), + self.sources.net_flow_usd.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Inflow (USD)", + &format_optional_usd(self.inflow_usd), + self.sources.inflow_usd.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Outflow (USD)", + &format_optional_usd(self.outflow_usd), + self.sources.outflow_usd.as_deref().unwrap_or(""), + ]); + println!("{}", table); + + if !self.bridge_flow.is_empty() { + let mut t = Table::new(); + t.set_header(vec!["Bridge", "Flow (USD)"]); + for entry in &self.bridge_flow { + t.add_row(vec![&entry.name, &format_usd(entry.flow_usd)]); + } + println!("Bridge flows:"); + println!("{}", t); + } + + if !self.cex_flow.is_empty() { + let mut t = Table::new(); + t.set_header(vec!["Exchange", "Flow (USD)"]); + for entry in &self.cex_flow { + t.add_row(vec![&entry.name, &format_usd(entry.flow_usd)]); + } + println!("CEX flows:"); + println!("{}", t); + } + + if !self.stablecoin_flow.is_empty() { + let mut t = Table::new(); + t.set_header(vec!["Stablecoin", "Flow (USD)"]); + for entry in &self.stablecoin_flow { + t.add_row(vec![&entry.name, &format_usd(entry.flow_usd)]); + } + println!("Stablecoin flows:"); + println!("{}", t); + } + } +} + +impl crate::output::TableRenderable for ChainStablecoins { + fn render_table(&self) { + use comfy_table::Table; + + let mut table = Table::new(); + table.set_header(vec!["Field", "Value", "Source"]); + table.add_row(vec!["Chain", &self.chain, ""]); + table.add_row(vec![ + "Stablecoin Supply (USD)", + &format_optional_usd(self.stablecoin_supply), + self.sources.stablecoin_supply.as_deref().unwrap_or(""), + ]); + table.add_row(vec![ + "Stablecoin Flow 24h (USD)", + &format_optional_usd(self.stablecoin_flow_24h), + self.sources.stablecoin_flow_24h.as_deref().unwrap_or(""), + ]); + println!("{}", table); + + if !self.stablecoin_types.is_empty() { + let mut t = Table::new(); + t.set_header(vec!["Stablecoin", "Supply (USD)", "Share"]); + for st in &self.stablecoin_types { + t.add_row(vec![ + &st.name, + &format_usd(st.supply), + &format!("{:.2}%", st.share_pct), + ]); + } + println!("Stablecoin breakdown:"); + println!("{}", t); + } + } +} + +impl crate::output::TableRenderable for ChainProtocols { + fn render_table(&self) { + use comfy_table::Table; + + println!("Chain: {}", self.chain); + if self.protocols.is_empty() { + println!("No protocols found."); + return; + } + + let mut table = Table::new(); + table.set_header(vec!["Protocol", "TVL (USD)", "Revenue (USD)", "Users", "Category"]); + for p in &self.protocols { + table.add_row(vec![ + &p.name, + &format_optional_usd(p.tvl), + &format_optional_usd(p.revenue), + &p.users + .map(|v| v.to_string()) + .unwrap_or_else(|| "N/A".to_string()), + p.category.as_deref().unwrap_or("N/A"), + ]); + } + println!("{}", table); + } +} + +fn format_usd(value: f64) -> String { + if value.abs() >= 1.0 { + format!("{:.2}", value) + } else { + format!("{:.8}", value) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +fn format_optional_usd(value: Option) -> String { + value.map(format_usd).unwrap_or_else(|| "N/A".to_string()) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index d9c52e3..53eada7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod chain; pub mod config; pub mod protocol; pub mod quote; From 1d76643b48384eaafaf349d72b297fb3913e87ff Mon Sep 17 00:00:00 2001 From: JunJie Date: Fri, 5 Jun 2026 15:37:11 +0800 Subject: [PATCH 02/14] feat: wire real data sources for chain analytics fields Previously several chain fields were hardcoded to None or fetched from wrong/empty endpoints. Wire up free DefiLlama/CoinGecko sources: - stablecoins: fix 404 by using the stablecoins.llama.fi host - stablecoin_flow_24h + flows net/inflow/outflow + per-coin breakdown, derived from per-chain chainCirculating current vs prevDay - protocols.revenue from overview/fees/{chain} dailyRevenue - native_price coverage via DefiLlama per-chain gecko_id (authoritative, all chains) with local chain_config and name-map fallbacks; drop the native_token guard so chains like Base (no symbol) still resolve - fix BSC resolution: DefiLlama names it "Binance" in /chains but "BSC" in stablecoins chainCirculating; add stablecoin_chain_name remap Fields without a free source (bridge_flow, cex_flow, active_addresses, tx_count_24h, throughput, protocols.users) remain N/A pending paid APIs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 247 ++++++++++++++++++++++++++++++++++++---------- src/api/mod.rs | 1 + src/config/mod.rs | 2 + 3 files changed, 200 insertions(+), 50 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index dbdfbf2..0a6be4f 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -6,13 +6,14 @@ use crate::api::send_retrying; use crate::error::{ChainError, Result}; use crate::models::chain::{ ChainFlows, ChainFlowsSources, ChainInfo, ChainInfoSources, ChainProtocolEntry, ChainProtocols, - ChainProtocolsSources, ChainStablecoins, ChainStablecoinsSources, StablecoinType, + ChainProtocolsSources, ChainStablecoins, ChainStablecoinsSources, FlowEntry, StablecoinType, }; #[derive(Clone)] pub struct ChainClient { client: Client, defillama_url: String, + stablecoins_url: String, coingecko_url: String, coingecko_key: Option, } @@ -27,6 +28,9 @@ struct DefiLlamaChain { tvl: Option, #[serde(rename = "chainId", deserialize_with = "deserialize_optional_u64_from_string_or_int")] chain_id: Option, + /// CoinGecko ID of the chain's native token, supplied by DefiLlama. Authoritative + /// and current across all chains (e.g. Polygon → "polygon-ecosystem-token"). + gecko_id: Option, } /// DefiLlama returns `chainId` as either a JSON number or a quoted string. @@ -104,12 +108,16 @@ struct DefiLlamaProtocol { struct DefiLlamaStablecoin { name: String, symbol: String, - #[allow(dead_code)] - chain: Option, - chains: Option>, - circulating: Option, - #[allow(dead_code)] - peggedUSD: Option, + /// Per-chain circulating breakdown, keyed by DefiLlama chain name. Each entry + /// carries the current supply and the supply 24h ago, enabling flow math. + chainCirculating: Option>, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct DefiLlamaChainCirculating { + current: Option, + circulatingPrevDay: Option, } #[derive(Debug, Deserialize)] @@ -139,12 +147,14 @@ impl ChainClient { pub fn new( client: Client, defillama_url: &str, + stablecoins_url: &str, coingecko_url: &str, coingecko_key: Option, ) -> Self { Self { client, defillama_url: defillama_url.trim_end_matches('/').to_string(), + stablecoins_url: stablecoins_url.trim_end_matches('/').to_string(), coingecko_url: coingecko_url.trim_end_matches('/').to_string(), coingecko_key, } @@ -157,17 +167,14 @@ impl ChainClient { let matched = chains.iter().find(|c| c.name == resolved).unwrap(); let chain_id = matched.chain_id; let native_token = matched.token_symbol.clone(); + let gecko_id = matched.gecko_id.clone(); let tvl = matched.tvl; let sources_base = "defillama:chains".to_string(); - // Fetch native token price, fees, and active users concurrently - let price_fut = async { - if let Some(ref token) = native_token { - self.fetch_native_price(&resolved, token).await - } else { - None - } - }; + // Fetch native token price, fees, and active users concurrently. + // No native_token guard: DefiLlama omits the symbol for some chains + // (e.g. Base) whose native price is still resolvable via chain config. + let price_fut = self.fetch_native_price(&resolved, gecko_id.as_deref(), chain_id); let fees_fut = self.fetch_chain_fees(&resolved); let active_fut = self.fetch_chain_active_users(&resolved); @@ -221,16 +228,60 @@ impl ChainClient { let chains = self.fetch_defillama_chains().await?; let resolved = resolve_chain(chain, &chains)?; - // Flow data is limited with public APIs - return structure with available data + // Stablecoin flows are the one fund-flow dimension available without a paid + // plan: DefiLlama exposes per-chain current vs 24h-ago circulating supply. + // Net mint/burn approximates net stablecoin flow on the chain. + // Bridge flows require the paid bridges API; CEX flows have no public source. + let stablecoins = self.fetch_defillama_stablecoins(&resolved).await?; + + let mut stablecoin_flow: Vec = stablecoins + .iter() + .map(|s| FlowEntry { + name: s.name.clone(), + flow_usd: s.supply - s.prev_day_supply, + }) + .filter(|e| e.flow_usd.abs() >= 1.0) + .collect(); + stablecoin_flow.sort_by(|a, b| { + b.flow_usd + .abs() + .partial_cmp(&a.flow_usd.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + stablecoin_flow.truncate(15); + + let has_data = !stablecoins.is_empty(); + let inflow: f64 = stablecoins + .iter() + .map(|s| s.supply - s.prev_day_supply) + .filter(|d| *d > 0.0) + .sum(); + let outflow: f64 = stablecoins + .iter() + .map(|s| s.supply - s.prev_day_supply) + .filter(|d| *d < 0.0) + .map(f64::abs) + .sum(); + let net = inflow - outflow; + + let sc_source = has_data.then(|| "defillama:stablecoins".to_string()); + Ok(ChainFlows { chain: resolved, - net_flow_usd: None, - inflow_usd: None, - outflow_usd: None, + net_flow_usd: has_data.then_some(net), + inflow_usd: has_data.then_some(inflow), + outflow_usd: has_data.then_some(outflow), bridge_flow: Vec::new(), cex_flow: Vec::new(), - stablecoin_flow: Vec::new(), - sources: ChainFlowsSources::default(), + stablecoin_flow, + sources: ChainFlowsSources { + net_flow_usd: sc_source.clone(), + inflow_usd: sc_source.clone(), + outflow_usd: sc_source.clone(), + bridge_flow: None, + cex_flow: None, + stablecoin_flow: sc_source, + }, }) } @@ -241,9 +292,11 @@ impl ChainClient { let stablecoins = self.fetch_defillama_stablecoins(&resolved).await?; let total_supply: f64 = stablecoins.iter().map(|s| s.supply).sum(); + let total_prev_day: f64 = stablecoins.iter().map(|s| s.prev_day_supply).sum(); - let stablecoin_types: Vec = stablecoins + let mut stablecoin_types: Vec = stablecoins .iter() + .filter(|s| s.supply > 0.0) .map(|s| StablecoinType { name: s.name.clone(), supply: s.supply, @@ -254,12 +307,17 @@ impl ChainClient { }, }) .collect(); + stablecoin_types.sort_by(|a, b| { + b.supply + .partial_cmp(&a.supply) + .unwrap_or(std::cmp::Ordering::Equal) + }); - let supply_source = if stablecoin_types.is_empty() { - None - } else { - Some("defillama:stablecoins".to_string()) - }; + let has_data = !stablecoins.is_empty(); + let supply_source = has_data.then(|| "defillama:stablecoins".to_string()); + // 24h net change in circulating supply (mints minus burns) on this chain. + let flow_24h = total_supply - total_prev_day; + let flow_source = has_data.then(|| "defillama:stablecoins".to_string()); Ok(ChainStablecoins { chain: resolved, @@ -269,11 +327,11 @@ impl ChainClient { None }, stablecoin_types, - stablecoin_flow_24h: None, + stablecoin_flow_24h: has_data.then_some(flow_24h), sources: ChainStablecoinsSources { stablecoin_supply: supply_source.clone(), stablecoin_types: supply_source, - stablecoin_flow_24h: None, + stablecoin_flow_24h: flow_source, }, }) } @@ -282,7 +340,14 @@ impl ChainClient { let chains = self.fetch_defillama_chains().await?; let resolved = resolve_chain(chain, &chains)?; - let all_protocols = self.fetch_defillama_protocols().await?; + // Fetch the protocol list and the chain's per-protocol 24h revenue together. + let (all_protocols, revenue_by_name) = tokio::join!( + self.fetch_defillama_protocols(), + self.fetch_chain_protocol_revenue(&resolved) + ); + let all_protocols = all_protocols?; + let revenue_by_name = revenue_by_name.unwrap_or_default(); + let has_revenue = !revenue_by_name.is_empty(); // Filter protocols by chain and sort by TVL descending let mut protocols: Vec = all_protocols @@ -302,7 +367,7 @@ impl ChainClient { ChainProtocolEntry { name: p.name.clone(), tvl: chain_tvl, - revenue: None, + revenue: revenue_by_name.get(&p.name).copied(), users: None, category: p.category.clone(), } @@ -320,15 +385,16 @@ impl ChainClient { let limit = limit as usize; protocols.truncate(limit); + let mut source = format!("defillama:protocols ({} of {})", limit.min(total), total); + if has_revenue { + source.push_str(", revenue: defillama:fees"); + } + Ok(ChainProtocols { chain: resolved, protocols, sources: ChainProtocolsSources { - protocols: Some(format!( - "defillama:protocols ({} of {})", - limit.min(total), - total - )), + protocols: Some(source), }, }) } @@ -373,11 +439,51 @@ impl ChainClient { }) } + /// Fetch per-protocol 24h revenue for a chain from DefiLlama + /// `/overview/fees/{chain}` (dataType=dailyRevenue). Returns a name→revenue + /// map; an empty map on any failure so callers degrade gracefully. + async fn fetch_chain_protocol_revenue( + &self, + chain_name: &str, + ) -> Result> { + let url = format!("{}/overview/fees/{}", self.defillama_url, chain_name); + let req = self.client.get(&url).query(&[ + ("excludeTotalDataChart", "true"), + ("excludeTotalDataChartBreakdown", "true"), + ("dataType", "dailyRevenue"), + ]); + let body = match send_retrying(req, "defillama.chain_revenue").await { + Ok(resp) => match resp.error_for_status() { + Ok(r) => r.text().await.map_err(ChainError::Http)?, + Err(_) => return Ok(std::collections::HashMap::new()), + }, + Err(_) => return Ok(std::collections::HashMap::new()), + }; + + let value: Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return Ok(std::collections::HashMap::new()), + }; + + let mut map = std::collections::HashMap::new(); + if let Some(protocols) = value.get("protocols").and_then(|p| p.as_array()) { + for p in protocols { + if let (Some(name), Some(rev)) = ( + p.get("name").and_then(|n| n.as_str()), + p.get("total24h").and_then(|t| t.as_f64()), + ) { + map.insert(name.to_string(), rev); + } + } + } + Ok(map) + } + async fn fetch_defillama_stablecoins( &self, chain_name: &str, ) -> Result> { - let url = format!("{}/stablecoins", self.defillama_url); + let url = format!("{}/stablecoins", self.stablecoins_url); let req = self.client.get(&url).query(&[("includePrices", "false")]); let body = send_retrying(req, "defillama.stablecoins") .await? @@ -396,36 +502,65 @@ impl ChainClient { let assets = resp.peggedAssets.unwrap_or_default(); + // The stablecoins endpoint keys `chainCirculating` differently from the + // canonical /chains name for some chains (e.g. "Binance" → "BSC"). + let target = stablecoin_chain_name(chain_name); + Ok(assets .into_iter() .filter_map(|asset| { - // Check if this stablecoin exists on the target chain - let chains = asset.chains.unwrap_or_default(); - if !chains.iter().any(|c| c.eq_ignore_ascii_case(chain_name)) { - return None; - } - - let supply = asset - .circulating + // Pull this stablecoin's per-chain supply (current + 24h ago) for the + // target chain. Skip coins not present on the chain. + let per_chain = asset.chainCirculating.as_ref()?; + let entry = per_chain + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(target)) + .map(|(_, v)| v)?; + + let supply = entry + .current .as_ref() .and_then(|c| c.peggedUSD) .unwrap_or(0.0); + // Fall back to current supply when prev-day is missing so the 24h + // delta reads as zero rather than a spurious full-supply swing. + let prev_day_supply = entry + .circulatingPrevDay + .as_ref() + .and_then(|c| c.peggedUSD) + .unwrap_or(supply); - if supply <= 0.0 { + if supply <= 0.0 && prev_day_supply <= 0.0 { return None; } Some(SimpleStablecoin { name: format!("{} ({})", asset.name, asset.symbol), supply, + prev_day_supply, }) }) .collect()) } - async fn fetch_native_price(&self, chain_name: &str, _token_symbol: &str) -> Option { - // Map chain name to CoinGecko ID for the native token - let coingecko_id = chain_to_coingecko_id(chain_name)?; + async fn fetch_native_price( + &self, + chain_name: &str, + gecko_id: Option<&str>, + chain_id: Option, + ) -> Option { + // Resolve the native token's CoinGecko ID, most-authoritative first: + // 1. DefiLlama's per-chain `gecko_id` (current, covers all chains) + // 2. local chain config (keyed by chain ID) + // 3. name-based fallback map + let coingecko_id: &str = gecko_id + .filter(|s| !s.is_empty()) + .or_else(|| { + chain_id + .and_then(crate::config::chain_config) + .map(|c| c.native_token.coingecko_id) + }) + .or_else(|| chain_to_coingecko_id(chain_name))?; let url = format!("{}/simple/price", self.coingecko_url); let mut req = self @@ -509,6 +644,7 @@ impl ChainClient { struct SimpleStablecoin { name: String, supply: f64, + prev_day_supply: f64, } /// Resolve user input (chain ID, abbreviation, alias, or name) to the @@ -555,13 +691,24 @@ fn resolve_chain(input: &str, chains: &[DefiLlamaChain]) -> Result { ))) } +/// DefiLlama uses inconsistent chain names across endpoints. The canonical name +/// from `/chains` may differ from the stablecoins endpoint's `chainCirculating` +/// key. Translate a canonical name to the stablecoins-endpoint spelling. +fn stablecoin_chain_name(canonical: &str) -> &str { + match canonical { + "Binance" => "BSC", + other => other, + } +} + /// Map common abbreviations and aliases to DefiLlama canonical chain names. fn chain_alias_to_name(alias: &str) -> Option<&'static str> { match alias { // Ethereum "eth" | "ethereum" => Some("Ethereum"), // BNB Chain - "bsc" | "bnb" | "binance" | "bnbchain" | "bnbsmartchain" => Some("BNB Chain"), + // DefiLlama's /chains endpoint names BSC "Binance" (not "BNB Chain"). + "bsc" | "bnb" | "binance" | "bnbchain" | "bnbsmartchain" | "bnb chain" => Some("Binance"), // Polygon "matic" | "polygon" | "polygonpos" => Some("Polygon"), // Arbitrum diff --git a/src/api/mod.rs b/src/api/mod.rs index 9f5daef..4be72ff 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -141,6 +141,7 @@ impl ApiClients { chain: ChainClient::new( client, crate::config::DEFAULT_DEFILLAMA_API_URL, + crate::config::DEFAULT_DEFILLAMA_STABLECOINS_API_URL, &config.coingecko_api_url, config.coingecko_api_key.clone(), ), diff --git a/src/config/mod.rs b/src/config/mod.rs index fb4c7f5..cd51510 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,6 +16,8 @@ pub const DEFAULT_GOLDRUSH_API_URL: &str = "https://api.covalenthq.com/v1"; pub const DEFAULT_ZERION_API_URL: &str = "https://api.zerion.io/v1"; pub const DEFAULT_DUNE_API_URL: &str = "https://api.dune.com/api/v1"; pub const DEFAULT_DEFILLAMA_API_URL: &str = "https://api.llama.fi"; +/// DefiLlama serves stablecoin data from a separate host, not `api.llama.fi`. +pub const DEFAULT_DEFILLAMA_STABLECOINS_API_URL: &str = "https://stablecoins.llama.fi"; pub const DEFAULT_KEYSTORE_PASSWORD_ENV: &str = "KEYSTORE_PASSWORD"; /// Compile-time default: set `DODO_API_KEY` at build time to bake a key into the binary. From 13a131d46d84b38deeb53e15f636098c853cf2b7 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 11:23:37 +0800 Subject: [PATCH 03/14] fix: correct chain fees endpoint and per-chain protocol TVL - fees_24h used /summary/fees/{chain} (a protocol endpoint) which returned wrong values for Ethereum and N/A for most chains. Switch to the chain endpoint /overview/fees/{chain} (Ethereum 24h fees now ~8.8M vs bogus ~0.3M) - DefiLlamaProtocol.chain_tvls was missing #[serde(rename = "chainTvls")] so it never deserialized; per-chain TVL silently fell back to the protocol's global TVL (e.g. Binance CEX showed 140B on every chain). Add the rename and drop the global fallback so a protocol's TVL is chain-specific, or null when the chain has no breakdown Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index 0a6be4f..2e4d387 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -93,7 +93,9 @@ struct DefiLlamaProtocol { slug: String, category: Option, chains: Option>, + #[allow(dead_code)] tvl: Option, + #[serde(rename = "chainTvls")] chain_tvls: Option>, #[allow(dead_code)] #[serde(rename = "change_1d")] @@ -359,11 +361,13 @@ impl ChainClient { .unwrap_or(false) }) .map(|p| { + // Use the chain-specific TVL only. Do NOT fall back to the + // protocol's global TVL, which would overstate chains where the + // protocol's TVL isn't broken down (e.g. CEX entries). let chain_tvl = p .chain_tvls .as_ref() - .and_then(|tvls| tvls.get(&resolved).copied()) - .or(p.tvl); + .and_then(|tvls| tvls.get(&resolved).copied()); ChainProtocolEntry { name: p.name.clone(), tvl: chain_tvl, @@ -584,13 +588,16 @@ impl ChainClient { resp.prices.get(coingecko_id)?.usd } - /// Fetch 24h fees for a chain from DefiLlama `/summary/fees/{chain}`. + /// Fetch 24h fees for a chain from DefiLlama `/overview/fees/{chain}`. + /// (`/summary/fees/{x}` is a *protocol* endpoint and returns wrong/missing + /// data when given a chain name.) async fn fetch_chain_fees(&self, chain_name: &str) -> Result> { - let url = format!("{}/summary/fees/{}", self.defillama_url, chain_name); - let req = self - .client - .get(&url) - .query(&[("dataType", "dailyFees")]); + let url = format!("{}/overview/fees/{}", self.defillama_url, chain_name); + let req = self.client.get(&url).query(&[ + ("excludeTotalDataChart", "true"), + ("excludeTotalDataChartBreakdown", "true"), + ("dataType", "dailyFees"), + ]); let body = match send_retrying(req, "defillama.chain_fees").await { Ok(resp) => match resp.error_for_status() { Ok(r) => r.text().await.map_err(ChainError::Http)?, From 838f2e5d40b4c2947591e861178c56ddcb649341 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 11:44:41 +0800 Subject: [PATCH 04/14] feat: wire chain activity fields from growthepie active_addresses, tx_count_24h, and throughput were hardcoded to None with no data source. Wire them to growthepie's free per-chain API (chains/{url_key}.json), mapping EVM chain ID to growthepie's url_key. - daa -> active_addresses, txcount -> tx_count_24h, throughput -> throughput - replaces the dead /overview/activeUsers fetch (always 404'd) - only covers chains growthepie tracks (Ethereum + L2s); chains like BSC and Avalanche keep these fields N/A, which is honest given no source Still unsourced: bridge_flow (needs DefiLlama Pro key), cex_flow and protocols.users (no usable public source). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 143 +++++++++++++++++++++++++++++++--------------- src/api/mod.rs | 1 + src/config/mod.rs | 2 + 3 files changed, 101 insertions(+), 45 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index 2e4d387..86b2940 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -14,6 +14,7 @@ pub struct ChainClient { client: Client, defillama_url: String, stablecoins_url: String, + growthepie_url: String, coingecko_url: String, coingecko_key: Option, } @@ -150,6 +151,7 @@ impl ChainClient { client: Client, defillama_url: &str, stablecoins_url: &str, + growthepie_url: &str, coingecko_url: &str, coingecko_key: Option, ) -> Self { @@ -157,6 +159,7 @@ impl ChainClient { client, defillama_url: defillama_url.trim_end_matches('/').to_string(), stablecoins_url: stablecoins_url.trim_end_matches('/').to_string(), + growthepie_url: growthepie_url.trim_end_matches('/').to_string(), coingecko_url: coingecko_url.trim_end_matches('/').to_string(), coingecko_key, } @@ -173,34 +176,32 @@ impl ChainClient { let tvl = matched.tvl; let sources_base = "defillama:chains".to_string(); - // Fetch native token price, fees, and active users concurrently. + // Fetch native token price, fees, and chain activity concurrently. // No native_token guard: DefiLlama omits the symbol for some chains // (e.g. Base) whose native price is still resolvable via chain config. let price_fut = self.fetch_native_price(&resolved, gecko_id.as_deref(), chain_id); let fees_fut = self.fetch_chain_fees(&resolved); - let active_fut = self.fetch_chain_active_users(&resolved); + // growthepie supplies active addresses / tx count / throughput, but only + // for the Ethereum-ecosystem chains it tracks (keyed by EVM chain ID). + let gp_key = chain_id.and_then(chain_id_to_growthepie); + let activity_fut = async { + match gp_key { + Some(key) => self.fetch_growthepie_activity(key).await, + None => GrowthepieActivity::default(), + } + }; - let (native_price, fees_result, active_result) = - tokio::join!(price_fut, fees_fut, active_fut); + let (native_price, fees_result, activity) = + tokio::join!(price_fut, fees_fut, activity_fut); let fees_24h = fees_result.unwrap_or(None); - let active_addresses = active_result.unwrap_or(None); + let active_addresses = activity.active_addresses; + let tx_count_24h = activity.tx_count_24h; + let throughput = activity.throughput; - let price_source = if native_price.is_some() { - Some("coingecko".to_string()) - } else { - None - }; - let fees_source = if fees_24h.is_some() { - Some("defillama:fees".to_string()) - } else { - None - }; - let active_source = if active_addresses.is_some() { - Some("defillama:activeUsers".to_string()) - } else { - None - }; + let price_source = native_price.is_some().then(|| "coingecko".to_string()); + let fees_source = fees_24h.is_some().then(|| "defillama:fees".to_string()); + let gp_source = || "growthepie".to_string(); Ok(ChainInfo { chain: resolved, @@ -209,19 +210,19 @@ impl ChainClient { native_price, tvl, active_addresses, - tx_count_24h: None, + tx_count_24h, fees_24h, - throughput: None, + throughput, sources: ChainInfoSources { chain: Some(sources_base.clone()), chain_id: Some(sources_base.clone()), native_token: Some(sources_base.clone()), native_price: price_source, tvl: Some(sources_base), - active_addresses: active_source, - tx_count_24h: None, + active_addresses: active_addresses.map(|_| gp_source()), + tx_count_24h: tx_count_24h.map(|_| gp_source()), fees_24h: fees_source, - throughput: None, + throughput: throughput.map(|_| gp_source()), }, }) } @@ -616,38 +617,56 @@ impl ChainClient { Ok(value.get("total24h").and_then(|v| v.as_f64())) } - /// Fetch active users for a chain from DefiLlama. - async fn fetch_chain_active_users(&self, chain_name: &str) -> Result> { - let url = format!("{}/overview/activeUsers", self.defillama_url); - let req = self - .client - .get(&url) - .query(&[("chain", chain_name)]); - let body = match send_retrying(req, "defillama.chain_active_users").await { + /// Fetch latest daily active addresses, tx count, and throughput for a chain + /// from growthepie's per-chain JSON. Returns defaults (all None) on any + /// failure so callers degrade gracefully. + async fn fetch_growthepie_activity(&self, url_key: &str) -> GrowthepieActivity { + let url = format!("{}/v1/chains/{}.json", self.growthepie_url, url_key); + let body = match send_retrying(self.client.get(&url), "growthepie.chain").await { Ok(resp) => match resp.error_for_status() { - Ok(r) => r.text().await.map_err(ChainError::Http)?, - Err(_) => return Ok(None), + Ok(r) => match r.text().await { + Ok(b) => b, + Err(_) => return GrowthepieActivity::default(), + }, + Err(_) => return GrowthepieActivity::default(), }, - Err(_) => return Ok(None), + Err(_) => return GrowthepieActivity::default(), }; let value: Value = match serde_json::from_str(&body) { Ok(v) => v, - Err(_) => return Ok(None), + Err(_) => return GrowthepieActivity::default(), }; - // Try to extract total active users for the chain - // Response may have a `total24h` or `users24h` field - let count = value - .get("total24h") - .or_else(|| value.get("users24h")) - .and_then(|v| v.as_f64()) - .map(|v| v as u64); + let metrics = value.get("data").and_then(|d| d.get("metrics")); + let latest = |key: &str| -> Option { + metrics? + .get(key)? + .get("daily")? + .get("data")? + .as_array()? + .last()? + .as_array()? + .get(1)? + .as_f64() + }; - Ok(count) + GrowthepieActivity { + active_addresses: latest("daa").map(|v| v as u64), + tx_count_24h: latest("txcount").map(|v| v as u64), + throughput: latest("throughput"), + } } } +/// Latest chain-activity metrics from growthepie. +#[derive(Default)] +struct GrowthepieActivity { + active_addresses: Option, + tx_count_24h: Option, + throughput: Option, +} + struct SimpleStablecoin { name: String, supply: f64, @@ -698,6 +717,40 @@ fn resolve_chain(input: &str, chains: &[DefiLlamaChain]) -> Result { ))) } +/// Map an EVM chain ID to growthepie's `url_key` (used in `chains/{key}.json`). +/// growthepie tracks the Ethereum ecosystem (L1 + L2s); chains it doesn't cover +/// (e.g. BSC, Avalanche) return None and keep activity fields as N/A. +fn chain_id_to_growthepie(chain_id: u64) -> Option<&'static str> { + Some(match chain_id { + 1 => "ethereum", + 10 => "optimism", + 130 => "unichain", + 137 => "polygon-pos", + 169 => "manta", + 252 => "fraxtal", + 324 => "zksync-era", + 480 => "worldchain", + 1088 => "metis", + 1135 => "lisk", + 1625 => "gravity", + 1868 => "soneium", + 2020 => "ronin", + 5000 => "mantle", + 8453 => "base", + 34443 => "mode", + 42161 => "arbitrum", + 42170 => "arbitrum-nova", + 42220 => "celo", + 48900 => "zircuit", + 57073 => "ink", + 59144 => "linea", + 98866 => "plume", + 167000 => "taiko", + 534352 => "scroll", + _ => return None, + }) +} + /// DefiLlama uses inconsistent chain names across endpoints. The canonical name /// from `/chains` may differ from the stablecoins endpoint's `chainCirculating` /// key. Translate a canonical name to the stablecoins-endpoint spelling. diff --git a/src/api/mod.rs b/src/api/mod.rs index 4be72ff..8dd1b8c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -142,6 +142,7 @@ impl ApiClients { client, crate::config::DEFAULT_DEFILLAMA_API_URL, crate::config::DEFAULT_DEFILLAMA_STABLECOINS_API_URL, + crate::config::DEFAULT_GROWTHEPIE_API_URL, &config.coingecko_api_url, config.coingecko_api_key.clone(), ), diff --git a/src/config/mod.rs b/src/config/mod.rs index cd51510..a2f3a65 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,6 +18,8 @@ pub const DEFAULT_DUNE_API_URL: &str = "https://api.dune.com/api/v1"; pub const DEFAULT_DEFILLAMA_API_URL: &str = "https://api.llama.fi"; /// DefiLlama serves stablecoin data from a separate host, not `api.llama.fi`. pub const DEFAULT_DEFILLAMA_STABLECOINS_API_URL: &str = "https://stablecoins.llama.fi"; +/// growthepie open API: per-chain activity (active addresses, tx count, throughput). +pub const DEFAULT_GROWTHEPIE_API_URL: &str = "https://api.growthepie.xyz"; pub const DEFAULT_KEYSTORE_PASSWORD_ENV: &str = "KEYSTORE_PASSWORD"; /// Compile-time default: set `DODO_API_KEY` at build time to bake a key into the binary. From ad9723bd0683ed32ead881e0917c37c99d5af829 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 14:03:27 +0800 Subject: [PATCH 05/14] refactor: drop chain fields with no data source Remove bridge_flow, cex_flow (ChainFlows) and users (ChainProtocolEntry) along with their renderers. These had no usable data source (bridge needs a paid DefiLlama Pro key; CEX flows and per-protocol users have no public API) and only ever rendered as empty/N/A, which was misleading. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 6 ------ src/models/chain.rs | 30 +----------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index 86b2940..f27e062 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -234,7 +234,6 @@ impl ChainClient { // Stablecoin flows are the one fund-flow dimension available without a paid // plan: DefiLlama exposes per-chain current vs 24h-ago circulating supply. // Net mint/burn approximates net stablecoin flow on the chain. - // Bridge flows require the paid bridges API; CEX flows have no public source. let stablecoins = self.fetch_defillama_stablecoins(&resolved).await?; let mut stablecoin_flow: Vec = stablecoins @@ -274,15 +273,11 @@ impl ChainClient { net_flow_usd: has_data.then_some(net), inflow_usd: has_data.then_some(inflow), outflow_usd: has_data.then_some(outflow), - bridge_flow: Vec::new(), - cex_flow: Vec::new(), stablecoin_flow, sources: ChainFlowsSources { net_flow_usd: sc_source.clone(), inflow_usd: sc_source.clone(), outflow_usd: sc_source.clone(), - bridge_flow: None, - cex_flow: None, stablecoin_flow: sc_source, }, }) @@ -373,7 +368,6 @@ impl ChainClient { name: p.name.clone(), tvl: chain_tvl, revenue: revenue_by_name.get(&p.name).copied(), - users: None, category: p.category.clone(), } }) diff --git a/src/models/chain.rs b/src/models/chain.rs index 78dec00..a5366df 100644 --- a/src/models/chain.rs +++ b/src/models/chain.rs @@ -37,8 +37,6 @@ pub struct ChainFlows { pub net_flow_usd: Option, pub inflow_usd: Option, pub outflow_usd: Option, - pub bridge_flow: Vec, - pub cex_flow: Vec, pub stablecoin_flow: Vec, pub sources: ChainFlowsSources, } @@ -48,8 +46,6 @@ pub struct ChainFlowsSources { pub net_flow_usd: Option, pub inflow_usd: Option, pub outflow_usd: Option, - pub bridge_flow: Option, - pub cex_flow: Option, pub stablecoin_flow: Option, } @@ -103,7 +99,6 @@ pub struct ChainProtocolEntry { pub name: String, pub tvl: Option, pub revenue: Option, - pub users: Option, pub category: Option, } @@ -200,26 +195,6 @@ impl crate::output::TableRenderable for ChainFlows { ]); println!("{}", table); - if !self.bridge_flow.is_empty() { - let mut t = Table::new(); - t.set_header(vec!["Bridge", "Flow (USD)"]); - for entry in &self.bridge_flow { - t.add_row(vec![&entry.name, &format_usd(entry.flow_usd)]); - } - println!("Bridge flows:"); - println!("{}", t); - } - - if !self.cex_flow.is_empty() { - let mut t = Table::new(); - t.set_header(vec!["Exchange", "Flow (USD)"]); - for entry in &self.cex_flow { - t.add_row(vec![&entry.name, &format_usd(entry.flow_usd)]); - } - println!("CEX flows:"); - println!("{}", t); - } - if !self.stablecoin_flow.is_empty() { let mut t = Table::new(); t.set_header(vec!["Stablecoin", "Flow (USD)"]); @@ -278,15 +253,12 @@ impl crate::output::TableRenderable for ChainProtocols { } let mut table = Table::new(); - table.set_header(vec!["Protocol", "TVL (USD)", "Revenue (USD)", "Users", "Category"]); + table.set_header(vec!["Protocol", "TVL (USD)", "Revenue (USD)", "Category"]); for p in &self.protocols { table.add_row(vec![ &p.name, &format_optional_usd(p.tvl), &format_optional_usd(p.revenue), - &p.users - .map(|v| v.to_string()) - .unwrap_or_else(|| "N/A".to_string()), p.category.as_deref().unwrap_or("N/A"), ]); } From 4d104e6e7b2b557dcc8a9a7fff73784774a7d7d4 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 15:30:17 +0800 Subject: [PATCH 06/14] fix: use growthepie dict-key (underscore) for chain activity URLs chain_id_to_growthepie returned the dashed url_key (e.g. "polygon-pos"), but growthepie's chains/{key}.json endpoint keys files by the master dict key (underscored, "polygon_pos") and 403s on the dashed form. Single-word chains were unaffected; polygon, zksync_era and arbitrum_nova returned no activity data. Use the underscore form. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index f27e062..134a6a6 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -711,7 +711,9 @@ fn resolve_chain(input: &str, chains: &[DefiLlamaChain]) -> Result { ))) } -/// Map an EVM chain ID to growthepie's `url_key` (used in `chains/{key}.json`). +/// Map an EVM chain ID to growthepie's chain key used in `chains/{key}.json`. +/// This is the master `chains` dict key (underscored, e.g. `polygon_pos`), NOT +/// the `url_key` (dashed) — the file endpoint 403s on the dashed form. /// growthepie tracks the Ethereum ecosystem (L1 + L2s); chains it doesn't cover /// (e.g. BSC, Avalanche) return None and keep activity fields as N/A. fn chain_id_to_growthepie(chain_id: u64) -> Option<&'static str> { @@ -719,10 +721,10 @@ fn chain_id_to_growthepie(chain_id: u64) -> Option<&'static str> { 1 => "ethereum", 10 => "optimism", 130 => "unichain", - 137 => "polygon-pos", + 137 => "polygon_pos", 169 => "manta", 252 => "fraxtal", - 324 => "zksync-era", + 324 => "zksync_era", 480 => "worldchain", 1088 => "metis", 1135 => "lisk", @@ -733,7 +735,7 @@ fn chain_id_to_growthepie(chain_id: u64) -> Option<&'static str> { 8453 => "base", 34443 => "mode", 42161 => "arbitrum", - 42170 => "arbitrum-nova", + 42170 => "arbitrum_nova", 42220 => "celo", 48900 => "zircuit", 57073 => "ink", From b2db4b14bc878ac5f76ce7c76d4246db6de965f9 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 16:02:49 +0800 Subject: [PATCH 07/14] docs: document chain analytics subcommands in chainpilot skill Add a `chain` Subcommands section (info/flows/stablecoins/protocols) with each command's fields, data sources, and coverage caveats (growthepie activity only for Ethereum-ecosystem chains; flows are stablecoin-scope only; protocol TVL is chain-specific or N/A). Also surface chain-level analytics in the skill description for trigger accuracy. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/chainpilot/SKILL.md | 66 ++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index f32dc34..89c0ec8 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -6,12 +6,14 @@ description: > managing ERC-20 approvals, querying token metadata, creating tokens through DODO's ERC20V3Factory, minting mintable tokens, checking wallet balances, cross-chain portfolio overviews, DeFi positions, PnL analysis, transaction - history, and wallet labels (via Debank / Zerion / Goldrush / Dune), and - running risk analysis. Always use this skill when the user mentions - chainpilot, wants to swap tokens, create a token, mint a token, check a - token's risk score, query wallet balances, DeFi positions, portfolio - breakdown, PnL, transaction history, wallet labels, approve or - revoke a spender, or inspect token contract metadata on any EVM chain + history, and wallet labels (via Debank / Zerion / Goldrush / Dune), + chain-level analytics (TVL, fees, native price, stablecoins, top protocols, + active addresses / tx count / throughput), and running risk analysis. Always + use this skill when the user mentions chainpilot, wants to swap tokens, create + a token, mint a token, check a token's risk score, query wallet balances, + DeFi positions, portfolio breakdown, PnL, transaction history, wallet labels, + inspect a chain's TVL / fees / stablecoins / top protocols / activity, approve + or revoke a spender, or inspect token contract metadata on any EVM chain (Ethereum, Arbitrum, Base, BNB Chain, Polygon, etc.). --- @@ -681,6 +683,58 @@ Single approval state. --- +## `chain` Subcommands + +Chain-level analytics. The `` argument accepts a name, chain ID, or alias +(e.g. `ethereum` / `1` / `eth`, `bsc` / `bnb`, `base`, `arbitrum`). Data comes +from free public sources (DefiLlama, CoinGecko, growthepie) — no API key required. +Every field is rendered with a `Source` column (or `sources` object in `--json`) +naming its data origin; fields with no available source show `N/A`. + +### `chain info` + +```bash +chainpilot chain info +``` + +Overview: chain ID, native token and USD price, TVL, 24h fees, and 24h activity +(active addresses, tx count, throughput). Activity fields come from growthepie and +are only available for the Ethereum-ecosystem chains it tracks (L1 + L2s such as +Base, Arbitrum, Optimism, Polygon, Linea, Scroll). Independent L1s like BNB Chain +and Avalanche show `N/A` for active addresses / tx count / throughput. + +### `chain flows` + +```bash +chainpilot chain flows +``` + +Stablecoin-based fund flow: net flow, inflow, outflow, and a per-stablecoin 24h +breakdown (mint vs burn). Scope is stablecoins only — not total cross-chain flow; +bridge and CEX flows have no free data source and are not reported. + +### `chain stablecoins` + +```bash +chainpilot chain stablecoins +``` + +Stablecoin supply on the chain, 24h supply change, and a per-coin breakdown with +share percentages. + +### `chain protocols` + +```bash +chainpilot chain protocols [--limit ] +``` + +Top protocols on the chain by chain-specific TVL, with 24h revenue and category. +`--limit` defaults to 20 (max 100). TVL is the protocol's TVL on this chain +specifically; it shows `N/A` when DefiLlama has no per-chain breakdown for that +protocol (no fallback to the protocol's global TVL). + +--- + ## Token Resolution Tokens can be a symbol, native token symbol, or a `0x` address. Resolution order: From 4dfe3a8fc22db783fd4dc46537ee9d2bd9a7aa14 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 16:42:15 +0800 Subject: [PATCH 08/14] fix: correct stale chain subcommand help text `chain flows` --help still advertised "(bridge, CEX, stablecoin flows)" after bridge/CEX were removed, and `chain info` omitted the fees and 24h activity fields. Align the help summaries with actual behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/chain.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/chain.rs b/src/cli/chain.rs index 1d09709..54a4cce 100644 --- a/src/cli/chain.rs +++ b/src/cli/chain.rs @@ -9,9 +9,9 @@ pub struct ChainCmd { #[derive(Subcommand)] pub enum ChainAction { - /// Chain overview (chain ID, native token, TVL, native price) + /// Chain overview (chain ID, native token, price, TVL, fees, 24h activity) Info(ChainArgs), - /// Chain fund flows (bridge, CEX, stablecoin flows) + /// Chain stablecoin fund flows (net, inflow, outflow, per-coin breakdown) Flows(ChainArgs), /// Chain stablecoin supply and distribution Stablecoins(ChainArgs), From a926e267ba6f8cc8acb72488165f689d174a9a42 Mon Sep 17 00:00:00 2001 From: JunJie Date: Mon, 8 Jun 2026 16:42:15 +0800 Subject: [PATCH 09/14] docs: document protocol analytics subcommands in chainpilot skill The protocol command (info/tvl/revenue/chains) had no skill documentation. Add a section covering each subcommand, its flags, and output fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/chainpilot/SKILL.md | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 89c0ec8..6549c39 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -683,6 +683,47 @@ Single approval state. --- +## `protocol` Subcommands + +Protocol-level analytics from DefiLlama. The `` argument is a DefiLlama +slug or protocol name (e.g. `lido`, `aave`, `uniswap`). No API key required. + +### `protocol info` + +```bash +chainpilot protocol info +``` + +Overview: name, category, primary chain, website, description, current TVL, 24h +revenue, and 24h fees. + +### `protocol tvl` + +```bash +chainpilot protocol tvl [--limit ] [--offset ] +``` + +Current TVL, 24h/7d/30d TVL change, and a TVL history series. `--limit` defaults +to 7 (max 1000) newest points; `--offset` skips that many newest points first. + +### `protocol revenue` + +```bash +chainpilot protocol revenue +``` + +Revenue (24h/7d/30d) and fees (24h/7d). + +### `protocol chains` + +```bash +chainpilot protocol chains +``` + +The protocol's TVL distribution across the chains it's deployed on. + +--- + ## `chain` Subcommands Chain-level analytics. The `` argument accepts a name, chain ID, or alias From 1e740695ac2cc6fe338e59a1c43de39e8d666407 Mon Sep 17 00:00:00 2001 From: JunJie Date: Tue, 9 Jun 2026 10:07:37 +0800 Subject: [PATCH 10/14] chore: bump version to 0.5.0-rc.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9bdd52..467feb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.4.0" +version = "0.5.0-rc.1" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 8345fbb..ffab693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.4.0" +version = "0.5.0-rc.1" description = "A CLI tool for on-chain DeFi operations on EVM-compatible networks" homepage = "https://github.com/DODOEX/ChainPilot" documentation = "https://github.com/DODOEX/ChainPilot#readme" From 3377e5ebddc8b673b456f2d879eb28c12282d866 Mon Sep 17 00:00:00 2001 From: JunJie Date: Tue, 9 Jun 2026 19:06:39 +0800 Subject: [PATCH 11/14] fix: exclude non-protocol categories from chain protocols list CEX, bridge and canonical-bridge/chain entries report a per-chain TVL in DefiLlama's /protocols response (e.g. Binance CEX has chainTvls["Ethereum"]), so they dominated the chain protocols list with large, misleading TVLs. Filter them out by category, and additionally require a chainTvls[chain] value so every row shows a real per-chain TVL. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/chain.rs | 54 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/api/chain.rs b/src/api/chain.rs index 134a6a6..b987cf3 100644 --- a/src/api/chain.rs +++ b/src/api/chain.rs @@ -93,6 +93,7 @@ struct DefiLlamaProtocol { #[allow(dead_code)] slug: String, category: Option, + #[allow(dead_code)] chains: Option>, #[allow(dead_code)] tvl: Option, @@ -347,29 +348,31 @@ impl ChainClient { let revenue_by_name = revenue_by_name.unwrap_or_default(); let has_revenue = !revenue_by_name.is_empty(); - // Filter protocols by chain and sort by TVL descending + // Keep only on-chain DeFi protocols with a chain-specific TVL for this + // chain. Two filters apply: + // 1. `chainTvls[chain]` must be present — we read the chain's net TVL + // from that exact key and deliberately do NOT fall back to the + // protocol's global TVL, which would overstate the chain. + // 2. The category must not be a non-protocol bucket. CEX, bridge and + // canonical-bridge/chain entries DO report a per-chain TVL (e.g. + // Binance CEX has chainTvls["Ethereum"]) and would otherwise + // dominate the list, so they're excluded by category. let mut protocols: Vec = all_protocols .iter() - .filter(|p| { - p.chains - .as_ref() - .map(|chains| chains.iter().any(|c| c.eq_ignore_ascii_case(&resolved))) - .unwrap_or(false) - }) - .map(|p| { - // Use the chain-specific TVL only. Do NOT fall back to the - // protocol's global TVL, which would overstate chains where the - // protocol's TVL isn't broken down (e.g. CEX entries). + .filter_map(|p| { + if is_excluded_category(p.category.as_deref()) { + return None; + } let chain_tvl = p .chain_tvls .as_ref() - .and_then(|tvls| tvls.get(&resolved).copied()); - ChainProtocolEntry { + .and_then(|tvls| tvls.get(&resolved).copied())?; + Some(ChainProtocolEntry { name: p.name.clone(), - tvl: chain_tvl, + tvl: Some(chain_tvl), revenue: revenue_by_name.get(&p.name).copied(), category: p.category.clone(), - } + }) }) .collect(); @@ -669,6 +672,27 @@ struct SimpleStablecoin { /// Resolve user input (chain ID, abbreviation, alias, or name) to the /// canonical DefiLlama chain name. Matching order: +/// DefiLlama categories that aren't on-chain DeFi protocols. Each still reports +/// a per-chain TVL (e.g. Binance CEX has `chainTvls["Ethereum"]`), so they're +/// excluded by category rather than by missing data. Compared case-insensitively. +const EXCLUDED_PROTOCOL_CATEGORIES: &[&str] = &[ + "CEX", + "Bridge", + "Canonical Bridge", + "Cross Chain Bridge", + "Chain", +]; + +/// Whether a protocol's category should be excluded from a chain's protocol +/// list. A missing category is kept (treated as a regular protocol). +fn is_excluded_category(category: Option<&str>) -> bool { + category.is_some_and(|c| { + EXCLUDED_PROTOCOL_CATEGORIES + .iter() + .any(|x| x.eq_ignore_ascii_case(c)) + }) +} + /// 1. Numeric chain ID (e.g. "1", "8453") /// 2. Well-known abbreviation/alias (e.g. "eth", "arb", "op") /// 3. Exact DefiLlama name (case-insensitive) From 58cd50fb46ed5567e4504f5abe522d53ee4a09d3 Mon Sep 17 00:00:00 2001 From: JunJie Date: Tue, 9 Jun 2026 19:06:39 +0800 Subject: [PATCH 12/14] chore: bump version to 0.5.0-rc.2 Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 467feb2..ec72b8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.5.0-rc.1" +version = "0.5.0-rc.2" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index ffab693..536d350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.5.0-rc.1" +version = "0.5.0-rc.2" description = "A CLI tool for on-chain DeFi operations on EVM-compatible networks" homepage = "https://github.com/DODOEX/ChainPilot" documentation = "https://github.com/DODOEX/ChainPilot#readme" From 33b4e27e2f903eb8a038d39409dedf5ceb3663cc Mon Sep 17 00:00:00 2001 From: JunJie Date: Tue, 9 Jun 2026 19:31:18 +0800 Subject: [PATCH 13/14] docs: note category filtering in chain protocols skill Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/chainpilot/SKILL.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 6549c39..009f4fa 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -769,10 +769,17 @@ share percentages. chainpilot chain protocols [--limit ] ``` -Top protocols on the chain by chain-specific TVL, with 24h revenue and category. -`--limit` defaults to 20 (max 100). TVL is the protocol's TVL on this chain -specifically; it shows `N/A` when DefiLlama has no per-chain breakdown for that -protocol (no fallback to the protocol's global TVL). +Top on-chain DeFi protocols on the chain by chain-specific TVL, with 24h revenue +and category. `--limit` defaults to 20 (max 100). TVL is the protocol's TVL on +this chain specifically (no fallback to its global TVL). + +The list is filtered to genuine on-chain protocols: + +- Non-protocol categories are excluded — `CEX`, `Bridge`, `Canonical Bridge`, + `Cross Chain Bridge`, and `Chain`. These report a per-chain TVL in DefiLlama + (e.g. Binance CEX has an Ethereum TVL) and would otherwise dominate the list. +- Protocols with no per-chain TVL for this chain are dropped, so every row shows + a real TVL figure. --- From 79b129c51134caec6d68df5ce60a159454d1a072 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 10 Jun 2026 10:08:24 +0800 Subject: [PATCH 14/14] chore: bump version to 1.0.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec72b8f..d67c852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.5.0-rc.2" +version = "1.0.0" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 536d350..a1ae3e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.5.0-rc.2" +version = "1.0.0" description = "A CLI tool for on-chain DeFi operations on EVM-compatible networks" homepage = "https://github.com/DODOEX/ChainPilot" documentation = "https://github.com/DODOEX/ChainPilot#readme"