Skip to main content

scope/chains/
mod.rs

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