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 crate::http::HttpClient;
100use async_trait::async_trait;
101use serde::{Deserialize, Serialize};
102use std::sync::Arc;
103
104/// Trait defining common blockchain client operations.
105///
106/// All chain-specific clients must implement this trait to provide
107/// a consistent interface for blockchain interactions.
108///
109/// ## Core Methods
110///
111/// Every implementation must provide: `chain_name`, `native_token_symbol`,
112/// `get_balance`, `get_transaction`, `get_transactions`, `get_block_number`,
113/// `enrich_balance_usd`, and `get_token_balances`.
114///
115/// ## Token Explorer Methods
116///
117/// The token-explorer methods (`get_token_info`, `get_token_holders`,
118/// `get_token_holder_count`) have default implementations that return
119/// "not supported" errors or empty results. Only chains with block-explorer
120/// support for these endpoints (currently EVM chains) need to override them.
121#[async_trait]
122pub trait ChainClient: Send + Sync {
123    /// Returns the name of the blockchain network.
124    fn chain_name(&self) -> &str;
125
126    /// Returns the native token symbol (e.g., "ETH", "MATIC").
127    fn native_token_symbol(&self) -> &str;
128
129    /// Fetches the native token balance for an address.
130    ///
131    /// # Arguments
132    ///
133    /// * `address` - The blockchain address to query
134    ///
135    /// # Returns
136    ///
137    /// Returns a [`Balance`] containing the balance in multiple formats.
138    async fn get_balance(&self, address: &str) -> Result<Balance>;
139
140    /// Enriches a balance with USD valuation via DexScreener.
141    ///
142    /// # Arguments
143    ///
144    /// * `balance` - The balance to enrich with a USD value
145    async fn enrich_balance_usd(&self, balance: &mut Balance);
146
147    /// Fetches transaction details by hash.
148    ///
149    /// # Arguments
150    ///
151    /// * `hash` - The transaction hash to query
152    ///
153    /// # Returns
154    ///
155    /// Returns [`Transaction`] details or an error if not found.
156    async fn get_transaction(&self, hash: &str) -> Result<Transaction>;
157
158    /// Fetches recent transactions for an address.
159    ///
160    /// # Arguments
161    ///
162    /// * `address` - The address to query
163    /// * `limit` - Maximum number of transactions to return
164    ///
165    /// # Returns
166    ///
167    /// Returns a vector of [`Transaction`] objects.
168    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>>;
169
170    /// Fetches the current block number.
171    async fn get_block_number(&self) -> Result<u64>;
172
173    /// Fetches token balances for an address.
174    ///
175    /// Returns a unified [`TokenBalance`] list regardless of chain
176    /// (ERC-20, SPL, TRC-20 all map to the same type).
177    async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>>;
178
179    /// Fetches token information for a contract address.
180    ///
181    /// Default implementation returns "not supported" error.
182    /// Override in chain clients that support token info lookups.
183    async fn get_token_info(&self, _address: &str) -> Result<Token> {
184        Err(crate::error::ScopeError::Chain(
185            "Token info lookup not supported on this chain".to_string(),
186        ))
187    }
188
189    /// Fetches top token holders for a contract address.
190    ///
191    /// Default implementation returns an empty vector.
192    /// Override in chain clients that support holder lookups.
193    async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
194        Ok(Vec::new())
195    }
196
197    /// Fetches total token holder count for a contract address.
198    ///
199    /// Default implementation returns 0.
200    /// Override in chain clients that support holder count lookups.
201    async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
202        Ok(0)
203    }
204
205    /// Fetches bytecode at address (EVM: eth_getCode).
206    /// Returns "0x" for EOA, non-empty hex for contracts.
207    /// Default: not supported.
208    async fn get_code(&self, _address: &str) -> Result<String> {
209        Err(crate::error::ScopeError::Chain(
210            "Code lookup not supported on this chain".to_string(),
211        ))
212    }
213
214    /// Reads a storage slot value at a contract address (EVM: eth_getStorageAt).
215    /// Returns the 32-byte hex value at the given slot position.
216    /// Default: not supported.
217    async fn get_storage_at(&self, _address: &str, _slot: &str) -> Result<String> {
218        Err(crate::error::ScopeError::Chain(
219            "Storage lookup not supported on this chain".to_string(),
220        ))
221    }
222}
223
224/// Factory trait for creating chain clients and DEX data sources.
225///
226/// Bundles both chain and DEX client creation so CLI functions
227/// only need one injected dependency instead of two.
228///
229/// # Example
230///
231/// ```rust,no_run
232/// use scope::chains::{ChainClientFactory, DefaultClientFactory};
233/// use scope::http::NativeHttpClient;
234/// use scope::Config;
235/// use std::sync::Arc;
236///
237/// let config = Config::default();
238/// let http = Arc::new(NativeHttpClient::new().unwrap());
239/// let factory = DefaultClientFactory { chains_config: config.chains.clone(), http };
240/// let client = factory.create_chain_client("ethereum").unwrap();
241/// ```
242pub trait ChainClientFactory: Send + Sync {
243    /// Creates a chain client for the given blockchain network.
244    ///
245    /// # Arguments
246    ///
247    /// * `chain` - The chain name (e.g., "ethereum", "solana", "tron")
248    fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>>;
249
250    /// Creates a DEX data source client.
251    fn create_dex_client(&self) -> Box<dyn DexDataSource>;
252}
253
254/// Default factory that creates real chain clients from configuration.
255///
256/// Holds a shared [`HttpClient`] transport so all chain clients route
257/// through the same backend (native `reqwest` or Ghola sidecar).
258pub struct DefaultClientFactory {
259    /// Chain configuration containing API keys and endpoints.
260    pub chains_config: crate::config::ChainsConfig,
261    /// Shared HTTP transport injected into every client.
262    pub http: Arc<dyn HttpClient>,
263}
264
265impl ChainClientFactory for DefaultClientFactory {
266    fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
267        match chain.to_lowercase().as_str() {
268            "solana" | "sol" => Ok(Box::new(SolanaClient::new_with_http(
269                &self.chains_config,
270                self.http.clone(),
271            )?)),
272            "tron" | "trx" => Ok(Box::new(TronClient::new_with_http(
273                &self.chains_config,
274                self.http.clone(),
275            )?)),
276            _ => Ok(Box::new(EthereumClient::for_chain_with_http(
277                chain,
278                &self.chains_config,
279                self.http.clone(),
280            )?)),
281        }
282    }
283
284    fn create_dex_client(&self) -> Box<dyn DexDataSource> {
285        Box::new(DexClient::new_with_http(self.http.clone()))
286    }
287}
288
289/// Balance representation with multiple formats.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct Balance {
292    /// Raw balance in smallest unit (e.g., wei).
293    pub raw: String,
294
295    /// Human-readable formatted balance.
296    pub formatted: String,
297
298    /// Number of decimals for the token.
299    pub decimals: u8,
300
301    /// Token symbol.
302    pub symbol: String,
303
304    /// USD value (if available).
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub usd_value: Option<f64>,
307}
308
309/// Transaction information.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct Transaction {
312    /// Transaction hash.
313    pub hash: String,
314
315    /// Block number (None if pending).
316    pub block_number: Option<u64>,
317
318    /// Block timestamp (None if pending).
319    pub timestamp: Option<u64>,
320
321    /// Sender address.
322    pub from: String,
323
324    /// Recipient address (None for contract creation).
325    pub to: Option<String>,
326
327    /// Value transferred in native token.
328    pub value: String,
329
330    /// Gas limit.
331    pub gas_limit: u64,
332
333    /// Gas used (None if pending).
334    pub gas_used: Option<u64>,
335
336    /// Gas price in wei.
337    pub gas_price: String,
338
339    /// Transaction nonce.
340    pub nonce: u64,
341
342    /// Input data.
343    pub input: String,
344
345    /// Transaction status (None if pending, Some(true) for success).
346    pub status: Option<bool>,
347}
348
349/// Token information.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct Token {
352    /// Contract address.
353    pub contract_address: String,
354
355    /// Token symbol.
356    pub symbol: String,
357
358    /// Token name.
359    pub name: String,
360
361    /// Decimal places.
362    pub decimals: u8,
363}
364
365/// Token balance for an address.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct TokenBalance {
368    /// Token information.
369    pub token: Token,
370
371    /// Raw balance.
372    pub balance: String,
373
374    /// Formatted balance.
375    pub formatted_balance: String,
376
377    /// USD value (if available).
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub usd_value: Option<f64>,
380}
381
382// ============================================================================
383// Token Analytics Types
384// ============================================================================
385
386/// A token holder with their balance and percentage of supply.
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct TokenHolder {
389    /// Holder's address.
390    pub address: String,
391
392    /// Raw balance amount.
393    pub balance: String,
394
395    /// Formatted balance with proper decimals.
396    pub formatted_balance: String,
397
398    /// Percentage of total supply held.
399    pub percentage: f64,
400
401    /// Rank among all holders (1 = largest).
402    pub rank: u32,
403}
404
405/// A price data point for historical charting.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct PricePoint {
408    /// Unix timestamp in seconds.
409    pub timestamp: i64,
410
411    /// Price in USD.
412    pub price: f64,
413}
414
415/// A volume data point for historical charting.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct VolumePoint {
418    /// Unix timestamp in seconds.
419    pub timestamp: i64,
420
421    /// Volume in USD.
422    pub volume: f64,
423}
424
425/// A holder count data point for historical charting.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct HolderCountPoint {
428    /// Unix timestamp in seconds.
429    pub timestamp: i64,
430
431    /// Number of holders.
432    pub count: u64,
433}
434
435/// DEX trading pair information.
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct DexPair {
438    /// DEX name (e.g., "Uniswap V3", "SushiSwap").
439    pub dex_name: String,
440
441    /// Pair address on the DEX.
442    pub pair_address: String,
443
444    /// Base token symbol.
445    pub base_token: String,
446
447    /// Quote token symbol.
448    pub quote_token: String,
449
450    /// Current price in USD.
451    pub price_usd: f64,
452
453    /// 24h trading volume in USD.
454    pub volume_24h: f64,
455
456    /// Liquidity in USD.
457    pub liquidity_usd: f64,
458
459    /// Price change percentage in 24h.
460    pub price_change_24h: f64,
461
462    /// Buy transactions in 24h.
463    pub buys_24h: u64,
464
465    /// Sell transactions in 24h.
466    pub sells_24h: u64,
467
468    /// Buy transactions in 6h.
469    pub buys_6h: u64,
470
471    /// Sell transactions in 6h.
472    pub sells_6h: u64,
473
474    /// Buy transactions in 1h.
475    pub buys_1h: u64,
476
477    /// Sell transactions in 1h.
478    pub sells_1h: u64,
479
480    /// Pair creation timestamp.
481    pub pair_created_at: Option<i64>,
482
483    /// Direct URL to this pair on DexScreener.
484    pub url: Option<String>,
485}
486
487/// Comprehensive token analytics data.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct TokenAnalytics {
490    /// Token information.
491    pub token: Token,
492
493    /// Blockchain network name.
494    pub chain: String,
495
496    /// Top token holders.
497    pub holders: Vec<TokenHolder>,
498
499    /// Total number of holders.
500    pub total_holders: u64,
501
502    /// 24-hour trading volume in USD.
503    pub volume_24h: f64,
504
505    /// 7-day trading volume in USD.
506    pub volume_7d: f64,
507
508    /// Current price in USD.
509    pub price_usd: f64,
510
511    /// 24-hour price change percentage.
512    pub price_change_24h: f64,
513
514    /// 7-day price change percentage.
515    pub price_change_7d: f64,
516
517    /// Total liquidity across DEXs in USD.
518    pub liquidity_usd: f64,
519
520    /// Market capitalization (if available).
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub market_cap: Option<f64>,
523
524    /// Fully diluted valuation (if available).
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub fdv: Option<f64>,
527
528    /// Total supply.
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub total_supply: Option<String>,
531
532    /// Circulating supply.
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub circulating_supply: Option<String>,
535
536    /// Historical price data for charting.
537    pub price_history: Vec<PricePoint>,
538
539    /// Historical volume data for charting.
540    pub volume_history: Vec<VolumePoint>,
541
542    /// Historical holder count data for charting.
543    pub holder_history: Vec<HolderCountPoint>,
544
545    /// DEX trading pairs.
546    pub dex_pairs: Vec<DexPair>,
547
548    /// Timestamp when this data was fetched.
549    pub fetched_at: i64,
550
551    /// Percentage of supply held by top 10 holders.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub top_10_concentration: Option<f64>,
554
555    /// Percentage of supply held by top 50 holders.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub top_50_concentration: Option<f64>,
558
559    /// Percentage of supply held by top 100 holders.
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub top_100_concentration: Option<f64>,
562
563    /// 6-hour price change percentage.
564    pub price_change_6h: f64,
565
566    /// 1-hour price change percentage.
567    pub price_change_1h: f64,
568
569    /// Total buy transactions in 24 hours.
570    pub total_buys_24h: u64,
571
572    /// Total sell transactions in 24 hours.
573    pub total_sells_24h: u64,
574
575    /// Total buy transactions in 6 hours.
576    pub total_buys_6h: u64,
577
578    /// Total sell transactions in 6 hours.
579    pub total_sells_6h: u64,
580
581    /// Total buy transactions in 1 hour.
582    pub total_buys_1h: u64,
583
584    /// Total sell transactions in 1 hour.
585    pub total_sells_1h: u64,
586
587    /// Token age in hours (since earliest pair creation).
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub token_age_hours: Option<f64>,
590
591    /// Token image URL.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub image_url: Option<String>,
594
595    /// Token website URLs.
596    pub websites: Vec<String>,
597
598    /// Token social media links.
599    pub socials: Vec<TokenSocial>,
600
601    /// DexScreener URL for the primary pair.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub dexscreener_url: Option<String>,
604}
605
606/// Social media link for a token.
607#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
608pub struct TokenSocial {
609    /// Platform name (twitter, telegram, discord, etc.)
610    pub platform: String,
611    /// URL or handle for the social account.
612    pub url: String,
613}
614
615// ============================================================================
616// NFT Types
617// ============================================================================
618
619/// NFT token metadata.
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct NftMetadata {
622    /// Token ID.
623    pub token_id: String,
624    /// NFT name.
625    pub name: Option<String>,
626    /// NFT description.
627    pub description: Option<String>,
628    /// Image URL.
629    pub image_url: Option<String>,
630    /// Token URI (metadata JSON location).
631    pub token_uri: Option<String>,
632    /// Token standard (ERC-721 or ERC-1155).
633    pub standard: String,
634    /// Collection/contract name.
635    pub collection_name: Option<String>,
636    /// Additional attributes.
637    pub attributes: Vec<NftAttribute>,
638}
639
640/// An NFT attribute (trait).
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct NftAttribute {
643    /// Trait type/name.
644    pub trait_type: String,
645    /// Trait value.
646    pub value: String,
647}
648
649// ============================================================================
650// Gas Analysis Types
651// ============================================================================
652
653/// Gas usage analysis for a contract or address.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct GasAnalysis {
656    /// Average gas used per transaction.
657    pub avg_gas_used: u64,
658    /// Maximum gas used in a single transaction.
659    pub max_gas_used: u64,
660    /// Minimum gas used in a single transaction.
661    pub min_gas_used: u64,
662    /// Total gas spent (sum of gas_used * gas_price).
663    pub total_gas_cost_wei: String,
664    /// Total gas cost in ETH/native token.
665    pub total_gas_cost_formatted: String,
666    /// Number of transactions analyzed.
667    pub tx_count: u64,
668    /// Gas usage by function selector (top callers).
669    pub gas_by_function: Vec<GasByFunction>,
670    /// Failed transaction count and wasted gas.
671    pub failed_tx_count: u64,
672    /// Gas wasted on failed transactions.
673    pub wasted_gas: u64,
674}
675
676/// Gas usage breakdown by function.
677#[derive(Debug, Clone, Serialize, Deserialize)]
678pub struct GasByFunction {
679    /// Function selector or name.
680    pub function: String,
681    /// Number of calls.
682    pub call_count: u64,
683    /// Average gas per call.
684    pub avg_gas: u64,
685    /// Total gas for this function.
686    pub total_gas: u64,
687}
688
689// ============================================================================
690// Chain Metadata
691// ============================================================================
692
693/// Metadata for a blockchain network (symbol, decimals, explorer URLs).
694///
695/// Used for normalized presentation across all chains.
696#[derive(Debug, Clone)]
697pub struct ChainMetadata {
698    /// Canonical chain identifier.
699    pub chain_id: &'static str,
700    /// Native token symbol (e.g., ETH, SOL, TRX).
701    pub native_symbol: &'static str,
702    /// Native token decimals.
703    pub native_decimals: u8,
704    /// Block explorer base URL for token pages.
705    pub explorer_token_base: &'static str,
706}
707
708/// Returns chain metadata for display and formatting.
709///
710/// Returns `None` for unknown chains.
711pub fn chain_metadata(chain: &str) -> Option<ChainMetadata> {
712    match chain.to_lowercase().as_str() {
713        "ethereum" | "eth" => Some(ChainMetadata {
714            chain_id: "ethereum",
715            native_symbol: "ETH",
716            native_decimals: 18,
717            explorer_token_base: "https://etherscan.io/token",
718        }),
719        "polygon" => Some(ChainMetadata {
720            chain_id: "polygon",
721            native_symbol: "MATIC",
722            native_decimals: 18,
723            explorer_token_base: "https://polygonscan.com/token",
724        }),
725        "arbitrum" => Some(ChainMetadata {
726            chain_id: "arbitrum",
727            native_symbol: "ETH",
728            native_decimals: 18,
729            explorer_token_base: "https://arbiscan.io/token",
730        }),
731        "optimism" => Some(ChainMetadata {
732            chain_id: "optimism",
733            native_symbol: "ETH",
734            native_decimals: 18,
735            explorer_token_base: "https://optimistic.etherscan.io/token",
736        }),
737        "base" => Some(ChainMetadata {
738            chain_id: "base",
739            native_symbol: "ETH",
740            native_decimals: 18,
741            explorer_token_base: "https://basescan.org/token",
742        }),
743        "bsc" => Some(ChainMetadata {
744            chain_id: "bsc",
745            native_symbol: "BNB",
746            native_decimals: 18,
747            explorer_token_base: "https://bscscan.com/token",
748        }),
749        "solana" | "sol" => Some(ChainMetadata {
750            chain_id: "solana",
751            native_symbol: "SOL",
752            native_decimals: 9,
753            explorer_token_base: "https://solscan.io/token",
754        }),
755        "tron" | "trx" => Some(ChainMetadata {
756            chain_id: "tron",
757            native_symbol: "TRX",
758            native_decimals: 6,
759            explorer_token_base: "https://tronscan.org/#/token20",
760        }),
761        _ => None,
762    }
763}
764
765/// Returns the native token symbol for a chain, or "???" if unknown.
766pub fn native_symbol(chain: &str) -> &'static str {
767    chain_metadata(chain)
768        .map(|m| m.native_symbol)
769        .unwrap_or("???")
770}
771
772// ============================================================================
773// Chain Inference
774// ============================================================================
775
776/// Infers the blockchain from an address format.
777///
778/// Returns `Some(chain_name)` if the address format is unambiguous,
779/// or `None` if the format is not recognized.
780///
781/// # Supported Formats
782///
783/// - **EVM** (ethereum): `0x` prefix + 40 hex chars (42 total)
784/// - **Tron**: Starts with `T` + 34 chars (Base58Check)
785/// - **Solana**: Base58, 32-44 chars, decodes to 32 bytes
786///
787/// # Examples
788///
789/// ```
790/// use scope::chains::infer_chain_from_address;
791///
792/// assert_eq!(infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"), Some("ethereum"));
793/// assert_eq!(infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"), Some("tron"));
794/// assert_eq!(infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"), Some("solana"));
795/// assert_eq!(infer_chain_from_address("invalid"), None);
796/// ```
797pub fn infer_chain_from_address(address: &str) -> Option<&'static str> {
798    // Tron: starts with 'T', 34 chars, valid base58
799    if address.starts_with('T') && address.len() == 34 && bs58::decode(address).into_vec().is_ok() {
800        return Some("tron");
801    }
802
803    // EVM: 0x prefix, 42 chars total (40 hex + "0x")
804    if address.starts_with("0x")
805        && address.len() == 42
806        && address[2..].chars().all(|c| c.is_ascii_hexdigit())
807    {
808        return Some("ethereum");
809    }
810
811    // Solana: base58, 32-44 chars, decodes to 32 bytes
812    if address.len() >= 32
813        && address.len() <= 44
814        && let Ok(decoded) = bs58::decode(address).into_vec()
815        && decoded.len() == 32
816    {
817        return Some("solana");
818    }
819
820    None
821}
822
823/// Infers the blockchain from a transaction hash format.
824///
825/// Returns `Some(chain_name)` if the hash format is unambiguous,
826/// or `None` if the format is not recognized.
827///
828/// # Supported Formats
829///
830/// - **EVM** (ethereum): `0x` prefix + 64 hex chars (66 total)
831/// - **Tron**: 64 hex chars (no prefix)
832/// - **Solana**: Base58, 80-90 chars, decodes to 64 bytes
833///
834/// # Examples
835///
836/// ```
837/// use scope::chains::infer_chain_from_hash;
838///
839/// // EVM hash
840/// let evm_hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
841/// assert_eq!(infer_chain_from_hash(evm_hash), Some("ethereum"));
842///
843/// // Tron hash (64 hex chars, no 0x prefix)
844/// let tron_hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
845/// assert_eq!(infer_chain_from_hash(tron_hash), Some("tron"));
846/// ```
847pub fn infer_chain_from_hash(hash: &str) -> Option<&'static str> {
848    // EVM: 0x prefix, 66 chars total (64 hex + "0x")
849    if hash.starts_with("0x")
850        && hash.len() == 66
851        && hash[2..].chars().all(|c| c.is_ascii_hexdigit())
852    {
853        return Some("ethereum");
854    }
855
856    // Tron: 64 hex chars, no prefix
857    if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
858        return Some("tron");
859    }
860
861    // Solana: base58, 80-90 chars, decodes to 64 bytes
862    if hash.len() >= 80
863        && hash.len() <= 90
864        && let Ok(decoded) = bs58::decode(hash).into_vec()
865        && decoded.len() == 64
866    {
867        return Some("solana");
868    }
869
870    None
871}
872
873/// Analyze gas usage from a set of transactions.
874///
875/// Computes statistics on gas consumption, identifies gas-heavy functions,
876/// and flags failed transactions.
877pub fn analyze_gas_usage(transactions: &[Transaction]) -> GasAnalysis {
878    if transactions.is_empty() {
879        return GasAnalysis {
880            avg_gas_used: 0,
881            max_gas_used: 0,
882            min_gas_used: 0,
883            total_gas_cost_wei: "0".to_string(),
884            total_gas_cost_formatted: "0".to_string(),
885            tx_count: 0,
886            gas_by_function: vec![],
887            failed_tx_count: 0,
888            wasted_gas: 0,
889        };
890    }
891
892    let mut total_gas: u64 = 0;
893    let mut max_gas: u64 = 0;
894    let mut min_gas: u64 = u64::MAX;
895    let mut failed_count: u64 = 0;
896    let mut wasted_gas: u64 = 0;
897    let mut function_gas: std::collections::HashMap<String, (u64, u64)> =
898        std::collections::HashMap::new();
899
900    for tx in transactions {
901        let gas_used = tx.gas_used.unwrap_or(0);
902        total_gas += gas_used;
903        if gas_used > max_gas {
904            max_gas = gas_used;
905        }
906        if gas_used < min_gas {
907            min_gas = gas_used;
908        }
909
910        // Track failed transactions
911        if tx.status == Some(false) {
912            failed_count += 1;
913            wasted_gas += gas_used;
914        }
915
916        // Group by function selector
917        let selector = if tx.input.len() >= 10 {
918            tx.input[..10].to_string()
919        } else if tx.input.is_empty() || tx.input == "0x" {
920            "transfer()".to_string()
921        } else {
922            tx.input.clone()
923        };
924
925        let entry = function_gas.entry(selector).or_insert((0, 0));
926        entry.0 += 1; // call count
927        entry.1 += gas_used; // total gas
928    }
929
930    let tx_count = transactions.len() as u64;
931    let avg_gas = total_gas.checked_div(tx_count).unwrap_or(0);
932
933    if min_gas == u64::MAX {
934        min_gas = 0;
935    }
936
937    // Sort functions by total gas usage
938    let mut gas_by_function: Vec<GasByFunction> = function_gas
939        .into_iter()
940        .map(|(function, (call_count, total_gas_fn))| GasByFunction {
941            function,
942            call_count,
943            avg_gas: total_gas_fn.checked_div(call_count).unwrap_or(0),
944            total_gas: total_gas_fn,
945        })
946        .collect();
947    gas_by_function.sort_by_key(|g| std::cmp::Reverse(g.total_gas));
948
949    // Format total gas cost (rough estimate using average gas price)
950    let total_gas_cost_formatted = format!("{} gas units", total_gas);
951
952    GasAnalysis {
953        avg_gas_used: avg_gas,
954        max_gas_used: max_gas,
955        min_gas_used: min_gas,
956        total_gas_cost_wei: total_gas.to_string(),
957        total_gas_cost_formatted,
958        tx_count,
959        gas_by_function,
960        failed_tx_count: failed_count,
961        wasted_gas,
962    }
963}
964
965// ============================================================================
966// Unit Tests
967// ============================================================================
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    #[test]
974    fn test_balance_serialization() {
975        let balance = Balance {
976            raw: "1000000000000000000".to_string(),
977            formatted: "1.0".to_string(),
978            decimals: 18,
979            symbol: "ETH".to_string(),
980            usd_value: Some(3500.0),
981        };
982
983        let json = serde_json::to_string(&balance).unwrap();
984        assert!(json.contains("1000000000000000000"));
985        assert!(json.contains("1.0"));
986        assert!(json.contains("ETH"));
987        assert!(json.contains("3500"));
988
989        let deserialized: Balance = serde_json::from_str(&json).unwrap();
990        assert_eq!(deserialized.raw, balance.raw);
991        assert_eq!(deserialized.decimals, 18);
992    }
993
994    #[test]
995    fn test_balance_without_usd() {
996        let balance = Balance {
997            raw: "1000000000000000000".to_string(),
998            formatted: "1.0".to_string(),
999            decimals: 18,
1000            symbol: "ETH".to_string(),
1001            usd_value: None,
1002        };
1003
1004        let json = serde_json::to_string(&balance).unwrap();
1005        assert!(!json.contains("usd_value"));
1006    }
1007
1008    #[test]
1009    fn test_transaction_serialization() {
1010        let tx = Transaction {
1011            hash: "0xabc123".to_string(),
1012            block_number: Some(12345678),
1013            timestamp: Some(1700000000),
1014            from: "0xfrom".to_string(),
1015            to: Some("0xto".to_string()),
1016            value: "1.0".to_string(),
1017            gas_limit: 21000,
1018            gas_used: Some(21000),
1019            gas_price: "20000000000".to_string(),
1020            nonce: 42,
1021            input: "0x".to_string(),
1022            status: Some(true),
1023        };
1024
1025        let json = serde_json::to_string(&tx).unwrap();
1026        assert!(json.contains("0xabc123"));
1027        assert!(json.contains("12345678"));
1028        assert!(json.contains("0xfrom"));
1029        assert!(json.contains("0xto"));
1030
1031        let deserialized: Transaction = serde_json::from_str(&json).unwrap();
1032        assert_eq!(deserialized.hash, tx.hash);
1033        assert_eq!(deserialized.nonce, 42);
1034    }
1035
1036    #[test]
1037    fn test_pending_transaction_serialization() {
1038        let tx = Transaction {
1039            hash: "0xpending".to_string(),
1040            block_number: None,
1041            timestamp: None,
1042            from: "0xfrom".to_string(),
1043            to: Some("0xto".to_string()),
1044            value: "1.0".to_string(),
1045            gas_limit: 21000,
1046            gas_used: None,
1047            gas_price: "20000000000".to_string(),
1048            nonce: 0,
1049            input: "0x".to_string(),
1050            status: None,
1051        };
1052
1053        let json = serde_json::to_string(&tx).unwrap();
1054        assert!(json.contains("0xpending"));
1055        assert!(json.contains("null")); // None values serialize as null
1056
1057        let deserialized: Transaction = serde_json::from_str(&json).unwrap();
1058        assert!(deserialized.block_number.is_none());
1059        assert!(deserialized.status.is_none());
1060    }
1061
1062    #[test]
1063    fn test_contract_creation_transaction() {
1064        let tx = Transaction {
1065            hash: "0xcreate".to_string(),
1066            block_number: Some(100),
1067            timestamp: Some(1700000000),
1068            from: "0xdeployer".to_string(),
1069            to: None, // Contract creation
1070            value: "0".to_string(),
1071            gas_limit: 1000000,
1072            gas_used: Some(500000),
1073            gas_price: "20000000000".to_string(),
1074            nonce: 0,
1075            input: "0x608060...".to_string(),
1076            status: Some(true),
1077        };
1078
1079        let json = serde_json::to_string(&tx).unwrap();
1080        assert!(json.contains("\"to\":null"));
1081    }
1082
1083    #[test]
1084    fn test_token_serialization() {
1085        let token = Token {
1086            contract_address: "0xtoken".to_string(),
1087            symbol: "USDC".to_string(),
1088            name: "USD Coin".to_string(),
1089            decimals: 6,
1090        };
1091
1092        let json = serde_json::to_string(&token).unwrap();
1093        assert!(json.contains("USDC"));
1094        assert!(json.contains("USD Coin"));
1095        assert!(json.contains("\"decimals\":6"));
1096
1097        let deserialized: Token = serde_json::from_str(&json).unwrap();
1098        assert_eq!(deserialized.decimals, 6);
1099    }
1100
1101    #[test]
1102    fn test_token_balance_serialization() {
1103        let token_balance = TokenBalance {
1104            token: Token {
1105                contract_address: "0xtoken".to_string(),
1106                symbol: "USDC".to_string(),
1107                name: "USD Coin".to_string(),
1108                decimals: 6,
1109            },
1110            balance: "1000000".to_string(),
1111            formatted_balance: "1.0".to_string(),
1112            usd_value: Some(1.0),
1113        };
1114
1115        let json = serde_json::to_string(&token_balance).unwrap();
1116        assert!(json.contains("USDC"));
1117        assert!(json.contains("1000000"));
1118        assert!(json.contains("1.0"));
1119    }
1120
1121    #[test]
1122    fn test_balance_debug() {
1123        let balance = Balance {
1124            raw: "1000".to_string(),
1125            formatted: "0.001".to_string(),
1126            decimals: 18,
1127            symbol: "ETH".to_string(),
1128            usd_value: None,
1129        };
1130
1131        let debug_str = format!("{:?}", balance);
1132        assert!(debug_str.contains("Balance"));
1133        assert!(debug_str.contains("1000"));
1134    }
1135
1136    #[test]
1137    fn test_transaction_debug() {
1138        let tx = Transaction {
1139            hash: "0xtest".to_string(),
1140            block_number: Some(1),
1141            timestamp: Some(0),
1142            from: "0x1".to_string(),
1143            to: Some("0x2".to_string()),
1144            value: "0".to_string(),
1145            gas_limit: 21000,
1146            gas_used: Some(21000),
1147            gas_price: "0".to_string(),
1148            nonce: 0,
1149            input: "0x".to_string(),
1150            status: Some(true),
1151        };
1152
1153        let debug_str = format!("{:?}", tx);
1154        assert!(debug_str.contains("Transaction"));
1155        assert!(debug_str.contains("0xtest"));
1156    }
1157
1158    // ============================================================================
1159    // Chain Inference Tests
1160    // ============================================================================
1161
1162    #[test]
1163    fn test_infer_chain_from_address_evm() {
1164        // Valid EVM addresses (0x + 40 hex chars)
1165        assert_eq!(
1166            super::infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"),
1167            Some("ethereum")
1168        );
1169        assert_eq!(
1170            super::infer_chain_from_address("0x0000000000000000000000000000000000000000"),
1171            Some("ethereum")
1172        );
1173        assert_eq!(
1174            super::infer_chain_from_address("0xABCDEF1234567890abcdef1234567890ABCDEF12"),
1175            Some("ethereum")
1176        );
1177    }
1178
1179    #[test]
1180    fn test_infer_chain_from_address_tron() {
1181        // Valid Tron addresses (T + 33 chars = 34 total, base58)
1182        assert_eq!(
1183            super::infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1184            Some("tron")
1185        );
1186        assert_eq!(
1187            super::infer_chain_from_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
1188            Some("tron")
1189        );
1190    }
1191
1192    #[test]
1193    fn test_infer_chain_from_address_solana() {
1194        // Valid Solana addresses (base58, 32-44 chars, decodes to 32 bytes)
1195        assert_eq!(
1196            super::infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"),
1197            Some("solana")
1198        );
1199        // System program address
1200        assert_eq!(
1201            super::infer_chain_from_address("11111111111111111111111111111111"),
1202            Some("solana")
1203        );
1204    }
1205
1206    #[test]
1207    fn test_infer_chain_from_address_invalid() {
1208        // Too short
1209        assert_eq!(super::infer_chain_from_address("0x123"), None);
1210        // Invalid characters
1211        assert_eq!(super::infer_chain_from_address("not_an_address"), None);
1212        // Empty
1213        assert_eq!(super::infer_chain_from_address(""), None);
1214        // EVM-like but wrong length
1215        assert_eq!(super::infer_chain_from_address("0x123456"), None);
1216        // Tron-like but not starting with T
1217        assert_eq!(
1218            super::infer_chain_from_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
1219            None
1220        );
1221    }
1222
1223    #[test]
1224    fn test_infer_chain_from_hash_evm() {
1225        // Valid EVM transaction hash (0x + 64 hex chars)
1226        assert_eq!(
1227            super::infer_chain_from_hash(
1228                "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1229            ),
1230            Some("ethereum")
1231        );
1232        assert_eq!(
1233            super::infer_chain_from_hash(
1234                "0x0000000000000000000000000000000000000000000000000000000000000000"
1235            ),
1236            Some("ethereum")
1237        );
1238    }
1239
1240    #[test]
1241    fn test_infer_chain_from_hash_tron() {
1242        // Valid Tron transaction hash (64 hex chars, no 0x prefix)
1243        assert_eq!(
1244            super::infer_chain_from_hash(
1245                "abc123def456789012345678901234567890123456789012345678901234abcd"
1246            ),
1247            Some("tron")
1248        );
1249        assert_eq!(
1250            super::infer_chain_from_hash(
1251                "0000000000000000000000000000000000000000000000000000000000000000"
1252            ),
1253            Some("tron")
1254        );
1255    }
1256
1257    #[test]
1258    fn test_infer_chain_from_hash_solana() {
1259        // Valid Solana signature (base58, 80-90 chars, decodes to 64 bytes)
1260        // This is a made-up example that fits the pattern
1261        let solana_sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
1262        assert_eq!(super::infer_chain_from_hash(solana_sig), Some("solana"));
1263    }
1264
1265    #[test]
1266    fn test_infer_chain_from_hash_invalid() {
1267        // Too short
1268        assert_eq!(super::infer_chain_from_hash("0x123"), None);
1269        // Invalid
1270        assert_eq!(super::infer_chain_from_hash("not_a_hash"), None);
1271        // Empty
1272        assert_eq!(super::infer_chain_from_hash(""), None);
1273        // 64 chars but with invalid hex (contains 'g')
1274        assert_eq!(
1275            super::infer_chain_from_hash(
1276                "abc123gef456789012345678901234567890123456789012345678901234abcd"
1277            ),
1278            None
1279        );
1280    }
1281
1282    // ============================================================================
1283    // DefaultClientFactory Tests
1284    // ============================================================================
1285
1286    #[test]
1287    fn test_default_client_factory_create_dex_client() {
1288        let config = crate::config::ChainsConfig::default();
1289        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1290        let factory = DefaultClientFactory {
1291            chains_config: config,
1292            http,
1293        };
1294        let dex = factory.create_dex_client();
1295        // Just verify it returns without panicking - the client is a Box<dyn DexDataSource>
1296        let _ = format!("{:?}", std::mem::size_of_val(&dex));
1297    }
1298
1299    #[test]
1300    fn test_default_client_factory_create_ethereum_client() {
1301        let config = crate::config::ChainsConfig::default();
1302        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1303        let factory = DefaultClientFactory {
1304            chains_config: config,
1305            http,
1306        };
1307        // ethereum, polygon, etc use EthereumClient::for_chain
1308        let client = factory.create_chain_client("ethereum");
1309        assert!(client.is_ok());
1310        assert_eq!(client.unwrap().chain_name(), "ethereum");
1311    }
1312
1313    #[test]
1314    fn test_default_client_factory_create_polygon_client() {
1315        let config = crate::config::ChainsConfig::default();
1316        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1317        let factory = DefaultClientFactory {
1318            chains_config: config,
1319            http,
1320        };
1321        let client = factory.create_chain_client("polygon");
1322        assert!(client.is_ok());
1323        assert_eq!(client.unwrap().chain_name(), "polygon");
1324    }
1325
1326    #[test]
1327    fn test_default_client_factory_create_solana_client() {
1328        let config = crate::config::ChainsConfig::default();
1329        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1330        let factory = DefaultClientFactory {
1331            chains_config: config,
1332            http,
1333        };
1334        let client = factory.create_chain_client("solana");
1335        assert!(client.is_ok());
1336        assert_eq!(client.unwrap().chain_name(), "solana");
1337    }
1338
1339    #[test]
1340    fn test_default_client_factory_create_sol_alias() {
1341        let config = crate::config::ChainsConfig::default();
1342        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1343        let factory = DefaultClientFactory {
1344            chains_config: config,
1345            http,
1346        };
1347        let client = factory.create_chain_client("sol");
1348        assert!(client.is_ok());
1349        assert_eq!(client.unwrap().chain_name(), "solana");
1350    }
1351
1352    #[test]
1353    fn test_default_client_factory_create_tron_client() {
1354        let config = crate::config::ChainsConfig::default();
1355        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1356        let factory = DefaultClientFactory {
1357            chains_config: config,
1358            http,
1359        };
1360        let client = factory.create_chain_client("tron");
1361        assert!(client.is_ok());
1362        assert_eq!(client.unwrap().chain_name(), "tron");
1363    }
1364
1365    #[test]
1366    fn test_default_client_factory_create_trx_alias() {
1367        let config = crate::config::ChainsConfig::default();
1368        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1369        let factory = DefaultClientFactory {
1370            chains_config: config,
1371            http,
1372        };
1373        let client = factory.create_chain_client("trx");
1374        assert!(client.is_ok());
1375        assert_eq!(client.unwrap().chain_name(), "tron");
1376    }
1377
1378    #[test]
1379    fn test_default_client_factory_create_arbitrum_client() {
1380        let config = crate::config::ChainsConfig::default();
1381        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1382        let factory = DefaultClientFactory {
1383            chains_config: config,
1384            http,
1385        };
1386        let client = factory.create_chain_client("arbitrum");
1387        assert!(client.is_ok());
1388        assert_eq!(client.unwrap().chain_name(), "arbitrum");
1389    }
1390
1391    #[test]
1392    fn test_default_client_factory_create_optimism_client() {
1393        let config = crate::config::ChainsConfig::default();
1394        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1395        let factory = DefaultClientFactory {
1396            chains_config: config,
1397            http,
1398        };
1399        let client = factory.create_chain_client("optimism");
1400        assert!(client.is_ok());
1401        assert_eq!(client.unwrap().chain_name(), "optimism");
1402    }
1403
1404    #[test]
1405    fn test_default_client_factory_create_base_client() {
1406        let config = crate::config::ChainsConfig::default();
1407        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1408        let factory = DefaultClientFactory {
1409            chains_config: config,
1410            http,
1411        };
1412        let client = factory.create_chain_client("base");
1413        assert!(client.is_ok());
1414        assert_eq!(client.unwrap().chain_name(), "base");
1415    }
1416
1417    #[test]
1418    fn test_default_client_factory_create_unsupported_chain_returns_err() {
1419        let config = crate::config::ChainsConfig::default();
1420        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1421        let factory = DefaultClientFactory {
1422            chains_config: config,
1423            http,
1424        };
1425        let client = factory.create_chain_client("avalanche");
1426        match &client {
1427            Err(e) => assert!(e.to_string().contains("Unsupported")),
1428            Ok(_) => panic!("expected Err for unsupported chain"),
1429        }
1430    }
1431
1432    // ============================================================================
1433    // HttpClient injection tests
1434    // ============================================================================
1435
1436    #[test]
1437    fn test_solana_client_new_with_http() {
1438        let config = crate::config::ChainsConfig::default();
1439        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1440        let client = SolanaClient::new_with_http(&config, http);
1441        assert!(client.is_ok());
1442        assert_eq!(client.unwrap().chain_name(), "solana");
1443    }
1444
1445    #[test]
1446    fn test_tron_client_new_with_http() {
1447        let config = crate::config::ChainsConfig::default();
1448        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1449        let client = TronClient::new_with_http(&config, http);
1450        assert!(client.is_ok());
1451        assert_eq!(client.unwrap().chain_name(), "tron");
1452    }
1453
1454    #[test]
1455    fn test_dex_client_new_with_http() {
1456        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1457        let client = DexClient::new_with_http(http);
1458        let _ = format!("{}", std::mem::size_of_val(&client));
1459    }
1460
1461    #[test]
1462    fn test_factory_shares_http_transport() {
1463        let config = crate::config::ChainsConfig::default();
1464        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1465        let weak = Arc::downgrade(&http);
1466        let factory = DefaultClientFactory {
1467            chains_config: config,
1468            http,
1469        };
1470        // Factory holds a clone of the Arc; original strong count was 1,
1471        // factory clone makes 2 minus the 1 we moved = factory has it.
1472        // Creating clients bumps the count via Arc::clone inside each
1473        // constructor.
1474        let _eth = factory.create_chain_client("ethereum").unwrap();
1475        let _sol = factory.create_chain_client("solana").unwrap();
1476        let _trx = factory.create_chain_client("tron").unwrap();
1477        let _dex = factory.create_dex_client();
1478        // The weak ref should still be valid (Arc not fully dropped)
1479        assert!(weak.upgrade().is_some());
1480    }
1481
1482    #[test]
1483    fn test_ethereum_client_new_with_http() {
1484        let config = crate::config::ChainsConfig::default();
1485        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1486        let client = EthereumClient::new_with_http(&config, http);
1487        assert!(client.is_ok());
1488        assert_eq!(client.unwrap().chain_name(), "ethereum");
1489    }
1490
1491    #[test]
1492    fn test_ethereum_client_for_chain_with_http() {
1493        let config = crate::config::ChainsConfig::default();
1494        let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
1495        let client = EthereumClient::for_chain_with_http("polygon", &config, http);
1496        assert!(client.is_ok());
1497        assert_eq!(client.unwrap().chain_name(), "polygon");
1498    }
1499
1500    // ============================================================================
1501    // ChainClient trait default method tests
1502    // ============================================================================
1503
1504    #[tokio::test]
1505    async fn test_chain_client_default_get_token_info() {
1506        use super::mocks::MockChainClient;
1507        // Create a client without token_info set (None)
1508        let client = MockChainClient::new("ethereum", "ETH");
1509        let result = client.get_token_info("0xsometoken").await;
1510        assert!(result.is_err());
1511    }
1512
1513    #[tokio::test]
1514    async fn test_chain_client_default_get_token_holders() {
1515        use super::mocks::MockChainClient;
1516        let client = MockChainClient::new("ethereum", "ETH");
1517        let holders = client.get_token_holders("0xsometoken", 10).await.unwrap();
1518        assert!(holders.is_empty());
1519    }
1520
1521    #[tokio::test]
1522    async fn test_chain_client_default_get_token_holder_count() {
1523        use super::mocks::MockChainClient;
1524        let client = MockChainClient::new("ethereum", "ETH");
1525        let count = client.get_token_holder_count("0xsometoken").await.unwrap();
1526        assert_eq!(count, 0);
1527    }
1528
1529    #[tokio::test]
1530    async fn test_mock_client_factory_creates_chain_client() {
1531        use super::mocks::MockClientFactory;
1532        let factory = MockClientFactory::new();
1533        let client = factory.create_chain_client("anything").unwrap();
1534        assert_eq!(client.chain_name(), "ethereum"); // defaults to ethereum mock
1535    }
1536
1537    #[tokio::test]
1538    async fn test_mock_client_factory_creates_dex_client() {
1539        use super::mocks::MockClientFactory;
1540        let factory = MockClientFactory::new();
1541        let dex = factory.create_dex_client();
1542        let price = dex.get_token_price("ethereum", "0xtest").await;
1543        assert_eq!(price, Some(1.0));
1544    }
1545
1546    #[tokio::test]
1547    async fn test_mock_chain_client_balance() {
1548        use super::mocks::MockChainClient;
1549        let client = MockChainClient::new("ethereum", "ETH");
1550        let balance = client.get_balance("0xtest").await.unwrap();
1551        assert_eq!(balance.formatted, "1.0");
1552        assert_eq!(balance.symbol, "ETH");
1553        assert_eq!(balance.usd_value, Some(2500.0));
1554    }
1555
1556    #[tokio::test]
1557    async fn test_mock_chain_client_transaction() {
1558        use super::mocks::MockChainClient;
1559        let client = MockChainClient::new("ethereum", "ETH");
1560        let tx = client.get_transaction("0xanyhash").await.unwrap();
1561        assert_eq!(tx.hash, "0xmocktx");
1562        assert_eq!(tx.nonce, 42);
1563    }
1564
1565    #[tokio::test]
1566    async fn test_mock_chain_client_block_number() {
1567        use super::mocks::MockChainClient;
1568        let client = MockChainClient::new("ethereum", "ETH");
1569        let block = client.get_block_number().await.unwrap();
1570        assert_eq!(block, 12345678);
1571    }
1572
1573    #[tokio::test]
1574    async fn test_mock_dex_source_data() {
1575        use super::mocks::MockDexSource;
1576        let dex = MockDexSource::new();
1577        let data = dex.get_token_data("ethereum", "0xtest").await.unwrap();
1578        assert_eq!(data.symbol, "MOCK");
1579        assert_eq!(data.price_usd, 1.0);
1580    }
1581
1582    #[tokio::test]
1583    async fn test_mock_dex_source_search() {
1584        use super::mocks::MockDexSource;
1585        let dex = MockDexSource::new();
1586        let results = dex.search_tokens("test", None).await.unwrap();
1587        assert!(results.is_empty());
1588    }
1589
1590    #[tokio::test]
1591    async fn test_mock_dex_source_native_price() {
1592        use super::mocks::MockDexSource;
1593        let dex = MockDexSource::new();
1594        let price = dex.get_native_token_price("ethereum").await;
1595        assert_eq!(price, Some(2500.0));
1596    }
1597
1598    // ========================================================================
1599    // Default ChainClient trait method tests
1600    // ========================================================================
1601
1602    /// Minimal ChainClient impl that uses all default methods.
1603    struct MinimalChainClient;
1604
1605    #[async_trait::async_trait]
1606    impl ChainClient for MinimalChainClient {
1607        fn chain_name(&self) -> &str {
1608            "test"
1609        }
1610
1611        fn native_token_symbol(&self) -> &str {
1612            "TEST"
1613        }
1614
1615        async fn get_balance(&self, _address: &str) -> Result<Balance> {
1616            Ok(Balance {
1617                raw: "0".to_string(),
1618                formatted: "0".to_string(),
1619                decimals: 18,
1620                symbol: "TEST".to_string(),
1621                usd_value: None,
1622            })
1623        }
1624
1625        async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
1626            unimplemented!()
1627        }
1628
1629        async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
1630            Ok(Vec::new())
1631        }
1632
1633        async fn get_block_number(&self) -> Result<u64> {
1634            Ok(0)
1635        }
1636
1637        async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
1638            Ok(Vec::new())
1639        }
1640
1641        async fn enrich_balance_usd(&self, _balance: &mut Balance) {}
1642    }
1643
1644    #[tokio::test]
1645    async fn test_default_get_token_info() {
1646        let client = MinimalChainClient;
1647        let result = client.get_token_info("0xtest").await;
1648        assert!(result.is_err());
1649        assert!(result.unwrap_err().to_string().contains("not supported"));
1650    }
1651
1652    #[tokio::test]
1653    async fn test_default_get_token_holders() {
1654        let client = MinimalChainClient;
1655        let holders = client.get_token_holders("0xtest", 10).await.unwrap();
1656        assert!(holders.is_empty());
1657    }
1658
1659    #[tokio::test]
1660    async fn test_default_get_token_holder_count() {
1661        let client = MinimalChainClient;
1662        let count = client.get_token_holder_count("0xtest").await.unwrap();
1663        assert_eq!(count, 0);
1664    }
1665
1666    // ============================================================================
1667    // Chain Metadata Tests
1668    // ============================================================================
1669
1670    #[test]
1671    fn test_chain_metadata_ethereum() {
1672        let meta = chain_metadata("ethereum").unwrap();
1673        assert_eq!(meta.chain_id, "ethereum");
1674        assert_eq!(meta.native_symbol, "ETH");
1675        assert_eq!(meta.native_decimals, 18);
1676        assert_eq!(meta.explorer_token_base, "https://etherscan.io/token");
1677    }
1678
1679    #[test]
1680    fn test_chain_metadata_ethereum_alias() {
1681        let meta = chain_metadata("eth").unwrap();
1682        assert_eq!(meta.chain_id, "ethereum");
1683        assert_eq!(meta.native_symbol, "ETH");
1684    }
1685
1686    #[test]
1687    fn test_chain_metadata_polygon() {
1688        let meta = chain_metadata("polygon").unwrap();
1689        assert_eq!(meta.chain_id, "polygon");
1690        assert_eq!(meta.native_symbol, "MATIC");
1691        assert_eq!(meta.native_decimals, 18);
1692        assert_eq!(meta.explorer_token_base, "https://polygonscan.com/token");
1693    }
1694
1695    #[test]
1696    fn test_chain_metadata_bsc() {
1697        let meta = chain_metadata("bsc").unwrap();
1698        assert_eq!(meta.chain_id, "bsc");
1699        assert_eq!(meta.native_symbol, "BNB");
1700        assert_eq!(meta.native_decimals, 18);
1701        assert_eq!(meta.explorer_token_base, "https://bscscan.com/token");
1702    }
1703
1704    #[test]
1705    fn test_chain_metadata_solana() {
1706        let meta = chain_metadata("solana").unwrap();
1707        assert_eq!(meta.chain_id, "solana");
1708        assert_eq!(meta.native_symbol, "SOL");
1709        assert_eq!(meta.native_decimals, 9);
1710        assert_eq!(meta.explorer_token_base, "https://solscan.io/token");
1711    }
1712
1713    #[test]
1714    fn test_chain_metadata_solana_alias() {
1715        let meta = chain_metadata("sol").unwrap();
1716        assert_eq!(meta.chain_id, "solana");
1717        assert_eq!(meta.native_symbol, "SOL");
1718    }
1719
1720    #[test]
1721    fn test_chain_metadata_tron() {
1722        let meta = chain_metadata("tron").unwrap();
1723        assert_eq!(meta.chain_id, "tron");
1724        assert_eq!(meta.native_symbol, "TRX");
1725        assert_eq!(meta.native_decimals, 6);
1726        assert_eq!(meta.explorer_token_base, "https://tronscan.org/#/token20");
1727    }
1728
1729    #[test]
1730    fn test_chain_metadata_tron_alias() {
1731        let meta = chain_metadata("trx").unwrap();
1732        assert_eq!(meta.chain_id, "tron");
1733        assert_eq!(meta.native_symbol, "TRX");
1734    }
1735
1736    #[test]
1737    fn test_chain_metadata_arbitrum() {
1738        let meta = chain_metadata("arbitrum").unwrap();
1739        assert_eq!(meta.chain_id, "arbitrum");
1740        assert_eq!(meta.native_symbol, "ETH");
1741        assert_eq!(meta.native_decimals, 18);
1742        assert_eq!(meta.explorer_token_base, "https://arbiscan.io/token");
1743    }
1744
1745    #[test]
1746    fn test_chain_metadata_optimism() {
1747        let meta = chain_metadata("optimism").unwrap();
1748        assert_eq!(meta.chain_id, "optimism");
1749        assert_eq!(meta.native_symbol, "ETH");
1750        assert_eq!(meta.native_decimals, 18);
1751        assert_eq!(
1752            meta.explorer_token_base,
1753            "https://optimistic.etherscan.io/token"
1754        );
1755    }
1756
1757    #[test]
1758    fn test_chain_metadata_base() {
1759        let meta = chain_metadata("base").unwrap();
1760        assert_eq!(meta.chain_id, "base");
1761        assert_eq!(meta.native_symbol, "ETH");
1762        assert_eq!(meta.native_decimals, 18);
1763        assert_eq!(meta.explorer_token_base, "https://basescan.org/token");
1764    }
1765
1766    #[test]
1767    fn test_chain_metadata_case_insensitive() {
1768        let meta1 = chain_metadata("ETHEREUM").unwrap();
1769        let meta2 = chain_metadata("Ethereum").unwrap();
1770        let meta3 = chain_metadata("ethereum").unwrap();
1771        assert_eq!(meta1.chain_id, meta2.chain_id);
1772        assert_eq!(meta2.chain_id, meta3.chain_id);
1773    }
1774
1775    #[test]
1776    fn test_chain_metadata_unknown() {
1777        assert!(chain_metadata("bitcoin").is_none());
1778        assert!(chain_metadata("litecoin").is_none());
1779        assert!(chain_metadata("unknown").is_none());
1780        assert!(chain_metadata("").is_none());
1781    }
1782
1783    #[test]
1784    fn test_native_symbol_ethereum() {
1785        assert_eq!(native_symbol("ethereum"), "ETH");
1786        assert_eq!(native_symbol("eth"), "ETH");
1787    }
1788
1789    #[test]
1790    fn test_native_symbol_polygon() {
1791        assert_eq!(native_symbol("polygon"), "MATIC");
1792    }
1793
1794    #[test]
1795    fn test_native_symbol_bsc() {
1796        assert_eq!(native_symbol("bsc"), "BNB");
1797    }
1798
1799    #[test]
1800    fn test_native_symbol_solana() {
1801        assert_eq!(native_symbol("solana"), "SOL");
1802        assert_eq!(native_symbol("sol"), "SOL");
1803    }
1804
1805    #[test]
1806    fn test_native_symbol_tron() {
1807        assert_eq!(native_symbol("tron"), "TRX");
1808        assert_eq!(native_symbol("trx"), "TRX");
1809    }
1810
1811    #[test]
1812    fn test_native_symbol_arbitrum() {
1813        assert_eq!(native_symbol("arbitrum"), "ETH");
1814    }
1815
1816    #[test]
1817    fn test_native_symbol_optimism() {
1818        assert_eq!(native_symbol("optimism"), "ETH");
1819    }
1820
1821    #[test]
1822    fn test_native_symbol_base() {
1823        assert_eq!(native_symbol("base"), "ETH");
1824    }
1825
1826    #[test]
1827    fn test_native_symbol_unknown() {
1828        assert_eq!(native_symbol("unknown"), "???");
1829        assert_eq!(native_symbol("bitcoin"), "???");
1830        assert_eq!(native_symbol(""), "???");
1831    }
1832
1833    #[test]
1834    fn test_native_symbol_case_insensitive() {
1835        assert_eq!(native_symbol("ETHEREUM"), "ETH");
1836        assert_eq!(native_symbol("Ethereum"), "ETH");
1837        assert_eq!(native_symbol("ethereum"), "ETH");
1838    }
1839
1840    #[tokio::test]
1841    async fn test_chain_client_default_get_code() {
1842        let client = MinimalChainClient;
1843        let result = client.get_code("0x1234").await;
1844        assert!(result.is_err());
1845        let err_msg = result.unwrap_err().to_string();
1846        assert!(err_msg.contains("not supported"));
1847    }
1848
1849    // ============================================================================
1850    // analyze_gas_usage Tests
1851    // ============================================================================
1852
1853    fn tx(hash: &str, gas_used: Option<u64>, input: &str, status: Option<bool>) -> Transaction {
1854        Transaction {
1855            hash: hash.to_string(),
1856            block_number: Some(1),
1857            timestamp: Some(1700000000),
1858            from: "0xfrom".to_string(),
1859            to: Some("0xto".to_string()),
1860            value: "0".to_string(),
1861            gas_limit: 21000,
1862            gas_used,
1863            gas_price: "20000000000".to_string(),
1864            nonce: 0,
1865            input: input.to_string(),
1866            status,
1867        }
1868    }
1869
1870    #[test]
1871    fn test_analyze_gas_usage_empty_transactions() {
1872        let txs: Vec<Transaction> = vec![];
1873        let result = super::analyze_gas_usage(&txs);
1874        assert_eq!(result.avg_gas_used, 0);
1875        assert_eq!(result.max_gas_used, 0);
1876        assert_eq!(result.min_gas_used, 0);
1877        assert_eq!(result.tx_count, 0);
1878        assert_eq!(result.failed_tx_count, 0);
1879        assert_eq!(result.wasted_gas, 0);
1880        assert!(result.gas_by_function.is_empty());
1881    }
1882
1883    #[test]
1884    fn test_analyze_gas_usage_single_tx() {
1885        let txs = vec![tx("0x1", Some(100_000), "0x", Some(true))];
1886        let result = super::analyze_gas_usage(&txs);
1887        assert_eq!(result.avg_gas_used, 100_000);
1888        assert_eq!(result.max_gas_used, 100_000);
1889        assert_eq!(result.min_gas_used, 100_000);
1890        assert_eq!(result.tx_count, 1);
1891    }
1892
1893    #[test]
1894    fn test_analyze_gas_usage_multiple_txs() {
1895        let txs = vec![
1896            tx("0x1", Some(50_000), "0xa9059cbb", Some(true)),
1897            tx("0x2", Some(150_000), "0xa9059cbb", Some(true)),
1898            tx("0x3", Some(100_000), "0xa9059cbb", Some(true)),
1899        ];
1900        let result = super::analyze_gas_usage(&txs);
1901        assert_eq!(result.avg_gas_used, 100_000); // (50+150+100)/3
1902        assert_eq!(result.max_gas_used, 150_000);
1903        assert_eq!(result.min_gas_used, 50_000);
1904        assert_eq!(result.tx_count, 3);
1905    }
1906
1907    #[test]
1908    fn test_analyze_gas_usage_failed_tx() {
1909        let txs = vec![
1910            tx("0x1", Some(80_000), "0x", Some(true)),
1911            tx("0x2", Some(120_000), "0x", Some(false)),
1912        ];
1913        let result = super::analyze_gas_usage(&txs);
1914        assert_eq!(result.failed_tx_count, 1);
1915        assert_eq!(result.wasted_gas, 120_000);
1916    }
1917
1918    #[test]
1919    fn test_analyze_gas_usage_gas_by_function() {
1920        // Selector is first 10 chars (0x + 8 hex) of input
1921        let txs = vec![
1922            tx("0x1", Some(100_000), "0xa9059cbb0000", Some(true)),
1923            tx("0x2", Some(200_000), "0xa9059cbb0000", Some(true)),
1924            tx("0x3", Some(50_000), "0x095ea7b30000", Some(true)),
1925        ];
1926        let result = super::analyze_gas_usage(&txs);
1927        assert_eq!(result.gas_by_function.len(), 2);
1928        let by_sel: std::collections::HashMap<_, _> = result
1929            .gas_by_function
1930            .iter()
1931            .map(|g| (g.function.as_str(), g))
1932            .collect();
1933        let transfer = by_sel.get("0xa9059cbb").unwrap();
1934        assert_eq!(transfer.call_count, 2);
1935        assert_eq!(transfer.total_gas, 300_000);
1936        assert_eq!(transfer.avg_gas, 150_000);
1937        let approve = by_sel.get("0x095ea7b3").unwrap();
1938        assert_eq!(approve.call_count, 1);
1939        assert_eq!(approve.total_gas, 50_000);
1940    }
1941
1942    #[test]
1943    fn test_analyze_gas_usage_input_0x_transfer() {
1944        let txs = vec![tx("0x1", Some(21_000), "0x", Some(true))];
1945        let result = super::analyze_gas_usage(&txs);
1946        assert_eq!(result.gas_by_function.len(), 1);
1947        assert_eq!(result.gas_by_function[0].function, "transfer()");
1948    }
1949
1950    #[test]
1951    fn test_analyze_gas_usage_input_empty_transfer() {
1952        let txs = vec![tx("0x1", Some(21_000), "", Some(true))];
1953        let result = super::analyze_gas_usage(&txs);
1954        assert_eq!(result.gas_by_function.len(), 1);
1955        assert_eq!(result.gas_by_function[0].function, "transfer()");
1956    }
1957
1958    #[test]
1959    fn test_analyze_gas_usage_gas_used_none() {
1960        let txs = vec![tx("0x1", None, "0x", Some(true))];
1961        let result = super::analyze_gas_usage(&txs);
1962        assert_eq!(result.avg_gas_used, 0);
1963        assert_eq!(result.max_gas_used, 0);
1964        assert_eq!(result.min_gas_used, 0);
1965    }
1966
1967    #[test]
1968    fn test_analyze_gas_usage_short_input_uses_full_input_as_selector() {
1969        let txs = vec![tx("0x1", Some(50_000), "0x1234567", Some(true))];
1970        let result = super::analyze_gas_usage(&txs);
1971        assert_eq!(result.gas_by_function.len(), 1);
1972        assert_eq!(result.gas_by_function[0].function, "0x1234567");
1973    }
1974}
1975
1976// ============================================================================
1977// Mock Test Utilities
1978// ============================================================================
1979
1980/// Test helper module providing mock implementations of chain client traits.
1981///
1982/// These mocks are available across all test modules in the crate for
1983/// end-to-end testing of CLI `run()` functions without network calls.
1984#[cfg(any(test, feature = "test-support"))]
1985pub mod mocks {
1986    use super::*;
1987    use crate::chains::dex::{DexDataSource, DexTokenData, TokenSearchResult};
1988    use async_trait::async_trait;
1989
1990    /// Mock chain client with configurable responses.
1991    #[derive(Debug, Clone)]
1992    pub struct MockChainClient {
1993        pub chain: String,
1994        pub symbol: String,
1995        pub balance: Balance,
1996        pub transaction: Transaction,
1997        pub transactions: Vec<Transaction>,
1998        pub token_balances: Vec<TokenBalance>,
1999        pub block_number: u64,
2000        pub token_info: Option<Token>,
2001        pub token_holders: Vec<TokenHolder>,
2002        pub token_holder_count: u64,
2003    }
2004
2005    impl MockChainClient {
2006        /// Creates a mock client with sensible default test data.
2007        pub fn new(chain: &str, symbol: &str) -> Self {
2008            Self {
2009                chain: chain.to_string(),
2010                symbol: symbol.to_string(),
2011                balance: Balance {
2012                    raw: "1000000000000000000".to_string(),
2013                    formatted: "1.0".to_string(),
2014                    decimals: 18,
2015                    symbol: symbol.to_string(),
2016                    usd_value: Some(2500.0),
2017                },
2018                transaction: Transaction {
2019                    hash: "0xmocktx".to_string(),
2020                    block_number: Some(12345678),
2021                    timestamp: Some(1700000000),
2022                    from: "0xfrom".to_string(),
2023                    to: Some("0xto".to_string()),
2024                    value: "1.0".to_string(),
2025                    gas_limit: 21000,
2026                    gas_used: Some(21000),
2027                    gas_price: "20000000000".to_string(),
2028                    nonce: 42,
2029                    input: "0x".to_string(),
2030                    status: Some(true),
2031                },
2032                transactions: vec![],
2033                token_balances: vec![],
2034                block_number: 12345678,
2035                token_info: None,
2036                token_holders: vec![],
2037                token_holder_count: 0,
2038            }
2039        }
2040    }
2041
2042    #[async_trait]
2043    impl ChainClient for MockChainClient {
2044        fn chain_name(&self) -> &str {
2045            &self.chain
2046        }
2047
2048        fn native_token_symbol(&self) -> &str {
2049            &self.symbol
2050        }
2051
2052        async fn get_balance(&self, _address: &str) -> Result<Balance> {
2053            Ok(self.balance.clone())
2054        }
2055
2056        async fn enrich_balance_usd(&self, _balance: &mut Balance) {
2057            // Mock: no-op, balance already has usd_value set
2058        }
2059
2060        async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
2061            Ok(self.transaction.clone())
2062        }
2063
2064        async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
2065            Ok(self.transactions.clone())
2066        }
2067
2068        async fn get_block_number(&self) -> Result<u64> {
2069            Ok(self.block_number)
2070        }
2071
2072        async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
2073            Ok(self.token_balances.clone())
2074        }
2075
2076        async fn get_token_info(&self, _address: &str) -> Result<Token> {
2077            match &self.token_info {
2078                Some(t) => Ok(t.clone()),
2079                None => Err(crate::error::ScopeError::Chain(
2080                    "Token info not available".to_string(),
2081                )),
2082            }
2083        }
2084
2085        async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
2086            Ok(self.token_holders.clone())
2087        }
2088
2089        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
2090            Ok(self.token_holder_count)
2091        }
2092    }
2093
2094    /// Mock DEX data source with configurable responses.
2095    #[derive(Debug, Clone)]
2096    pub struct MockDexSource {
2097        pub token_price: Option<f64>,
2098        pub native_price: Option<f64>,
2099        pub token_data: Option<DexTokenData>,
2100        pub search_results: Vec<TokenSearchResult>,
2101    }
2102
2103    impl Default for MockDexSource {
2104        fn default() -> Self {
2105            Self::new()
2106        }
2107    }
2108
2109    impl MockDexSource {
2110        /// Creates a mock DEX source with default test data.
2111        pub fn new() -> Self {
2112            Self {
2113                token_price: Some(1.0),
2114                native_price: Some(2500.0),
2115                token_data: Some(DexTokenData {
2116                    address: "0xmocktoken".to_string(),
2117                    symbol: "MOCK".to_string(),
2118                    name: "Mock Token".to_string(),
2119                    price_usd: 1.0,
2120                    price_change_24h: 5.0,
2121                    price_change_6h: 2.0,
2122                    price_change_1h: 0.5,
2123                    price_change_5m: 0.1,
2124                    volume_24h: 1_000_000.0,
2125                    volume_6h: 250_000.0,
2126                    volume_1h: 50_000.0,
2127                    liquidity_usd: 5_000_000.0,
2128                    market_cap: Some(100_000_000.0),
2129                    fdv: Some(200_000_000.0),
2130                    pairs: vec![],
2131                    price_history: vec![],
2132                    volume_history: vec![],
2133                    total_buys_24h: 500,
2134                    total_sells_24h: 450,
2135                    total_buys_6h: 120,
2136                    total_sells_6h: 110,
2137                    total_buys_1h: 20,
2138                    total_sells_1h: 18,
2139                    earliest_pair_created_at: Some(1690000000),
2140                    image_url: None,
2141                    websites: vec![],
2142                    socials: vec![],
2143                    dexscreener_url: None,
2144                }),
2145                search_results: vec![],
2146            }
2147        }
2148    }
2149
2150    #[async_trait]
2151    impl DexDataSource for MockDexSource {
2152        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
2153            self.token_price
2154        }
2155
2156        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
2157            self.native_price
2158        }
2159
2160        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
2161            match &self.token_data {
2162                Some(data) => Ok(data.clone()),
2163                None => Err(crate::error::ScopeError::NotFound(
2164                    "No DEX data found".to_string(),
2165                )),
2166            }
2167        }
2168
2169        async fn search_tokens(
2170            &self,
2171            _query: &str,
2172            _chain: Option<&str>,
2173        ) -> Result<Vec<TokenSearchResult>> {
2174            Ok(self.search_results.clone())
2175        }
2176    }
2177
2178    /// Mock client factory that returns pre-configured mock clients.
2179    pub struct MockClientFactory {
2180        pub mock_client: MockChainClient,
2181        pub mock_dex: MockDexSource,
2182    }
2183
2184    impl Default for MockClientFactory {
2185        fn default() -> Self {
2186            Self::new()
2187        }
2188    }
2189
2190    impl MockClientFactory {
2191        /// Creates a factory with default mock data for Ethereum.
2192        pub fn new() -> Self {
2193            Self {
2194                mock_client: MockChainClient::new("ethereum", "ETH"),
2195                mock_dex: MockDexSource::new(),
2196            }
2197        }
2198    }
2199
2200    impl ChainClientFactory for MockClientFactory {
2201        fn create_chain_client(&self, _chain: &str) -> Result<Box<dyn ChainClient>> {
2202            Ok(Box::new(self.mock_client.clone()))
2203        }
2204
2205        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
2206            Box::new(self.mock_dex.clone())
2207        }
2208    }
2209}