Skip to main content

scope/chains/
mod.rs

1//! # Blockchain Client Module
2//!
3//! This module provides abstractions and implementations for interacting
4//! with various blockchain networks. It defines a common `ChainClient` trait
5//! that all chain-specific implementations must satisfy.
6//!
7//! ## Capabilities
8//!
9//! All chain clients support:
10//! - **Balance queries** with optional USD valuation via DexScreener
11//! - **Transaction lookup** by hash/signature with full details
12//! - **Transaction history** for addresses with pagination
13//! - **Token balances** (ERC-20, SPL, TRC-20) for address book tracking
14//!
15//! ## Supported Chains
16//!
17//! ### EVM-Compatible Chains
18//!
19//! - **Ethereum** - Ethereum Mainnet (via Etherscan V2 API)
20//! - **Polygon** - Polygon PoS
21//! - **Arbitrum** - Arbitrum One
22//! - **Optimism** - Optimism Mainnet
23//! - **Base** - Base (Coinbase L2)
24//! - **BSC** - BNB Smart Chain (Binance)
25//!
26//! ### Non-EVM Chains
27//!
28//! - **Solana** - Solana Mainnet (JSON-RPC with `jsonParsed` encoding)
29//! - **Tron** - Tron Mainnet (TronGrid API, base58check address validation)
30//!
31//! ### DEX Data
32//!
33//! - **DexScreener** - Token prices, volume, liquidity, and trading data across all DEX pairs
34//!
35//! ## Usage
36//!
37//! ### Ethereum/EVM Client
38//!
39//! ```rust,no_run
40//! use scope::chains::{ChainClient, EthereumClient};
41//! use scope::Config;
42//!
43//! #[tokio::main]
44//! async fn main() -> scope::Result<()> {
45//!     let config = Config::load(None)?;
46//!     let client = EthereumClient::new(&config.chains)?;
47//!     
48//!     let balance = client.get_balance("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").await?;
49//!     println!("Balance: {} ETH", balance.formatted);
50//!     Ok(())
51//! }
52//! ```
53//!
54//! ### Solana Client
55//!
56//! ```rust,no_run
57//! use scope::chains::SolanaClient;
58//! use scope::Config;
59//!
60//! #[tokio::main]
61//! async fn main() -> scope::Result<()> {
62//!     let config = Config::load(None)?;
63//!     let client = SolanaClient::new(&config.chains)?;
64//!     
65//!     let balance = client.get_balance("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy").await?;
66//!     println!("Balance: {} SOL", balance.formatted);
67//!     Ok(())
68//! }
69//! ```
70//!
71//! ### Tron Client
72//!
73//! ```rust,no_run
74//! use scope::chains::TronClient;
75//! use scope::Config;
76//!
77//! #[tokio::main]
78//! async fn main() -> scope::Result<()> {
79//!     let config = Config::load(None)?;
80//!     let client = TronClient::new(&config.chains)?;
81//!     
82//!     let balance = client.get_balance("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf").await?;
83//!     println!("Balance: {} TRX", balance.formatted);
84//!     Ok(())
85//! }
86//! ```
87
88pub mod dex;
89pub mod ethereum;
90pub mod solana;
91pub mod tron;
92
93pub use dex::{DexClient, DexDataSource, DiscoverToken, TokenSearchResult};
94pub use ethereum::{ApiType, EthereumClient};
95pub use solana::{SolanaClient, validate_solana_address, validate_solana_signature};
96pub use tron::{TronClient, validate_tron_address, validate_tron_tx_hash};
97
98use crate::error::Result;
99use async_trait::async_trait;
100use serde::{Deserialize, Serialize};
101
102/// Trait defining common blockchain client operations.
103///
104/// All chain-specific clients must implement this trait to provide
105/// a consistent interface for blockchain interactions.
106///
107/// ## Core Methods
108///
109/// Every implementation must provide: `chain_name`, `native_token_symbol`,
110/// `get_balance`, `get_transaction`, `get_transactions`, `get_block_number`,
111/// `enrich_balance_usd`, and `get_token_balances`.
112///
113/// ## Token Explorer Methods
114///
115/// The token-explorer methods (`get_token_info`, `get_token_holders`,
116/// `get_token_holder_count`) have default implementations that return
117/// "not supported" errors or empty results. Only chains with block-explorer
118/// support for these endpoints (currently EVM chains) need to override them.
119#[async_trait]
120pub trait ChainClient: Send + Sync {
121    /// Returns the name of the blockchain network.
122    fn chain_name(&self) -> &str;
123
124    /// Returns the native token symbol (e.g., "ETH", "MATIC").
125    fn native_token_symbol(&self) -> &str;
126
127    /// Fetches the native token balance for an address.
128    ///
129    /// # Arguments
130    ///
131    /// * `address` - The blockchain address to query
132    ///
133    /// # Returns
134    ///
135    /// Returns a [`Balance`] containing the balance in multiple formats.
136    async fn get_balance(&self, address: &str) -> Result<Balance>;
137
138    /// Enriches a balance with USD valuation via DexScreener.
139    ///
140    /// # Arguments
141    ///
142    /// * `balance` - The balance to enrich with a USD value
143    async fn enrich_balance_usd(&self, balance: &mut Balance);
144
145    /// Fetches transaction details by hash.
146    ///
147    /// # Arguments
148    ///
149    /// * `hash` - The transaction hash to query
150    ///
151    /// # Returns
152    ///
153    /// Returns [`Transaction`] details or an error if not found.
154    async fn get_transaction(&self, hash: &str) -> Result<Transaction>;
155
156    /// Fetches recent transactions for an address.
157    ///
158    /// # Arguments
159    ///
160    /// * `address` - The address to query
161    /// * `limit` - Maximum number of transactions to return
162    ///
163    /// # Returns
164    ///
165    /// Returns a vector of [`Transaction`] objects.
166    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>>;
167
168    /// Fetches the current block number.
169    async fn get_block_number(&self) -> Result<u64>;
170
171    /// Fetches token balances for an address.
172    ///
173    /// Returns a unified [`TokenBalance`] list regardless of chain
174    /// (ERC-20, SPL, TRC-20 all map to the same type).
175    async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>>;
176
177    /// Fetches token information for a contract address.
178    ///
179    /// Default implementation returns "not supported" error.
180    /// Override in chain clients that support token info lookups.
181    async fn get_token_info(&self, _address: &str) -> Result<Token> {
182        Err(crate::error::ScopeError::Chain(
183            "Token info lookup not supported on this chain".to_string(),
184        ))
185    }
186
187    /// Fetches top token holders for a contract address.
188    ///
189    /// Default implementation returns an empty vector.
190    /// Override in chain clients that support holder lookups.
191    async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
192        Ok(Vec::new())
193    }
194
195    /// Fetches total token holder count for a contract address.
196    ///
197    /// Default implementation returns 0.
198    /// Override in chain clients that support holder count lookups.
199    async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
200        Ok(0)
201    }
202
203    /// Fetches bytecode at address (EVM: eth_getCode).
204    /// Returns "0x" for EOA, non-empty hex for contracts.
205    /// Default: not supported.
206    async fn get_code(&self, _address: &str) -> Result<String> {
207        Err(crate::error::ScopeError::Chain(
208            "Code lookup not supported on this chain".to_string(),
209        ))
210    }
211}
212
213/// Factory trait for creating chain clients and DEX data sources.
214///
215/// Bundles both chain and DEX client creation so CLI functions
216/// only need one injected dependency instead of two.
217///
218/// # Example
219///
220/// ```rust,no_run
221/// use scope::chains::{ChainClientFactory, DefaultClientFactory};
222/// use scope::Config;
223///
224/// let config = Config::default();
225/// let factory = DefaultClientFactory { chains_config: config.chains.clone() };
226/// let client = factory.create_chain_client("ethereum").unwrap();
227/// ```
228pub trait ChainClientFactory: Send + Sync {
229    /// Creates a chain client for the given blockchain network.
230    ///
231    /// # Arguments
232    ///
233    /// * `chain` - The chain name (e.g., "ethereum", "solana", "tron")
234    fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>>;
235
236    /// Creates a DEX data source client.
237    fn create_dex_client(&self) -> Box<dyn DexDataSource>;
238}
239
240/// Default factory that creates real chain clients from configuration.
241pub struct DefaultClientFactory {
242    /// Chain configuration containing API keys and endpoints.
243    pub chains_config: crate::config::ChainsConfig,
244}
245
246impl ChainClientFactory for DefaultClientFactory {
247    fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
248        match chain.to_lowercase().as_str() {
249            "solana" | "sol" => Ok(Box::new(SolanaClient::new(&self.chains_config)?)),
250            "tron" | "trx" => Ok(Box::new(TronClient::new(&self.chains_config)?)),
251            _ => Ok(Box::new(EthereumClient::for_chain(
252                chain,
253                &self.chains_config,
254            )?)),
255        }
256    }
257
258    fn create_dex_client(&self) -> Box<dyn DexDataSource> {
259        Box::new(DexClient::new())
260    }
261}
262
263/// Balance representation with multiple formats.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Balance {
266    /// Raw balance in smallest unit (e.g., wei).
267    pub raw: String,
268
269    /// Human-readable formatted balance.
270    pub formatted: String,
271
272    /// Number of decimals for the token.
273    pub decimals: u8,
274
275    /// Token symbol.
276    pub symbol: String,
277
278    /// USD value (if available).
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub usd_value: Option<f64>,
281}
282
283/// Transaction information.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct Transaction {
286    /// Transaction hash.
287    pub hash: String,
288
289    /// Block number (None if pending).
290    pub block_number: Option<u64>,
291
292    /// Block timestamp (None if pending).
293    pub timestamp: Option<u64>,
294
295    /// Sender address.
296    pub from: String,
297
298    /// Recipient address (None for contract creation).
299    pub to: Option<String>,
300
301    /// Value transferred in native token.
302    pub value: String,
303
304    /// Gas limit.
305    pub gas_limit: u64,
306
307    /// Gas used (None if pending).
308    pub gas_used: Option<u64>,
309
310    /// Gas price in wei.
311    pub gas_price: String,
312
313    /// Transaction nonce.
314    pub nonce: u64,
315
316    /// Input data.
317    pub input: String,
318
319    /// Transaction status (None if pending, Some(true) for success).
320    pub status: Option<bool>,
321}
322
323/// Token information.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Token {
326    /// Contract address.
327    pub contract_address: String,
328
329    /// Token symbol.
330    pub symbol: String,
331
332    /// Token name.
333    pub name: String,
334
335    /// Decimal places.
336    pub decimals: u8,
337}
338
339/// Token balance for an address.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TokenBalance {
342    /// Token information.
343    pub token: Token,
344
345    /// Raw balance.
346    pub balance: String,
347
348    /// Formatted balance.
349    pub formatted_balance: String,
350
351    /// USD value (if available).
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub usd_value: Option<f64>,
354}
355
356// ============================================================================
357// Token Analytics Types
358// ============================================================================
359
360/// A token holder with their balance and percentage of supply.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct TokenHolder {
363    /// Holder's address.
364    pub address: String,
365
366    /// Raw balance amount.
367    pub balance: String,
368
369    /// Formatted balance with proper decimals.
370    pub formatted_balance: String,
371
372    /// Percentage of total supply held.
373    pub percentage: f64,
374
375    /// Rank among all holders (1 = largest).
376    pub rank: u32,
377}
378
379/// A price data point for historical charting.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PricePoint {
382    /// Unix timestamp in seconds.
383    pub timestamp: i64,
384
385    /// Price in USD.
386    pub price: f64,
387}
388
389/// A volume data point for historical charting.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct VolumePoint {
392    /// Unix timestamp in seconds.
393    pub timestamp: i64,
394
395    /// Volume in USD.
396    pub volume: f64,
397}
398
399/// A holder count data point for historical charting.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct HolderCountPoint {
402    /// Unix timestamp in seconds.
403    pub timestamp: i64,
404
405    /// Number of holders.
406    pub count: u64,
407}
408
409/// DEX trading pair information.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct DexPair {
412    /// DEX name (e.g., "Uniswap V3", "SushiSwap").
413    pub dex_name: String,
414
415    /// Pair address on the DEX.
416    pub pair_address: String,
417
418    /// Base token symbol.
419    pub base_token: String,
420
421    /// Quote token symbol.
422    pub quote_token: String,
423
424    /// Current price in USD.
425    pub price_usd: f64,
426
427    /// 24h trading volume in USD.
428    pub volume_24h: f64,
429
430    /// Liquidity in USD.
431    pub liquidity_usd: f64,
432
433    /// Price change percentage in 24h.
434    pub price_change_24h: f64,
435
436    /// Buy transactions in 24h.
437    pub buys_24h: u64,
438
439    /// Sell transactions in 24h.
440    pub sells_24h: u64,
441
442    /// Buy transactions in 6h.
443    pub buys_6h: u64,
444
445    /// Sell transactions in 6h.
446    pub sells_6h: u64,
447
448    /// Buy transactions in 1h.
449    pub buys_1h: u64,
450
451    /// Sell transactions in 1h.
452    pub sells_1h: u64,
453
454    /// Pair creation timestamp.
455    pub pair_created_at: Option<i64>,
456
457    /// Direct URL to this pair on DexScreener.
458    pub url: Option<String>,
459}
460
461/// Comprehensive token analytics data.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct TokenAnalytics {
464    /// Token information.
465    pub token: Token,
466
467    /// Blockchain network name.
468    pub chain: String,
469
470    /// Top token holders.
471    pub holders: Vec<TokenHolder>,
472
473    /// Total number of holders.
474    pub total_holders: u64,
475
476    /// 24-hour trading volume in USD.
477    pub volume_24h: f64,
478
479    /// 7-day trading volume in USD.
480    pub volume_7d: f64,
481
482    /// Current price in USD.
483    pub price_usd: f64,
484
485    /// 24-hour price change percentage.
486    pub price_change_24h: f64,
487
488    /// 7-day price change percentage.
489    pub price_change_7d: f64,
490
491    /// Total liquidity across DEXs in USD.
492    pub liquidity_usd: f64,
493
494    /// Market capitalization (if available).
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub market_cap: Option<f64>,
497
498    /// Fully diluted valuation (if available).
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub fdv: Option<f64>,
501
502    /// Total supply.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub total_supply: Option<String>,
505
506    /// Circulating supply.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub circulating_supply: Option<String>,
509
510    /// Historical price data for charting.
511    pub price_history: Vec<PricePoint>,
512
513    /// Historical volume data for charting.
514    pub volume_history: Vec<VolumePoint>,
515
516    /// Historical holder count data for charting.
517    pub holder_history: Vec<HolderCountPoint>,
518
519    /// DEX trading pairs.
520    pub dex_pairs: Vec<DexPair>,
521
522    /// Timestamp when this data was fetched.
523    pub fetched_at: i64,
524
525    /// Percentage of supply held by top 10 holders.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub top_10_concentration: Option<f64>,
528
529    /// Percentage of supply held by top 50 holders.
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub top_50_concentration: Option<f64>,
532
533    /// Percentage of supply held by top 100 holders.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub top_100_concentration: Option<f64>,
536
537    /// 6-hour price change percentage.
538    pub price_change_6h: f64,
539
540    /// 1-hour price change percentage.
541    pub price_change_1h: f64,
542
543    /// Total buy transactions in 24 hours.
544    pub total_buys_24h: u64,
545
546    /// Total sell transactions in 24 hours.
547    pub total_sells_24h: u64,
548
549    /// Total buy transactions in 6 hours.
550    pub total_buys_6h: u64,
551
552    /// Total sell transactions in 6 hours.
553    pub total_sells_6h: u64,
554
555    /// Total buy transactions in 1 hour.
556    pub total_buys_1h: u64,
557
558    /// Total sell transactions in 1 hour.
559    pub total_sells_1h: u64,
560
561    /// Token age in hours (since earliest pair creation).
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub token_age_hours: Option<f64>,
564
565    /// Token image URL.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub image_url: Option<String>,
568
569    /// Token website URLs.
570    pub websites: Vec<String>,
571
572    /// Token social media links.
573    pub socials: Vec<TokenSocial>,
574
575    /// DexScreener URL for the primary pair.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub dexscreener_url: Option<String>,
578}
579
580/// Social media link for a token.
581#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
582pub struct TokenSocial {
583    /// Platform name (twitter, telegram, discord, etc.)
584    pub platform: String,
585    /// URL or handle for the social account.
586    pub url: String,
587}
588
589// ============================================================================
590// Chain Metadata
591// ============================================================================
592
593/// Metadata for a blockchain network (symbol, decimals, explorer URLs).
594///
595/// Used for normalized presentation across all chains.
596#[derive(Debug, Clone)]
597pub struct ChainMetadata {
598    /// Canonical chain identifier.
599    pub chain_id: &'static str,
600    /// Native token symbol (e.g., ETH, SOL, TRX).
601    pub native_symbol: &'static str,
602    /// Native token decimals.
603    pub native_decimals: u8,
604    /// Block explorer base URL for token pages.
605    pub explorer_token_base: &'static str,
606}
607
608/// Returns chain metadata for display and formatting.
609///
610/// Returns `None` for unknown chains.
611pub fn chain_metadata(chain: &str) -> Option<ChainMetadata> {
612    match chain.to_lowercase().as_str() {
613        "ethereum" | "eth" => Some(ChainMetadata {
614            chain_id: "ethereum",
615            native_symbol: "ETH",
616            native_decimals: 18,
617            explorer_token_base: "https://etherscan.io/token",
618        }),
619        "polygon" => Some(ChainMetadata {
620            chain_id: "polygon",
621            native_symbol: "MATIC",
622            native_decimals: 18,
623            explorer_token_base: "https://polygonscan.com/token",
624        }),
625        "arbitrum" => Some(ChainMetadata {
626            chain_id: "arbitrum",
627            native_symbol: "ETH",
628            native_decimals: 18,
629            explorer_token_base: "https://arbiscan.io/token",
630        }),
631        "optimism" => Some(ChainMetadata {
632            chain_id: "optimism",
633            native_symbol: "ETH",
634            native_decimals: 18,
635            explorer_token_base: "https://optimistic.etherscan.io/token",
636        }),
637        "base" => Some(ChainMetadata {
638            chain_id: "base",
639            native_symbol: "ETH",
640            native_decimals: 18,
641            explorer_token_base: "https://basescan.org/token",
642        }),
643        "bsc" => Some(ChainMetadata {
644            chain_id: "bsc",
645            native_symbol: "BNB",
646            native_decimals: 18,
647            explorer_token_base: "https://bscscan.com/token",
648        }),
649        "solana" | "sol" => Some(ChainMetadata {
650            chain_id: "solana",
651            native_symbol: "SOL",
652            native_decimals: 9,
653            explorer_token_base: "https://solscan.io/token",
654        }),
655        "tron" | "trx" => Some(ChainMetadata {
656            chain_id: "tron",
657            native_symbol: "TRX",
658            native_decimals: 6,
659            explorer_token_base: "https://tronscan.org/#/token20",
660        }),
661        _ => None,
662    }
663}
664
665/// Returns the native token symbol for a chain, or "???" if unknown.
666pub fn native_symbol(chain: &str) -> &'static str {
667    chain_metadata(chain)
668        .map(|m| m.native_symbol)
669        .unwrap_or("???")
670}
671
672// ============================================================================
673// Chain Inference
674// ============================================================================
675
676/// Infers the blockchain from an address format.
677///
678/// Returns `Some(chain_name)` if the address format is unambiguous,
679/// or `None` if the format is not recognized.
680///
681/// # Supported Formats
682///
683/// - **EVM** (ethereum): `0x` prefix + 40 hex chars (42 total)
684/// - **Tron**: Starts with `T` + 34 chars (Base58Check)
685/// - **Solana**: Base58, 32-44 chars, decodes to 32 bytes
686///
687/// # Examples
688///
689/// ```
690/// use scope::chains::infer_chain_from_address;
691///
692/// assert_eq!(infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"), Some("ethereum"));
693/// assert_eq!(infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"), Some("tron"));
694/// assert_eq!(infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"), Some("solana"));
695/// assert_eq!(infer_chain_from_address("invalid"), None);
696/// ```
697pub fn infer_chain_from_address(address: &str) -> Option<&'static str> {
698    // Tron: starts with 'T', 34 chars, valid base58
699    if address.starts_with('T') && address.len() == 34 && bs58::decode(address).into_vec().is_ok() {
700        return Some("tron");
701    }
702
703    // EVM: 0x prefix, 42 chars total (40 hex + "0x")
704    if address.starts_with("0x")
705        && address.len() == 42
706        && address[2..].chars().all(|c| c.is_ascii_hexdigit())
707    {
708        return Some("ethereum");
709    }
710
711    // Solana: base58, 32-44 chars, decodes to 32 bytes
712    if address.len() >= 32
713        && address.len() <= 44
714        && let Ok(decoded) = bs58::decode(address).into_vec()
715        && decoded.len() == 32
716    {
717        return Some("solana");
718    }
719
720    None
721}
722
723/// Infers the blockchain from a transaction hash format.
724///
725/// Returns `Some(chain_name)` if the hash format is unambiguous,
726/// or `None` if the format is not recognized.
727///
728/// # Supported Formats
729///
730/// - **EVM** (ethereum): `0x` prefix + 64 hex chars (66 total)
731/// - **Tron**: 64 hex chars (no prefix)
732/// - **Solana**: Base58, 80-90 chars, decodes to 64 bytes
733///
734/// # Examples
735///
736/// ```
737/// use scope::chains::infer_chain_from_hash;
738///
739/// // EVM hash
740/// let evm_hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
741/// assert_eq!(infer_chain_from_hash(evm_hash), Some("ethereum"));
742///
743/// // Tron hash (64 hex chars, no 0x prefix)
744/// let tron_hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
745/// assert_eq!(infer_chain_from_hash(tron_hash), Some("tron"));
746/// ```
747pub fn infer_chain_from_hash(hash: &str) -> Option<&'static str> {
748    // EVM: 0x prefix, 66 chars total (64 hex + "0x")
749    if hash.starts_with("0x")
750        && hash.len() == 66
751        && hash[2..].chars().all(|c| c.is_ascii_hexdigit())
752    {
753        return Some("ethereum");
754    }
755
756    // Tron: 64 hex chars, no prefix
757    if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
758        return Some("tron");
759    }
760
761    // Solana: base58, 80-90 chars, decodes to 64 bytes
762    if hash.len() >= 80
763        && hash.len() <= 90
764        && let Ok(decoded) = bs58::decode(hash).into_vec()
765        && decoded.len() == 64
766    {
767        return Some("solana");
768    }
769
770    None
771}
772
773// ============================================================================
774// Unit Tests
775// ============================================================================
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_balance_serialization() {
783        let balance = Balance {
784            raw: "1000000000000000000".to_string(),
785            formatted: "1.0".to_string(),
786            decimals: 18,
787            symbol: "ETH".to_string(),
788            usd_value: Some(3500.0),
789        };
790
791        let json = serde_json::to_string(&balance).unwrap();
792        assert!(json.contains("1000000000000000000"));
793        assert!(json.contains("1.0"));
794        assert!(json.contains("ETH"));
795        assert!(json.contains("3500"));
796
797        let deserialized: Balance = serde_json::from_str(&json).unwrap();
798        assert_eq!(deserialized.raw, balance.raw);
799        assert_eq!(deserialized.decimals, 18);
800    }
801
802    #[test]
803    fn test_balance_without_usd() {
804        let balance = Balance {
805            raw: "1000000000000000000".to_string(),
806            formatted: "1.0".to_string(),
807            decimals: 18,
808            symbol: "ETH".to_string(),
809            usd_value: None,
810        };
811
812        let json = serde_json::to_string(&balance).unwrap();
813        assert!(!json.contains("usd_value"));
814    }
815
816    #[test]
817    fn test_transaction_serialization() {
818        let tx = Transaction {
819            hash: "0xabc123".to_string(),
820            block_number: Some(12345678),
821            timestamp: Some(1700000000),
822            from: "0xfrom".to_string(),
823            to: Some("0xto".to_string()),
824            value: "1.0".to_string(),
825            gas_limit: 21000,
826            gas_used: Some(21000),
827            gas_price: "20000000000".to_string(),
828            nonce: 42,
829            input: "0x".to_string(),
830            status: Some(true),
831        };
832
833        let json = serde_json::to_string(&tx).unwrap();
834        assert!(json.contains("0xabc123"));
835        assert!(json.contains("12345678"));
836        assert!(json.contains("0xfrom"));
837        assert!(json.contains("0xto"));
838
839        let deserialized: Transaction = serde_json::from_str(&json).unwrap();
840        assert_eq!(deserialized.hash, tx.hash);
841        assert_eq!(deserialized.nonce, 42);
842    }
843
844    #[test]
845    fn test_pending_transaction_serialization() {
846        let tx = Transaction {
847            hash: "0xpending".to_string(),
848            block_number: None,
849            timestamp: None,
850            from: "0xfrom".to_string(),
851            to: Some("0xto".to_string()),
852            value: "1.0".to_string(),
853            gas_limit: 21000,
854            gas_used: None,
855            gas_price: "20000000000".to_string(),
856            nonce: 0,
857            input: "0x".to_string(),
858            status: None,
859        };
860
861        let json = serde_json::to_string(&tx).unwrap();
862        assert!(json.contains("0xpending"));
863        assert!(json.contains("null")); // None values serialize as null
864
865        let deserialized: Transaction = serde_json::from_str(&json).unwrap();
866        assert!(deserialized.block_number.is_none());
867        assert!(deserialized.status.is_none());
868    }
869
870    #[test]
871    fn test_contract_creation_transaction() {
872        let tx = Transaction {
873            hash: "0xcreate".to_string(),
874            block_number: Some(100),
875            timestamp: Some(1700000000),
876            from: "0xdeployer".to_string(),
877            to: None, // Contract creation
878            value: "0".to_string(),
879            gas_limit: 1000000,
880            gas_used: Some(500000),
881            gas_price: "20000000000".to_string(),
882            nonce: 0,
883            input: "0x608060...".to_string(),
884            status: Some(true),
885        };
886
887        let json = serde_json::to_string(&tx).unwrap();
888        assert!(json.contains("\"to\":null"));
889    }
890
891    #[test]
892    fn test_token_serialization() {
893        let token = Token {
894            contract_address: "0xtoken".to_string(),
895            symbol: "USDC".to_string(),
896            name: "USD Coin".to_string(),
897            decimals: 6,
898        };
899
900        let json = serde_json::to_string(&token).unwrap();
901        assert!(json.contains("USDC"));
902        assert!(json.contains("USD Coin"));
903        assert!(json.contains("\"decimals\":6"));
904
905        let deserialized: Token = serde_json::from_str(&json).unwrap();
906        assert_eq!(deserialized.decimals, 6);
907    }
908
909    #[test]
910    fn test_token_balance_serialization() {
911        let token_balance = TokenBalance {
912            token: Token {
913                contract_address: "0xtoken".to_string(),
914                symbol: "USDC".to_string(),
915                name: "USD Coin".to_string(),
916                decimals: 6,
917            },
918            balance: "1000000".to_string(),
919            formatted_balance: "1.0".to_string(),
920            usd_value: Some(1.0),
921        };
922
923        let json = serde_json::to_string(&token_balance).unwrap();
924        assert!(json.contains("USDC"));
925        assert!(json.contains("1000000"));
926        assert!(json.contains("1.0"));
927    }
928
929    #[test]
930    fn test_balance_debug() {
931        let balance = Balance {
932            raw: "1000".to_string(),
933            formatted: "0.001".to_string(),
934            decimals: 18,
935            symbol: "ETH".to_string(),
936            usd_value: None,
937        };
938
939        let debug_str = format!("{:?}", balance);
940        assert!(debug_str.contains("Balance"));
941        assert!(debug_str.contains("1000"));
942    }
943
944    #[test]
945    fn test_transaction_debug() {
946        let tx = Transaction {
947            hash: "0xtest".to_string(),
948            block_number: Some(1),
949            timestamp: Some(0),
950            from: "0x1".to_string(),
951            to: Some("0x2".to_string()),
952            value: "0".to_string(),
953            gas_limit: 21000,
954            gas_used: Some(21000),
955            gas_price: "0".to_string(),
956            nonce: 0,
957            input: "0x".to_string(),
958            status: Some(true),
959        };
960
961        let debug_str = format!("{:?}", tx);
962        assert!(debug_str.contains("Transaction"));
963        assert!(debug_str.contains("0xtest"));
964    }
965
966    // ============================================================================
967    // Chain Inference Tests
968    // ============================================================================
969
970    #[test]
971    fn test_infer_chain_from_address_evm() {
972        // Valid EVM addresses (0x + 40 hex chars)
973        assert_eq!(
974            super::infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"),
975            Some("ethereum")
976        );
977        assert_eq!(
978            super::infer_chain_from_address("0x0000000000000000000000000000000000000000"),
979            Some("ethereum")
980        );
981        assert_eq!(
982            super::infer_chain_from_address("0xABCDEF1234567890abcdef1234567890ABCDEF12"),
983            Some("ethereum")
984        );
985    }
986
987    #[test]
988    fn test_infer_chain_from_address_tron() {
989        // Valid Tron addresses (T + 33 chars = 34 total, base58)
990        assert_eq!(
991            super::infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
992            Some("tron")
993        );
994        assert_eq!(
995            super::infer_chain_from_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
996            Some("tron")
997        );
998    }
999
1000    #[test]
1001    fn test_infer_chain_from_address_solana() {
1002        // Valid Solana addresses (base58, 32-44 chars, decodes to 32 bytes)
1003        assert_eq!(
1004            super::infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"),
1005            Some("solana")
1006        );
1007        // System program address
1008        assert_eq!(
1009            super::infer_chain_from_address("11111111111111111111111111111111"),
1010            Some("solana")
1011        );
1012    }
1013
1014    #[test]
1015    fn test_infer_chain_from_address_invalid() {
1016        // Too short
1017        assert_eq!(super::infer_chain_from_address("0x123"), None);
1018        // Invalid characters
1019        assert_eq!(super::infer_chain_from_address("not_an_address"), None);
1020        // Empty
1021        assert_eq!(super::infer_chain_from_address(""), None);
1022        // EVM-like but wrong length
1023        assert_eq!(super::infer_chain_from_address("0x123456"), None);
1024        // Tron-like but not starting with T
1025        assert_eq!(
1026            super::infer_chain_from_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1027            None
1028        );
1029    }
1030
1031    #[test]
1032    fn test_infer_chain_from_hash_evm() {
1033        // Valid EVM transaction hash (0x + 64 hex chars)
1034        assert_eq!(
1035            super::infer_chain_from_hash(
1036                "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1037            ),
1038            Some("ethereum")
1039        );
1040        assert_eq!(
1041            super::infer_chain_from_hash(
1042                "0x0000000000000000000000000000000000000000000000000000000000000000"
1043            ),
1044            Some("ethereum")
1045        );
1046    }
1047
1048    #[test]
1049    fn test_infer_chain_from_hash_tron() {
1050        // Valid Tron transaction hash (64 hex chars, no 0x prefix)
1051        assert_eq!(
1052            super::infer_chain_from_hash(
1053                "abc123def456789012345678901234567890123456789012345678901234abcd"
1054            ),
1055            Some("tron")
1056        );
1057        assert_eq!(
1058            super::infer_chain_from_hash(
1059                "0000000000000000000000000000000000000000000000000000000000000000"
1060            ),
1061            Some("tron")
1062        );
1063    }
1064
1065    #[test]
1066    fn test_infer_chain_from_hash_solana() {
1067        // Valid Solana signature (base58, 80-90 chars, decodes to 64 bytes)
1068        // This is a made-up example that fits the pattern
1069        let solana_sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
1070        assert_eq!(super::infer_chain_from_hash(solana_sig), Some("solana"));
1071    }
1072
1073    #[test]
1074    fn test_infer_chain_from_hash_invalid() {
1075        // Too short
1076        assert_eq!(super::infer_chain_from_hash("0x123"), None);
1077        // Invalid
1078        assert_eq!(super::infer_chain_from_hash("not_a_hash"), None);
1079        // Empty
1080        assert_eq!(super::infer_chain_from_hash(""), None);
1081        // 64 chars but with invalid hex (contains 'g')
1082        assert_eq!(
1083            super::infer_chain_from_hash(
1084                "abc123gef456789012345678901234567890123456789012345678901234abcd"
1085            ),
1086            None
1087        );
1088    }
1089
1090    // ============================================================================
1091    // DefaultClientFactory Tests
1092    // ============================================================================
1093
1094    #[test]
1095    fn test_default_client_factory_create_dex_client() {
1096        let config = crate::config::ChainsConfig::default();
1097        let factory = DefaultClientFactory {
1098            chains_config: config,
1099        };
1100        let dex = factory.create_dex_client();
1101        // Just verify it returns without panicking - the client is a Box<dyn DexDataSource>
1102        let _ = format!("{:?}", std::mem::size_of_val(&dex));
1103    }
1104
1105    #[test]
1106    fn test_default_client_factory_create_ethereum_client() {
1107        let config = crate::config::ChainsConfig::default();
1108        let factory = DefaultClientFactory {
1109            chains_config: config,
1110        };
1111        // ethereum, polygon, etc use EthereumClient::for_chain
1112        let client = factory.create_chain_client("ethereum");
1113        assert!(client.is_ok());
1114        assert_eq!(client.unwrap().chain_name(), "ethereum");
1115    }
1116
1117    #[test]
1118    fn test_default_client_factory_create_polygon_client() {
1119        let config = crate::config::ChainsConfig::default();
1120        let factory = DefaultClientFactory {
1121            chains_config: config,
1122        };
1123        let client = factory.create_chain_client("polygon");
1124        assert!(client.is_ok());
1125        assert_eq!(client.unwrap().chain_name(), "polygon");
1126    }
1127
1128    #[test]
1129    fn test_default_client_factory_create_solana_client() {
1130        let config = crate::config::ChainsConfig::default();
1131        let factory = DefaultClientFactory {
1132            chains_config: config,
1133        };
1134        let client = factory.create_chain_client("solana");
1135        assert!(client.is_ok());
1136        assert_eq!(client.unwrap().chain_name(), "solana");
1137    }
1138
1139    #[test]
1140    fn test_default_client_factory_create_sol_alias() {
1141        let config = crate::config::ChainsConfig::default();
1142        let factory = DefaultClientFactory {
1143            chains_config: config,
1144        };
1145        let client = factory.create_chain_client("sol");
1146        assert!(client.is_ok());
1147        assert_eq!(client.unwrap().chain_name(), "solana");
1148    }
1149
1150    #[test]
1151    fn test_default_client_factory_create_tron_client() {
1152        let config = crate::config::ChainsConfig::default();
1153        let factory = DefaultClientFactory {
1154            chains_config: config,
1155        };
1156        let client = factory.create_chain_client("tron");
1157        assert!(client.is_ok());
1158        assert_eq!(client.unwrap().chain_name(), "tron");
1159    }
1160
1161    #[test]
1162    fn test_default_client_factory_create_trx_alias() {
1163        let config = crate::config::ChainsConfig::default();
1164        let factory = DefaultClientFactory {
1165            chains_config: config,
1166        };
1167        let client = factory.create_chain_client("trx");
1168        assert!(client.is_ok());
1169        assert_eq!(client.unwrap().chain_name(), "tron");
1170    }
1171
1172    // ============================================================================
1173    // ChainClient trait default method tests
1174    // ============================================================================
1175
1176    #[tokio::test]
1177    async fn test_chain_client_default_get_token_info() {
1178        use super::mocks::MockChainClient;
1179        // Create a client without token_info set (None)
1180        let client = MockChainClient::new("ethereum", "ETH");
1181        let result = client.get_token_info("0xsometoken").await;
1182        assert!(result.is_err());
1183    }
1184
1185    #[tokio::test]
1186    async fn test_chain_client_default_get_token_holders() {
1187        use super::mocks::MockChainClient;
1188        let client = MockChainClient::new("ethereum", "ETH");
1189        let holders = client.get_token_holders("0xsometoken", 10).await.unwrap();
1190        assert!(holders.is_empty());
1191    }
1192
1193    #[tokio::test]
1194    async fn test_chain_client_default_get_token_holder_count() {
1195        use super::mocks::MockChainClient;
1196        let client = MockChainClient::new("ethereum", "ETH");
1197        let count = client.get_token_holder_count("0xsometoken").await.unwrap();
1198        assert_eq!(count, 0);
1199    }
1200
1201    #[tokio::test]
1202    async fn test_mock_client_factory_creates_chain_client() {
1203        use super::mocks::MockClientFactory;
1204        let factory = MockClientFactory::new();
1205        let client = factory.create_chain_client("anything").unwrap();
1206        assert_eq!(client.chain_name(), "ethereum"); // defaults to ethereum mock
1207    }
1208
1209    #[tokio::test]
1210    async fn test_mock_client_factory_creates_dex_client() {
1211        use super::mocks::MockClientFactory;
1212        let factory = MockClientFactory::new();
1213        let dex = factory.create_dex_client();
1214        let price = dex.get_token_price("ethereum", "0xtest").await;
1215        assert_eq!(price, Some(1.0));
1216    }
1217
1218    #[tokio::test]
1219    async fn test_mock_chain_client_balance() {
1220        use super::mocks::MockChainClient;
1221        let client = MockChainClient::new("ethereum", "ETH");
1222        let balance = client.get_balance("0xtest").await.unwrap();
1223        assert_eq!(balance.formatted, "1.0");
1224        assert_eq!(balance.symbol, "ETH");
1225        assert_eq!(balance.usd_value, Some(2500.0));
1226    }
1227
1228    #[tokio::test]
1229    async fn test_mock_chain_client_transaction() {
1230        use super::mocks::MockChainClient;
1231        let client = MockChainClient::new("ethereum", "ETH");
1232        let tx = client.get_transaction("0xanyhash").await.unwrap();
1233        assert_eq!(tx.hash, "0xmocktx");
1234        assert_eq!(tx.nonce, 42);
1235    }
1236
1237    #[tokio::test]
1238    async fn test_mock_chain_client_block_number() {
1239        use super::mocks::MockChainClient;
1240        let client = MockChainClient::new("ethereum", "ETH");
1241        let block = client.get_block_number().await.unwrap();
1242        assert_eq!(block, 12345678);
1243    }
1244
1245    #[tokio::test]
1246    async fn test_mock_dex_source_data() {
1247        use super::mocks::MockDexSource;
1248        let dex = MockDexSource::new();
1249        let data = dex.get_token_data("ethereum", "0xtest").await.unwrap();
1250        assert_eq!(data.symbol, "MOCK");
1251        assert_eq!(data.price_usd, 1.0);
1252    }
1253
1254    #[tokio::test]
1255    async fn test_mock_dex_source_search() {
1256        use super::mocks::MockDexSource;
1257        let dex = MockDexSource::new();
1258        let results = dex.search_tokens("test", None).await.unwrap();
1259        assert!(results.is_empty());
1260    }
1261
1262    #[tokio::test]
1263    async fn test_mock_dex_source_native_price() {
1264        use super::mocks::MockDexSource;
1265        let dex = MockDexSource::new();
1266        let price = dex.get_native_token_price("ethereum").await;
1267        assert_eq!(price, Some(2500.0));
1268    }
1269
1270    // ========================================================================
1271    // Default ChainClient trait method tests
1272    // ========================================================================
1273
1274    /// Minimal ChainClient impl that uses all default methods.
1275    struct MinimalChainClient;
1276
1277    #[async_trait::async_trait]
1278    impl ChainClient for MinimalChainClient {
1279        fn chain_name(&self) -> &str {
1280            "test"
1281        }
1282
1283        fn native_token_symbol(&self) -> &str {
1284            "TEST"
1285        }
1286
1287        async fn get_balance(&self, _address: &str) -> Result<Balance> {
1288            Ok(Balance {
1289                raw: "0".to_string(),
1290                formatted: "0".to_string(),
1291                decimals: 18,
1292                symbol: "TEST".to_string(),
1293                usd_value: None,
1294            })
1295        }
1296
1297        async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1298            unimplemented!()
1299        }
1300
1301        async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1302            Ok(Vec::new())
1303        }
1304
1305        async fn get_block_number(&self) -> Result<u64> {
1306            Ok(0)
1307        }
1308
1309        async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1310            Ok(Vec::new())
1311        }
1312
1313        async fn enrich_balance_usd(&self, _balance: &mut Balance) {}
1314    }
1315
1316    #[tokio::test]
1317    async fn test_default_get_token_info() {
1318        let client = MinimalChainClient;
1319        let result = client.get_token_info("0xtest").await;
1320        assert!(result.is_err());
1321        assert!(result.unwrap_err().to_string().contains("not supported"));
1322    }
1323
1324    #[tokio::test]
1325    async fn test_default_get_token_holders() {
1326        let client = MinimalChainClient;
1327        let holders = client.get_token_holders("0xtest", 10).await.unwrap();
1328        assert!(holders.is_empty());
1329    }
1330
1331    #[tokio::test]
1332    async fn test_default_get_token_holder_count() {
1333        let client = MinimalChainClient;
1334        let count = client.get_token_holder_count("0xtest").await.unwrap();
1335        assert_eq!(count, 0);
1336    }
1337
1338    // ============================================================================
1339    // Chain Metadata Tests
1340    // ============================================================================
1341
1342    #[test]
1343    fn test_chain_metadata_ethereum() {
1344        let meta = chain_metadata("ethereum").unwrap();
1345        assert_eq!(meta.chain_id, "ethereum");
1346        assert_eq!(meta.native_symbol, "ETH");
1347        assert_eq!(meta.native_decimals, 18);
1348        assert_eq!(meta.explorer_token_base, "https://etherscan.io/token");
1349    }
1350
1351    #[test]
1352    fn test_chain_metadata_ethereum_alias() {
1353        let meta = chain_metadata("eth").unwrap();
1354        assert_eq!(meta.chain_id, "ethereum");
1355        assert_eq!(meta.native_symbol, "ETH");
1356    }
1357
1358    #[test]
1359    fn test_chain_metadata_polygon() {
1360        let meta = chain_metadata("polygon").unwrap();
1361        assert_eq!(meta.chain_id, "polygon");
1362        assert_eq!(meta.native_symbol, "MATIC");
1363        assert_eq!(meta.native_decimals, 18);
1364        assert_eq!(meta.explorer_token_base, "https://polygonscan.com/token");
1365    }
1366
1367    #[test]
1368    fn test_chain_metadata_bsc() {
1369        let meta = chain_metadata("bsc").unwrap();
1370        assert_eq!(meta.chain_id, "bsc");
1371        assert_eq!(meta.native_symbol, "BNB");
1372        assert_eq!(meta.native_decimals, 18);
1373        assert_eq!(meta.explorer_token_base, "https://bscscan.com/token");
1374    }
1375
1376    #[test]
1377    fn test_chain_metadata_solana() {
1378        let meta = chain_metadata("solana").unwrap();
1379        assert_eq!(meta.chain_id, "solana");
1380        assert_eq!(meta.native_symbol, "SOL");
1381        assert_eq!(meta.native_decimals, 9);
1382        assert_eq!(meta.explorer_token_base, "https://solscan.io/token");
1383    }
1384
1385    #[test]
1386    fn test_chain_metadata_solana_alias() {
1387        let meta = chain_metadata("sol").unwrap();
1388        assert_eq!(meta.chain_id, "solana");
1389        assert_eq!(meta.native_symbol, "SOL");
1390    }
1391
1392    #[test]
1393    fn test_chain_metadata_tron() {
1394        let meta = chain_metadata("tron").unwrap();
1395        assert_eq!(meta.chain_id, "tron");
1396        assert_eq!(meta.native_symbol, "TRX");
1397        assert_eq!(meta.native_decimals, 6);
1398        assert_eq!(meta.explorer_token_base, "https://tronscan.org/#/token20");
1399    }
1400
1401    #[test]
1402    fn test_chain_metadata_tron_alias() {
1403        let meta = chain_metadata("trx").unwrap();
1404        assert_eq!(meta.chain_id, "tron");
1405        assert_eq!(meta.native_symbol, "TRX");
1406    }
1407
1408    #[test]
1409    fn test_chain_metadata_arbitrum() {
1410        let meta = chain_metadata("arbitrum").unwrap();
1411        assert_eq!(meta.chain_id, "arbitrum");
1412        assert_eq!(meta.native_symbol, "ETH");
1413        assert_eq!(meta.native_decimals, 18);
1414        assert_eq!(meta.explorer_token_base, "https://arbiscan.io/token");
1415    }
1416
1417    #[test]
1418    fn test_chain_metadata_optimism() {
1419        let meta = chain_metadata("optimism").unwrap();
1420        assert_eq!(meta.chain_id, "optimism");
1421        assert_eq!(meta.native_symbol, "ETH");
1422        assert_eq!(meta.native_decimals, 18);
1423        assert_eq!(
1424            meta.explorer_token_base,
1425            "https://optimistic.etherscan.io/token"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_chain_metadata_base() {
1431        let meta = chain_metadata("base").unwrap();
1432        assert_eq!(meta.chain_id, "base");
1433        assert_eq!(meta.native_symbol, "ETH");
1434        assert_eq!(meta.native_decimals, 18);
1435        assert_eq!(meta.explorer_token_base, "https://basescan.org/token");
1436    }
1437
1438    #[test]
1439    fn test_chain_metadata_case_insensitive() {
1440        let meta1 = chain_metadata("ETHEREUM").unwrap();
1441        let meta2 = chain_metadata("Ethereum").unwrap();
1442        let meta3 = chain_metadata("ethereum").unwrap();
1443        assert_eq!(meta1.chain_id, meta2.chain_id);
1444        assert_eq!(meta2.chain_id, meta3.chain_id);
1445    }
1446
1447    #[test]
1448    fn test_chain_metadata_unknown() {
1449        assert!(chain_metadata("bitcoin").is_none());
1450        assert!(chain_metadata("litecoin").is_none());
1451        assert!(chain_metadata("unknown").is_none());
1452        assert!(chain_metadata("").is_none());
1453    }
1454
1455    #[test]
1456    fn test_native_symbol_ethereum() {
1457        assert_eq!(native_symbol("ethereum"), "ETH");
1458        assert_eq!(native_symbol("eth"), "ETH");
1459    }
1460
1461    #[test]
1462    fn test_native_symbol_polygon() {
1463        assert_eq!(native_symbol("polygon"), "MATIC");
1464    }
1465
1466    #[test]
1467    fn test_native_symbol_bsc() {
1468        assert_eq!(native_symbol("bsc"), "BNB");
1469    }
1470
1471    #[test]
1472    fn test_native_symbol_solana() {
1473        assert_eq!(native_symbol("solana"), "SOL");
1474        assert_eq!(native_symbol("sol"), "SOL");
1475    }
1476
1477    #[test]
1478    fn test_native_symbol_tron() {
1479        assert_eq!(native_symbol("tron"), "TRX");
1480        assert_eq!(native_symbol("trx"), "TRX");
1481    }
1482
1483    #[test]
1484    fn test_native_symbol_arbitrum() {
1485        assert_eq!(native_symbol("arbitrum"), "ETH");
1486    }
1487
1488    #[test]
1489    fn test_native_symbol_optimism() {
1490        assert_eq!(native_symbol("optimism"), "ETH");
1491    }
1492
1493    #[test]
1494    fn test_native_symbol_base() {
1495        assert_eq!(native_symbol("base"), "ETH");
1496    }
1497
1498    #[test]
1499    fn test_native_symbol_unknown() {
1500        assert_eq!(native_symbol("unknown"), "???");
1501        assert_eq!(native_symbol("bitcoin"), "???");
1502        assert_eq!(native_symbol(""), "???");
1503    }
1504
1505    #[test]
1506    fn test_native_symbol_case_insensitive() {
1507        assert_eq!(native_symbol("ETHEREUM"), "ETH");
1508        assert_eq!(native_symbol("Ethereum"), "ETH");
1509        assert_eq!(native_symbol("ethereum"), "ETH");
1510    }
1511
1512    #[tokio::test]
1513    async fn test_chain_client_default_get_code() {
1514        let client = MinimalChainClient;
1515        let result = client.get_code("0x1234").await;
1516        assert!(result.is_err());
1517        let err_msg = result.unwrap_err().to_string();
1518        assert!(err_msg.contains("not supported"));
1519    }
1520}
1521
1522// ============================================================================
1523// Mock Test Utilities
1524// ============================================================================
1525
1526/// Test helper module providing mock implementations of chain client traits.
1527///
1528/// These mocks are available across all test modules in the crate for
1529/// end-to-end testing of CLI `run()` functions without network calls.
1530#[cfg(test)]
1531pub mod mocks {
1532    use super::*;
1533    use crate::chains::dex::{DexDataSource, DexTokenData, TokenSearchResult};
1534    use async_trait::async_trait;
1535
1536    /// Mock chain client with configurable responses.
1537    #[derive(Debug, Clone)]
1538    pub struct MockChainClient {
1539        pub chain: String,
1540        pub symbol: String,
1541        pub balance: Balance,
1542        pub transaction: Transaction,
1543        pub transactions: Vec<Transaction>,
1544        pub token_balances: Vec<TokenBalance>,
1545        pub block_number: u64,
1546        pub token_info: Option<Token>,
1547        pub token_holders: Vec<TokenHolder>,
1548        pub token_holder_count: u64,
1549    }
1550
1551    impl MockChainClient {
1552        /// Creates a mock client with sensible default test data.
1553        pub fn new(chain: &str, symbol: &str) -> Self {
1554            Self {
1555                chain: chain.to_string(),
1556                symbol: symbol.to_string(),
1557                balance: Balance {
1558                    raw: "1000000000000000000".to_string(),
1559                    formatted: "1.0".to_string(),
1560                    decimals: 18,
1561                    symbol: symbol.to_string(),
1562                    usd_value: Some(2500.0),
1563                },
1564                transaction: Transaction {
1565                    hash: "0xmocktx".to_string(),
1566                    block_number: Some(12345678),
1567                    timestamp: Some(1700000000),
1568                    from: "0xfrom".to_string(),
1569                    to: Some("0xto".to_string()),
1570                    value: "1.0".to_string(),
1571                    gas_limit: 21000,
1572                    gas_used: Some(21000),
1573                    gas_price: "20000000000".to_string(),
1574                    nonce: 42,
1575                    input: "0x".to_string(),
1576                    status: Some(true),
1577                },
1578                transactions: vec![],
1579                token_balances: vec![],
1580                block_number: 12345678,
1581                token_info: None,
1582                token_holders: vec![],
1583                token_holder_count: 0,
1584            }
1585        }
1586    }
1587
1588    #[async_trait]
1589    impl ChainClient for MockChainClient {
1590        fn chain_name(&self) -> &str {
1591            &self.chain
1592        }
1593
1594        fn native_token_symbol(&self) -> &str {
1595            &self.symbol
1596        }
1597
1598        async fn get_balance(&self, _address: &str) -> Result<Balance> {
1599            Ok(self.balance.clone())
1600        }
1601
1602        async fn enrich_balance_usd(&self, _balance: &mut Balance) {
1603            // Mock: no-op, balance already has usd_value set
1604        }
1605
1606        async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1607            Ok(self.transaction.clone())
1608        }
1609
1610        async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1611            Ok(self.transactions.clone())
1612        }
1613
1614        async fn get_block_number(&self) -> Result<u64> {
1615            Ok(self.block_number)
1616        }
1617
1618        async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1619            Ok(self.token_balances.clone())
1620        }
1621
1622        async fn get_token_info(&self, _address: &str) -> Result<Token> {
1623            match &self.token_info {
1624                Some(t) => Ok(t.clone()),
1625                None => Err(crate::error::ScopeError::Chain(
1626                    "Token info not available".to_string(),
1627                )),
1628            }
1629        }
1630
1631        async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
1632            Ok(self.token_holders.clone())
1633        }
1634
1635        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
1636            Ok(self.token_holder_count)
1637        }
1638    }
1639
1640    /// Mock DEX data source with configurable responses.
1641    #[derive(Debug, Clone)]
1642    pub struct MockDexSource {
1643        pub token_price: Option<f64>,
1644        pub native_price: Option<f64>,
1645        pub token_data: Option<DexTokenData>,
1646        pub search_results: Vec<TokenSearchResult>,
1647    }
1648
1649    impl Default for MockDexSource {
1650        fn default() -> Self {
1651            Self::new()
1652        }
1653    }
1654
1655    impl MockDexSource {
1656        /// Creates a mock DEX source with default test data.
1657        pub fn new() -> Self {
1658            Self {
1659                token_price: Some(1.0),
1660                native_price: Some(2500.0),
1661                token_data: Some(DexTokenData {
1662                    address: "0xmocktoken".to_string(),
1663                    symbol: "MOCK".to_string(),
1664                    name: "Mock Token".to_string(),
1665                    price_usd: 1.0,
1666                    price_change_24h: 5.0,
1667                    price_change_6h: 2.0,
1668                    price_change_1h: 0.5,
1669                    price_change_5m: 0.1,
1670                    volume_24h: 1_000_000.0,
1671                    volume_6h: 250_000.0,
1672                    volume_1h: 50_000.0,
1673                    liquidity_usd: 5_000_000.0,
1674                    market_cap: Some(100_000_000.0),
1675                    fdv: Some(200_000_000.0),
1676                    pairs: vec![],
1677                    price_history: vec![],
1678                    volume_history: vec![],
1679                    total_buys_24h: 500,
1680                    total_sells_24h: 450,
1681                    total_buys_6h: 120,
1682                    total_sells_6h: 110,
1683                    total_buys_1h: 20,
1684                    total_sells_1h: 18,
1685                    earliest_pair_created_at: Some(1690000000),
1686                    image_url: None,
1687                    websites: vec![],
1688                    socials: vec![],
1689                    dexscreener_url: None,
1690                }),
1691                search_results: vec![],
1692            }
1693        }
1694    }
1695
1696    #[async_trait]
1697    impl DexDataSource for MockDexSource {
1698        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
1699            self.token_price
1700        }
1701
1702        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
1703            self.native_price
1704        }
1705
1706        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
1707            match &self.token_data {
1708                Some(data) => Ok(data.clone()),
1709                None => Err(crate::error::ScopeError::NotFound(
1710                    "No DEX data found".to_string(),
1711                )),
1712            }
1713        }
1714
1715        async fn search_tokens(
1716            &self,
1717            _query: &str,
1718            _chain: Option<&str>,
1719        ) -> Result<Vec<TokenSearchResult>> {
1720            Ok(self.search_results.clone())
1721        }
1722    }
1723
1724    /// Mock client factory that returns pre-configured mock clients.
1725    pub struct MockClientFactory {
1726        pub mock_client: MockChainClient,
1727        pub mock_dex: MockDexSource,
1728    }
1729
1730    impl Default for MockClientFactory {
1731        fn default() -> Self {
1732            Self::new()
1733        }
1734    }
1735
1736    impl MockClientFactory {
1737        /// Creates a factory with default mock data for Ethereum.
1738        pub fn new() -> Self {
1739            Self {
1740                mock_client: MockChainClient::new("ethereum", "ETH"),
1741                mock_dex: MockDexSource::new(),
1742            }
1743        }
1744    }
1745
1746    impl ChainClientFactory for MockClientFactory {
1747        fn create_chain_client(&self, _chain: &str) -> Result<Box<dyn ChainClient>> {
1748            Ok(Box::new(self.mock_client.clone()))
1749        }
1750
1751        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1752            Box::new(self.mock_dex.clone())
1753        }
1754    }
1755}