diff --git a/Cargo.lock b/Cargo.lock index e9bdd52..d67c852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.4.0" +version = "1.0.0" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 8345fbb..a1ae3e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.4.0" +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" diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index f32dc34..009f4fa 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,106 @@ 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 +(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 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. + +--- + ## Token Resolution Tokens can be a symbol, native token symbol, or a `0x` address. Resolution order: diff --git a/src/api/chain.rs b/src/api/chain.rs new file mode 100644 index 0000000..b987cf3 --- /dev/null +++ b/src/api/chain.rs @@ -0,0 +1,890 @@ +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, FlowEntry, StablecoinType, +}; + +#[derive(Clone)] +pub struct ChainClient { + client: Client, + defillama_url: String, + stablecoins_url: String, + growthepie_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, + /// 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. +/// 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, + #[allow(dead_code)] + chains: Option>, + #[allow(dead_code)] + tvl: Option, + #[serde(rename = "chainTvls")] + 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, + /// 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)] +#[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, + stablecoins_url: &str, + growthepie_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(), + growthepie_url: growthepie_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 gecko_id = matched.gecko_id.clone(); + let tvl = matched.tvl; + let sources_base = "defillama:chains".to_string(); + + // 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); + // 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, activity) = + tokio::join!(price_fut, fees_fut, activity_fut); + + let fees_24h = fees_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 = 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, + chain_id, + native_token, + native_price, + tvl, + active_addresses, + tx_count_24h, + fees_24h, + 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_addresses.map(|_| gp_source()), + tx_count_24h: tx_count_24h.map(|_| gp_source()), + fees_24h: fees_source, + throughput: throughput.map(|_| gp_source()), + }, + }) + } + + pub async fn chain_flows(&self, chain: &str) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + // 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. + 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: has_data.then_some(net), + inflow_usd: has_data.then_some(inflow), + outflow_usd: has_data.then_some(outflow), + stablecoin_flow, + sources: ChainFlowsSources { + net_flow_usd: sc_source.clone(), + inflow_usd: sc_source.clone(), + outflow_usd: sc_source.clone(), + stablecoin_flow: sc_source, + }, + }) + } + + 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 total_prev_day: f64 = stablecoins.iter().map(|s| s.prev_day_supply).sum(); + + let mut stablecoin_types: Vec = stablecoins + .iter() + .filter(|s| s.supply > 0.0) + .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(); + stablecoin_types.sort_by(|a, b| { + b.supply + .partial_cmp(&a.supply) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + 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, + stablecoin_supply: if total_supply > 0.0 { + Some(total_supply) + } else { + None + }, + stablecoin_types, + stablecoin_flow_24h: has_data.then_some(flow_24h), + sources: ChainStablecoinsSources { + stablecoin_supply: supply_source.clone(), + stablecoin_types: supply_source, + stablecoin_flow_24h: flow_source, + }, + }) + } + + pub async fn chain_protocols(&self, chain: &str, limit: u32) -> Result { + let chains = self.fetch_defillama_chains().await?; + let resolved = resolve_chain(chain, &chains)?; + + // 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(); + + // 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_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())?; + Some(ChainProtocolEntry { + name: p.name.clone(), + tvl: Some(chain_tvl), + revenue: revenue_by_name.get(&p.name).copied(), + 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); + + 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(source), + }, + }) + } + + // ── 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) + )) + }) + } + + /// 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.stablecoins_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(); + + // 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| { + // 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 && 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, + 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 + .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 `/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!("{}/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)?, + 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 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) => match r.text().await { + Ok(b) => b, + Err(_) => return GrowthepieActivity::default(), + }, + Err(_) => return GrowthepieActivity::default(), + }, + Err(_) => return GrowthepieActivity::default(), + }; + + let value: Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return GrowthepieActivity::default(), + }; + + 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() + }; + + 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, + prev_day_supply: f64, +} + +/// 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) +/// 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 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> { + 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. +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 + // 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 + "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..8dd1b8c 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,15 @@ 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, + 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/cli/chain.rs b/src/cli/chain.rs new file mode 100644 index 0000000..54a4cce --- /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, price, TVL, fees, 24h activity) + Info(ChainArgs), + /// Chain stablecoin fund flows (net, inflow, outflow, per-coin breakdown) + 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/config/mod.rs b/src/config/mod.rs index fb4c7f5..a2f3a65 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,6 +16,10 @@ 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"; +/// 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. diff --git a/src/models/chain.rs b/src/models/chain.rs new file mode 100644 index 0000000..a5366df --- /dev/null +++ b/src/models/chain.rs @@ -0,0 +1,282 @@ +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 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 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 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.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)", "Category"]); + for p in &self.protocols { + table.add_row(vec![ + &p.name, + &format_optional_usd(p.tvl), + &format_optional_usd(p.revenue), + 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;