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