From 0a10aa3df69e9dfd25eee003b4552e6e5e9634e8 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 10:33:32 +0800 Subject: [PATCH 01/13] feat(token): add `token price` subcommand with CoinGecko primary + DexScreener fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `token price ` subcommand returning price, 1h/24h/7d changes, 24h high/low with per-field source tracking (CoinGecko primary, DexScreener backup for price/change_1h/change_24h) - Rename `liquidity` → `primary_liquidity` across models, table labels, and JSON output - Add `TokenMetadataClient` with CoinGecko and DexScreener integration - Add JSON Output Field Reference section to SKILL.md documenting which fields are display-ready vs raw integers Co-Authored-By: Claude Opus 4.7 --- .env.example | 11 + Cargo.lock | 3 + Cargo.toml | 3 + skills/chainpilot/SKILL.md | 22 + src/api/mod.rs | 6 +- src/api/token_metadata.rs | 1046 ++++++++++++++++++++++++++++++++++++ src/chain/erc20.rs | 37 +- src/chain/rpc.rs | 18 + src/cli/token.rs | 6 +- src/commands/risk.rs | 10 - src/commands/swap.rs | 36 ++ src/commands/token.rs | 87 +++ src/config/mod.rs | 81 +++ src/models/token.rs | 78 ++- src/output/table.rs | 142 ++++- src/store/quote.rs | 32 +- 16 files changed, 1576 insertions(+), 42 deletions(-) create mode 100644 src/api/token_metadata.rs diff --git a/.env.example b/.env.example index 3012f02..959b7c6 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,17 @@ DODO_PROJECT_ID= # DODO route API endpoint (rarely needs changing) # DODO_API_URL=https://api.dodoex.io/route-service/v2/widget/getdodoroute +# Optional token metadata and market data sources used by `token info`. +# CoinGecko and DexScreener work without keys, subject to their public rate limits. +# COINGECKO_API_URL=https://api.coingecko.com/api/v3 +# COINGECKO_API_KEY= +# DEXSCREENER_API_URL=https://api.dexscreener.com/latest/dex +# OKX_DEX_API_URL=https://web3.okx.com +# OKX_API_KEY= +# OKX_API_SECRET= +# OKX_API_PASSPHRASE= +# OKX_PROJECT_ID= + # ── Wallet ──────────────────────────────────────────────────────────────────── # Private key used for signing transactions. # PRIVATE_KEY=0x... diff --git a/Cargo.lock b/Cargo.lock index dbeb9b6..7021ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,6 +1178,7 @@ dependencies = [ "alloy-signer-local", "alloy-sol-types", "anyhow", + "base64", "chrono", "clap", "colored", @@ -1185,10 +1186,12 @@ dependencies = [ "dirs", "dotenvy", "hex", + "hmac", "rand 0.8.5", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5379b45..4d7e5a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hex = "0.4" url = "2" +base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" thiserror = "2.0" anyhow = "1.0" dotenvy = "0.15" diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index bc6e402..4d47965 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -303,6 +303,28 @@ chainpilot [--chain-id ] token contract On-chain contract details: proxy, owner, implementation address. +### `token price` + +```bash +chainpilot [--chain-id ] token price +``` + +Real-time price, short-term and mid-term changes, and 24h high/low. CoinGecko is the primary data +source; DexScreener is a fallback for `price`, `price_change_1h`, and `price_change_24h` when +CoinGecko has no value (e.g. long-tail tokens not listed there). + +| Field | Primary | Fallback | Notes | +|---|---|---|---| +| `price` | CoinGecko | DexScreener | USD spot price | +| `price_change_1h` | CoinGecko | DexScreener | % change over 1 hour | +| `price_change_24h` | CoinGecko | DexScreener | % change over 24 hours | +| `price_change_7d` | CoinGecko | — | % change over 7 days | +| `high_24h` | CoinGecko | — | 24h high (USD) | +| `low_24h` | CoinGecko | — | 24h low (USD) | + +The JSON output includes a `sources` map indicating which API supplied each field, so callers can +distinguish CoinGecko-backed values from DexScreener-backed ones. + ### `token add` ```bash diff --git a/src/api/mod.rs b/src/api/mod.rs index 921b103..60850b7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,8 @@ mod dodo; +mod token_metadata; pub use dodo::DodoClient; +pub use token_metadata::TokenMetadataClient; use crate::config::AppConfig; use crate::error::Result; @@ -8,6 +10,7 @@ use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE, USER_AGENT}; pub struct ApiClients { pub dodo: DodoClient, + pub token_metadata: TokenMetadataClient, } impl ApiClients { @@ -31,11 +34,12 @@ impl ApiClients { Ok(Self { dodo: DodoClient::new( - client, + client.clone(), &config.dodo_api_url, &config.dodo_api_key, &config.dodo_project_id, ), + token_metadata: TokenMetadataClient::new(client, config), }) } } diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs new file mode 100644 index 0000000..05e9087 --- /dev/null +++ b/src/api/token_metadata.rs @@ -0,0 +1,1046 @@ +use std::time::Duration; + +use base64::Engine; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde::Deserialize; +use sha2::Sha256; + +use crate::config::AppConfig; +use crate::models::token::{ + TokenInfo, TokenPrice, TokenPriceSources, TokenSearchCandidate, TokenSearchResult, + TokenSocialLinks, +}; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub struct TokenMetadataClient { + client: Client, + coingecko_base_url: String, + coingecko_api_key: Option, + dexscreener_base_url: String, + okx_base_url: String, + okx_api_key: Option, + okx_api_secret: Option, + okx_api_passphrase: Option, + okx_project_id: Option, +} + +#[derive(Debug, Default)] +struct TokenMetadataPatch { + name: Option<(String, String)>, + symbol: Option<(String, String)>, + address: Option<(String, String)>, + website: Option<(String, String)>, + social_links: Option<(TokenSocialLinks, String)>, + price: Option<(f64, String)>, + market_cap: Option<(f64, String)>, + fdv: Option<(f64, String)>, + primary_liquidity: Option<(f64, String)>, + volume_24h: Option<(f64, String)>, + price_change_24h: Option<(f64, String)>, + risk_level: Option<(String, String)>, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoToken { + symbol: Option, + name: Option, + links: Option, + market_data: Option, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoSearchResponse { + coins: Option>, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoSearchCoin { + symbol: Option, + name: Option, + platforms: Option>, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoLinks { + homepage: Option>, + blockchain_site: Option>, + official_forum_url: Option>, + chat_url: Option>, + twitter_screen_name: Option, + telegram_channel_identifier: Option, + repos_url: Option, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoRepos { + github: Option>, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoMarketData { + current_price: Option, + market_cap: Option, + fully_diluted_valuation: Option, + total_volume: Option, + high_24h: Option, + low_24h: Option, + price_change_percentage_1h_in_currency: Option, + price_change_percentage_24h: Option, + price_change_percentage_7d_in_currency: Option, +} + +#[derive(Debug, Deserialize)] +struct UsdValue { + usd: Option, +} + +#[derive(Debug, Deserialize)] +struct DexScreenerResponse { + pairs: Option>, +} + +#[derive(Debug, Deserialize)] +struct DexScreenerPair { + #[serde(rename = "baseToken")] + base_token: Option, + liquidity: Option, + #[serde(rename = "priceUsd")] + price_usd: Option, + #[serde(rename = "priceChange")] + price_change: Option, +} + +#[derive(Debug, Deserialize)] +struct DexScreenerPriceChange { + h1: Option, + h24: Option, +} + +#[derive(Debug, Deserialize)] +struct DexScreenerToken { + address: Option, + name: Option, + symbol: Option, +} + +#[derive(Debug, Deserialize)] +struct OkxSearchEnvelope { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct OkxSearchData { + #[serde(rename = "tokenList")] + token_list: Option>, +} + +#[derive(Debug, Deserialize)] +struct DexScreenerLiquidity { + usd: Option, +} + +#[derive(Debug, Deserialize)] +struct OkxEnvelope { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct OkxToken { + #[serde(rename = "tokenContractAddress")] + token_contract_address: Option, + #[serde(rename = "tokenSymbol")] + token_symbol: Option, + #[serde(rename = "tokenName")] + token_name: Option, + #[serde(rename = "tagList")] + tag_list: Option, +} + +#[derive(Debug, Deserialize)] +struct OkxTagList { + #[serde(rename = "communityRecognized")] + community_recognized: Option, +} + +impl TokenMetadataClient { + pub fn new(client: Client, config: &AppConfig) -> Self { + Self { + client, + coingecko_base_url: config.coingecko_api_url.trim_end_matches('/').to_string(), + coingecko_api_key: config.coingecko_api_key.clone(), + dexscreener_base_url: config.dexscreener_api_url.trim_end_matches('/').to_string(), + okx_base_url: config.okx_dex_api_url.trim_end_matches('/').to_string(), + okx_api_key: config.okx_api_key.clone(), + okx_api_secret: config.okx_api_secret.clone(), + okx_api_passphrase: config.okx_api_passphrase.clone(), + okx_project_id: config.okx_project_id.clone(), + } + } + + pub async fn enrich(&self, mut info: TokenInfo) -> TokenInfo { + let chain_slug = coingecko_platform_id(info.chain_id); + let address = info.address.clone(); + + let coingecko = match chain_slug { + Some(platform) => self.fetch_coingecko(platform, &address).await.ok(), + None => None, + }; + let dexscreener = self.fetch_dexscreener(&address).await.ok(); + let okx = self.fetch_okx_token(info.chain_id, &address).await; + + let mut patch = TokenMetadataPatch::default(); + apply_coingecko(&mut patch, coingecko); + apply_dexscreener(&mut patch, dexscreener, &address); + apply_okx(&mut patch, okx); + apply_patch(&mut info, patch); + info + } + + pub async fn fetch_price(&self, chain_id: u64, address: &str, symbol: &str) -> TokenPrice { + let chain_slug = coingecko_platform_id(chain_id); + + let coingecko = match chain_slug { + Some(platform) => self.fetch_coingecko(platform, address).await.ok(), + None => None, + }; + let dexscreener = self.fetch_dexscreener(address).await.ok(); + + let mut price = TokenPrice { + address: address.to_string(), + symbol: symbol.to_string(), + chain_id, + price: None, + price_change_1h: None, + price_change_24h: None, + price_change_7d: None, + high_24h: None, + low_24h: None, + sources: TokenPriceSources::default(), + }; + + apply_coingecko_price(&mut price, coingecko); + apply_dexscreener_price(&mut price, dexscreener, address); + price + } + + pub async fn search_symbol(&self, query: &str, chain_id: u64) -> TokenSearchResult { + let mut candidates = Vec::new(); + + candidates.extend(self.search_okx(query, chain_id).await); + candidates.extend(self.search_coingecko(query, chain_id).await); + candidates.extend(self.search_dexscreener(query).await); + + TokenSearchResult { + query: query.to_string(), + chain_id, + candidates, + } + } + + async fn search_coingecko(&self, query: &str, chain_id: u64) -> Vec { + let Some(platform) = coingecko_platform_id(chain_id) else { + return Vec::new(); + }; + let url = format!("{}/search", self.coingecko_base_url); + let mut req = self + .client + .get(url) + .timeout(Duration::from_secs(8)) + .query(&[("query", query)]); + if let Some(key) = &self.coingecko_api_key { + req = req.header("x-cg-demo-api-key", key); + } + + let Ok(response) = req + .send() + .await + .and_then(reqwest::Response::error_for_status) + else { + return Vec::new(); + }; + let Ok(search) = response.json::().await else { + return Vec::new(); + }; + let query_upper = query.to_uppercase(); + search + .coins + .unwrap_or_default() + .into_iter() + .filter_map(|coin| { + let symbol = non_empty(coin.symbol).map(|symbol| symbol.to_uppercase())?; + if symbol != query_upper { + return None; + } + let address = coin + .platforms + .as_ref() + .and_then(|platforms| platforms.get(platform)) + .and_then(|address| non_empty(Some(address.clone()))); + Some(TokenSearchCandidate { + source: "coingecko".to_string(), + symbol, + name: non_empty(coin.name), + address, + chain: Some(platform.to_string()), + primary_liquidity: None, + }) + }) + .take(3) + .collect() + } + + async fn search_dexscreener(&self, query: &str) -> Vec { + let url = format!("{}/search", self.dexscreener_base_url); + let Ok(response) = self + .client + .get(url) + .timeout(Duration::from_secs(8)) + .query(&[("q", query)]) + .send() + .await + .and_then(reqwest::Response::error_for_status) + else { + return Vec::new(); + }; + let Ok(search) = response.json::().await else { + return Vec::new(); + }; + let query_upper = query.to_uppercase(); + let mut pairs = search.pairs.unwrap_or_default(); + pairs.sort_by(|a, b| { + liquidity_usd(b) + .partial_cmp(&liquidity_usd(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + pairs + .into_iter() + .filter_map(|pair| { + let token = pair.base_token?; + let symbol = non_empty(token.symbol).map(|symbol| symbol.to_uppercase())?; + if symbol != query_upper { + return None; + } + Some(TokenSearchCandidate { + source: "dexscreener".to_string(), + symbol, + name: non_empty(token.name), + address: non_empty(token.address), + chain: None, + primary_liquidity: pair.liquidity.and_then(|liquidity| liquidity.usd), + }) + }) + .take(3) + .collect() + } + + async fn search_okx(&self, query: &str, chain_id: u64) -> Vec { + let api_key = match self.okx_api_key.as_ref() { + Some(value) => value, + None => return Vec::new(), + }; + let secret = match self.okx_api_secret.as_ref() { + Some(value) => value, + None => return Vec::new(), + }; + let passphrase = match self.okx_api_passphrase.as_ref() { + Some(value) => value, + None => return Vec::new(), + }; + let request_path = "/api/v6/dex/market/token/search"; + let body = serde_json::json!({ + "chainIndex": chain_id.to_string(), + "tokenSymbol": query, + }) + .to_string(); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let Ok(signature) = okx_signature(×tamp, "POST", request_path, &body, secret) else { + return Vec::new(); + }; + let url = format!("{}{}", self.okx_base_url, request_path); + + let mut req = self + .client + .post(url) + .timeout(Duration::from_secs(8)) + .header("OK-ACCESS-KEY", api_key) + .header("OK-ACCESS-SIGN", signature) + .header("OK-ACCESS-TIMESTAMP", timestamp) + .header("OK-ACCESS-PASSPHRASE", passphrase) + .body(body); + if let Some(project_id) = &self.okx_project_id { + req = req.header("OK-ACCESS-PROJECT", project_id); + } + + let Ok(response) = req + .send() + .await + .and_then(reqwest::Response::error_for_status) + else { + return Vec::new(); + }; + let Ok(search) = response.json::().await else { + return Vec::new(); + }; + let query_upper = query.to_uppercase(); + search + .data + .and_then(|data| data.token_list) + .unwrap_or_default() + .into_iter() + .filter_map(|token| { + let symbol = non_empty(token.token_symbol).map(|symbol| symbol.to_uppercase())?; + if symbol != query_upper { + return None; + } + Some(TokenSearchCandidate { + source: "okx-onchainos".to_string(), + symbol, + name: non_empty(token.token_name), + address: non_empty(token.token_contract_address), + chain: Some(chain_id.to_string()), + primary_liquidity: None, + }) + }) + .take(3) + .collect() + } + + async fn fetch_coingecko( + &self, + platform: &str, + address: &str, + ) -> Result { + let url = format!( + "{}/coins/{}/contract/{}", + self.coingecko_base_url, platform, address + ); + let mut req = self + .client + .get(url) + .timeout(Duration::from_secs(8)) + .query(&[ + ("localization", "false"), + ("tickers", "false"), + ("community_data", "false"), + ("developer_data", "false"), + ("sparkline", "false"), + ]); + if let Some(key) = &self.coingecko_api_key { + req = req.header("x-cg-demo-api-key", key); + } + req.send().await?.error_for_status()?.json().await + } + + async fn fetch_dexscreener( + &self, + address: &str, + ) -> Result { + let url = format!("{}/tokens/{}", self.dexscreener_base_url, address); + self.client + .get(url) + .timeout(Duration::from_secs(8)) + .send() + .await? + .error_for_status()? + .json() + .await + } + + async fn fetch_okx_token(&self, chain_id: u64, address: &str) -> Option { + let api_key = self.okx_api_key.as_ref()?; + let secret = self.okx_api_secret.as_ref()?; + let passphrase = self.okx_api_passphrase.as_ref()?; + let request_path = "/api/v6/dex/market/token/basic-info"; + let body = serde_json::json!([ + { + "chainIndex": chain_id.to_string(), + "tokenContractAddress": address, + } + ]) + .to_string(); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let signature = okx_signature(×tamp, "POST", request_path, &body, secret).ok()?; + let url = format!("{}{}", self.okx_base_url, request_path); + + let mut req = self + .client + .post(url) + .timeout(Duration::from_secs(8)) + .header("OK-ACCESS-KEY", api_key) + .header("OK-ACCESS-SIGN", signature) + .header("OK-ACCESS-TIMESTAMP", timestamp) + .header("OK-ACCESS-PASSPHRASE", passphrase) + .body(body); + if let Some(project_id) = &self.okx_project_id { + req = req.header("OK-ACCESS-PROJECT", project_id); + } + + let envelope = req + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json::>>() + .await + .ok()?; + + envelope.data?.into_iter().find(|token| { + token + .token_contract_address + .as_deref() + .is_some_and(|token_address| token_address.eq_ignore_ascii_case(address)) + }) + } +} + +fn apply_coingecko(patch: &mut TokenMetadataPatch, token: Option) { + let Some(token) = token else { + return; + }; + if let Some(name) = non_empty(token.name) { + patch.name = Some((name, "coingecko".to_string())); + } + if let Some(symbol) = non_empty(token.symbol).map(|s| s.to_uppercase()) { + patch.symbol = Some((symbol, "coingecko".to_string())); + } + + if let Some(links) = token.links { + if let Some(url) = first_non_empty(links.homepage) { + patch.website = Some((url, "coingecko".to_string())); + } + let mut social = TokenSocialLinks::default(); + social.x = non_empty(links.twitter_screen_name).map(|name| { + if name.starts_with("http") { + name + } else { + format!("https://x.com/{}", name.trim_start_matches('@')) + } + }); + social.telegram = non_empty(links.telegram_channel_identifier).map(|name| { + if name.starts_with("http") { + name + } else { + format!("https://t.me/{}", name.trim_start_matches('@')) + } + }); + social.discord = first_non_empty(links.chat_url); + social.github = links + .repos_url + .and_then(|repos| first_non_empty(repos.github)); + social.docs = first_non_empty(links.blockchain_site) + .or_else(|| first_non_empty(links.official_forum_url)); + if has_social_links(&social) { + patch.social_links = Some((social, "coingecko".to_string())); + } + } + + if let Some(market) = token.market_data { + if let Some(value) = market.current_price.and_then(|v| v.usd) { + patch.price = Some((value, "coingecko".to_string())); + } + if let Some(value) = market.market_cap.and_then(|v| v.usd) { + patch.market_cap = Some((value, "coingecko".to_string())); + } + if let Some(value) = market.fully_diluted_valuation.and_then(|v| v.usd) { + patch.fdv = Some((value, "coingecko".to_string())); + } + if let Some(value) = market.total_volume.and_then(|v| v.usd) { + patch.volume_24h = Some((value, "coingecko".to_string())); + } + if let Some(value) = market.price_change_percentage_24h { + patch.price_change_24h = Some((value, "coingecko".to_string())); + } + } +} + +fn apply_dexscreener( + patch: &mut TokenMetadataPatch, + response: Option, + address: &str, +) { + let Some(response) = response else { + return; + }; + let Some(pair) = response + .pairs + .unwrap_or_default() + .into_iter() + .filter(|pair| { + pair.base_token + .as_ref() + .and_then(|token| token.address.as_deref()) + .is_some_and(|base| base.eq_ignore_ascii_case(address)) + }) + .max_by(|a, b| { + liquidity_usd(a) + .partial_cmp(&liquidity_usd(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + else { + return; + }; + + if patch.address.is_none() { + if let Some(token) = &pair.base_token { + if let Some(address) = non_empty(token.address.clone()) { + patch.address = Some((address, "dexscreener".to_string())); + } + } + } + if patch.name.is_none() { + if let Some(name) = pair + .base_token + .as_ref() + .and_then(|t| non_empty(t.name.clone())) + { + patch.name = Some((name, "dexscreener".to_string())); + } + } + if patch.symbol.is_none() { + if let Some(symbol) = pair + .base_token + .as_ref() + .and_then(|t| non_empty(t.symbol.clone())) + { + patch.symbol = Some((symbol, "dexscreener".to_string())); + } + } + if let Some(value) = pair.liquidity.and_then(|v| v.usd) { + patch.primary_liquidity = Some((value, "dexscreener".to_string())); + } +} + +fn apply_coingecko_price(price: &mut TokenPrice, token: Option) { + let Some(token) = token else { + return; + }; + let Some(market) = token.market_data else { + return; + }; + + if let Some(value) = market.current_price.and_then(|v| v.usd) { + price.price = Some(value); + price.sources.price = Some("coingecko".to_string()); + } + if let Some(value) = market + .price_change_percentage_1h_in_currency + .and_then(|v| v.usd) + { + price.price_change_1h = Some(value); + price.sources.price_change_1h = Some("coingecko".to_string()); + } + if let Some(value) = market.price_change_percentage_24h { + price.price_change_24h = Some(value); + price.sources.price_change_24h = Some("coingecko".to_string()); + } + if let Some(value) = market + .price_change_percentage_7d_in_currency + .and_then(|v| v.usd) + { + price.price_change_7d = Some(value); + price.sources.price_change_7d = Some("coingecko".to_string()); + } + if let Some(value) = market.high_24h.and_then(|v| v.usd) { + price.high_24h = Some(value); + price.sources.high_24h = Some("coingecko".to_string()); + } + if let Some(value) = market.low_24h.and_then(|v| v.usd) { + price.low_24h = Some(value); + price.sources.low_24h = Some("coingecko".to_string()); + } +} + +fn apply_dexscreener_price( + price: &mut TokenPrice, + response: Option, + address: &str, +) { + let Some(response) = response else { + return; + }; + let Some(pair) = response + .pairs + .unwrap_or_default() + .into_iter() + .filter(|pair| { + pair.base_token + .as_ref() + .and_then(|token| token.address.as_deref()) + .is_some_and(|base| base.eq_ignore_ascii_case(address)) + }) + .max_by(|a, b| { + liquidity_usd(a) + .partial_cmp(&liquidity_usd(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + else { + return; + }; + + if price.price.is_none() { + if let Some(value) = pair + .price_usd + .as_deref() + .and_then(|s| s.parse::().ok()) + { + price.price = Some(value); + price.sources.price = Some("dexscreener".to_string()); + } + } + if let Some(change) = pair.price_change { + if price.price_change_1h.is_none() { + if let Some(value) = change.h1 { + price.price_change_1h = Some(value); + price.sources.price_change_1h = Some("dexscreener".to_string()); + } + } + if price.price_change_24h.is_none() { + if let Some(value) = change.h24 { + price.price_change_24h = Some(value); + price.sources.price_change_24h = Some("dexscreener".to_string()); + } + } + } +} + +fn apply_okx(patch: &mut TokenMetadataPatch, token: Option) { + let Some(token) = token else { + return; + }; + if patch.name.is_none() { + if let Some(name) = non_empty(token.token_name) { + patch.name = Some((name, "okx-onchainos".to_string())); + } + } + if patch.symbol.is_none() { + if let Some(symbol) = non_empty(token.token_symbol) { + patch.symbol = Some((symbol, "okx-onchainos".to_string())); + } + } + if patch.address.is_none() { + if let Some(address) = non_empty(token.token_contract_address) { + patch.address = Some((address, "okx-onchainos".to_string())); + } + } + if patch.risk_level.is_none() { + if let Some(community_recognized) = + token.tag_list.and_then(|tags| tags.community_recognized) + { + let risk = if community_recognized { + "low" + } else { + "unknown" + }; + patch.risk_level = Some((risk.to_string(), "okx-onchainos".to_string())); + } + } +} + +fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { + let mut sources = info.metadata_sources.clone(); + + if let Some((value, source)) = patch.name { + info.name = value; + sources.identity.get_or_insert(source); + } + if let Some((value, source)) = patch.symbol { + info.symbol = value; + sources.identity.get_or_insert(source); + } + if let Some((value, source)) = patch.address { + info.address = value; + sources.address = Some(source); + } + if let Some((value, source)) = patch.website { + info.website = Some(value); + sources.website = Some(source); + } + if let Some((value, source)) = patch.social_links { + info.social_links = value; + sources.social_links = Some(source); + } + if let Some((value, source)) = patch.price { + info.price = Some(value); + sources.price = Some(source); + } + if let Some((value, source)) = patch.market_cap { + info.market_cap = Some(value); + sources.market_cap = Some(source); + } + if let Some((value, source)) = patch.fdv { + info.fdv = Some(value); + sources.fdv = Some(source); + } + if let Some((value, source)) = patch.primary_liquidity { + info.primary_liquidity = Some(value); + sources.primary_liquidity = Some(source); + } + if let Some((value, source)) = patch.volume_24h { + info.volume_24h = Some(value); + sources.volume_24h = Some(source); + } + if let Some((value, source)) = patch.price_change_24h { + info.price_change_24h = Some(value); + sources.price_change_24h = Some(source); + } + if let Some((value, source)) = patch.risk_level { + info.risk_level = Some(value); + sources.risk_level = Some(source); + } + + info.metadata_sources = sources; +} + +fn coingecko_platform_id(chain_id: u64) -> Option<&'static str> { + match chain_id { + 1 => Some("ethereum"), + 56 => Some("binance-smart-chain"), + 137 => Some("polygon-pos"), + 42161 => Some("arbitrum-one"), + 10 => Some("optimistic-ethereum"), + 43114 => Some("avalanche"), + 8453 => Some("base"), + 59144 => Some("linea"), + 534352 => Some("scroll"), + 5000 => Some("mantle"), + 1313161554 => Some("aurora"), + _ => None, + } +} + +fn okx_signature( + timestamp: &str, + method: &str, + request_path: &str, + body: &str, + secret: &str, +) -> Result { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?; + mac.update(format!("{}{}{}{}", timestamp, method, request_path, body).as_bytes()); + Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())) +} + +fn first_non_empty(values: Option>) -> Option { + values? + .into_iter() + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn non_empty(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn has_social_links(links: &TokenSocialLinks) -> bool { + links.x.is_some() + || links.telegram.is_some() + || links.discord.is_some() + || links.github.is_some() + || links.docs.is_some() +} + +fn liquidity_usd(pair: &DexScreenerPair) -> f64 { + pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coingecko_platform_id_maps_supported_chains() { + assert_eq!(coingecko_platform_id(1), Some("ethereum")); + assert_eq!(coingecko_platform_id(8453), Some("base")); + assert_eq!(coingecko_platform_id(999999), None); + } + + #[test] + fn apply_dexscreener_uses_highest_liquidity_matching_base_pair() { + let mut patch = TokenMetadataPatch::default(); + apply_dexscreener( + &mut patch, + Some(DexScreenerResponse { + pairs: Some(vec![ + DexScreenerPair { + base_token: Some(DexScreenerToken { + address: Some("0xabc".to_string()), + name: Some("Low".to_string()), + symbol: Some("LOW".to_string()), + }), + liquidity: Some(DexScreenerLiquidity { usd: Some(10.0) }), + price_usd: None, + price_change: None, + }, + DexScreenerPair { + base_token: Some(DexScreenerToken { + address: Some("0xAbC".to_string()), + name: Some("High".to_string()), + symbol: Some("HIGH".to_string()), + }), + liquidity: Some(DexScreenerLiquidity { usd: Some(20.0) }), + price_usd: None, + price_change: None, + }, + ]), + }), + "0xabc", + ); + + assert_eq!(patch.name.unwrap().0, "High"); + assert_eq!(patch.primary_liquidity.unwrap().0, 20.0); + assert!(patch.price.is_none()); + assert!(patch.volume_24h.is_none()); + assert!(patch.price_change_24h.is_none()); + } + + #[test] + fn apply_dexscreener_accepts_highest_liquidity_pair_across_chains() { + let mut patch = TokenMetadataPatch::default(); + apply_dexscreener( + &mut patch, + Some(DexScreenerResponse { + pairs: Some(vec![DexScreenerPair { + base_token: Some(DexScreenerToken { + address: Some("0xabc".to_string()), + name: Some("Wrong Chain".to_string()), + symbol: Some("WRONG".to_string()), + }), + liquidity: Some(DexScreenerLiquidity { usd: Some(20.0) }), + price_usd: None, + price_change: None, + }]), + }), + "0xabc", + ); + + assert_eq!(patch.name.unwrap().0, "Wrong Chain"); + assert_eq!(patch.primary_liquidity.unwrap().0, 20.0); + assert!(patch.price.is_none()); + } + + fn empty_token_price() -> TokenPrice { + TokenPrice { + address: "0xabc".to_string(), + symbol: "TEST".to_string(), + chain_id: 1, + price: None, + price_change_1h: None, + price_change_24h: None, + price_change_7d: None, + high_24h: None, + low_24h: None, + sources: TokenPriceSources::default(), + } + } + + #[test] + fn apply_coingecko_price_populates_all_fields_from_market_data() { + let mut price = empty_token_price(); + apply_coingecko_price( + &mut price, + Some(CoinGeckoToken { + symbol: None, + name: None, + links: None, + market_data: Some(CoinGeckoMarketData { + current_price: Some(UsdValue { usd: Some(1.5) }), + market_cap: None, + fully_diluted_valuation: None, + total_volume: None, + high_24h: Some(UsdValue { usd: Some(2.0) }), + low_24h: Some(UsdValue { usd: Some(1.0) }), + price_change_percentage_1h_in_currency: Some(UsdValue { usd: Some(0.5) }), + price_change_percentage_24h: Some(3.0), + price_change_percentage_7d_in_currency: Some(UsdValue { usd: Some(10.0) }), + }), + }), + ); + + assert_eq!(price.price, Some(1.5)); + assert_eq!(price.price_change_1h, Some(0.5)); + assert_eq!(price.price_change_24h, Some(3.0)); + assert_eq!(price.price_change_7d, Some(10.0)); + assert_eq!(price.high_24h, Some(2.0)); + assert_eq!(price.low_24h, Some(1.0)); + assert_eq!(price.sources.price.as_deref(), Some("coingecko")); + assert_eq!(price.sources.price_change_7d.as_deref(), Some("coingecko")); + assert_eq!(price.sources.high_24h.as_deref(), Some("coingecko")); + } + + #[test] + fn apply_dexscreener_price_only_fills_unset_fields() { + let mut price = empty_token_price(); + price.price = Some(1.5); + price.sources.price = Some("coingecko".to_string()); + price.price_change_24h = Some(3.0); + price.sources.price_change_24h = Some("coingecko".to_string()); + + apply_dexscreener_price( + &mut price, + Some(DexScreenerResponse { + pairs: Some(vec![DexScreenerPair { + base_token: Some(DexScreenerToken { + address: Some("0xabc".to_string()), + name: None, + symbol: None, + }), + liquidity: Some(DexScreenerLiquidity { usd: Some(100.0) }), + price_usd: Some("9.9".to_string()), + price_change: Some(DexScreenerPriceChange { + h1: Some(0.7), + h24: Some(99.0), + }), + }]), + }), + "0xabc", + ); + + assert_eq!(price.price, Some(1.5)); + assert_eq!(price.sources.price.as_deref(), Some("coingecko")); + assert_eq!(price.price_change_24h, Some(3.0)); + assert_eq!(price.sources.price_change_24h.as_deref(), Some("coingecko")); + assert_eq!(price.price_change_1h, Some(0.7)); + assert_eq!( + price.sources.price_change_1h.as_deref(), + Some("dexscreener") + ); + } + + #[test] + fn apply_dexscreener_price_fills_when_coingecko_absent() { + let mut price = empty_token_price(); + apply_dexscreener_price( + &mut price, + Some(DexScreenerResponse { + pairs: Some(vec![DexScreenerPair { + base_token: Some(DexScreenerToken { + address: Some("0xabc".to_string()), + name: None, + symbol: None, + }), + liquidity: Some(DexScreenerLiquidity { usd: Some(100.0) }), + price_usd: Some("2.5".to_string()), + price_change: Some(DexScreenerPriceChange { + h1: Some(-1.0), + h24: Some(5.0), + }), + }]), + }), + "0xabc", + ); + + assert_eq!(price.price, Some(2.5)); + assert_eq!(price.sources.price.as_deref(), Some("dexscreener")); + assert_eq!(price.price_change_1h, Some(-1.0)); + assert_eq!(price.price_change_24h, Some(5.0)); + assert!(price.price_change_7d.is_none()); + assert!(price.high_24h.is_none()); + assert!(price.low_24h.is_none()); + } +} diff --git a/src/chain/erc20.rs b/src/chain/erc20.rs index 459af27..fa7a97d 100644 --- a/src/chain/erc20.rs +++ b/src/chain/erc20.rs @@ -6,7 +6,7 @@ use alloy_sol_types::{sol, SolCall}; use crate::chain::OnChainClient; use crate::error::{ChainError, Result}; -use crate::models::token::{TokenContract, TokenInfo}; +use crate::models::token::{TokenContract, TokenInfo, TokenMetadataSources, TokenSocialLinks}; sol! { #[sol(rpc)] @@ -14,7 +14,6 @@ sol! { function name() external view returns (string); function symbol() external view returns (string); function decimals() external view returns (uint8); - function totalSupply() external view returns (uint256); function balanceOf(address owner) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); function mint(address to, uint256 amount) external; @@ -55,15 +54,6 @@ pub async fn get_token_info(client: &OnChainClient, token_address: Address) -> R .call() .await .map_err(|e| ChainError::Rpc(format!("decimals failed: {:?}", e)))?; - let total_supply = erc20 - .totalSupply() - .call() - .await - .map_err(|e| ChainError::Rpc(format!("totalSupply failed: {:?}", e)))?; - - let total_supply_str = total_supply.to_string(); - let decimals_u64 = decimals as u64; - let total_supply_display = parse_token_amount(&total_supply_str, decimals_u64); Ok(TokenInfo { address: token_address.to_string(), @@ -71,9 +61,22 @@ pub async fn get_token_info(client: &OnChainClient, token_address: Address) -> R name, decimals, chain_id: client.chain_id, - total_supply: total_supply_str, - total_supply_display, - source: "on-chain".to_string(), + chain: crate::config::chain_config(client.chain_id).map(|c| c.name.to_string()), + website: None, + social_links: TokenSocialLinks::default(), + price: None, + market_cap: None, + fdv: None, + primary_liquidity: None, + volume_24h: None, + price_change_24h: None, + risk_level: None, + metadata_sources: TokenMetadataSources { + identity: Some("on-chain".to_string()), + address: Some("resolver".to_string()), + chain: crate::config::chain_config(client.chain_id).map(|_| "chain-config".to_string()), + ..TokenMetadataSources::default() + }, }) } @@ -175,12 +178,6 @@ pub async fn inspect_token_contract( }) } -fn parse_token_amount(raw: &str, decimals: u64) -> f64 { - let raw_uint: u128 = raw.parse().unwrap_or(0); - let divisor = 10u128.pow(decimals as u32) as f64; - raw_uint as f64 / divisor -} - fn address_from_storage(storage: U256) -> Address { let bytes = storage.to_be_bytes::<32>(); Address::from_slice(&bytes[12..]) diff --git a/src/chain/rpc.rs b/src/chain/rpc.rs index 0f32572..d27d1bb 100644 --- a/src/chain/rpc.rs +++ b/src/chain/rpc.rs @@ -289,6 +289,16 @@ mod tests { } fn base_config() -> AppConfig { + let ( + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, + ) = crate::config::test_metadata_config_fields(); AppConfig { rpc_url: "https://ethereum-rpc.publicnode.com".to_string(), rpc_url_overridden: false, @@ -301,6 +311,14 @@ mod tests { dodo_api_url: "https://api.dodoex.io".to_string(), dodo_api_key: String::new(), dodo_project_id: String::new(), + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, data_dir: std::env::temp_dir(), } } diff --git a/src/cli/token.rs b/src/cli/token.rs index 3fa0c0e..40d1eca 100644 --- a/src/cli/token.rs +++ b/src/cli/token.rs @@ -9,10 +9,12 @@ pub struct TokenCmd { #[derive(Subcommand)] pub enum TokenAction { - /// Token metadata (name, symbol, decimals, supply) + /// Token metadata; unresolved symbols return candidate matches Info(TokenIdentArg), /// On-chain contract details Contract(TokenIdentArg), + /// Real-time price, % changes, and 24h high/low (CoinGecko primary, DexScreener fallback) + Price(TokenIdentArg), /// Save a custom token so later symbol lookups can resolve it Add(TokenAddArgs), /// Create a token via DODO's ERC20V3Factory @@ -27,7 +29,7 @@ pub enum TokenAction { #[derive(Args)] pub struct TokenIdentArg { - /// Token symbol or contract address + /// Token symbol or contract address; unknown symbols return external-source candidates pub token: String, } diff --git a/src/commands/risk.rs b/src/commands/risk.rs index 4279bc4..087540e 100644 --- a/src/commands/risk.rs +++ b/src/commands/risk.rs @@ -73,16 +73,6 @@ async fn token_risk( let mut signals = Vec::new(); let mut overall_risk = RiskLevel::Low; - if info.total_supply_display < 1_000_000.0 { - signals.push(RiskSignal { - signal: "low_total_supply".to_string(), - description: "Total supply is very low, may indicate thin liquidity".to_string(), - severity: RiskLevel::High, - value: serde_json::json!({ "supply": info.total_supply_display }), - }); - overall_risk = RiskLevel::High; - } - if info.decimals == 0 { signals.push(RiskSignal { signal: "zero_decimals".to_string(), diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 7749810..da8df21 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -1340,6 +1340,16 @@ mod tests { } fn test_config(chain_id: u64) -> AppConfig { + let ( + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, + ) = crate::config::test_metadata_config_fields(); AppConfig { rpc_url: "https://rpc.example.com".to_string(), rpc_url_overridden: false, @@ -1352,6 +1362,14 @@ mod tests { dodo_api_url: "https://api.example.com".to_string(), dodo_api_key: String::new(), dodo_project_id: String::new(), + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, data_dir: std::env::temp_dir().join(format!("chainpilot_test_{}", Uuid::new_v4())), } } @@ -1861,6 +1879,16 @@ mod tests { #[tokio::test] async fn send_approval_with_deps_returns_tx_hash_and_sender() { let private_key = "0x59c6995e998f97a5a0044966f0945382dbf7f50a3f2f72f5f7a0b7d7d4f5e5f1"; + let ( + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, + ) = crate::config::test_metadata_config_fields(); let signer = crate::chain::resolve_signer(&AppConfig { rpc_url: String::new(), rpc_url_overridden: false, @@ -1873,6 +1901,14 @@ mod tests { dodo_api_url: String::new(), dodo_api_key: String::new(), dodo_project_id: String::new(), + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, data_dir: std::env::temp_dir(), }) .unwrap(); diff --git a/src/commands/token.rs b/src/commands/token.rs index c228c20..91b10a9 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -28,6 +28,7 @@ pub async fn handle( match cmd.action { TokenAction::Info(args) => info(args, api, config, store, output_mode).await, TokenAction::Contract(args) => contract(args, api, config, store, output_mode).await, + TokenAction::Price(args) => price(args, api, config, store, output_mode).await, TokenAction::Add(args) => add(args, api, config, store, output_mode).await, TokenAction::Create(cmd) => match cmd.action { TokenCreateAction::Std(args) => create_std(args, config, store, output_mode).await, @@ -56,6 +57,30 @@ async fn info( let onchain = &chain_client; let token_ref = match resolve_token(&args.token, chain_id, onchain, api, config, store).await { Ok(t) => t, + Err(ChainError::TokenNotFound(_)) => { + let search = api + .token_metadata + .search_symbol(&args.token, chain_id) + .await; + if search.candidates.is_empty() { + return Ok( + crate::output::print_output::( + Err(ChainError::TokenNotFound(args.token)), + "token.info", + output_mode, + OutputContext::new(chain_id, false), + ), + ); + } + return Ok(crate::output::print_output::< + crate::models::token::TokenSearchResult, + >( + Ok(search), + "token.search", + output_mode, + OutputContext::new(chain_id, false), + )); + } Err(e) => { return Ok( crate::output::print_output::( @@ -93,6 +118,7 @@ async fn info( ); } }; + let info = api.token_metadata.enrich(info).await; Ok( crate::output::print_output::( Ok(info), @@ -103,6 +129,67 @@ async fn info( ) } +async fn price( + args: crate::cli::token::TokenIdentArg, + api: &ApiClients, + config: &AppConfig, + store: &QuoteStore, + output_mode: OutputMode, +) -> Result { + let chain_id = config.chain_id; + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + let onchain = &chain_client; + let token_ref = match resolve_token(&args.token, chain_id, onchain, api, config, store).await { + Ok(t) => t, + Err(ChainError::TokenNotFound(_)) => { + let search = api + .token_metadata + .search_symbol(&args.token, chain_id) + .await; + if search.candidates.is_empty() { + return Ok(crate::output::print_output::< + crate::models::token::TokenPrice, + >( + Err(ChainError::TokenNotFound(args.token)), + "token.price", + output_mode, + OutputContext::new(chain_id, false), + )); + } + return Ok(crate::output::print_output::< + crate::models::token::TokenSearchResult, + >( + Ok(search), + "token.search", + output_mode, + OutputContext::new(chain_id, false), + )); + } + Err(e) => { + return Ok(crate::output::print_output::< + crate::models::token::TokenPrice, + >( + Err(e), + "token.price", + output_mode, + OutputContext::new(chain_id, false), + )); + } + }; + let price = api + .token_metadata + .fetch_price(chain_id, &token_ref.address, &token_ref.symbol) + .await; + Ok(crate::output::print_output::< + crate::models::token::TokenPrice, + >( + Ok(price), + "token.price", + output_mode, + OutputContext::new(chain_id, false), + )) +} + async fn contract( args: TokenIdentArg, api: &ApiClients, diff --git a/src/config/mod.rs b/src/config/mod.rs index bba59f9..b292b1b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,6 +9,9 @@ pub const DEFAULT_CHAIN_ID: u64 = 1; /// Fallback RPC used only when no chain config matches the active chain_id. const FALLBACK_RPC_URL: &str = "https://ethereum-rpc.publicnode.com"; pub const DEFAULT_DODO_API_URL: &str = "https://api.dodoex.io/route-service/v2/widget/getdodoroute"; +pub const DEFAULT_COINGECKO_API_URL: &str = "https://api.coingecko.com/api/v3"; +pub const DEFAULT_DEXSCREENER_API_URL: &str = "https://api.dexscreener.com/latest/dex"; +pub const DEFAULT_OKX_DEX_API_URL: &str = "https://web3.okx.com"; 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. @@ -43,6 +46,14 @@ pub struct AppConfig { /// Project ID for the DODO tokenlist API (`/config-center/user/tokenlist/v2`). /// Set via `DODO_PROJECT_ID`. Without this, tokenlist lookup is skipped. pub dodo_project_id: String, + pub coingecko_api_url: String, + pub coingecko_api_key: Option, + pub dexscreener_api_url: String, + pub okx_dex_api_url: String, + pub okx_api_key: Option, + pub okx_api_secret: Option, + pub okx_api_passphrase: Option, + pub okx_project_id: Option, pub data_dir: PathBuf, } @@ -84,6 +95,18 @@ impl AppConfig { let dodo_project_id = std::env::var("DODO_PROJECT_ID") .unwrap_or_else(|_| DEFAULT_DODO_PROJECT_ID.to_string()); + let coingecko_api_url = std::env::var("COINGECKO_API_URL") + .unwrap_or_else(|_| DEFAULT_COINGECKO_API_URL.to_string()); + let coingecko_api_key = std::env::var("COINGECKO_API_KEY").ok(); + let dexscreener_api_url = std::env::var("DEXSCREENER_API_URL") + .unwrap_or_else(|_| DEFAULT_DEXSCREENER_API_URL.to_string()); + let okx_dex_api_url = std::env::var("OKX_DEX_API_URL") + .unwrap_or_else(|_| DEFAULT_OKX_DEX_API_URL.to_string()); + let okx_api_key = std::env::var("OKX_API_KEY").ok(); + let okx_api_secret = std::env::var("OKX_API_SECRET").ok(); + let okx_api_passphrase = std::env::var("OKX_API_PASSPHRASE").ok(); + let okx_project_id = std::env::var("OKX_PROJECT_ID").ok(); + Ok(Self { rpc_url, rpc_url_overridden, @@ -96,6 +119,14 @@ impl AppConfig { dodo_api_url, dodo_api_key, dodo_project_id, + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, data_dir, }) } @@ -149,6 +180,29 @@ impl AppConfig { } } +#[cfg(test)] +pub fn test_metadata_config_fields() -> ( + String, + Option, + String, + String, + Option, + Option, + Option, + Option, +) { + ( + DEFAULT_COINGECKO_API_URL.to_string(), + None, + DEFAULT_DEXSCREENER_API_URL.to_string(), + DEFAULT_OKX_DEX_API_URL.to_string(), + None, + None, + None, + None, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -337,6 +391,33 @@ mod tests { ); } + #[test] + fn token_metadata_fields_read_from_env() { + with_env( + &[ + ("COINGECKO_API_URL", Some("https://cg.example.com")), + ("COINGECKO_API_KEY", Some("cg-key")), + ("DEXSCREENER_API_URL", Some("https://dex.example.com")), + ("OKX_DEX_API_URL", Some("https://okx.example.com")), + ("OKX_API_KEY", Some("okx-key")), + ("OKX_API_SECRET", Some("okx-secret")), + ("OKX_API_PASSPHRASE", Some("okx-pass")), + ("OKX_PROJECT_ID", Some("okx-project")), + ], + || { + let cfg = AppConfig::load().unwrap(); + assert_eq!(cfg.coingecko_api_url, "https://cg.example.com"); + assert_eq!(cfg.coingecko_api_key.as_deref(), Some("cg-key")); + assert_eq!(cfg.dexscreener_api_url, "https://dex.example.com"); + assert_eq!(cfg.okx_dex_api_url, "https://okx.example.com"); + assert_eq!(cfg.okx_api_key.as_deref(), Some("okx-key")); + assert_eq!(cfg.okx_api_secret.as_deref(), Some("okx-secret")); + assert_eq!(cfg.okx_api_passphrase.as_deref(), Some("okx-pass")); + assert_eq!(cfg.okx_project_id.as_deref(), Some("okx-project")); + }, + ); + } + #[test] fn data_dir_defaults_to_local_chain_dir() { with_env(&[], || { diff --git a/src/models/token.rs b/src/models/token.rs index 595b376..ecdd786 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -8,9 +8,83 @@ pub struct TokenInfo { pub name: String, pub decimals: u8, pub chain_id: u64, - pub total_supply: String, - pub total_supply_display: f64, + pub chain: Option, + pub website: Option, + pub social_links: TokenSocialLinks, + pub price: Option, + pub market_cap: Option, + pub fdv: Option, + pub primary_liquidity: Option, + pub volume_24h: Option, + pub price_change_24h: Option, + pub risk_level: Option, + pub metadata_sources: TokenMetadataSources, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenSearchResult { + pub query: String, + pub chain_id: u64, + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenSearchCandidate { pub source: String, + pub symbol: String, + pub name: Option, + pub address: Option, + pub chain: Option, + pub primary_liquidity: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenSocialLinks { + pub x: Option, + pub telegram: Option, + pub discord: Option, + pub github: Option, + pub docs: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenMetadataSources { + pub identity: Option, + pub address: Option, + pub chain: Option, + pub website: Option, + pub social_links: Option, + pub price: Option, + pub market_cap: Option, + pub fdv: Option, + pub primary_liquidity: Option, + pub volume_24h: Option, + pub price_change_24h: Option, + pub risk_level: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenPrice { + pub address: String, + pub symbol: String, + pub chain_id: u64, + pub price: Option, + pub price_change_1h: Option, + pub price_change_24h: Option, + pub price_change_7d: Option, + pub high_24h: Option, + pub low_24h: Option, + pub sources: TokenPriceSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenPriceSources { + pub price: Option, + pub price_change_1h: Option, + pub price_change_24h: Option, + pub price_change_7d: Option, + pub high_24h: Option, + pub low_24h: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/output/table.rs b/src/output/table.rs index a7f08da..43043ce 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -209,14 +209,126 @@ impl TableRenderable for crate::models::token::TokenInfo { let mut table = Table::new(); table.set_header(vec!["Field", "Value"]); table.add_row(vec!["Address", &self.address]); + table.add_row(vec!["Chain ID", &self.chain_id.to_string()]); + if let Some(ref chain) = self.chain { + table.add_row(vec!["Chain", chain]); + } table.add_row(vec!["Symbol", &self.symbol]); table.add_row(vec!["Name", &self.name]); table.add_row(vec!["Decimals", &self.decimals.to_string()]); - table.add_row(vec![ - "Total Supply", - &format!("{} ({})", self.total_supply_display, self.total_supply), + if let Some(ref website) = self.website { + table.add_row(vec!["Website", website]); + } + let social_links = format_social_links(&self.social_links); + if !social_links.is_empty() { + table.add_row(vec!["Social", &social_links]); + } + if let Some(price) = self.price { + table.add_row(vec!["Price (USD)", &format_usd(price)]); + } + if let Some(market_cap) = self.market_cap { + table.add_row(vec!["Market Cap (USD)", &format_usd(market_cap)]); + } + if let Some(fdv) = self.fdv { + table.add_row(vec!["FDV (USD)", &format_usd(fdv)]); + } + if let Some(liquidity) = self.primary_liquidity { + table.add_row(vec!["Primary Liquidity (USD)", &format_usd(liquidity)]); + } + if let Some(volume) = self.volume_24h { + table.add_row(vec!["Volume 24h (USD)", &format_usd(volume)]); + } + if let Some(change) = self.price_change_24h { + table.add_row(vec!["Price Change 24h", &format!("{}%", change)]); + } + if let Some(ref risk_level) = self.risk_level { + table.add_row(vec!["Risk Level", risk_level]); + } + println!("{}", table); + } +} + +impl TableRenderable for crate::models::token::TokenPrice { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value", "Source"]); + table.add_row(vec!["Address", &self.address, ""]); + table.add_row(vec!["Symbol", &self.symbol, ""]); + table.add_row(vec!["Chain ID", &self.chain_id.to_string(), ""]); + add_price_row( + &mut table, + "Price (USD)", + self.price.map(format_usd), + self.sources.price.as_deref(), + ); + add_price_row( + &mut table, + "Change 1h", + self.price_change_1h.map(format_pct), + self.sources.price_change_1h.as_deref(), + ); + add_price_row( + &mut table, + "Change 24h", + self.price_change_24h.map(format_pct), + self.sources.price_change_24h.as_deref(), + ); + add_price_row( + &mut table, + "Change 7d", + self.price_change_7d.map(format_pct), + self.sources.price_change_7d.as_deref(), + ); + add_price_row( + &mut table, + "High 24h (USD)", + self.high_24h.map(format_usd), + self.sources.high_24h.as_deref(), + ); + add_price_row( + &mut table, + "Low 24h (USD)", + self.low_24h.map(format_usd), + self.sources.low_24h.as_deref(), + ); + println!("{}", table); + } +} + +fn add_price_row(table: &mut Table, label: &str, value: Option, source: Option<&str>) { + let value = value.unwrap_or_else(|| "N/A".to_string()); + table.add_row(vec![label, &value, source.unwrap_or("")]); +} + +fn format_pct(value: f64) -> String { + format!("{:.2}%", value) +} + +impl TableRenderable for crate::models::token::TokenSearchResult { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec![ + "Source", + "Symbol", + "Name", + "Address", + "Chain", + "Primary Liquidity", ]); - table.add_row(vec!["Source", &self.source]); + for candidate in &self.candidates { + table.add_row(vec![ + candidate.source.as_str(), + candidate.symbol.as_str(), + candidate.name.as_deref().unwrap_or(""), + candidate.address.as_deref().unwrap_or(""), + candidate.chain.as_deref().unwrap_or(""), + &candidate + .primary_liquidity + .map(format_usd) + .unwrap_or_else(String::new), + ]); + } + println!("Token not found in DODO tokenlist. Candidate matches:"); println!("{}", table); } } @@ -395,6 +507,28 @@ fn format_native_value(raw_wei: &str) -> String { } } +fn format_usd(value: f64) -> String { + if value.abs() >= 1.0 { + format!("{:.2}", value) + } else { + format!("{:.8}", value).trim_end_matches('0').to_string() + } +} + +fn format_social_links(links: &crate::models::token::TokenSocialLinks) -> String { + [ + ("X", links.x.as_deref()), + ("Telegram", links.telegram.as_deref()), + ("Discord", links.discord.as_deref()), + ("GitHub", links.github.as_deref()), + ("Docs", links.docs.as_deref()), + ] + .into_iter() + .filter_map(|(label, value)| value.map(|value| format!("{}: {}", label, value))) + .collect::>() + .join("\n") +} + fn raw_to_decimal_string(raw: &str, decimals: u8) -> Option { let digits = raw.strip_prefix('+').unwrap_or(raw); if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { diff --git a/src/store/quote.rs b/src/store/quote.rs index fb45dea..06e22a5 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -188,6 +188,16 @@ mod tests { /// Build a QuoteStore backed by a unique temp directory. fn temp_store() -> (QuoteStore, PathBuf) { let dir = std::env::temp_dir().join(format!("chain_test_{}", Uuid::new_v4())); + let ( + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, + ) = crate::config::test_metadata_config_fields(); let config = AppConfig { rpc_url: "https://test.example.com".to_string(), rpc_url_overridden: false, @@ -200,6 +210,14 @@ mod tests { dodo_api_url: String::new(), dodo_api_key: String::new(), dodo_project_id: String::new(), + coingecko_api_url, + coingecko_api_key, + dexscreener_api_url, + okx_dex_api_url, + okx_api_key, + okx_api_secret, + okx_api_passphrase, + okx_project_id, data_dir: dir.clone(), }; let store = QuoteStore::new(&config).expect("create store"); @@ -259,9 +277,17 @@ mod tests { name: name.to_string(), decimals, chain_id, - total_supply: "0".to_string(), - total_supply_display: 0.0, - source: "on-chain".to_string(), + chain: None, + website: None, + social_links: crate::models::token::TokenSocialLinks::default(), + price: None, + market_cap: None, + fdv: None, + primary_liquidity: None, + volume_24h: None, + price_change_24h: None, + risk_level: None, + metadata_sources: crate::models::token::TokenMetadataSources::default(), } } From c635cb2b43e079e78f43eca641e4dcc02a167d73 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 10:56:04 +0800 Subject: [PATCH 02/13] =?UTF-8?q?refactor(token):=20rename=20metadata=5Fso?= =?UTF-8?q?urces=20=E2=86=92=20sources,=20drop=20address=20source=20tracki?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename TokenMetadataSources → TokenInfoSources, metadata_sources → sources - Remove address field from sources (user already knows the address they queried) - Only update info.address when the external API returns a different value Co-Authored-By: Claude Opus 4.7 --- src/api/token_metadata.rs | 10 ++++++---- src/chain/erc20.rs | 7 +++---- src/models/token.rs | 5 ++--- src/store/quote.rs | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 05e9087..9591f1b 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -741,7 +741,7 @@ fn apply_okx(patch: &mut TokenMetadataPatch, token: Option) { } fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { - let mut sources = info.metadata_sources.clone(); + let mut sources = info.sources.clone(); if let Some((value, source)) = patch.name { info.name = value; @@ -752,8 +752,10 @@ fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { sources.identity.get_or_insert(source); } if let Some((value, source)) = patch.address { - info.address = value; - sources.address = Some(source); + if info.address != value { + info.address = value; + sources.identity.get_or_insert(source); + } } if let Some((value, source)) = patch.website { info.website = Some(value); @@ -792,7 +794,7 @@ fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { sources.risk_level = Some(source); } - info.metadata_sources = sources; + info.sources = sources; } fn coingecko_platform_id(chain_id: u64) -> Option<&'static str> { diff --git a/src/chain/erc20.rs b/src/chain/erc20.rs index fa7a97d..9648ef7 100644 --- a/src/chain/erc20.rs +++ b/src/chain/erc20.rs @@ -6,7 +6,7 @@ use alloy_sol_types::{sol, SolCall}; use crate::chain::OnChainClient; use crate::error::{ChainError, Result}; -use crate::models::token::{TokenContract, TokenInfo, TokenMetadataSources, TokenSocialLinks}; +use crate::models::token::{TokenContract, TokenInfo, TokenInfoSources, TokenSocialLinks}; sol! { #[sol(rpc)] @@ -71,11 +71,10 @@ pub async fn get_token_info(client: &OnChainClient, token_address: Address) -> R volume_24h: None, price_change_24h: None, risk_level: None, - metadata_sources: TokenMetadataSources { + sources: TokenInfoSources { identity: Some("on-chain".to_string()), - address: Some("resolver".to_string()), chain: crate::config::chain_config(client.chain_id).map(|_| "chain-config".to_string()), - ..TokenMetadataSources::default() + ..TokenInfoSources::default() }, }) } diff --git a/src/models/token.rs b/src/models/token.rs index ecdd786..2c06737 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -18,7 +18,7 @@ pub struct TokenInfo { pub volume_24h: Option, pub price_change_24h: Option, pub risk_level: Option, - pub metadata_sources: TokenMetadataSources, + pub sources: TokenInfoSources, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -48,9 +48,8 @@ pub struct TokenSocialLinks { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TokenMetadataSources { +pub struct TokenInfoSources { pub identity: Option, - pub address: Option, pub chain: Option, pub website: Option, pub social_links: Option, diff --git a/src/store/quote.rs b/src/store/quote.rs index 06e22a5..e15b3f4 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -287,7 +287,7 @@ mod tests { volume_24h: None, price_change_24h: None, risk_level: None, - metadata_sources: crate::models::token::TokenMetadataSources::default(), + sources: crate::models::token::TokenInfoSources::default(), } } From 874d037ce9e320298703a02a6b0d6d53f80b4bdc Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 11:10:02 +0800 Subject: [PATCH 03/13] fix(token): support native token price lookup via CoinGecko coin ID Native tokens (ETH, BNB, AVAX, etc.) use a sentinel address that CoinGecko's contract endpoint doesn't recognize. Now uses /coins/{id} for native tokens and DexScreener wrapped-address fallback. - Add coingecko_id to NativeToken config for all 17 chains - Add fetch_coingecko_by_id method to TokenMetadataClient - In fetch_price, detect native sentinel and route to correct API Co-Authored-By: Claude Opus 4.7 --- src/api/token_metadata.rs | 50 ++++++++++++++++++++++++++++++---- src/config/chains/arbitrum.rs | 1 + src/config/chains/aurora.rs | 1 + src/config/chains/avalanche.rs | 1 + src/config/chains/base.rs | 1 + src/config/chains/bsc.rs | 1 + src/config/chains/conflux.rs | 1 + src/config/chains/ethereum.rs | 1 + src/config/chains/linea.rs | 1 + src/config/chains/manta.rs | 1 + src/config/chains/mantle.rs | 1 + src/config/chains/mod.rs | 1 + src/config/chains/okchain.rs | 1 + src/config/chains/optimism.rs | 1 + src/config/chains/plume.rs | 1 + src/config/chains/polygon.rs | 1 + src/config/chains/scroll.rs | 1 + src/config/chains/sepolia.rs | 1 + src/config/chains/taiko.rs | 1 + 19 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 9591f1b..29d5a5d 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -200,13 +200,29 @@ impl TokenMetadataClient { } pub async fn fetch_price(&self, chain_id: u64, address: &str, symbol: &str) -> TokenPrice { - let chain_slug = coingecko_platform_id(chain_id); + let is_native = address.eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + let cc = crate::config::chain_config(chain_id); - let coingecko = match chain_slug { - Some(platform) => self.fetch_coingecko(platform, address).await.ok(), - None => None, + let coingecko = if is_native { + match cc.map(|c| c.native_token.coingecko_id) { + Some(id) => self.fetch_coingecko_by_id(id).await.ok(), + None => None, + } + } else { + match coingecko_platform_id(chain_id) { + Some(platform) => self.fetch_coingecko(platform, address).await.ok(), + None => None, + } }; - let dexscreener = self.fetch_dexscreener(address).await.ok(); + + // DexScreener: native tokens trade as their wrapped version on DEXes + let dexscreener_addr = if is_native { + cc.map(|c| c.native_token.wrapped_address) + .unwrap_or(address) + } else { + address + }; + let dexscreener = self.fetch_dexscreener(dexscreener_addr).await.ok(); let mut price = TokenPrice { address: address.to_string(), @@ -222,7 +238,7 @@ impl TokenMetadataClient { }; apply_coingecko_price(&mut price, coingecko); - apply_dexscreener_price(&mut price, dexscreener, address); + apply_dexscreener_price(&mut price, dexscreener, dexscreener_addr); price } @@ -434,6 +450,28 @@ impl TokenMetadataClient { req.send().await?.error_for_status()?.json().await } + async fn fetch_coingecko_by_id( + &self, + coin_id: &str, + ) -> Result { + let url = format!("{}/coins/{}", self.coingecko_base_url, coin_id); + let mut req = self + .client + .get(url) + .timeout(Duration::from_secs(8)) + .query(&[ + ("localization", "false"), + ("tickers", "false"), + ("community_data", "false"), + ("developer_data", "false"), + ("sparkline", "false"), + ]); + if let Some(key) = &self.coingecko_api_key { + req = req.header("x-cg-demo-api-key", key); + } + req.send().await?.error_for_status()?.json().await + } + async fn fetch_dexscreener( &self, address: &str, diff --git a/src/config/chains/arbitrum.rs b/src/config/chains/arbitrum.rs index 2e72606..fd6e88f 100644 --- a/src/config/chains/arbitrum.rs +++ b/src/config/chains/arbitrum.rs @@ -17,5 +17,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/aurora.rs b/src/config/chains/aurora.rs index 0184dd8..f59c7dc 100644 --- a/src/config/chains/aurora.rs +++ b/src/config/chains/aurora.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0xC9BdeEd33CD01541e1eeD10f90519d2C06Fe3feB", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/avalanche.rs b/src/config/chains/avalanche.rs index 2d710eb..832f79e 100644 --- a/src/config/chains/avalanche.rs +++ b/src/config/chains/avalanche.rs @@ -18,5 +18,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WAVAX", wrapped_address: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + coingecko_id: "avalanche-2", }, }; diff --git a/src/config/chains/base.rs b/src/config/chains/base.rs index b002d5d..c8b0ba4 100644 --- a/src/config/chains/base.rs +++ b/src/config/chains/base.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x4200000000000000000000000000000000000006", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/bsc.rs b/src/config/chains/bsc.rs index 3283215..c7300e2 100644 --- a/src/config/chains/bsc.rs +++ b/src/config/chains/bsc.rs @@ -18,5 +18,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WBNB", wrapped_address: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + coingecko_id: "binancecoin", }, }; diff --git a/src/config/chains/conflux.rs b/src/config/chains/conflux.rs index c7bef0c..8030bc6 100644 --- a/src/config/chains/conflux.rs +++ b/src/config/chains/conflux.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WCFX", wrapped_address: "0x14b2d3bc65e74dae1030eafd8ac30c533c976a9b", + coingecko_id: "conflux-token", }, }; diff --git a/src/config/chains/ethereum.rs b/src/config/chains/ethereum.rs index 808f65b..d4e0976 100644 --- a/src/config/chains/ethereum.rs +++ b/src/config/chains/ethereum.rs @@ -17,5 +17,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/linea.rs b/src/config/chains/linea.rs index 34432fe..6fffb4a 100644 --- a/src/config/chains/linea.rs +++ b/src/config/chains/linea.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/manta.rs b/src/config/chains/manta.rs index 3dc2258..20e9d0e 100644 --- a/src/config/chains/manta.rs +++ b/src/config/chains/manta.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x0Dc808adcE2099A9F62AA87D9670745AbA741746", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/mantle.rs b/src/config/chains/mantle.rs index e7af418..c43bf9a 100644 --- a/src/config/chains/mantle.rs +++ b/src/config/chains/mantle.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WMNT", wrapped_address: "0x78c1b0C915c4FAA5FffA6CAbf0219DA63d7f4cb8", + coingecko_id: "mantle", }, }; diff --git a/src/config/chains/mod.rs b/src/config/chains/mod.rs index fd2b5cb..f73dabd 100644 --- a/src/config/chains/mod.rs +++ b/src/config/chains/mod.rs @@ -26,6 +26,7 @@ pub struct NativeToken { pub decimals: u8, pub wrapped_symbol: &'static str, pub wrapped_address: &'static str, + pub coingecko_id: &'static str, } pub struct ChainContracts { diff --git a/src/config/chains/okchain.rs b/src/config/chains/okchain.rs index 3d8cf18..743678c 100644 --- a/src/config/chains/okchain.rs +++ b/src/config/chains/okchain.rs @@ -17,5 +17,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WOKT", wrapped_address: "0x8F8526dbfd6E38E3D8307702cA8469Bae6C56C15", + coingecko_id: "oec-token", }, }; diff --git a/src/config/chains/optimism.rs b/src/config/chains/optimism.rs index 0c5f07a..dd34915 100644 --- a/src/config/chains/optimism.rs +++ b/src/config/chains/optimism.rs @@ -17,5 +17,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x4200000000000000000000000000000000000006", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/plume.rs b/src/config/chains/plume.rs index a0feed4..d5a2097 100644 --- a/src/config/chains/plume.rs +++ b/src/config/chains/plume.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WPLUME", wrapped_address: "0xEa237441c92CAe6FC17Caaf9a7acB3f953be4bd1", + coingecko_id: "plume-network", }, }; diff --git a/src/config/chains/polygon.rs b/src/config/chains/polygon.rs index 4fcc5ff..9cfd837 100644 --- a/src/config/chains/polygon.rs +++ b/src/config/chains/polygon.rs @@ -18,5 +18,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WMATIC", wrapped_address: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + coingecko_id: "matic-network", }, }; diff --git a/src/config/chains/scroll.rs b/src/config/chains/scroll.rs index f032572..b82aa82 100644 --- a/src/config/chains/scroll.rs +++ b/src/config/chains/scroll.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x5300000000000000000000000000000000000004", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/sepolia.rs b/src/config/chains/sepolia.rs index ec92c6a..1d3af27 100644 --- a/src/config/chains/sepolia.rs +++ b/src/config/chains/sepolia.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0x7B07164ecFaF0F0D85DFC062Bc205a4674c75Aa0", + coingecko_id: "ethereum", }, }; diff --git a/src/config/chains/taiko.rs b/src/config/chains/taiko.rs index e64cee3..d856fbf 100644 --- a/src/config/chains/taiko.rs +++ b/src/config/chains/taiko.rs @@ -14,5 +14,6 @@ pub static CONFIG: ChainConfig = ChainConfig { decimals: 18, wrapped_symbol: "WETH", wrapped_address: "0xA51894664A773981C6C112C43ce576f315d5b1B6", + coingecko_id: "ethereum", }, }; From 55d443506295a76166f2cb6e091854f59f7fdf18 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 11:17:32 +0800 Subject: [PATCH 04/13] fix(token): support native token info via CoinGecko coin ID + enrichment token info ETH/BNB/etc. previously failed with RPC error on the sentinel address. Now builds TokenInfo from chain config for native tokens and enriches via /coins/{id} (CoinGecko) + wrapped address (DexScreener). - In info handler: detect native sentinel, build TokenInfo from chain config - In enrich: use fetch_coingecko_by_id for native tokens - In apply_patch: skip address overwrite when current address is native sentinel Co-Authored-By: Claude Opus 4.7 --- src/api/token_metadata.rs | 38 ++++++++++++++----- src/commands/token.rs | 78 +++++++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 29d5a5d..2dabb86 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -181,19 +181,36 @@ impl TokenMetadataClient { } pub async fn enrich(&self, mut info: TokenInfo) -> TokenInfo { - let chain_slug = coingecko_platform_id(info.chain_id); - let address = info.address.clone(); + let is_native = info + .address + .eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + let cc = crate::config::chain_config(info.chain_id); - let coingecko = match chain_slug { - Some(platform) => self.fetch_coingecko(platform, &address).await.ok(), - None => None, + let coingecko = if is_native { + match cc.map(|c| c.native_token.coingecko_id) { + Some(id) => self.fetch_coingecko_by_id(id).await.ok(), + None => None, + } + } else { + match coingecko_platform_id(info.chain_id) { + Some(platform) => self.fetch_coingecko(platform, &info.address).await.ok(), + None => None, + } + }; + + let dexscreener_addr = if is_native { + cc.map(|c| c.native_token.wrapped_address) + .unwrap_or(info.address.as_str()) + .to_string() + } else { + info.address.clone() }; - let dexscreener = self.fetch_dexscreener(&address).await.ok(); - let okx = self.fetch_okx_token(info.chain_id, &address).await; + let dexscreener = self.fetch_dexscreener(&dexscreener_addr).await.ok(); + let okx = self.fetch_okx_token(info.chain_id, &info.address).await; let mut patch = TokenMetadataPatch::default(); apply_coingecko(&mut patch, coingecko); - apply_dexscreener(&mut patch, dexscreener, &address); + apply_dexscreener(&mut patch, dexscreener, &dexscreener_addr); apply_okx(&mut patch, okx); apply_patch(&mut info, patch); info @@ -790,7 +807,10 @@ fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { sources.identity.get_or_insert(source); } if let Some((value, source)) = patch.address { - if info.address != value { + let is_native = info + .address + .eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + if !is_native && info.address != value { info.address = value; sources.identity.get_or_insert(source); } diff --git a/src/commands/token.rs b/src/commands/token.rs index 91b10a9..f5f038e 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -92,30 +92,60 @@ async fn info( ); } }; - let addr: Address = match token_ref.address.parse() { - Ok(a) => a, - Err(_) => { - return Ok( - crate::output::print_output::( - Err(ChainError::InvalidAddress(token_ref.address.clone())), - "token.info", - output_mode, - OutputContext::new(chain_id, false), - ), - ); - } - }; - let info = match crate::chain::get_token_info(onchain, addr).await { - Ok(i) => i, - Err(e) => { - return Ok( - crate::output::print_output::( - Err(e), - "token.info", - output_mode, - OutputContext::new(chain_id, false), - ), - ); + let is_native = token_ref + .address + .eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + let info = if is_native { + crate::config::chain_config(chain_id) + .map(|cc| crate::models::token::TokenInfo { + address: token_ref.address.clone(), + symbol: cc.native_token.symbol.to_string(), + name: cc.native_token.name.to_string(), + decimals: cc.native_token.decimals, + chain_id, + chain: Some(cc.name.to_string()), + website: None, + social_links: crate::models::token::TokenSocialLinks::default(), + price: None, + market_cap: None, + fdv: None, + primary_liquidity: None, + volume_24h: None, + price_change_24h: None, + risk_level: None, + sources: crate::models::token::TokenInfoSources { + identity: Some("chain-config".to_string()), + chain: Some("chain-config".to_string()), + ..crate::models::token::TokenInfoSources::default() + }, + }) + .ok_or(ChainError::UnsupportedChain(chain_id))? + } else { + let addr: Address = match token_ref.address.parse() { + Ok(a) => a, + Err(_) => { + return Ok( + crate::output::print_output::( + Err(ChainError::InvalidAddress(token_ref.address.clone())), + "token.info", + output_mode, + OutputContext::new(chain_id, false), + ), + ); + } + }; + match crate::chain::get_token_info(onchain, addr).await { + Ok(i) => i, + Err(e) => { + return Ok( + crate::output::print_output::( + Err(e), + "token.info", + output_mode, + OutputContext::new(chain_id, false), + ), + ); + } } }; let info = api.token_metadata.enrich(info).await; From 0328f77021c92fcbf344e4d8bb225963a1500ab9 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 14:02:20 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(token):=20add=20`token=20liquidity`?= =?UTF-8?q?=20subcommand=20+=20rename=20primary=5Fliquidity=20=E2=86=92=20?= =?UTF-8?q?top=5Fliquidity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `token liquidity ` showing DexScreener liquidity data: top liquidity, pair count, top pair details (DEX, address, 24h volume) - Rename primary_liquidity → top_liquidity across all models and labels - Extend DexScreenerPair with dex_id, pair_address, volume fields - Handle native tokens via wrapped address for DexScreener lookup Co-Authored-By: Claude Opus 4.7 --- skills/chainpilot/SKILL.md | 18 ++++++ src/api/token_metadata.rs | 126 ++++++++++++++++++++++++++++++++++--- src/chain/erc20.rs | 2 +- src/cli/token.rs | 2 + src/commands/token.rs | 64 ++++++++++++++++++- src/models/token.rs | 24 ++++++- src/output/table.rs | 39 ++++++++++-- src/store/quote.rs | 2 +- 8 files changed, 257 insertions(+), 20 deletions(-) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 4d47965..8185dc3 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -325,6 +325,24 @@ CoinGecko has no value (e.g. long-tail tokens not listed there). The JSON output includes a `sources` map indicating which API supplied each field, so callers can distinguish CoinGecko-backed values from DexScreener-backed ones. +### `token liquidity` + +```bash +chainpilot [--chain-id ] token liquidity +``` + +Liquidity overview from DexScreener: top liquidity across all pairs, pair count, +and top pair details (DEX, pair address, 24h volume). + +| Field | Source | Notes | +|---|---|---| +| `top_liquidity` | DexScreener | Highest single-pair liquidity (USD) | +| `pair_count` | DexScreener | Number of matching base-token pairs | +| `top_pair.dex` | DexScreener | DEX ID of the top pair (e.g. `uniswap`) | +| `top_pair.pair_address` | DexScreener | Contract address of the top pair | +| `top_pair.liquidity` | DexScreener | Top pair's liquidity (USD) | +| `top_pair.volume_24h` | DexScreener | Top pair's 24h trading volume (USD) | + ### `token add` ```bash diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 2dabb86..bcce235 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -37,7 +37,7 @@ struct TokenMetadataPatch { price: Option<(f64, String)>, market_cap: Option<(f64, String)>, fdv: Option<(f64, String)>, - primary_liquidity: Option<(f64, String)>, + top_liquidity: Option<(f64, String)>, volume_24h: Option<(f64, String)>, price_change_24h: Option<(f64, String)>, risk_level: Option<(String, String)>, @@ -111,6 +111,11 @@ struct DexScreenerPair { price_usd: Option, #[serde(rename = "priceChange")] price_change: Option, + #[serde(rename = "dexId")] + dex_id: Option, + #[serde(rename = "pairAddress")] + pair_address: Option, + volume: Option, } #[derive(Debug, Deserialize)] @@ -119,6 +124,11 @@ struct DexScreenerPriceChange { h24: Option, } +#[derive(Debug, Deserialize)] +struct DexScreenerVolume { + h24: Option, +} + #[derive(Debug, Deserialize)] struct DexScreenerToken { address: Option, @@ -259,6 +269,87 @@ impl TokenMetadataClient { price } + pub async fn fetch_liquidity( + &self, + chain_id: u64, + address: &str, + symbol: &str, + ) -> crate::models::token::TokenLiquidity { + use crate::models::token::{TokenLiquidity, TokenLiquidityTopPair}; + + let is_native = address.eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + let dexscreener_addr = if is_native { + crate::config::chain_config(chain_id) + .map(|c| c.native_token.wrapped_address) + .unwrap_or(address) + } else { + address + }; + + let pairs = match self.fetch_dexscreener(dexscreener_addr).await { + Ok(resp) => resp.pairs.unwrap_or_default(), + Err(_) => { + return TokenLiquidity { + address: address.to_string(), + symbol: symbol.to_string(), + chain_id, + top_liquidity: None, + pair_count: 0, + top_pair: None, + }; + } + }; + + let matching: Vec<&DexScreenerPair> = pairs + .iter() + .filter(|p| { + p.base_token + .as_ref() + .and_then(|t| t.address.as_deref()) + .is_some_and(|a| a.eq_ignore_ascii_case(dexscreener_addr)) + }) + .collect(); + + let top_liquidity = matching + .iter() + .filter_map(|p| p.liquidity.as_ref().and_then(|l| l.usd)) + .fold(0.0f64, f64::max); + let top_liquidity = if top_liquidity > 0.0 { + Some(top_liquidity) + } else { + None + }; + + let top_pair = matching + .iter() + .max_by(|a, b| { + let la = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0); + let lb = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0); + la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal) + }) + .and_then(|p| { + let pair_address = p.pair_address.clone()?; + let dex = p.dex_id.clone().unwrap_or_else(|| "unknown".to_string()); + let liquidity = p.liquidity.as_ref().and_then(|l| l.usd); + let volume_24h = p.volume.as_ref().and_then(|v| v.h24); + Some(TokenLiquidityTopPair { + pair_address, + dex, + liquidity, + volume_24h, + }) + }); + + TokenLiquidity { + address: address.to_string(), + symbol: symbol.to_string(), + chain_id, + top_liquidity, + pair_count: matching.len(), + top_pair, + } + } + pub async fn search_symbol(&self, query: &str, chain_id: u64) -> TokenSearchResult { let mut candidates = Vec::new(); @@ -318,7 +409,7 @@ impl TokenMetadataClient { name: non_empty(coin.name), address, chain: Some(platform.to_string()), - primary_liquidity: None, + top_liquidity: None, }) }) .take(3) @@ -362,7 +453,7 @@ impl TokenMetadataClient { name: non_empty(token.name), address: non_empty(token.address), chain: None, - primary_liquidity: pair.liquidity.and_then(|liquidity| liquidity.usd), + top_liquidity: pair.liquidity.and_then(|liquidity| liquidity.usd), }) }) .take(3) @@ -434,7 +525,7 @@ impl TokenMetadataClient { name: non_empty(token.token_name), address: non_empty(token.token_contract_address), chain: Some(chain_id.to_string()), - primary_liquidity: None, + top_liquidity: None, }) }) .take(3) @@ -665,7 +756,7 @@ fn apply_dexscreener( } } if let Some(value) = pair.liquidity.and_then(|v| v.usd) { - patch.primary_liquidity = Some((value, "dexscreener".to_string())); + patch.top_liquidity = Some((value, "dexscreener".to_string())); } } @@ -835,9 +926,9 @@ fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { info.fdv = Some(value); sources.fdv = Some(source); } - if let Some((value, source)) = patch.primary_liquidity { - info.primary_liquidity = Some(value); - sources.primary_liquidity = Some(source); + if let Some((value, source)) = patch.top_liquidity { + info.top_liquidity = Some(value); + sources.top_liquidity = Some(source); } if let Some((value, source)) = patch.volume_24h { info.volume_24h = Some(value); @@ -936,6 +1027,9 @@ mod tests { liquidity: Some(DexScreenerLiquidity { usd: Some(10.0) }), price_usd: None, price_change: None, + dex_id: None, + pair_address: None, + volume: None, }, DexScreenerPair { base_token: Some(DexScreenerToken { @@ -946,6 +1040,9 @@ mod tests { liquidity: Some(DexScreenerLiquidity { usd: Some(20.0) }), price_usd: None, price_change: None, + dex_id: None, + pair_address: None, + volume: None, }, ]), }), @@ -953,7 +1050,7 @@ mod tests { ); assert_eq!(patch.name.unwrap().0, "High"); - assert_eq!(patch.primary_liquidity.unwrap().0, 20.0); + assert_eq!(patch.top_liquidity.unwrap().0, 20.0); assert!(patch.price.is_none()); assert!(patch.volume_24h.is_none()); assert!(patch.price_change_24h.is_none()); @@ -974,13 +1071,16 @@ mod tests { liquidity: Some(DexScreenerLiquidity { usd: Some(20.0) }), price_usd: None, price_change: None, + dex_id: None, + pair_address: None, + volume: None, }]), }), "0xabc", ); assert_eq!(patch.name.unwrap().0, "Wrong Chain"); - assert_eq!(patch.primary_liquidity.unwrap().0, 20.0); + assert_eq!(patch.top_liquidity.unwrap().0, 20.0); assert!(patch.price.is_none()); } @@ -1056,6 +1156,9 @@ mod tests { h1: Some(0.7), h24: Some(99.0), }), + dex_id: None, + pair_address: None, + volume: None, }]), }), "0xabc", @@ -1090,6 +1193,9 @@ mod tests { h1: Some(-1.0), h24: Some(5.0), }), + dex_id: None, + pair_address: None, + volume: None, }]), }), "0xabc", diff --git a/src/chain/erc20.rs b/src/chain/erc20.rs index 9648ef7..57eb53f 100644 --- a/src/chain/erc20.rs +++ b/src/chain/erc20.rs @@ -67,7 +67,7 @@ pub async fn get_token_info(client: &OnChainClient, token_address: Address) -> R price: None, market_cap: None, fdv: None, - primary_liquidity: None, + top_liquidity: None, volume_24h: None, price_change_24h: None, risk_level: None, diff --git a/src/cli/token.rs b/src/cli/token.rs index 40d1eca..1c11b03 100644 --- a/src/cli/token.rs +++ b/src/cli/token.rs @@ -15,6 +15,8 @@ pub enum TokenAction { Contract(TokenIdentArg), /// Real-time price, % changes, and 24h high/low (CoinGecko primary, DexScreener fallback) Price(TokenIdentArg), + /// Liquidity overview: top liquidity, pair count, top pair details (DexScreener) + Liquidity(TokenIdentArg), /// Save a custom token so later symbol lookups can resolve it Add(TokenAddArgs), /// Create a token via DODO's ERC20V3Factory diff --git a/src/commands/token.rs b/src/commands/token.rs index f5f038e..c1f7ac2 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -29,6 +29,7 @@ pub async fn handle( TokenAction::Info(args) => info(args, api, config, store, output_mode).await, TokenAction::Contract(args) => contract(args, api, config, store, output_mode).await, TokenAction::Price(args) => price(args, api, config, store, output_mode).await, + TokenAction::Liquidity(args) => liquidity(args, api, config, store, output_mode).await, TokenAction::Add(args) => add(args, api, config, store, output_mode).await, TokenAction::Create(cmd) => match cmd.action { TokenCreateAction::Std(args) => create_std(args, config, store, output_mode).await, @@ -109,7 +110,7 @@ async fn info( price: None, market_cap: None, fdv: None, - primary_liquidity: None, + top_liquidity: None, volume_24h: None, price_change_24h: None, risk_level: None, @@ -220,6 +221,67 @@ async fn price( )) } +async fn liquidity( + args: crate::cli::token::TokenIdentArg, + api: &ApiClients, + config: &AppConfig, + store: &QuoteStore, + output_mode: OutputMode, +) -> Result { + let chain_id = config.chain_id; + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + let onchain = &chain_client; + let token_ref = match resolve_token(&args.token, chain_id, onchain, api, config, store).await { + Ok(t) => t, + Err(ChainError::TokenNotFound(_)) => { + let search = api + .token_metadata + .search_symbol(&args.token, chain_id) + .await; + if search.candidates.is_empty() { + return Ok(crate::output::print_output::< + crate::models::token::TokenLiquidity, + >( + Err(ChainError::TokenNotFound(args.token)), + "token.liquidity", + output_mode, + OutputContext::new(chain_id, false), + )); + } + return Ok(crate::output::print_output::< + crate::models::token::TokenSearchResult, + >( + Ok(search), + "token.search", + output_mode, + OutputContext::new(chain_id, false), + )); + } + Err(e) => { + return Ok(crate::output::print_output::< + crate::models::token::TokenLiquidity, + >( + Err(e), + "token.liquidity", + output_mode, + OutputContext::new(chain_id, false), + )); + } + }; + let result = api + .token_metadata + .fetch_liquidity(chain_id, &token_ref.address, &token_ref.symbol) + .await; + Ok(crate::output::print_output::< + crate::models::token::TokenLiquidity, + >( + Ok(result), + "token.liquidity", + output_mode, + OutputContext::new(chain_id, false), + )) +} + async fn contract( args: TokenIdentArg, api: &ApiClients, diff --git a/src/models/token.rs b/src/models/token.rs index 2c06737..a1bbc06 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -14,7 +14,7 @@ pub struct TokenInfo { pub price: Option, pub market_cap: Option, pub fdv: Option, - pub primary_liquidity: Option, + pub top_liquidity: Option, pub volume_24h: Option, pub price_change_24h: Option, pub risk_level: Option, @@ -35,7 +35,7 @@ pub struct TokenSearchCandidate { pub name: Option, pub address: Option, pub chain: Option, - pub primary_liquidity: Option, + pub top_liquidity: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -56,7 +56,7 @@ pub struct TokenInfoSources { pub price: Option, pub market_cap: Option, pub fdv: Option, - pub primary_liquidity: Option, + pub top_liquidity: Option, pub volume_24h: Option, pub price_change_24h: Option, pub risk_level: Option, @@ -86,6 +86,24 @@ pub struct TokenPriceSources { pub low_24h: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenLiquidity { + pub address: String, + pub symbol: String, + pub chain_id: u64, + pub top_liquidity: Option, + pub pair_count: usize, + pub top_pair: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenLiquidityTopPair { + pub pair_address: String, + pub dex: String, + pub liquidity: Option, + pub volume_24h: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenContract { pub address: String, diff --git a/src/output/table.rs b/src/output/table.rs index 43043ce..ce4858f 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -232,8 +232,8 @@ impl TableRenderable for crate::models::token::TokenInfo { if let Some(fdv) = self.fdv { table.add_row(vec!["FDV (USD)", &format_usd(fdv)]); } - if let Some(liquidity) = self.primary_liquidity { - table.add_row(vec!["Primary Liquidity (USD)", &format_usd(liquidity)]); + if let Some(liquidity) = self.top_liquidity { + table.add_row(vec!["Top Liquidity (USD)", &format_usd(liquidity)]); } if let Some(volume) = self.volume_24h { table.add_row(vec!["Volume 24h (USD)", &format_usd(volume)]); @@ -300,6 +300,37 @@ fn add_price_row(table: &mut Table, label: &str, value: Option, source: table.add_row(vec![label, &value, source.unwrap_or("")]); } +impl TableRenderable for crate::models::token::TokenLiquidity { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value"]); + table.add_row(vec!["Address", &self.address]); + table.add_row(vec!["Symbol", &self.symbol]); + table.add_row(vec!["Chain ID", &self.chain_id.to_string()]); + table.add_row(vec![ + "Top Liquidity (USD)", + &self + .top_liquidity + .map(format_usd) + .unwrap_or_else(|| "N/A".to_string()), + ]); + table.add_row(vec!["Pair Count", &self.pair_count.to_string()]); + if let Some(ref top) = self.top_pair { + table.add_row(vec!["Top Pair Address", &top.pair_address]); + table.add_row(vec!["Top Pair DEX", &top.dex]); + table.add_row(vec![ + "Top Pair Liquidity (USD)", + &top.liquidity.map(format_usd).unwrap_or_else(|| "N/A".to_string()), + ]); + table.add_row(vec![ + "Top Pair Volume 24h (USD)", + &top.volume_24h.map(format_usd).unwrap_or_else(|| "N/A".to_string()), + ]); + } + println!("{}", table); + } +} + fn format_pct(value: f64) -> String { format!("{:.2}%", value) } @@ -313,7 +344,7 @@ impl TableRenderable for crate::models::token::TokenSearchResult { "Name", "Address", "Chain", - "Primary Liquidity", + "Top Liquidity", ]); for candidate in &self.candidates { table.add_row(vec![ @@ -323,7 +354,7 @@ impl TableRenderable for crate::models::token::TokenSearchResult { candidate.address.as_deref().unwrap_or(""), candidate.chain.as_deref().unwrap_or(""), &candidate - .primary_liquidity + .top_liquidity .map(format_usd) .unwrap_or_else(String::new), ]); diff --git a/src/store/quote.rs b/src/store/quote.rs index e15b3f4..649bf4a 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -283,7 +283,7 @@ mod tests { price: None, market_cap: None, fdv: None, - primary_liquidity: None, + top_liquidity: None, volume_24h: None, price_change_24h: None, risk_level: None, From 0a4726db8c5c2927f25abeb51bee50ea3cab9e09 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 14:08:26 +0800 Subject: [PATCH 06/13] feat(token): add sources field to token liquidity output Co-Authored-By: Claude Opus 4.7 --- src/api/token_metadata.rs | 14 ++++++++++++++ src/models/token.rs | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index bcce235..6b6d062 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -296,6 +296,7 @@ impl TokenMetadataClient { top_liquidity: None, pair_count: 0, top_pair: None, + sources: crate::models::token::TokenLiquiditySources::default(), }; } }; @@ -340,6 +341,18 @@ impl TokenMetadataClient { }) }); + use crate::models::token::TokenLiquiditySources; + + let src = if matching.is_empty() { + TokenLiquiditySources::default() + } else { + TokenLiquiditySources { + top_liquidity: Some("dexscreener".to_string()), + pair_count: Some("dexscreener".to_string()), + top_pair: Some("dexscreener".to_string()), + } + }; + TokenLiquidity { address: address.to_string(), symbol: symbol.to_string(), @@ -347,6 +360,7 @@ impl TokenMetadataClient { top_liquidity, pair_count: matching.len(), top_pair, + sources: src, } } diff --git a/src/models/token.rs b/src/models/token.rs index a1bbc06..cb53ef3 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -94,6 +94,14 @@ pub struct TokenLiquidity { pub top_liquidity: Option, pub pair_count: usize, pub top_pair: Option, + pub sources: TokenLiquiditySources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenLiquiditySources { + pub top_liquidity: Option, + pub pair_count: Option, + pub top_pair: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] From 71d9c828dfd80443f455187f8db2f2e1a2db88cf Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 14:45:34 +0800 Subject: [PATCH 07/13] feat(token): add `token risk` subcommand with OKX OnchainOS integration Risk analysis for ERC-20 tokens: risk_level, risk_score, honeypot, blacklist, transfer_restricted, mintable, owner_privileged, tax_buy, tax_sell. Native tokens return hardcoded low-risk defaults. Co-Authored-By: Claude Opus 4.7 --- skills/chainpilot/SKILL.md | 23 ++++++ src/api/token_metadata.rs | 156 +++++++++++++++++++++++++++++++++++++ src/cli/token.rs | 2 + src/commands/token.rs | 62 +++++++++++++++ src/models/token.rs | 30 +++++++ src/output/table.rs | 50 ++++++++++++ 6 files changed, 323 insertions(+) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 8185dc3..efefe7b 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -343,6 +343,29 @@ and top pair details (DEX, pair address, 24h volume). | `top_pair.liquidity` | DexScreener | Top pair's liquidity (USD) | | `top_pair.volume_24h` | DexScreener | Top pair's 24h trading volume (USD) | +### `token risk` + +```bash +chainpilot [--chain-id ] token risk +``` + +Token risk analysis from OKX OnchainOS: honeypot detection, blacklist status, +transfer restrictions, minting, owner privileges, and buy/sell tax. + +| Field | Source | Notes | +|---|---|---| +| `risk_level` | OKX OnchainOS | e.g. `low`, `medium`, `high` | +| `risk_score` | OKX OnchainOS | Numeric risk score | +| `honeypot` | OKX OnchainOS | Whether the token is a honeypot | +| `blacklist` | OKX OnchainOS | Whether the token has a blacklist | +| `transfer_restricted` | OKX OnchainOS | Whether transfers are restricted | +| `mintable` | OKX OnchainOS | Whether new tokens can be minted | +| `owner_privileged` | OKX OnchainOS | Whether owner has special privileges | +| `tax_buy` | OKX OnchainOS | Buy tax percentage | +| `tax_sell` | OKX OnchainOS | Sell tax percentage | + +Native tokens (ETH, BNB, etc.) return hardcoded low-risk defaults. + ### `token add` ```bash diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 6b6d062..5a399fa 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -175,6 +175,30 @@ struct OkxTagList { community_recognized: Option, } +#[derive(Debug, Deserialize)] +struct OkxRiskToken { + #[serde(rename = "tokenContractAddress")] + token_contract_address: Option, + #[serde(rename = "riskLevel")] + risk_level: Option, + #[serde(rename = "riskScore")] + risk_score: Option, + #[serde(rename = "isHoneypot")] + is_honeypot: Option, + #[serde(rename = "isBlacklist")] + is_blacklist: Option, + #[serde(rename = "isTransferRestrict")] + is_transfer_restrict: Option, + #[serde(rename = "isMintable")] + is_mintable: Option, + #[serde(rename = "isOwnerPrivileged")] + is_owner_privileged: Option, + #[serde(rename = "buyTax")] + buy_tax: Option, + #[serde(rename = "sellTax")] + sell_tax: Option, +} + impl TokenMetadataClient { pub fn new(client: Client, config: &AppConfig) -> Self { Self { @@ -364,6 +388,138 @@ impl TokenMetadataClient { } } + pub async fn fetch_risk( + &self, + chain_id: u64, + address: &str, + symbol: &str, + ) -> crate::models::token::TokenRisk { + use crate::models::token::{TokenRisk, TokenRiskSources}; + + let is_native = address.eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR); + + // Native tokens are inherently low-risk + if is_native { + return TokenRisk { + address: address.to_string(), + symbol: symbol.to_string(), + chain_id, + risk_level: Some("low".to_string()), + risk_score: Some(0.0), + honeypot: Some(false), + blacklist: Some(false), + transfer_restricted: Some(false), + mintable: Some(false), + owner_privileged: Some(false), + tax_buy: Some(0.0), + tax_sell: Some(0.0), + sources: TokenRiskSources { + risk_level: Some("chain-config".to_string()), + risk_score: Some("chain-config".to_string()), + honeypot: Some("chain-config".to_string()), + blacklist: Some("chain-config".to_string()), + transfer_restricted: Some("chain-config".to_string()), + mintable: Some("chain-config".to_string()), + owner_privileged: Some("chain-config".to_string()), + tax_buy: Some("chain-config".to_string()), + tax_sell: Some("chain-config".to_string()), + }, + }; + } + + let okx = self.fetch_okx_risk(chain_id, address).await; + + let mut risk = TokenRisk { + address: address.to_string(), + symbol: symbol.to_string(), + chain_id, + risk_level: None, + risk_score: None, + honeypot: None, + blacklist: None, + transfer_restricted: None, + mintable: None, + owner_privileged: None, + tax_buy: None, + tax_sell: None, + sources: TokenRiskSources::default(), + }; + + if let Some(data) = okx { + let src = "okx-onchainos"; + macro_rules! set { + ($model:ident, $val:expr) => { + if risk.$model.is_none() { + risk.$model = Some($val); + risk.sources.$model = Some(src.to_string()); + } + }; + } + if let Some(v) = data.risk_level { set!(risk_level, v); } + if let Some(v) = data.risk_score { set!(risk_score, v); } + if let Some(v) = data.is_honeypot { set!(honeypot, v); } + if let Some(v) = data.is_blacklist { set!(blacklist, v); } + if let Some(v) = data.is_transfer_restrict { set!(transfer_restricted, v); } + if let Some(v) = data.is_mintable { set!(mintable, v); } + if let Some(v) = data.is_owner_privileged { set!(owner_privileged, v); } + if let Some(v) = data.buy_tax { set!(tax_buy, v); } + if let Some(v) = data.sell_tax { set!(tax_sell, v); } + } + + risk + } + + async fn fetch_okx_risk( + &self, + chain_id: u64, + address: &str, + ) -> Option { + let api_key = self.okx_api_key.as_ref()?; + let secret = self.okx_api_secret.as_ref()?; + let passphrase = self.okx_api_passphrase.as_ref()?; + let request_path = "/api/v6/dex/market/token/risk"; + let body = serde_json::json!([ + { + "chainIndex": chain_id.to_string(), + "tokenContractAddress": address, + } + ]) + .to_string(); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let signature = okx_signature(×tamp, "POST", request_path, &body, secret).ok()?; + let url = format!("{}{}", self.okx_base_url, request_path); + + let mut req = self + .client + .post(url) + .timeout(Duration::from_secs(8)) + .header("OK-ACCESS-KEY", api_key) + .header("OK-ACCESS-SIGN", signature) + .header("OK-ACCESS-TIMESTAMP", timestamp) + .header("OK-ACCESS-PASSPHRASE", passphrase) + .body(body); + if let Some(project_id) = &self.okx_project_id { + req = req.header("OK-ACCESS-PROJECT", project_id); + } + + let envelope = req + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json::>>() + .await + .ok()?; + + envelope.data?.into_iter().find(|token| { + token + .token_contract_address + .as_deref() + .is_some_and(|a| a.eq_ignore_ascii_case(address)) + }) + } + pub async fn search_symbol(&self, query: &str, chain_id: u64) -> TokenSearchResult { let mut candidates = Vec::new(); diff --git a/src/cli/token.rs b/src/cli/token.rs index 1c11b03..415764d 100644 --- a/src/cli/token.rs +++ b/src/cli/token.rs @@ -17,6 +17,8 @@ pub enum TokenAction { Price(TokenIdentArg), /// Liquidity overview: top liquidity, pair count, top pair details (DexScreener) Liquidity(TokenIdentArg), + /// Token risk analysis: honeypot, blacklist, taxes, owner privileges (OKX OnchainOS) + Risk(TokenIdentArg), /// Save a custom token so later symbol lookups can resolve it Add(TokenAddArgs), /// Create a token via DODO's ERC20V3Factory diff --git a/src/commands/token.rs b/src/commands/token.rs index c1f7ac2..7a376b4 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -30,6 +30,7 @@ pub async fn handle( TokenAction::Contract(args) => contract(args, api, config, store, output_mode).await, TokenAction::Price(args) => price(args, api, config, store, output_mode).await, TokenAction::Liquidity(args) => liquidity(args, api, config, store, output_mode).await, + TokenAction::Risk(args) => risk(args, api, config, store, output_mode).await, TokenAction::Add(args) => add(args, api, config, store, output_mode).await, TokenAction::Create(cmd) => match cmd.action { TokenCreateAction::Std(args) => create_std(args, config, store, output_mode).await, @@ -282,6 +283,67 @@ async fn liquidity( )) } +async fn risk( + args: crate::cli::token::TokenIdentArg, + api: &ApiClients, + config: &AppConfig, + store: &QuoteStore, + output_mode: OutputMode, +) -> Result { + let chain_id = config.chain_id; + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + let onchain = &chain_client; + let token_ref = match resolve_token(&args.token, chain_id, onchain, api, config, store).await { + Ok(t) => t, + Err(ChainError::TokenNotFound(_)) => { + let search = api + .token_metadata + .search_symbol(&args.token, chain_id) + .await; + if search.candidates.is_empty() { + return Ok(crate::output::print_output::< + crate::models::token::TokenRisk, + >( + Err(ChainError::TokenNotFound(args.token)), + "token.risk", + output_mode, + OutputContext::new(chain_id, false), + )); + } + return Ok(crate::output::print_output::< + crate::models::token::TokenSearchResult, + >( + Ok(search), + "token.search", + output_mode, + OutputContext::new(chain_id, false), + )); + } + Err(e) => { + return Ok(crate::output::print_output::< + crate::models::token::TokenRisk, + >( + Err(e), + "token.risk", + output_mode, + OutputContext::new(chain_id, false), + )); + } + }; + let result = api + .token_metadata + .fetch_risk(chain_id, &token_ref.address, &token_ref.symbol) + .await; + Ok(crate::output::print_output::< + crate::models::token::TokenRisk, + >( + Ok(result), + "token.risk", + output_mode, + OutputContext::new(chain_id, false), + )) +} + async fn contract( args: TokenIdentArg, api: &ApiClients, diff --git a/src/models/token.rs b/src/models/token.rs index cb53ef3..f604cf3 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -112,6 +112,36 @@ pub struct TokenLiquidityTopPair { pub volume_24h: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenRisk { + pub address: String, + pub symbol: String, + pub chain_id: u64, + pub risk_level: Option, + pub risk_score: Option, + pub honeypot: Option, + pub blacklist: Option, + pub transfer_restricted: Option, + pub mintable: Option, + pub owner_privileged: Option, + pub tax_buy: Option, + pub tax_sell: Option, + pub sources: TokenRiskSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenRiskSources { + pub risk_level: Option, + pub risk_score: Option, + pub honeypot: Option, + pub blacklist: Option, + pub transfer_restricted: Option, + pub mintable: Option, + pub owner_privileged: Option, + pub tax_buy: Option, + pub tax_sell: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenContract { pub address: String, diff --git a/src/output/table.rs b/src/output/table.rs index ce4858f..6b7248b 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -331,6 +331,56 @@ impl TableRenderable for crate::models::token::TokenLiquidity { } } +impl TableRenderable for crate::models::token::TokenRisk { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value", "Source"]); + table.add_row(vec!["Address", &self.address, ""]); + table.add_row(vec!["Symbol", &self.symbol, ""]); + table.add_row(vec!["Chain ID", &self.chain_id.to_string(), ""]); + add_risk_row(&mut table, "Risk Level", self.risk_level.as_deref(), self.sources.risk_level.as_deref()); + add_risk_row_score(&mut table, "Risk Score", self.risk_score, self.sources.risk_score.as_deref()); + add_risk_row_bool(&mut table, "Honeypot", self.honeypot, self.sources.honeypot.as_deref()); + add_risk_row_bool(&mut table, "Blacklist", self.blacklist, self.sources.blacklist.as_deref()); + add_risk_row_bool(&mut table, "Transfer Restricted", self.transfer_restricted, self.sources.transfer_restricted.as_deref()); + add_risk_row_bool(&mut table, "Mintable", self.mintable, self.sources.mintable.as_deref()); + add_risk_row_bool(&mut table, "Owner Privileged", self.owner_privileged, self.sources.owner_privileged.as_deref()); + add_risk_row_pct(&mut table, "Buy Tax", self.tax_buy, self.sources.tax_buy.as_deref()); + add_risk_row_pct(&mut table, "Sell Tax", self.tax_sell, self.sources.tax_sell.as_deref()); + println!("{}", table); + } +} + +fn add_risk_row(table: &mut Table, label: &str, value: Option<&str>, source: Option<&str>) { + let value = value.unwrap_or("N/A"); + table.add_row(vec![label, value, source.unwrap_or("")]); +} + +fn add_risk_row_bool(table: &mut Table, label: &str, value: Option, source: Option<&str>) { + let value = match value { + Some(true) => "Yes", + Some(false) => "No", + None => "N/A", + }; + table.add_row(vec![label, value, source.unwrap_or("")]); +} + +fn add_risk_row_score(table: &mut Table, label: &str, value: Option, source: Option<&str>) { + let value = match value { + Some(v) => format!("{}", v), + None => "N/A".to_string(), + }; + table.add_row(vec![label, &value, source.unwrap_or("")]); +} + +fn add_risk_row_pct(table: &mut Table, label: &str, value: Option, source: Option<&str>) { + let value = match value { + Some(v) => format_pct(v), + None => "N/A".to_string(), + }; + table.add_row(vec![label, &value, source.unwrap_or("")]); +} + fn format_pct(value: f64) -> String { format!("{:.2}%", value) } From dc0d9fcbbd394e56ef9e42d60b94484ddedcc29a Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 14:54:02 +0800 Subject: [PATCH 08/13] docs(chainpilot): document OKX credentials requirement for token risk Co-Authored-By: Claude Opus 4.7 --- skills/chainpilot/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index efefe7b..23ddb9f 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -70,8 +70,8 @@ the user explicitly asks for a different one. Runtime env vars are intentionally limited to `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, -`WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, and -`DODO_API_URL`. +`WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, +`DODO_API_URL`, `OKX_API_KEY`, `OKX_API_SECRET`, and `OKX_API_PASSPHRASE`. Config precedence: CLI flag > env var > `.env` file > compile-time default. @@ -352,6 +352,10 @@ chainpilot [--chain-id ] token risk Token risk analysis from OKX OnchainOS: honeypot detection, blacklist status, transfer restrictions, minting, owner privileges, and buy/sell tax. +> **Requires OKX credentials**: Set `OKX_API_KEY`, `OKX_API_SECRET`, and +> `OKX_API_PASSPHRASE` environment variables. Without them all risk fields +> return `null`. Native tokens (ETH, BNB, etc.) do not require credentials. + | Field | Source | Notes | |---|---|---| | `risk_level` | OKX OnchainOS | e.g. `low`, `medium`, `high` | From 840a0dcf35b6ca283dc0a87f45d3467199b7201e Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 19:32:21 +0800 Subject: [PATCH 09/13] feat: add config command, migrate risk to GoPlus, remove OKX integration - Add `config` command (set/get/list/unset) for managing API keys in persistent config.env file with cross-platform support - Switch token risk analysis from OKX OnchainOS to GoPlus Security API (free, no credentials required), with trust_list-based scoring for centralized tokens like USDT/USDC - Remove all OKX API integration (search, metadata, risk) and related dependencies (hmac, base64, sha2) - Fix token search filtering: exclude null addresses and non-EVM addresses from CoinGecko and DexScreener results - Update SKILL.md with config command docs and GoPlus risk documentation Co-Authored-By: Claude Opus 4.7 --- .env.example | 5 - Cargo.lock | 3 - Cargo.toml | 3 - skills/chainpilot/SKILL.md | 79 +++++-- src/api/token_metadata.rs | 455 ++++++++++++++----------------------- src/chain/rpc.rs | 17 +- src/cli/config.rs | 34 +++ src/cli/mod.rs | 3 + src/cli/token.rs | 2 +- src/commands/config.rs | 251 ++++++++++++++++++++ src/commands/mod.rs | 2 + src/commands/swap.rs | 34 +-- src/config/mod.rs | 47 +--- src/main.rs | 24 ++ src/models/config.rs | 15 ++ src/models/mod.rs | 1 + src/output/table.rs | 48 ++++ src/store/quote.rs | 17 +- 18 files changed, 620 insertions(+), 420 deletions(-) create mode 100644 src/cli/config.rs create mode 100644 src/commands/config.rs create mode 100644 src/models/config.rs diff --git a/.env.example b/.env.example index 959b7c6..7b68a86 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,6 @@ DODO_PROJECT_ID= # COINGECKO_API_URL=https://api.coingecko.com/api/v3 # COINGECKO_API_KEY= # DEXSCREENER_API_URL=https://api.dexscreener.com/latest/dex -# OKX_DEX_API_URL=https://web3.okx.com -# OKX_API_KEY= -# OKX_API_SECRET= -# OKX_API_PASSPHRASE= -# OKX_PROJECT_ID= # ── Wallet ──────────────────────────────────────────────────────────────────── # Private key used for signing transactions. diff --git a/Cargo.lock b/Cargo.lock index 7021ddf..dbeb9b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,7 +1178,6 @@ dependencies = [ "alloy-signer-local", "alloy-sol-types", "anyhow", - "base64", "chrono", "clap", "colored", @@ -1186,12 +1185,10 @@ dependencies = [ "dirs", "dotenvy", "hex", - "hmac", "rand 0.8.5", "reqwest 0.12.28", "serde", "serde_json", - "sha2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 4d7e5a5..5379b45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hex = "0.4" url = "2" -base64 = "0.22" -hmac = "0.12" -sha2 = "0.10" thiserror = "2.0" anyhow = "1.0" dotenvy = "0.15" diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 23ddb9f..182686e 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -71,7 +71,7 @@ the user explicitly asks for a different one. Runtime env vars are intentionally limited to `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, `WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, -`DODO_API_URL`, `OKX_API_KEY`, `OKX_API_SECRET`, and `OKX_API_PASSPHRASE`. +and `DODO_API_URL`. Config precedence: CLI flag > env var > `.env` file > compile-time default. @@ -349,24 +349,21 @@ and top pair details (DEX, pair address, 24h volume). chainpilot [--chain-id ] token risk ``` -Token risk analysis from OKX OnchainOS: honeypot detection, blacklist status, -transfer restrictions, minting, owner privileges, and buy/sell tax. - -> **Requires OKX credentials**: Set `OKX_API_KEY`, `OKX_API_SECRET`, and -> `OKX_API_PASSPHRASE` environment variables. Without them all risk fields -> return `null`. Native tokens (ETH, BNB, etc.) do not require credentials. +Token risk analysis from GoPlus Security: honeypot detection, blacklist status, +transfer restrictions, minting, owner privileges, and buy/sell tax. Free API, +no credentials required. | Field | Source | Notes | |---|---|---| -| `risk_level` | OKX OnchainOS | e.g. `low`, `medium`, `high` | -| `risk_score` | OKX OnchainOS | Numeric risk score | -| `honeypot` | OKX OnchainOS | Whether the token is a honeypot | -| `blacklist` | OKX OnchainOS | Whether the token has a blacklist | -| `transfer_restricted` | OKX OnchainOS | Whether transfers are restricted | -| `mintable` | OKX OnchainOS | Whether new tokens can be minted | -| `owner_privileged` | OKX OnchainOS | Whether owner has special privileges | -| `tax_buy` | OKX OnchainOS | Buy tax percentage | -| `tax_sell` | OKX OnchainOS | Sell tax percentage | +| `risk_level` | GoPlus (derived) | `low`, `medium`, or `high` | +| `risk_score` | GoPlus (derived) | 0–100 composite score | +| `honeypot` | GoPlus | Whether the token is a honeypot | +| `blacklist` | GoPlus | Whether the token has a blacklist | +| `transfer_restricted` | GoPlus | Whether transfers are pausable | +| `mintable` | GoPlus | Whether new tokens can be minted | +| `owner_privileged` | GoPlus | Whether owner can change balances | +| `tax_buy` | GoPlus | Buy tax percentage | +| `tax_sell` | GoPlus | Sell tax percentage | Native tokens (ETH, BNB, etc.) return hardcoded low-risk defaults. @@ -518,6 +515,56 @@ For unsupported chain IDs, pass `--rpc-url` manually. --- +## `config` Subcommands + +Manage API keys and configuration values. Settings are persisted in a config file +(`~/.local/share/chain/config.env` on Linux) and take precedence over `.env` file values. + +### `config set` + +```bash +chainpilot config set +``` + +Save an API key or configuration value. The value is written to the persistent config file +and immediately available in the current session. + +### `config get` + +```bash +chainpilot config get +``` + +Show the current value of a configuration key. Sensitive values are partially masked. + +### `config list` + +```bash +chainpilot config list +``` + +Show all configurable keys with their current values (sensitive values masked). + +### `config unset` + +```bash +chainpilot config unset +``` + +Remove a configuration key from the config file. + +### Configurable Keys + +| Key | Env Var | Sensitive | Description | +|---|---|---|---| +| `dodo_api_key` | `DODO_API_KEY` | Yes | DODO API key for swap routing | +| `dodo_project_id` | `DODO_PROJECT_ID` | No | DODO project ID for tokenlist API | +| `coingecko_api_key` | `COINGECKO_API_KEY` | Yes | CoinGecko API key for price data | + +**Config precedence**: CLI flag > `config.env` file > env var / `.env` file > compile-time default. + +--- + ## Scripting Patterns ```bash diff --git a/src/api/token_metadata.rs b/src/api/token_metadata.rs index 5a399fa..2bfd3d2 100644 --- a/src/api/token_metadata.rs +++ b/src/api/token_metadata.rs @@ -1,30 +1,19 @@ use std::time::Duration; -use base64::Engine; -use hmac::{Hmac, Mac}; use reqwest::Client; use serde::Deserialize; -use sha2::Sha256; - use crate::config::AppConfig; use crate::models::token::{ TokenInfo, TokenPrice, TokenPriceSources, TokenSearchCandidate, TokenSearchResult, TokenSocialLinks, }; -type HmacSha256 = Hmac; - #[derive(Clone)] pub struct TokenMetadataClient { client: Client, coingecko_base_url: String, coingecko_api_key: Option, dexscreener_base_url: String, - okx_base_url: String, - okx_api_key: Option, - okx_api_secret: Option, - okx_api_passphrase: Option, - okx_project_id: Option, } #[derive(Debug, Default)] @@ -104,6 +93,8 @@ struct DexScreenerResponse { #[derive(Debug, Deserialize)] struct DexScreenerPair { + #[serde(rename = "chainId")] + chain_id: Option, #[serde(rename = "baseToken")] base_token: Option, liquidity: Option, @@ -136,67 +127,35 @@ struct DexScreenerToken { symbol: Option, } -#[derive(Debug, Deserialize)] -struct OkxSearchEnvelope { - data: Option, -} - -#[derive(Debug, Deserialize)] -struct OkxSearchData { - #[serde(rename = "tokenList")] - token_list: Option>, -} - #[derive(Debug, Deserialize)] struct DexScreenerLiquidity { usd: Option, } #[derive(Debug, Deserialize)] -struct OkxEnvelope { - data: Option, -} - -#[derive(Debug, Deserialize)] -struct OkxToken { - #[serde(rename = "tokenContractAddress")] - token_contract_address: Option, - #[serde(rename = "tokenSymbol")] - token_symbol: Option, - #[serde(rename = "tokenName")] - token_name: Option, - #[serde(rename = "tagList")] - tag_list: Option, -} - -#[derive(Debug, Deserialize)] -struct OkxTagList { - #[serde(rename = "communityRecognized")] - community_recognized: Option, +struct GoPlusResponse { + code: u64, + result: Option>, } -#[derive(Debug, Deserialize)] -struct OkxRiskToken { - #[serde(rename = "tokenContractAddress")] - token_contract_address: Option, - #[serde(rename = "riskLevel")] - risk_level: Option, - #[serde(rename = "riskScore")] - risk_score: Option, - #[serde(rename = "isHoneypot")] - is_honeypot: Option, - #[serde(rename = "isBlacklist")] - is_blacklist: Option, - #[serde(rename = "isTransferRestrict")] - is_transfer_restrict: Option, - #[serde(rename = "isMintable")] - is_mintable: Option, - #[serde(rename = "isOwnerPrivileged")] - is_owner_privileged: Option, - #[serde(rename = "buyTax")] - buy_tax: Option, - #[serde(rename = "sellTax")] - sell_tax: Option, +#[derive(Clone, Debug, Deserialize)] +struct GoPlusTokenSecurity { + #[serde(rename = "is_honeypot")] + is_honeypot: Option, + #[serde(rename = "is_blacklisted")] + is_blacklisted: Option, + #[serde(rename = "transfer_pausable")] + transfer_pausable: Option, + #[serde(rename = "is_mintable")] + is_mintable: Option, + #[serde(rename = "owner_change_balance")] + owner_change_balance: Option, + #[serde(rename = "buy_tax")] + buy_tax: Option, + #[serde(rename = "sell_tax")] + sell_tax: Option, + #[serde(rename = "trust_list")] + trust_list: Option, } impl TokenMetadataClient { @@ -206,11 +165,6 @@ impl TokenMetadataClient { coingecko_base_url: config.coingecko_api_url.trim_end_matches('/').to_string(), coingecko_api_key: config.coingecko_api_key.clone(), dexscreener_base_url: config.dexscreener_api_url.trim_end_matches('/').to_string(), - okx_base_url: config.okx_dex_api_url.trim_end_matches('/').to_string(), - okx_api_key: config.okx_api_key.clone(), - okx_api_secret: config.okx_api_secret.clone(), - okx_api_passphrase: config.okx_api_passphrase.clone(), - okx_project_id: config.okx_project_id.clone(), } } @@ -240,12 +194,24 @@ impl TokenMetadataClient { info.address.clone() }; let dexscreener = self.fetch_dexscreener(&dexscreener_addr).await.ok(); - let okx = self.fetch_okx_token(info.chain_id, &info.address).await; let mut patch = TokenMetadataPatch::default(); apply_coingecko(&mut patch, coingecko); apply_dexscreener(&mut patch, dexscreener, &dexscreener_addr); - apply_okx(&mut patch, okx); + + // Native tokens are inherently low-risk + if is_native { + patch.risk_level = Some(("low".to_string(), "chain-config".to_string())); + } + + // Use GoPlus for risk_level + if patch.risk_level.is_none() && !is_native { + if let Some(goplus) = self.fetch_goplus_risk(info.chain_id, &info.address).await { + let a = assess_goplus_risk(&goplus); + patch.risk_level = Some((a.risk_level, "goplus".to_string())); + } + } + apply_patch(&mut info, patch); info } @@ -427,7 +393,7 @@ impl TokenMetadataClient { }; } - let okx = self.fetch_okx_risk(chain_id, address).await; + let goplus = self.fetch_goplus_risk(chain_id, address).await; let mut risk = TokenRisk { address: address.to_string(), @@ -445,8 +411,9 @@ impl TokenMetadataClient { sources: TokenRiskSources::default(), }; - if let Some(data) = okx { - let src = "okx-onchainos"; + if let Some(data) = goplus { + let src = "goplus"; + let a = assess_goplus_risk(&data); macro_rules! set { ($model:ident, $val:expr) => { if risk.$model.is_none() { @@ -455,75 +422,53 @@ impl TokenMetadataClient { } }; } - if let Some(v) = data.risk_level { set!(risk_level, v); } - if let Some(v) = data.risk_score { set!(risk_score, v); } - if let Some(v) = data.is_honeypot { set!(honeypot, v); } - if let Some(v) = data.is_blacklist { set!(blacklist, v); } - if let Some(v) = data.is_transfer_restrict { set!(transfer_restricted, v); } - if let Some(v) = data.is_mintable { set!(mintable, v); } - if let Some(v) = data.is_owner_privileged { set!(owner_privileged, v); } - if let Some(v) = data.buy_tax { set!(tax_buy, v); } - if let Some(v) = data.sell_tax { set!(tax_sell, v); } + set!(honeypot, a.honeypot); + set!(blacklist, a.blacklist); + set!(transfer_restricted, a.transfer_restricted); + set!(mintable, a.mintable); + set!(owner_privileged, a.owner_privileged); + if let Some(v) = a.tax_buy { set!(tax_buy, v); } + if let Some(v) = a.tax_sell { set!(tax_sell, v); } + set!(risk_score, a.risk_score); + set!(risk_level, a.risk_level); } risk } - async fn fetch_okx_risk( + async fn fetch_goplus_risk( &self, chain_id: u64, address: &str, - ) -> Option { - let api_key = self.okx_api_key.as_ref()?; - let secret = self.okx_api_secret.as_ref()?; - let passphrase = self.okx_api_passphrase.as_ref()?; - let request_path = "/api/v6/dex/market/token/risk"; - let body = serde_json::json!([ - { - "chainIndex": chain_id.to_string(), - "tokenContractAddress": address, - } - ]) - .to_string(); - let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); - let signature = okx_signature(×tamp, "POST", request_path, &body, secret).ok()?; - let url = format!("{}{}", self.okx_base_url, request_path); - - let mut req = self + ) -> Option { + let goplus_chain = goplus_chain_id(chain_id)?; + let url = format!( + "https://api.gopluslabs.io/api/v1/token_security/{}", + goplus_chain + ); + let resp = self .client - .post(url) - .timeout(Duration::from_secs(8)) - .header("OK-ACCESS-KEY", api_key) - .header("OK-ACCESS-SIGN", signature) - .header("OK-ACCESS-TIMESTAMP", timestamp) - .header("OK-ACCESS-PASSPHRASE", passphrase) - .body(body); - if let Some(project_id) = &self.okx_project_id { - req = req.header("OK-ACCESS-PROJECT", project_id); - } - - let envelope = req + .get(&url) + .query(&[("contract_addresses", address)]) + .timeout(Duration::from_secs(10)) .send() .await - .ok()? - .error_for_status() - .ok()? - .json::>>() - .await .ok()?; - - envelope.data?.into_iter().find(|token| { - token - .token_contract_address - .as_deref() - .is_some_and(|a| a.eq_ignore_ascii_case(address)) - }) + let status = resp.status(); + let body_text = resp.text().await.ok()?; + tracing::debug!("GoPlus risk API status={status}, body={}", if body_text.len() > 500 { &body_text[..500] } else { &body_text }); + let data: GoPlusResponse = serde_json::from_str(&body_text).ok()?; + if data.code != 1 { + return None; + } + let result = data.result?; + let addr_lower = address.to_lowercase(); + result.get(&addr_lower).cloned().or_else(|| result.into_values().next()) } pub async fn search_symbol(&self, query: &str, chain_id: u64) -> TokenSearchResult { let mut candidates = Vec::new(); - candidates.extend(self.search_okx(query, chain_id).await); candidates.extend(self.search_coingecko(query, chain_id).await); candidates.extend(self.search_dexscreener(query).await); @@ -572,12 +517,12 @@ impl TokenMetadataClient { .platforms .as_ref() .and_then(|platforms| platforms.get(platform)) - .and_then(|address| non_empty(Some(address.clone()))); + .and_then(|address| non_empty(Some(address.clone())))?; Some(TokenSearchCandidate { source: "coingecko".to_string(), symbol, name: non_empty(coin.name), - address, + address: Some(address), chain: Some(platform.to_string()), top_liquidity: None, }) @@ -617,12 +562,17 @@ impl TokenMetadataClient { if symbol != query_upper { return None; } + let address = non_empty(token.address).filter(|a| { + // Only include EVM addresses (0x prefix, 42 chars) + a.starts_with("0x") && a.len() == 42 + })?; + let chain_id = pair.chain_id.as_deref().and_then(|c| c.parse::().ok()); Some(TokenSearchCandidate { source: "dexscreener".to_string(), symbol, name: non_empty(token.name), - address: non_empty(token.address), - chain: None, + address: Some(address), + chain: chain_id.map(|id| id.to_string()), top_liquidity: pair.liquidity.and_then(|liquidity| liquidity.usd), }) }) @@ -630,77 +580,6 @@ impl TokenMetadataClient { .collect() } - async fn search_okx(&self, query: &str, chain_id: u64) -> Vec { - let api_key = match self.okx_api_key.as_ref() { - Some(value) => value, - None => return Vec::new(), - }; - let secret = match self.okx_api_secret.as_ref() { - Some(value) => value, - None => return Vec::new(), - }; - let passphrase = match self.okx_api_passphrase.as_ref() { - Some(value) => value, - None => return Vec::new(), - }; - let request_path = "/api/v6/dex/market/token/search"; - let body = serde_json::json!({ - "chainIndex": chain_id.to_string(), - "tokenSymbol": query, - }) - .to_string(); - let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); - let Ok(signature) = okx_signature(×tamp, "POST", request_path, &body, secret) else { - return Vec::new(); - }; - let url = format!("{}{}", self.okx_base_url, request_path); - - let mut req = self - .client - .post(url) - .timeout(Duration::from_secs(8)) - .header("OK-ACCESS-KEY", api_key) - .header("OK-ACCESS-SIGN", signature) - .header("OK-ACCESS-TIMESTAMP", timestamp) - .header("OK-ACCESS-PASSPHRASE", passphrase) - .body(body); - if let Some(project_id) = &self.okx_project_id { - req = req.header("OK-ACCESS-PROJECT", project_id); - } - - let Ok(response) = req - .send() - .await - .and_then(reqwest::Response::error_for_status) - else { - return Vec::new(); - }; - let Ok(search) = response.json::().await else { - return Vec::new(); - }; - let query_upper = query.to_uppercase(); - search - .data - .and_then(|data| data.token_list) - .unwrap_or_default() - .into_iter() - .filter_map(|token| { - let symbol = non_empty(token.token_symbol).map(|symbol| symbol.to_uppercase())?; - if symbol != query_upper { - return None; - } - Some(TokenSearchCandidate { - source: "okx-onchainos".to_string(), - symbol, - name: non_empty(token.token_name), - address: non_empty(token.token_contract_address), - chain: Some(chain_id.to_string()), - top_liquidity: None, - }) - }) - .take(3) - .collect() - } async fn fetch_coingecko( &self, @@ -765,52 +644,6 @@ impl TokenMetadataClient { .await } - async fn fetch_okx_token(&self, chain_id: u64, address: &str) -> Option { - let api_key = self.okx_api_key.as_ref()?; - let secret = self.okx_api_secret.as_ref()?; - let passphrase = self.okx_api_passphrase.as_ref()?; - let request_path = "/api/v6/dex/market/token/basic-info"; - let body = serde_json::json!([ - { - "chainIndex": chain_id.to_string(), - "tokenContractAddress": address, - } - ]) - .to_string(); - let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); - let signature = okx_signature(×tamp, "POST", request_path, &body, secret).ok()?; - let url = format!("{}{}", self.okx_base_url, request_path); - - let mut req = self - .client - .post(url) - .timeout(Duration::from_secs(8)) - .header("OK-ACCESS-KEY", api_key) - .header("OK-ACCESS-SIGN", signature) - .header("OK-ACCESS-TIMESTAMP", timestamp) - .header("OK-ACCESS-PASSPHRASE", passphrase) - .body(body); - if let Some(project_id) = &self.okx_project_id { - req = req.header("OK-ACCESS-PROJECT", project_id); - } - - let envelope = req - .send() - .await - .ok()? - .error_for_status() - .ok()? - .json::>>() - .await - .ok()?; - - envelope.data?.into_iter().find(|token| { - token - .token_contract_address - .as_deref() - .is_some_and(|token_address| token_address.eq_ignore_ascii_case(address)) - }) - } } fn apply_coingecko(patch: &mut TokenMetadataPatch, token: Option) { @@ -1023,39 +856,6 @@ fn apply_dexscreener_price( } } -fn apply_okx(patch: &mut TokenMetadataPatch, token: Option) { - let Some(token) = token else { - return; - }; - if patch.name.is_none() { - if let Some(name) = non_empty(token.token_name) { - patch.name = Some((name, "okx-onchainos".to_string())); - } - } - if patch.symbol.is_none() { - if let Some(symbol) = non_empty(token.token_symbol) { - patch.symbol = Some((symbol, "okx-onchainos".to_string())); - } - } - if patch.address.is_none() { - if let Some(address) = non_empty(token.token_contract_address) { - patch.address = Some((address, "okx-onchainos".to_string())); - } - } - if patch.risk_level.is_none() { - if let Some(community_recognized) = - token.tag_list.and_then(|tags| tags.community_recognized) - { - let risk = if community_recognized { - "low" - } else { - "unknown" - }; - patch.risk_level = Some((risk.to_string(), "okx-onchainos".to_string())); - } - } -} - fn apply_patch(info: &mut TokenInfo, patch: TokenMetadataPatch) { let mut sources = info.sources.clone(); @@ -1133,16 +933,86 @@ fn coingecko_platform_id(chain_id: u64) -> Option<&'static str> { } } -fn okx_signature( - timestamp: &str, - method: &str, - request_path: &str, - body: &str, - secret: &str, -) -> Result { - let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?; - mac.update(format!("{}{}{}{}", timestamp, method, request_path, body).as_bytes()); - Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())) +fn goplus_chain_id(chain_id: u64) -> Option<&'static str> { + match chain_id { + 1 => Some("1"), + 56 => Some("56"), + 137 => Some("137"), + 42161 => Some("42161"), + 10 => Some("10"), + 43114 => Some("43114"), + 8453 => Some("8453"), + 59144 => Some("59144"), + 534352 => Some("534352"), + 169 => Some("169"), + 5000 => Some("5000"), + 1313161554 => Some("1313161554"), + 66 => Some("66"), + 1030 => Some("1030"), + 167000 => Some("167000"), + 98866 => Some("98866"), + 11155111 => Some("11155111"), + _ => None, + } +} + +struct GoPlusRiskAssessment { + honeypot: bool, + blacklist: bool, + transfer_restricted: bool, + mintable: bool, + owner_privileged: bool, + tax_buy: Option, + tax_sell: Option, + risk_score: f64, + risk_level: String, +} + +fn assess_goplus_risk(data: &GoPlusTokenSecurity) -> GoPlusRiskAssessment { + let honeypot = data.is_honeypot.as_deref() == Some("1"); + let blacklist = data.is_blacklisted.as_deref() == Some("1"); + let transfer_restricted = data.transfer_pausable.as_deref() == Some("1"); + let mintable = data.is_mintable.as_deref() == Some("1"); + let owner_privileged = data.owner_change_balance.as_deref() == Some("1"); + let trusted = data.trust_list.as_deref() == Some("1"); + let buy_tax = data.buy_tax.as_deref().and_then(|s| { + if s.is_empty() { Some(0.0) } else { s.parse::().ok() } + }); + let sell_tax = data.sell_tax.as_deref().and_then(|s| { + if s.is_empty() { Some(0.0) } else { s.parse::().ok() } + }); + + // Trusted tokens (e.g. USDT, USDC) have their centralized-control + // penalties heavily discounted because those features are expected + // for regulated/centralized assets, not scam signals. + let centralization_weight: f64 = if trusted { 0.1 } else { 1.0 }; + + let mut score: f64 = 0.0; + // Honeypot is always critical regardless of trust status + if honeypot { score += 100.0; } + // Centralized-control signals: discounted for trusted tokens + if blacklist { score += 30.0 * centralization_weight; } + if transfer_restricted { score += 20.0 * centralization_weight; } + if mintable { score += 15.0 * centralization_weight; } + if owner_privileged { score += 15.0 * centralization_weight; } + // Tax signals apply at full weight regardless of trust + if let Some(t) = buy_tax { score += t.min(50.0); } + if let Some(t) = sell_tax { score += t.min(50.0); } + score = score.min(100.0); + + let level = if score >= 70.0 { "high" } else if score >= 30.0 { "medium" } else { "low" }; + + GoPlusRiskAssessment { + honeypot, + blacklist, + transfer_restricted, + mintable, + owner_privileged, + tax_buy: buy_tax, + tax_sell: sell_tax, + risk_score: score, + risk_level: level.to_string(), + } } fn first_non_empty(values: Option>) -> Option { @@ -1200,6 +1070,7 @@ mod tests { dex_id: None, pair_address: None, volume: None, + chain_id: None, }, DexScreenerPair { base_token: Some(DexScreenerToken { @@ -1213,6 +1084,7 @@ mod tests { dex_id: None, pair_address: None, volume: None, + chain_id: None, }, ]), }), @@ -1244,6 +1116,7 @@ mod tests { dex_id: None, pair_address: None, volume: None, + chain_id: None, }]), }), "0xabc", @@ -1329,6 +1202,7 @@ mod tests { dex_id: None, pair_address: None, volume: None, + chain_id: None, }]), }), "0xabc", @@ -1366,6 +1240,7 @@ mod tests { dex_id: None, pair_address: None, volume: None, + chain_id: None, }]), }), "0xabc", diff --git a/src/chain/rpc.rs b/src/chain/rpc.rs index d27d1bb..019d560 100644 --- a/src/chain/rpc.rs +++ b/src/chain/rpc.rs @@ -289,16 +289,8 @@ mod tests { } fn base_config() -> AppConfig { - let ( - coingecko_api_url, - coingecko_api_key, - dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, - ) = crate::config::test_metadata_config_fields(); + let (coingecko_api_url, coingecko_api_key, dexscreener_api_url) = + crate::config::test_metadata_config_fields(); AppConfig { rpc_url: "https://ethereum-rpc.publicnode.com".to_string(), rpc_url_overridden: false, @@ -314,11 +306,6 @@ mod tests { coingecko_api_url, coingecko_api_key, dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, data_dir: std::env::temp_dir(), } } diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..636a5af --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,34 @@ +use clap::Args; +use clap::Subcommand; + +#[derive(Args)] +pub struct ConfigCmd { + #[command(subcommand)] + pub action: ConfigAction, +} + +#[derive(Subcommand)] +pub enum ConfigAction { + /// Set an API key or configuration value + Set(ConfigSetArgs), + /// Get the current value of a configuration key + Get(ConfigKeyArg), + /// List all configuration keys and their (masked) values + List, + /// Remove a configuration key + Unset(ConfigKeyArg), +} + +#[derive(Args)] +pub struct ConfigSetArgs { + /// Configuration key name + pub key: String, + /// Value to set + pub value: String, +} + +#[derive(Args)] +pub struct ConfigKeyArg { + /// Configuration key name + pub key: String, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f59df2d..9663008 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod risk; pub mod swap; pub mod token; @@ -63,4 +64,6 @@ pub enum Commands { Wallet(wallet::WalletCmd), /// Risk analysis (token, wallet, approval) Risk(risk::RiskCmd), + /// Manage API keys and configuration + Config(config::ConfigCmd), } diff --git a/src/cli/token.rs b/src/cli/token.rs index 415764d..4e5b8ca 100644 --- a/src/cli/token.rs +++ b/src/cli/token.rs @@ -17,7 +17,7 @@ pub enum TokenAction { Price(TokenIdentArg), /// Liquidity overview: top liquidity, pair count, top pair details (DexScreener) Liquidity(TokenIdentArg), - /// Token risk analysis: honeypot, blacklist, taxes, owner privileges (OKX OnchainOS) + /// Token risk analysis: honeypot, blacklist, taxes, owner privileges (GoPlus Security) Risk(TokenIdentArg), /// Save a custom token so later symbol lookups can resolve it Add(TokenAddArgs), diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..8a5f1ca --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,251 @@ +use std::process::ExitCode; + +use crate::cli::config::{ConfigAction, ConfigCmd, ConfigKeyArg, ConfigSetArgs}; +use crate::config::AppConfig; +use crate::error::{ChainError, Result}; +use crate::models::config::{ConfigEntry, ConfigStatus}; +use crate::output::{OutputContext, OutputMode}; + +const CONFIGURABLE_KEYS: &[(&str, &str, bool)] = &[ + // (user-facing key, env var name, is_sensitive) + ("dodo_api_key", "DODO_API_KEY", true), + ("dodo_project_id", "DODO_PROJECT_ID", false), + ("coingecko_api_key", "COINGECKO_API_KEY", true), +]; + +fn find_key(key: &str) -> Option<(&'static str, &'static str, bool)> { + let lower = key.to_lowercase(); + CONFIGURABLE_KEYS + .iter() + .find(|(name, _, _)| *name == lower) + .map(|&(name, env, sensitive)| (name, env, sensitive)) +} + +fn mask_value(value: &str) -> String { + if value.len() <= 8 { + return "*".repeat(value.len()); + } + format!( + "{}...{}", + &value[..4], + &value[value.len() - 4..] + ) +} + +fn read_config_file(path: &std::path::Path) -> Vec<(String, String)> { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + Some((key.trim().to_string(), value.trim().to_string())) + }) + .collect() +} + +fn write_config_file(path: &std::path::Path, entries: &[(String, String)]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content: String = entries + .iter() + .map(|(k, v)| format!("{}={}\n", k, v)) + .collect(); + std::fs::write(path, content) +} + +pub async fn handle( + cmd: ConfigCmd, + config: &AppConfig, + _output_mode: OutputMode, +) -> Result { + let env_path = config.config_env_path(); + match cmd.action { + ConfigAction::Set(args) => set(args, &env_path, _output_mode, config), + ConfigAction::Get(args) => get(args, &env_path, _output_mode, config), + ConfigAction::List => list(&env_path, _output_mode, config), + ConfigAction::Unset(args) => unset(args, &env_path, _output_mode, config), + } +} + +fn set( + args: ConfigSetArgs, + env_path: &std::path::Path, + output_mode: OutputMode, + config: &AppConfig, +) -> Result { + let (key, env_var, _sensitive) = + find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + "Unknown config key '{}'. Valid keys: {}", + args.key, + CONFIGURABLE_KEYS + .iter() + .map(|(k, _, _)| *k) + .collect::>() + .join(", ") + )))?; + + let mut entries = read_config_file(env_path); + if let Some(entry) = entries.iter_mut().find(|(k, _)| k == env_var) { + entry.1 = args.value.clone(); + } else { + entries.push((env_var.to_string(), args.value.clone())); + } + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + + // Also set in current process so subsequent commands in the same session can use it. + std::env::set_var(env_var, &args.value); + + let status = ConfigStatus { + key: key.to_string(), + action: "set".to_string(), + message: "Saved successfully".to_string(), + }; + Ok(crate::output::print_output::( + Ok(status), + "config.set", + output_mode, + OutputContext::new(config.chain_id, false), + )) +} + +fn get( + args: ConfigKeyArg, + env_path: &std::path::Path, + output_mode: OutputMode, + config: &AppConfig, +) -> Result { + let (key, env_var, sensitive) = + find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + "Unknown config key '{}'. Valid keys: {}", + args.key, + CONFIGURABLE_KEYS + .iter() + .map(|(k, _, _)| *k) + .collect::>() + .join(", ") + )))?; + + // Read from config file first, then fall back to env var. + let entries = read_config_file(env_path); + let raw_value = entries + .iter() + .find(|(k, _)| k == env_var) + .map(|(_, v)| v.clone()) + .or_else(|| std::env::var(env_var).ok()); + + let display_value = if sensitive { + raw_value.map(|v| mask_value(&v)) + } else { + raw_value + }; + + let entry = ConfigEntry { + key: key.to_string(), + value: display_value, + masked: sensitive, + }; + Ok(crate::output::print_output::( + Ok(entry), + "config.get", + output_mode, + OutputContext::new(config.chain_id, false), + )) +} + +fn list( + env_path: &std::path::Path, + output_mode: OutputMode, + config: &AppConfig, +) -> Result { + let entries = read_config_file(env_path); + let result: Vec = CONFIGURABLE_KEYS + .iter() + .map(|&(key, env_var, sensitive)| { + let raw_value = entries + .iter() + .find(|(k, _)| k == env_var) + .map(|(_, v)| v.clone()) + .or_else(|| std::env::var(env_var).ok()); + + let display_value = if sensitive { + raw_value.map(|v| mask_value(&v)) + } else { + raw_value + }; + + ConfigEntry { + key: key.to_string(), + value: display_value, + masked: sensitive, + } + }) + .collect(); + + Ok(crate::output::print_output::>( + Ok(result), + "config.list", + output_mode, + OutputContext::new(config.chain_id, false), + )) +} + +fn unset( + args: ConfigKeyArg, + env_path: &std::path::Path, + output_mode: OutputMode, + config: &AppConfig, +) -> Result { + let (key, env_var, _sensitive) = + find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + "Unknown config key '{}'. Valid keys: {}", + args.key, + CONFIGURABLE_KEYS + .iter() + .map(|(k, _, _)| *k) + .collect::>() + .join(", ") + )))?; + + let mut entries = read_config_file(env_path); + let before_len = entries.len(); + entries.retain(|(k, _)| k != env_var); + + if entries.len() == before_len { + let status = ConfigStatus { + key: key.to_string(), + action: "unset".to_string(), + message: "Key was not set in config file".to_string(), + }; + return Ok(crate::output::print_output::( + Ok(status), + "config.unset", + output_mode, + OutputContext::new(config.chain_id, false), + )); + } + + write_config_file(env_path, &entries).map_err(|e| ChainError::Config(e.to_string()))?; + + // Remove from current process env so it falls back to default. + std::env::remove_var(env_var); + + let status = ConfigStatus { + key: key.to_string(), + action: "unset".to_string(), + message: "Removed successfully".to_string(), + }; + Ok(crate::output::print_output::( + Ok(status), + "config.unset", + output_mode, + OutputContext::new(config.chain_id, false), + )) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 550f45c..06c0d82 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 config; pub mod risk; pub mod swap; pub mod token; @@ -30,6 +31,7 @@ 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::Config(cmd) => config::handle(cmd, &config, output_mode).await, } } diff --git a/src/commands/swap.rs b/src/commands/swap.rs index da8df21..df34ce5 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -1340,16 +1340,8 @@ mod tests { } fn test_config(chain_id: u64) -> AppConfig { - let ( - coingecko_api_url, - coingecko_api_key, - dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, - ) = crate::config::test_metadata_config_fields(); + let (coingecko_api_url, coingecko_api_key, dexscreener_api_url) = + crate::config::test_metadata_config_fields(); AppConfig { rpc_url: "https://rpc.example.com".to_string(), rpc_url_overridden: false, @@ -1365,11 +1357,6 @@ mod tests { coingecko_api_url, coingecko_api_key, dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, data_dir: std::env::temp_dir().join(format!("chainpilot_test_{}", Uuid::new_v4())), } } @@ -1879,16 +1866,8 @@ mod tests { #[tokio::test] async fn send_approval_with_deps_returns_tx_hash_and_sender() { let private_key = "0x59c6995e998f97a5a0044966f0945382dbf7f50a3f2f72f5f7a0b7d7d4f5e5f1"; - let ( - coingecko_api_url, - coingecko_api_key, - dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, - ) = crate::config::test_metadata_config_fields(); + let (coingecko_api_url, coingecko_api_key, dexscreener_api_url) = + crate::config::test_metadata_config_fields(); let signer = crate::chain::resolve_signer(&AppConfig { rpc_url: String::new(), rpc_url_overridden: false, @@ -1904,11 +1883,6 @@ mod tests { coingecko_api_url, coingecko_api_key, dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, data_dir: std::env::temp_dir(), }) .unwrap(); diff --git a/src/config/mod.rs b/src/config/mod.rs index b292b1b..3e5b92f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,7 +11,6 @@ const FALLBACK_RPC_URL: &str = "https://ethereum-rpc.publicnode.com"; pub const DEFAULT_DODO_API_URL: &str = "https://api.dodoex.io/route-service/v2/widget/getdodoroute"; pub const DEFAULT_COINGECKO_API_URL: &str = "https://api.coingecko.com/api/v3"; pub const DEFAULT_DEXSCREENER_API_URL: &str = "https://api.dexscreener.com/latest/dex"; -pub const DEFAULT_OKX_DEX_API_URL: &str = "https://web3.okx.com"; 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. @@ -49,11 +48,6 @@ pub struct AppConfig { pub coingecko_api_url: String, pub coingecko_api_key: Option, pub dexscreener_api_url: String, - pub okx_dex_api_url: String, - pub okx_api_key: Option, - pub okx_api_secret: Option, - pub okx_api_passphrase: Option, - pub okx_project_id: Option, pub data_dir: PathBuf, } @@ -100,12 +94,6 @@ impl AppConfig { let coingecko_api_key = std::env::var("COINGECKO_API_KEY").ok(); let dexscreener_api_url = std::env::var("DEXSCREENER_API_URL") .unwrap_or_else(|_| DEFAULT_DEXSCREENER_API_URL.to_string()); - let okx_dex_api_url = std::env::var("OKX_DEX_API_URL") - .unwrap_or_else(|_| DEFAULT_OKX_DEX_API_URL.to_string()); - let okx_api_key = std::env::var("OKX_API_KEY").ok(); - let okx_api_secret = std::env::var("OKX_API_SECRET").ok(); - let okx_api_passphrase = std::env::var("OKX_API_PASSPHRASE").ok(); - let okx_project_id = std::env::var("OKX_PROJECT_ID").ok(); Ok(Self { rpc_url, @@ -122,11 +110,6 @@ impl AppConfig { coingecko_api_url, coingecko_api_key, dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, data_dir, }) } @@ -178,28 +161,18 @@ impl AppConfig { pub fn custom_tokens_path(&self) -> PathBuf { self.data_dir.join("custom_tokens.json") } + + pub fn config_env_path(&self) -> PathBuf { + self.data_dir.join("config.env") + } } #[cfg(test)] -pub fn test_metadata_config_fields() -> ( - String, - Option, - String, - String, - Option, - Option, - Option, - Option, -) { +pub fn test_metadata_config_fields() -> (String, Option, String) { ( DEFAULT_COINGECKO_API_URL.to_string(), None, DEFAULT_DEXSCREENER_API_URL.to_string(), - DEFAULT_OKX_DEX_API_URL.to_string(), - None, - None, - None, - None, ) } @@ -398,22 +371,12 @@ mod tests { ("COINGECKO_API_URL", Some("https://cg.example.com")), ("COINGECKO_API_KEY", Some("cg-key")), ("DEXSCREENER_API_URL", Some("https://dex.example.com")), - ("OKX_DEX_API_URL", Some("https://okx.example.com")), - ("OKX_API_KEY", Some("okx-key")), - ("OKX_API_SECRET", Some("okx-secret")), - ("OKX_API_PASSPHRASE", Some("okx-pass")), - ("OKX_PROJECT_ID", Some("okx-project")), ], || { let cfg = AppConfig::load().unwrap(); assert_eq!(cfg.coingecko_api_url, "https://cg.example.com"); assert_eq!(cfg.coingecko_api_key.as_deref(), Some("cg-key")); assert_eq!(cfg.dexscreener_api_url, "https://dex.example.com"); - assert_eq!(cfg.okx_dex_api_url, "https://okx.example.com"); - assert_eq!(cfg.okx_api_key.as_deref(), Some("okx-key")); - assert_eq!(cfg.okx_api_secret.as_deref(), Some("okx-secret")); - assert_eq!(cfg.okx_api_passphrase.as_deref(), Some("okx-pass")); - assert_eq!(cfg.okx_project_id.as_deref(), Some("okx-project")); }, ); } diff --git a/src/main.rs b/src/main.rs index 920c95b..0c6188c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,29 @@ use crate::cli::Cli; use crate::config::AppConfig; use crate::output::OutputMode; +/// Load the persistent config file (`config.env` in the data directory). +/// Unlike `dotenvy::dotenv()`, this always sets env vars so config file +/// values take precedence over the CWD `.env` file. +fn load_config_env() { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("chain"); + let config_path = data_dir.join("config.env"); + let content = match std::fs::read_to_string(&config_path) { + Ok(c) => c, + Err(_) => return, + }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + std::env::set_var(key.trim(), value.trim()); + } + } +} + fn apply_cli_overrides(config: &mut AppConfig, cli: &Cli) { // CLI args take highest precedence (above runtime env vars and compile-time defaults). if let Some(rpc_url) = cli.rpc_url.clone() { @@ -57,6 +80,7 @@ fn apply_cli_overrides(config: &mut AppConfig, cli: &Cli) { #[tokio::main] async fn main() -> Result { dotenvy::dotenv().ok(); + load_config_env(); if std::env::var("RUST_LOG").is_ok() { tracing_subscriber::fmt() diff --git a/src/models/config.rs b/src/models/config.rs new file mode 100644 index 0000000..a31025f --- /dev/null +++ b/src/models/config.rs @@ -0,0 +1,15 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct ConfigEntry { + pub key: String, + pub value: Option, + pub masked: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConfigStatus { + pub key: String, + pub action: String, + pub message: String, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index cb43bbb..04c816b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod quote; pub mod risk; pub mod swap; diff --git a/src/output/table.rs b/src/output/table.rs index 6b7248b..7f56d60 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -553,6 +553,54 @@ impl TableRenderable for crate::models::risk::ApprovalRisk { } } +impl TableRenderable for crate::models::config::ConfigEntry { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value"]); + table.add_row(vec!["Key", &self.key]); + let display_value = self + .value + .as_deref() + .unwrap_or("(not set)"); + table.add_row(vec!["Value", display_value]); + if self.masked { + table.add_row(vec!["Note", "Sensitive value, partially masked"]); + } + println!("{}", table); + } +} + +impl TableRenderable for Vec { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Key", "Value", "Sensitive"]); + for entry in self { + let display_value = entry + .value + .as_deref() + .unwrap_or("(not set)"); + let sensitive = if entry.masked { "Yes" } else { "" }; + table.add_row(vec![ + entry.key.as_str(), + display_value, + sensitive, + ]); + } + println!("{}", table); + } +} + +impl TableRenderable for crate::models::config::ConfigStatus { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value"]); + table.add_row(vec!["Key", &self.key]); + table.add_row(vec!["Action", &self.action]); + table.add_row(vec!["Status", &self.message]); + println!("{}", table); + } +} + impl TableRenderable for Vec { fn render_table(&self) { let mut table = Table::new(); diff --git a/src/store/quote.rs b/src/store/quote.rs index 649bf4a..91432a6 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -188,16 +188,8 @@ mod tests { /// Build a QuoteStore backed by a unique temp directory. fn temp_store() -> (QuoteStore, PathBuf) { let dir = std::env::temp_dir().join(format!("chain_test_{}", Uuid::new_v4())); - let ( - coingecko_api_url, - coingecko_api_key, - dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, - ) = crate::config::test_metadata_config_fields(); + let (coingecko_api_url, coingecko_api_key, dexscreener_api_url) = + crate::config::test_metadata_config_fields(); let config = AppConfig { rpc_url: "https://test.example.com".to_string(), rpc_url_overridden: false, @@ -213,11 +205,6 @@ mod tests { coingecko_api_url, coingecko_api_key, dexscreener_api_url, - okx_dex_api_url, - okx_api_key, - okx_api_secret, - okx_api_passphrase, - okx_project_id, data_dir: dir.clone(), }; let store = QuoteStore::new(&config).expect("create store"); From a0aa065d465f4dc0a47598ad1b9a5b9a4486cc45 Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 19:36:40 +0800 Subject: [PATCH 10/13] fix(release): skip release instead of failing on version validation errors When version/channel validation fails (e.g. stable version on non-main branch), set should_release=false and exit 0 instead of exit 1. This allows the workflow to succeed while skipping build and release jobs. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d88db5..64084e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,13 +71,29 @@ jobs: fi if [[ "$is_main" == "true" && "$prerelease" == "true" ]]; then - echo "main branch cannot publish prerelease versions: $version" >&2 - exit 1 + echo "main branch cannot publish prerelease versions: $version — skipping release" + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "cargo_version=$cargo_version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "stable=$stable" >> "$GITHUB_OUTPUT" + echo "previous_tag=" >> "$GITHUB_OUTPUT" + exit 0 fi if [[ "$is_main" != "true" && "$stable" == "true" ]]; then - echo "non-main branch must use prerelease versions with a suffix: $version" >&2 - exit 1 + echo "non-main branch must use prerelease versions with a suffix: $version — skipping release" + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "cargo_version=$cargo_version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "stable=$stable" >> "$GITHUB_OUTPUT" + echo "previous_tag=" >> "$GITHUB_OUTPUT" + exit 0 fi if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then From c20f7606b81b60445d394aac90c11921c814a10e Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 20:00:01 +0800 Subject: [PATCH 11/13] fix: secure persisted config precedence --- skills/chainpilot/SKILL.md | 60 ++++++++++++++++++++++++++++++-------- src/commands/config.rs | 60 ++++++++++++++++++++++++++++---------- src/main.rs | 42 ++++++++++++++++++++++---- 3 files changed, 129 insertions(+), 33 deletions(-) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 182686e..46daea3 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -71,9 +71,15 @@ the user explicitly asks for a different one. Runtime env vars are intentionally limited to `PRIVATE_KEY`, `KEYSTORE_PATH`, `KEYSTORE_PASSWORD_FILE`, `KEYSTORE_PASSWORD_ENV`, `KEYSTORE_PASSWORD`, `WALLET_ADDRESS`, `CHAIN_ID`, `DODO_API_KEY`, `DODO_PROJECT_ID`, -and `DODO_API_URL`. +`DODO_API_URL`, `COINGECKO_API_URL`, `COINGECKO_API_KEY`, and +`DEXSCREENER_API_URL`. -Config precedence: CLI flag > env var > `.env` file > compile-time default. +Runtime config precedence: CLI flag > existing environment variable / `.env` file +> persistent `config.env` file > compile-time default. + +Use `chainpilot config set` for supported API keys instead of asking the user to +paste secrets into shell commands. `config.env` is stored in ChainPilot's local +data directory and sensitive values are masked by `config get` / `config list`. If `--keystore-path` is set, password resolution order is: @@ -182,8 +188,20 @@ lookups can fall back to this local store when the DODO tokenlist does not have the symbol. **Token not found handling**: If `chainpilot swap quote` returns an error indicating -the token symbol was not found, use the Coingecko API to search for the token's -contract address on the target chain: +the token symbol was not found, first try the built-in token search surfaced by +the metadata commands: + +```bash +chainpilot [--chain-id ] token info +``` + +If candidates are returned, show the candidate address, chain, source, and +liquidity to the user and require explicit confirmation before retrying the swap +with an address. Never pass an externally sourced address directly into a swap +command without the user first approving it. + +If the CLI returns no candidates, optionally use the CoinGecko API to search for +the token's contract address on the target chain: ```bash # Search token address via Coingecko @@ -192,8 +210,7 @@ curl -s "https://api.coingecko.com/api/v3/search?query=" | jq '.coins[] Then show the found address (filtered by target chain, e.g. `ethereum`, `polygon`, `arbitrum`, `base`, `bnb`) to the user and **require explicit confirmation** -before retrying with the address. Never pass a CoinGecko-sourced address -directly into a command without the user first approving it. +before retrying with the address. ### `swap simulate` @@ -292,8 +309,12 @@ chainpilot swap history [--limit ] [--status pending|success|failed] chainpilot [--chain-id ] token info ``` -ERC-20 metadata: name, symbol, decimals, total supply. -`` can be a symbol (`USDC`) or contract address (`0x...`). +Token metadata from on-chain reads plus external enrichment. Output can include +name, symbol, decimals, chain, website/social links, price, market cap, FDV, +liquidity, volume, 24h price change, risk level, and per-field sources. +`` can be a symbol (`USDC`), native token symbol (`ETH`), or contract +address (`0x...`). Unknown symbols may return candidate matches from +CoinGecko/DexScreener instead of hard failing. ### `token contract` @@ -367,6 +388,10 @@ no credentials required. Native tokens (ETH, BNB, etc.) return hardcoded low-risk defaults. +Use `token risk` for the current token-specific GoPlus implementation. The +older top-level `risk token` command remains available but may expose a simpler +legacy risk shape. + ### `token add` ```bash @@ -480,13 +505,17 @@ Single approval state. ## Token Resolution -Tokens can be a symbol or a `0x` address. Resolution order: +Tokens can be a symbol, native token symbol, or a `0x` address. Resolution order: 1. Native token symbol (`ETH`, `BNB`, etc.) 2. Raw `0x` address — decimals fetched on-chain 3. DODO tokenlist cache (1-hour TTL) 4. Local custom token store (`token add` and successful address-based quotes) +For `token info`, `token price`, `token liquidity`, and `token risk`, unresolved +symbols can return external-source candidates. Treat those as suggestions only; +confirm with the user before using any candidate address in a swap or approval. + --- ## Supported Chains @@ -518,7 +547,8 @@ For unsupported chain IDs, pass `--rpc-url` manually. ## `config` Subcommands Manage API keys and configuration values. Settings are persisted in a config file -(`~/.local/share/chain/config.env` on Linux) and take precedence over `.env` file values. +(`~/.local/share/chain/config.env` on Linux). Existing environment variables and +`.env` values take precedence over this persisted file at runtime. ### `config set` @@ -527,7 +557,8 @@ chainpilot config set ``` Save an API key or configuration value. The value is written to the persistent config file -and immediately available in the current session. +and immediately available to the current process. On Unix, the config file is +written with owner-only permissions (`0600`). ### `config get` @@ -561,7 +592,12 @@ Remove a configuration key from the config file. | `dodo_project_id` | `DODO_PROJECT_ID` | No | DODO project ID for tokenlist API | | `coingecko_api_key` | `COINGECKO_API_KEY` | Yes | CoinGecko API key for price data | -**Config precedence**: CLI flag > `config.env` file > env var / `.env` file > compile-time default. +Only these keys are supported by `chainpilot config` today. Other runtime +settings, such as `COINGECKO_API_URL` and `DEXSCREENER_API_URL`, can still be +provided via environment variables or `.env`. + +**Runtime config precedence**: CLI flag > existing environment variable / `.env` +file > `config.env` file > compile-time default. --- diff --git a/src/commands/config.rs b/src/commands/config.rs index 8a5f1ca..85c0f46 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,5 +1,8 @@ use std::process::ExitCode; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use crate::cli::config::{ConfigAction, ConfigCmd, ConfigKeyArg, ConfigSetArgs}; use crate::config::AppConfig; use crate::error::{ChainError, Result}; @@ -25,11 +28,7 @@ fn mask_value(value: &str) -> String { if value.len() <= 8 { return "*".repeat(value.len()); } - format!( - "{}...{}", - &value[..4], - &value[value.len() - 4..] - ) + format!("{}...{}", &value[..4], &value[value.len() - 4..]) } fn read_config_file(path: &std::path::Path) -> Vec<(String, String)> { @@ -58,7 +57,18 @@ fn write_config_file(path: &std::path::Path, entries: &[(String, String)]) -> st .iter() .map(|(k, v)| format!("{}={}\n", k, v)) .collect(); - std::fs::write(path, content) + std::fs::write(path, content)?; + set_config_file_permissions(path) +} + +#[cfg(unix)] +fn set_config_file_permissions(path: &std::path::Path) -> std::io::Result<()> { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_config_file_permissions(_path: &std::path::Path) -> std::io::Result<()> { + Ok(()) } pub async fn handle( @@ -81,8 +91,8 @@ fn set( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, _sensitive) = - find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + let (key, env_var, _sensitive) = find_key(&args.key).ok_or_else(|| { + ChainError::Config(format!( "Unknown config key '{}'. Valid keys: {}", args.key, CONFIGURABLE_KEYS @@ -90,7 +100,8 @@ fn set( .map(|(k, _, _)| *k) .collect::>() .join(", ") - )))?; + )) + })?; let mut entries = read_config_file(env_path); if let Some(entry) = entries.iter_mut().find(|(k, _)| k == env_var) { @@ -122,8 +133,8 @@ fn get( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, sensitive) = - find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + let (key, env_var, sensitive) = find_key(&args.key).ok_or_else(|| { + ChainError::Config(format!( "Unknown config key '{}'. Valid keys: {}", args.key, CONFIGURABLE_KEYS @@ -131,7 +142,8 @@ fn get( .map(|(k, _, _)| *k) .collect::>() .join(", ") - )))?; + )) + })?; // Read from config file first, then fall back to env var. let entries = read_config_file(env_path); @@ -203,8 +215,8 @@ fn unset( output_mode: OutputMode, config: &AppConfig, ) -> Result { - let (key, env_var, _sensitive) = - find_key(&args.key).ok_or_else(|| ChainError::Config(format!( + let (key, env_var, _sensitive) = find_key(&args.key).ok_or_else(|| { + ChainError::Config(format!( "Unknown config key '{}'. Valid keys: {}", args.key, CONFIGURABLE_KEYS @@ -212,7 +224,8 @@ fn unset( .map(|(k, _, _)| *k) .collect::>() .join(", ") - )))?; + )) + })?; let mut entries = read_config_file(env_path); let before_len = entries.len(); @@ -249,3 +262,20 @@ fn unset( OutputContext::new(config.chain_id, false), )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn write_config_file_uses_owner_only_permissions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.env"); + + write_config_file(&path, &[("DODO_API_KEY".to_string(), "secret".to_string())]).unwrap(); + + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/src/main.rs b/src/main.rs index 0c6188c..85c9429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,20 @@ use crate::config::AppConfig; use crate::output::OutputMode; /// Load the persistent config file (`config.env` in the data directory). -/// Unlike `dotenvy::dotenv()`, this always sets env vars so config file -/// values take precedence over the CWD `.env` file. +/// Existing environment variables keep precedence over persisted values. fn load_config_env() { - let data_dir = dirs::data_local_dir() + let config_path = default_config_env_path(); + load_config_env_from_path(&config_path); +} + +fn default_config_env_path() -> std::path::PathBuf { + dirs::data_local_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("chain"); - let config_path = data_dir.join("config.env"); + .join("chain") + .join("config.env") +} + +fn load_config_env_from_path(config_path: &std::path::Path) { let content = match std::fs::read_to_string(&config_path) { Ok(c) => c, Err(_) => return, @@ -36,7 +43,10 @@ fn load_config_env() { continue; } if let Some((key, value)) = line.split_once('=') { - std::env::set_var(key.trim(), value.trim()); + let key = key.trim(); + if std::env::var_os(key).is_none() { + std::env::set_var(key, value.trim()); + } } } } @@ -230,4 +240,24 @@ mod tests { }, ); } + + #[test] + fn load_config_env_does_not_override_existing_environment() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.env"); + std::fs::write(&path, "DODO_API_KEY=persisted\nDODO_PROJECT_ID=from-file\n").unwrap(); + + with_env( + &[ + ("DODO_API_KEY", Some("from-env")), + ("DODO_PROJECT_ID", None), + ], + || { + load_config_env_from_path(&path); + + assert_eq!(std::env::var("DODO_API_KEY").unwrap(), "from-env"); + assert_eq!(std::env::var("DODO_PROJECT_ID").unwrap(), "from-file"); + }, + ); + } } From deee4cdbfb943592274094728045b8baf6703f4f Mon Sep 17 00:00:00 2001 From: JunJie Date: Wed, 27 May 2026 20:34:04 +0800 Subject: [PATCH 12/13] chore: bump version to 0.3.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 dbeb9b6..96b4305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,7 +1171,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.2.0" +version = "0.3.0-rc.1" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 5379b45..bb348b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.2.0" +version = "0.3.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 42de0558868c12829d73bda92b8d25206080c1e9 Mon Sep 17 00:00:00 2001 From: JunJie Date: Fri, 29 May 2026 17:36:12 +0800 Subject: [PATCH 13/13] chore: bump version to 0.3.0 Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96b4305..7bd01ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,7 +1171,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "0.3.0-rc.1" +version = "0.3.0" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index bb348b5..5c0ab5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "0.3.0-rc.1" +version = "0.3.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"