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