Skip to main content

scope/chains/
ethereum.rs

1//! # Ethereum Client
2//!
3//! This module provides an Ethereum blockchain client that supports
4//! Ethereum mainnet and EVM-compatible chains (Polygon, Arbitrum, etc.).
5// Allow nested ifs for readability in API response parsing
6#![allow(clippy::collapsible_if)]
7//!
8//! ## Features
9//!
10//! - Balance queries via block explorer APIs (with USD valuation via DexScreener)
11//! - Transaction details lookup via Etherscan proxy API (`eth_getTransactionByHash`)
12//! - Transaction receipt fetching for gas usage and status
13//! - Transaction history retrieval
14//! - ERC-20 token balance fetching (via `tokentx` + `tokenbalance` endpoints)
15//! - Token holder count estimation with pagination
16//! - Token information and holder analytics
17//! - Support for both block explorer API and JSON-RPC modes
18//!
19//! ## Usage
20//!
21//! ```rust,no_run
22//! use scope::chains::EthereumClient;
23//! use scope::config::ChainsConfig;
24//!
25//! #[tokio::main]
26//! async fn main() -> scope::Result<()> {
27//!     let config = ChainsConfig::default();
28//!     let client = EthereumClient::new(&config)?;
29//!     
30//!     let mut balance = client.get_balance("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").await?;
31//!     client.enrich_balance_usd(&mut balance).await;
32//!     println!("Balance: {} (${:.2})", balance.formatted, balance.usd_value.unwrap_or(0.0));
33//!     Ok(())
34//! }
35//! ```
36
37use crate::chains::{Balance, ChainClient, Token, TokenBalance, TokenHolder, Transaction};
38use crate::config::ChainsConfig;
39use crate::error::{Result, ScopeError};
40use async_trait::async_trait;
41use reqwest::Client;
42use serde::Deserialize;
43
44/// Default Etherscan V2 API base URL.
45///
46/// All EVM chains (Ethereum, Polygon, Arbitrum, etc.) use this single
47/// endpoint with a `chainid` query parameter to select the network.
48const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
49
50/// Default JSON-RPC fallback for custom EVM chain.
51const DEFAULT_AEGIS_RPC: &str = "http://localhost:8545";
52
53/// API type for the client endpoint.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ApiType {
56    /// Block explorer API (Etherscan-compatible).
57    BlockExplorer,
58    /// Direct JSON-RPC endpoint.
59    JsonRpc,
60}
61
62/// Ethereum and EVM-compatible chain client.
63///
64/// Uses block explorer APIs (Etherscan, etc.) or JSON-RPC for data retrieval.
65/// Supports multiple networks through configuration.
66#[derive(Debug, Clone)]
67pub struct EthereumClient {
68    /// HTTP client for API requests.
69    client: Client,
70
71    /// Base URL for the block explorer API or JSON-RPC endpoint.
72    base_url: String,
73
74    /// Chain ID for Etherscan V2 API.
75    chain_id: Option<String>,
76
77    /// API key for the block explorer.
78    api_key: Option<String>,
79
80    /// Chain name for display purposes.
81    chain_name: String,
82
83    /// Native token symbol.
84    native_symbol: String,
85
86    /// Native token decimals.
87    native_decimals: u8,
88
89    /// Type of API endpoint (block explorer or JSON-RPC).
90    api_type: ApiType,
91}
92
93/// Response from Etherscan-compatible APIs.
94#[derive(Debug, Deserialize)]
95struct ApiResponse<T> {
96    status: String,
97    message: String,
98    result: T,
99}
100
101/// Balance response from API.
102#[derive(Debug, Deserialize)]
103#[serde(untagged)]
104#[allow(dead_code)] // Reserved for future error handling
105enum BalanceResult {
106    /// Successful balance string.
107    Balance(String),
108    /// Error message.
109    Error(String),
110}
111
112/// Transaction list response from API.
113#[derive(Debug, Deserialize)]
114struct TxListItem {
115    hash: String,
116    #[serde(rename = "blockNumber")]
117    block_number: String,
118    #[serde(rename = "timeStamp")]
119    timestamp: String,
120    from: String,
121    to: String,
122    value: String,
123    gas: String,
124    #[serde(rename = "gasUsed")]
125    gas_used: String,
126    #[serde(rename = "gasPrice")]
127    gas_price: String,
128    nonce: String,
129    input: String,
130    #[serde(rename = "isError")]
131    is_error: String,
132}
133
134/// Proxy API response wrapper (JSON-RPC style from Etherscan proxy endpoints).
135#[derive(Debug, Deserialize)]
136struct ProxyResponse<T> {
137    result: Option<T>,
138}
139
140/// Transaction object from eth_getTransactionByHash proxy endpoint.
141#[derive(Debug, Deserialize)]
142#[serde(rename_all = "camelCase")]
143struct ProxyTransaction {
144    #[serde(default)]
145    block_number: Option<String>,
146    #[serde(default)]
147    from: Option<String>,
148    #[serde(default)]
149    to: Option<String>,
150    #[serde(default)]
151    gas: Option<String>,
152    #[serde(default)]
153    gas_price: Option<String>,
154    #[serde(default)]
155    value: Option<String>,
156    #[serde(default)]
157    nonce: Option<String>,
158    #[serde(default)]
159    input: Option<String>,
160}
161
162/// Transaction receipt from eth_getTransactionReceipt proxy endpoint.
163#[derive(Debug, Deserialize)]
164#[serde(rename_all = "camelCase")]
165struct ProxyTransactionReceipt {
166    #[serde(default)]
167    gas_used: Option<String>,
168    #[serde(default)]
169    status: Option<String>,
170}
171
172/// Token holder list item from API.
173#[derive(Debug, Deserialize)]
174struct TokenHolderItem {
175    #[serde(rename = "TokenHolderAddress")]
176    address: String,
177    #[serde(rename = "TokenHolderQuantity")]
178    quantity: String,
179}
180
181/// Token info response from API.
182#[derive(Debug, Deserialize)]
183#[allow(dead_code)]
184struct TokenInfoItem {
185    #[serde(rename = "contractAddress")]
186    contract_address: Option<String>,
187    #[serde(rename = "tokenName")]
188    token_name: Option<String>,
189    #[serde(rename = "symbol")]
190    symbol: Option<String>,
191    #[serde(rename = "divisor")]
192    divisor: Option<String>,
193    #[serde(rename = "tokenType")]
194    token_type: Option<String>,
195    #[serde(rename = "totalSupply")]
196    total_supply: Option<String>,
197}
198
199impl EthereumClient {
200    /// Creates a new Ethereum client with the given configuration.
201    ///
202    /// # Arguments
203    ///
204    /// * `config` - Chain configuration containing API keys and endpoints
205    ///
206    /// # Returns
207    ///
208    /// Returns a configured [`EthereumClient`] instance.
209    ///
210    /// # Examples
211    ///
212    /// ```rust,no_run
213    /// use scope::chains::EthereumClient;
214    /// use scope::config::ChainsConfig;
215    ///
216    /// let config = ChainsConfig::default();
217    /// let client = EthereumClient::new(&config).unwrap();
218    /// ```
219    pub fn new(config: &ChainsConfig) -> Result<Self> {
220        let client = Client::builder()
221            .timeout(std::time::Duration::from_secs(30))
222            .build()
223            .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
224
225        Ok(Self {
226            client,
227            base_url: ETHERSCAN_V2_API.to_string(),
228            chain_id: Some("1".to_string()),
229            api_key: config.api_keys.get("etherscan").cloned(),
230            chain_name: "ethereum".to_string(),
231            native_symbol: "ETH".to_string(),
232            native_decimals: 18,
233            api_type: ApiType::BlockExplorer,
234        })
235    }
236
237    /// Creates a client with a custom base URL (for testing or alternative networks).
238    ///
239    /// # Arguments
240    ///
241    /// * `base_url` - The base URL for the block explorer API
242    pub fn with_base_url(base_url: &str) -> Self {
243        Self {
244            client: Client::new(),
245            base_url: base_url.to_string(),
246            chain_id: None,
247            api_key: None,
248            chain_name: "ethereum".to_string(),
249            native_symbol: "ETH".to_string(),
250            native_decimals: 18,
251            api_type: ApiType::BlockExplorer,
252        }
253    }
254
255    /// Creates a client for a specific EVM chain.
256    ///
257    /// # Arguments
258    ///
259    /// * `chain` - Chain identifier (ethereum, polygon, arbitrum, optimism, base, bsc)
260    /// * `config` - Chain configuration
261    ///
262    /// # Supported Chains
263    ///
264    /// - `ethereum` - Ethereum mainnet via Etherscan
265    /// - `polygon` - Polygon via PolygonScan
266    /// - `arbitrum` - Arbitrum One via Arbiscan
267    /// - `optimism` - Optimism via Etherscan
268    /// - `base` - Base via Basescan
269    /// - `bsc` - BNB Smart Chain (BSC) via BscScan
270    ///
271    /// # API Version
272    ///
273    /// Uses Etherscan V2 API format which requires an API key for most endpoints.
274    /// Get a free API key at <https://etherscan.io/apis>
275    pub fn for_chain(chain: &str, config: &ChainsConfig) -> Result<Self> {
276        // Etherscan V2 API uses chainid parameter
277        // V2 format: https://api.etherscan.io/v2/api?chainid=X&module=...
278        let (base_url, chain_id, api_key_name, symbol) = match chain {
279            "ethereum" => (ETHERSCAN_V2_API, "1", "etherscan", "ETH"),
280            "polygon" => (ETHERSCAN_V2_API, "137", "polygonscan", "MATIC"),
281            "arbitrum" => (ETHERSCAN_V2_API, "42161", "arbiscan", "ETH"),
282            "optimism" => (ETHERSCAN_V2_API, "10", "optimism", "ETH"),
283            "base" => (ETHERSCAN_V2_API, "8453", "basescan", "ETH"),
284            "bsc" => (ETHERSCAN_V2_API, "56", "bscscan", "BNB"),
285            "aegis" => {
286                // Aegis/Wraith uses direct JSON-RPC, not block explorer API.
287                // Fall back to localhost if not configured.
288                let rpc_url = config.aegis_rpc.as_deref().unwrap_or(DEFAULT_AEGIS_RPC);
289                return Self::for_aegis(rpc_url, config);
290            }
291            _ => {
292                return Err(ScopeError::Chain(format!("Unsupported chain: {}", chain)));
293            }
294        };
295
296        let client = Client::builder()
297            .timeout(std::time::Duration::from_secs(30))
298            .build()
299            .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
300
301        Ok(Self {
302            client,
303            base_url: base_url.to_string(),
304            chain_id: Some(chain_id.to_string()),
305            api_key: config.api_keys.get(api_key_name).cloned(),
306            chain_name: chain.to_string(),
307            native_symbol: symbol.to_string(),
308            native_decimals: 18,
309            api_type: ApiType::BlockExplorer,
310        })
311    }
312
313    /// Creates a client for a custom EVM chain using JSON-RPC.
314    ///
315    /// # Arguments
316    ///
317    /// * `rpc_url` - The JSON-RPC endpoint URL
318    /// * `_config` - Chain configuration (reserved for future use)
319    fn for_aegis(rpc_url: &str, _config: &ChainsConfig) -> Result<Self> {
320        let client = Client::builder()
321            .timeout(std::time::Duration::from_secs(30))
322            .build()
323            .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
324
325        Ok(Self {
326            client,
327            base_url: rpc_url.to_string(),
328            chain_id: None,
329            api_key: None,
330            chain_name: "aegis".to_string(),
331            native_symbol: "WRAITH".to_string(),
332            native_decimals: 18,
333            api_type: ApiType::JsonRpc,
334        })
335    }
336
337    /// Returns the chain name.
338    pub fn chain_name(&self) -> &str {
339        &self.chain_name
340    }
341
342    /// Returns the native token symbol.
343    pub fn native_token_symbol(&self) -> &str {
344        &self.native_symbol
345    }
346
347    /// Builds an API URL with the chainid parameter for V2 API.
348    fn build_api_url(&self, params: &str) -> String {
349        let mut url = format!("{}?", self.base_url);
350
351        // Add chainid for V2 API
352        if let Some(ref chain_id) = self.chain_id {
353            url.push_str(&format!("chainid={}&", chain_id));
354        }
355
356        url.push_str(params);
357
358        // Add API key if available
359        if let Some(ref key) = self.api_key {
360            url.push_str(&format!("&apikey={}", key));
361        }
362
363        url
364    }
365
366    /// Fetches the native token balance for an address.
367    ///
368    /// # Arguments
369    ///
370    /// * `address` - The Ethereum address to query
371    ///
372    /// # Returns
373    ///
374    /// Returns a [`Balance`] struct with the balance in multiple formats.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
379    /// Returns [`ScopeError::Request`] if the API request fails.
380    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
381        // Validate address
382        validate_eth_address(address)?;
383
384        match self.api_type {
385            ApiType::BlockExplorer => self.get_balance_explorer(address).await,
386            ApiType::JsonRpc => self.get_balance_rpc(address).await,
387        }
388    }
389
390    /// Fetches balance using block explorer API (Etherscan-compatible).
391    async fn get_balance_explorer(&self, address: &str) -> Result<Balance> {
392        let url = self.build_api_url(&format!(
393            "module=account&action=balance&address={}&tag=latest",
394            address
395        ));
396
397        tracing::debug!(url = %url, "Fetching balance via block explorer");
398
399        let response: ApiResponse<String> = self.client.get(&url).send().await?.json().await?;
400
401        if response.status != "1" {
402            return Err(ScopeError::Chain(format!(
403                "API error: {}",
404                response.message
405            )));
406        }
407
408        self.parse_balance_wei(&response.result)
409    }
410
411    /// Fetches balance using JSON-RPC (eth_getBalance).
412    async fn get_balance_rpc(&self, address: &str) -> Result<Balance> {
413        #[derive(serde::Serialize)]
414        struct RpcRequest<'a> {
415            jsonrpc: &'a str,
416            method: &'a str,
417            params: Vec<&'a str>,
418            id: u64,
419        }
420
421        #[derive(Deserialize)]
422        struct RpcResponse {
423            result: Option<String>,
424            error: Option<RpcError>,
425        }
426
427        #[derive(Deserialize)]
428        struct RpcError {
429            message: String,
430        }
431
432        let request = RpcRequest {
433            jsonrpc: "2.0",
434            method: "eth_getBalance",
435            params: vec![address, "latest"],
436            id: 1,
437        };
438
439        tracing::debug!(url = %self.base_url, address = %address, "Fetching balance via JSON-RPC");
440
441        let response: RpcResponse = self
442            .client
443            .post(&self.base_url)
444            .json(&request)
445            .send()
446            .await?
447            .json()
448            .await?;
449
450        if let Some(error) = response.error {
451            return Err(ScopeError::Chain(format!("RPC error: {}", error.message)));
452        }
453
454        let result = response
455            .result
456            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
457
458        // Parse hex balance (e.g., "0x1234")
459        let hex_balance = result.trim_start_matches("0x");
460        let wei = u128::from_str_radix(hex_balance, 16)
461            .map_err(|_| ScopeError::Chain("Invalid balance hex response".to_string()))?;
462
463        self.parse_balance_wei(&wei.to_string())
464    }
465
466    /// Parses a wei balance string into a Balance struct.
467    fn parse_balance_wei(&self, wei_str: &str) -> Result<Balance> {
468        let wei: u128 = wei_str
469            .parse()
470            .map_err(|_| ScopeError::Chain("Invalid balance response".to_string()))?;
471
472        let eth = wei as f64 / 10_f64.powi(self.native_decimals as i32);
473
474        Ok(Balance {
475            raw: wei_str.to_string(),
476            formatted: format!("{:.6} {}", eth, self.native_symbol),
477            decimals: self.native_decimals,
478            symbol: self.native_symbol.clone(),
479            usd_value: None, // Populated by caller via enrich_balance_usd
480        })
481    }
482
483    /// Enriches a balance with a USD value using DexScreener price lookup.
484    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
485        let dex = crate::chains::DexClient::new();
486        if let Some(price) = dex.get_native_token_price(&self.chain_name).await {
487            let amount: f64 =
488                balance.raw.parse().unwrap_or(0.0) / 10_f64.powi(self.native_decimals as i32);
489            balance.usd_value = Some(amount * price);
490        }
491    }
492
493    /// Fetches transaction details by hash.
494    ///
495    /// # Arguments
496    ///
497    /// * `hash` - The transaction hash
498    ///
499    /// # Returns
500    ///
501    /// Returns [`Transaction`] details.
502    pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
503        // Validate hash
504        validate_tx_hash(hash)?;
505
506        match self.api_type {
507            ApiType::BlockExplorer => self.get_transaction_explorer(hash).await,
508            ApiType::JsonRpc => self.get_transaction_rpc(hash).await,
509        }
510    }
511
512    /// Fetches transaction via Etherscan proxy API (eth_getTransactionByHash).
513    async fn get_transaction_explorer(&self, hash: &str) -> Result<Transaction> {
514        // Fetch the transaction object
515        let tx_url = self.build_api_url(&format!(
516            "module=proxy&action=eth_getTransactionByHash&txhash={}",
517            hash
518        ));
519
520        tracing::debug!(url = %tx_url, "Fetching transaction via block explorer proxy");
521
522        let tx_response: ProxyResponse<ProxyTransaction> =
523            self.client.get(&tx_url).send().await?.json().await?;
524
525        let proxy_tx = tx_response
526            .result
527            .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
528
529        // Fetch the receipt for gas_used and status
530        let receipt_url = self.build_api_url(&format!(
531            "module=proxy&action=eth_getTransactionReceipt&txhash={}",
532            hash
533        ));
534
535        tracing::debug!(url = %receipt_url, "Fetching transaction receipt");
536
537        let receipt_response: ProxyResponse<ProxyTransactionReceipt> =
538            self.client.get(&receipt_url).send().await?.json().await?;
539
540        let receipt = receipt_response.result;
541
542        // Parse block number from hex
543        let block_number = proxy_tx
544            .block_number
545            .as_deref()
546            .and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
547
548        // Parse gas limit from hex
549        let gas_limit = proxy_tx
550            .gas
551            .as_deref()
552            .and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
553            .unwrap_or(0);
554
555        // Parse gas price from hex to decimal string
556        let gas_price = proxy_tx
557            .gas_price
558            .as_deref()
559            .and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
560            .map(|gp| gp.to_string())
561            .unwrap_or_else(|| "0".to_string());
562
563        // Parse nonce from hex
564        let nonce = proxy_tx
565            .nonce
566            .as_deref()
567            .and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
568            .unwrap_or(0);
569
570        // Parse value from hex wei to decimal string
571        let value = proxy_tx
572            .value
573            .as_deref()
574            .and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
575            .map(|v| v.to_string())
576            .unwrap_or_else(|| "0".to_string());
577
578        // Parse receipt fields
579        let gas_used = receipt.as_ref().and_then(|r| {
580            r.gas_used
581                .as_deref()
582                .and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
583        });
584
585        let status = receipt
586            .as_ref()
587            .and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
588
589        // Get block timestamp if we have a block number
590        let timestamp = if let Some(bn) = block_number {
591            self.get_block_timestamp(bn).await.ok()
592        } else {
593            None
594        };
595
596        Ok(Transaction {
597            hash: hash.to_string(),
598            block_number,
599            timestamp,
600            from: proxy_tx.from.unwrap_or_default(),
601            to: proxy_tx.to,
602            value,
603            gas_limit,
604            gas_used,
605            gas_price,
606            nonce,
607            input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
608            status,
609        })
610    }
611
612    /// Fetches transaction via JSON-RPC (eth_getTransactionByHash).
613    async fn get_transaction_rpc(&self, hash: &str) -> Result<Transaction> {
614        #[derive(serde::Serialize)]
615        struct RpcRequest<'a> {
616            jsonrpc: &'a str,
617            method: &'a str,
618            params: Vec<&'a str>,
619            id: u64,
620        }
621
622        let request = RpcRequest {
623            jsonrpc: "2.0",
624            method: "eth_getTransactionByHash",
625            params: vec![hash],
626            id: 1,
627        };
628
629        let response: ProxyResponse<ProxyTransaction> = self
630            .client
631            .post(&self.base_url)
632            .json(&request)
633            .send()
634            .await?
635            .json()
636            .await?;
637
638        let proxy_tx = response
639            .result
640            .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
641
642        // Also fetch receipt
643        let receipt_request = RpcRequest {
644            jsonrpc: "2.0",
645            method: "eth_getTransactionReceipt",
646            params: vec![hash],
647            id: 2,
648        };
649
650        let receipt_response: ProxyResponse<ProxyTransactionReceipt> = self
651            .client
652            .post(&self.base_url)
653            .json(&receipt_request)
654            .send()
655            .await?
656            .json()
657            .await?;
658
659        let receipt = receipt_response.result;
660
661        let block_number = proxy_tx
662            .block_number
663            .as_deref()
664            .and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
665
666        let gas_limit = proxy_tx
667            .gas
668            .as_deref()
669            .and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
670            .unwrap_or(0);
671
672        let gas_price = proxy_tx
673            .gas_price
674            .as_deref()
675            .and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
676            .map(|gp| gp.to_string())
677            .unwrap_or_else(|| "0".to_string());
678
679        let nonce = proxy_tx
680            .nonce
681            .as_deref()
682            .and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
683            .unwrap_or(0);
684
685        let value = proxy_tx
686            .value
687            .as_deref()
688            .and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
689            .map(|v| v.to_string())
690            .unwrap_or_else(|| "0".to_string());
691
692        let gas_used = receipt.as_ref().and_then(|r| {
693            r.gas_used
694                .as_deref()
695                .and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
696        });
697
698        let status = receipt
699            .as_ref()
700            .and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
701
702        Ok(Transaction {
703            hash: hash.to_string(),
704            block_number,
705            timestamp: None, // JSON-RPC doesn't easily give us timestamp without another call
706            from: proxy_tx.from.unwrap_or_default(),
707            to: proxy_tx.to,
708            value,
709            gas_limit,
710            gas_used,
711            gas_price,
712            nonce,
713            input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
714            status,
715        })
716    }
717
718    /// Fetches block timestamp for a given block number.
719    async fn get_block_timestamp(&self, block_number: u64) -> Result<u64> {
720        let hex_block = format!("0x{:x}", block_number);
721        let url = self.build_api_url(&format!(
722            "module=proxy&action=eth_getBlockByNumber&tag={}&boolean=false",
723            hex_block
724        ));
725
726        #[derive(Deserialize)]
727        struct BlockResult {
728            timestamp: Option<String>,
729        }
730
731        let response: ProxyResponse<BlockResult> =
732            self.client.get(&url).send().await?.json().await?;
733
734        let block = response
735            .result
736            .ok_or_else(|| ScopeError::Chain(format!("Block not found: {}", block_number)))?;
737
738        block
739            .timestamp
740            .as_deref()
741            .and_then(|ts| u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok())
742            .ok_or_else(|| ScopeError::Chain("Invalid block timestamp".to_string()))
743    }
744
745    /// Fetches recent transactions for an address.
746    ///
747    /// # Arguments
748    ///
749    /// * `address` - The address to query
750    /// * `limit` - Maximum number of transactions
751    ///
752    /// # Returns
753    ///
754    /// Returns a vector of [`Transaction`] objects.
755    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
756        validate_eth_address(address)?;
757
758        let url = self.build_api_url(&format!(
759            "module=account&action=txlist&address={}&startblock=0&endblock=99999999&page=1&offset={}&sort=desc",
760            address, limit
761        ));
762
763        tracing::debug!(url = %url, "Fetching transactions");
764
765        let response: ApiResponse<Vec<TxListItem>> =
766            self.client.get(&url).send().await?.json().await?;
767
768        if response.status != "1" && response.message != "No transactions found" {
769            return Err(ScopeError::Chain(format!(
770                "API error: {}",
771                response.message
772            )));
773        }
774
775        let transactions = response
776            .result
777            .into_iter()
778            .map(|tx| Transaction {
779                hash: tx.hash,
780                block_number: tx.block_number.parse().ok(),
781                timestamp: tx.timestamp.parse().ok(),
782                from: tx.from,
783                to: if tx.to.is_empty() { None } else { Some(tx.to) },
784                value: tx.value,
785                gas_limit: tx.gas.parse().unwrap_or(0),
786                gas_used: tx.gas_used.parse().ok(),
787                gas_price: tx.gas_price,
788                nonce: tx.nonce.parse().unwrap_or(0),
789                input: tx.input,
790                status: Some(tx.is_error == "0"),
791            })
792            .collect();
793
794        Ok(transactions)
795    }
796
797    /// Fetches the current block number.
798    pub async fn get_block_number(&self) -> Result<u64> {
799        let url = self.build_api_url("module=proxy&action=eth_blockNumber");
800
801        #[derive(Deserialize)]
802        struct BlockResponse {
803            result: String,
804        }
805
806        let response: BlockResponse = self.client.get(&url).send().await?.json().await?;
807
808        // Parse hex block number
809        let block_hex = response.result.trim_start_matches("0x");
810        let block_number = u64::from_str_radix(block_hex, 16)
811            .map_err(|_| ScopeError::Chain("Invalid block number response".to_string()))?;
812
813        Ok(block_number)
814    }
815
816    /// Fetches ERC-20 token balances for an address.
817    ///
818    /// Uses Etherscan's tokentx endpoint to find unique tokens the address
819    /// has interacted with, then fetches current balances for each.
820    pub async fn get_erc20_balances(
821        &self,
822        address: &str,
823    ) -> Result<Vec<crate::chains::TokenBalance>> {
824        validate_eth_address(address)?;
825
826        // Step 1: Get recent ERC-20 token transfers to find unique tokens
827        let url = self.build_api_url(&format!(
828            "module=account&action=tokentx&address={}&page=1&offset=100&sort=desc",
829            address
830        ));
831
832        tracing::debug!(url = %url, "Fetching ERC-20 token transfers");
833
834        let response = self.client.get(&url).send().await?.text().await?;
835
836        #[derive(Deserialize)]
837        struct TokenTxItem {
838            #[serde(rename = "contractAddress")]
839            contract_address: String,
840            #[serde(rename = "tokenSymbol")]
841            token_symbol: String,
842            #[serde(rename = "tokenName")]
843            token_name: String,
844            #[serde(rename = "tokenDecimal")]
845            token_decimal: String,
846        }
847
848        let parsed: std::result::Result<ApiResponse<Vec<TokenTxItem>>, _> =
849            serde_json::from_str(&response);
850
851        let token_txs = match parsed {
852            Ok(api_resp) if api_resp.status == "1" => api_resp.result,
853            _ => return Ok(vec![]),
854        };
855
856        // Step 2: Deduplicate by contract address
857        let mut seen = std::collections::HashSet::new();
858        let unique_tokens: Vec<&TokenTxItem> = token_txs
859            .iter()
860            .filter(|tx| seen.insert(tx.contract_address.to_lowercase()))
861            .collect();
862
863        // Step 3: Fetch current balance for each unique token
864        let mut balances = Vec::new();
865        for token_tx in unique_tokens.iter().take(20) {
866            // Cap at 20 to avoid rate limits
867            let balance_url = self.build_api_url(&format!(
868                "module=account&action=tokenbalance&contractaddress={}&address={}&tag=latest",
869                token_tx.contract_address, address
870            ));
871
872            if let Ok(resp) = self.client.get(&balance_url).send().await {
873                if let Ok(bal_resp) = resp.json::<ApiResponse<String>>().await {
874                    if bal_resp.status == "1" {
875                        let raw_balance = bal_resp.result;
876                        let decimals: u8 = token_tx.token_decimal.parse().unwrap_or(18);
877
878                        // Skip zero balances
879                        if raw_balance == "0" {
880                            continue;
881                        }
882
883                        let formatted = format_token_balance(&raw_balance, decimals);
884
885                        balances.push(crate::chains::TokenBalance {
886                            token: Token {
887                                contract_address: token_tx.contract_address.clone(),
888                                symbol: token_tx.token_symbol.clone(),
889                                name: token_tx.token_name.clone(),
890                                decimals,
891                            },
892                            balance: raw_balance,
893                            formatted_balance: formatted,
894                            usd_value: None,
895                        });
896                    }
897                }
898            }
899        }
900
901        Ok(balances)
902    }
903
904    /// Fetches token information for a contract address.
905    ///
906    /// # Arguments
907    ///
908    /// * `token_address` - The token contract address
909    ///
910    /// # Returns
911    ///
912    /// Returns [`Token`] information including name, symbol, and decimals.
913    pub async fn get_token_info(&self, token_address: &str) -> Result<Token> {
914        validate_eth_address(token_address)?;
915
916        // Try the Pro API tokeninfo endpoint first
917        let url = self.build_api_url(&format!(
918            "module=token&action=tokeninfo&contractaddress={}",
919            token_address
920        ));
921
922        tracing::debug!(url = %url, "Fetching token info (Pro API)");
923
924        let response = self.client.get(&url).send().await?;
925        let response_text = response.text().await?;
926
927        // Try to parse as successful response
928        if let Ok(api_response) =
929            serde_json::from_str::<ApiResponse<Vec<TokenInfoItem>>>(&response_text)
930        {
931            if api_response.status == "1" && !api_response.result.is_empty() {
932                let info = &api_response.result[0];
933                let decimals = info
934                    .divisor
935                    .as_ref()
936                    .and_then(|d| d.parse::<u32>().ok())
937                    .map(|d| (d as f64).log10() as u8)
938                    .unwrap_or(18);
939
940                return Ok(Token {
941                    contract_address: token_address.to_string(),
942                    symbol: info.symbol.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
943                    name: info
944                        .token_name
945                        .clone()
946                        .unwrap_or_else(|| "Unknown Token".to_string()),
947                    decimals,
948                });
949            }
950        }
951
952        // Fall back to tokensupply endpoint (free) to verify it's a valid ERC20
953        // and get the total supply
954        self.get_token_info_from_supply(token_address).await
955    }
956
957    /// Gets basic token info using the tokensupply endpoint.
958    async fn get_token_info_from_supply(&self, token_address: &str) -> Result<Token> {
959        let url = self.build_api_url(&format!(
960            "module=stats&action=tokensupply&contractaddress={}",
961            token_address
962        ));
963
964        tracing::debug!(url = %url, "Fetching token supply");
965
966        let response = self.client.get(&url).send().await?;
967        let response_text = response.text().await?;
968
969        // Check if the token supply call succeeded (indicates valid ERC20)
970        if let Ok(api_response) = serde_json::from_str::<ApiResponse<String>>(&response_text) {
971            if api_response.status == "1" {
972                // Valid ERC20 token, but we don't have name/symbol
973                // Try to get them from contract source if verified
974                if let Some(contract_info) = self.try_get_contract_name(token_address).await {
975                    return Ok(Token {
976                        contract_address: token_address.to_string(),
977                        symbol: contract_info.0,
978                        name: contract_info.1,
979                        decimals: 18,
980                    });
981                }
982
983                // Return with address-based placeholder
984                let short_addr = format!(
985                    "{}...{}",
986                    &token_address[..6],
987                    &token_address[token_address.len() - 4..]
988                );
989                return Ok(Token {
990                    contract_address: token_address.to_string(),
991                    symbol: short_addr.clone(),
992                    name: format!("Token {}", short_addr),
993                    decimals: 18,
994                });
995            }
996        }
997
998        // Not a valid ERC20 token
999        Ok(Token {
1000            contract_address: token_address.to_string(),
1001            symbol: "UNKNOWN".to_string(),
1002            name: "Unknown Token".to_string(),
1003            decimals: 18,
1004        })
1005    }
1006
1007    /// Tries to get contract name from verified source code.
1008    async fn try_get_contract_name(&self, token_address: &str) -> Option<(String, String)> {
1009        let url = self.build_api_url(&format!(
1010            "module=contract&action=getsourcecode&address={}",
1011            token_address
1012        ));
1013
1014        let response = self.client.get(&url).send().await.ok()?;
1015        let text = response.text().await.ok()?;
1016
1017        // Parse the response to get ContractName
1018        #[derive(serde::Deserialize)]
1019        struct SourceCodeResult {
1020            #[serde(rename = "ContractName")]
1021            contract_name: Option<String>,
1022        }
1023
1024        #[derive(serde::Deserialize)]
1025        struct SourceCodeResponse {
1026            status: String,
1027            result: Vec<SourceCodeResult>,
1028        }
1029
1030        if let Ok(api_response) = serde_json::from_str::<SourceCodeResponse>(&text) {
1031            if api_response.status == "1" && !api_response.result.is_empty() {
1032                if let Some(name) = &api_response.result[0].contract_name {
1033                    if !name.is_empty() {
1034                        // Use contract name as both symbol and name
1035                        // Try to extract symbol from name (often in format "NameToken" or "NAME")
1036                        let symbol = if name.len() <= 6 {
1037                            name.to_uppercase()
1038                        } else {
1039                            // Take first letters that are uppercase
1040                            name.chars()
1041                                .filter(|c| c.is_uppercase())
1042                                .take(6)
1043                                .collect::<String>()
1044                        };
1045                        let symbol = if symbol.is_empty() {
1046                            name[..name.len().min(6)].to_uppercase()
1047                        } else {
1048                            symbol
1049                        };
1050                        return Some((symbol, name.clone()));
1051                    }
1052                }
1053            }
1054        }
1055
1056        None
1057    }
1058
1059    /// Fetches the top token holders for a given token.
1060    ///
1061    /// # Arguments
1062    ///
1063    /// * `token_address` - The token contract address
1064    /// * `limit` - Maximum number of holders to return (max 1000 for most APIs)
1065    ///
1066    /// # Returns
1067    ///
1068    /// Returns a vector of [`TokenHolder`] objects sorted by balance.
1069    ///
1070    /// # Note
1071    ///
1072    /// This requires an Etherscan Pro API key for most networks.
1073    pub async fn get_token_holders(
1074        &self,
1075        token_address: &str,
1076        limit: u32,
1077    ) -> Result<Vec<TokenHolder>> {
1078        validate_eth_address(token_address)?;
1079
1080        let effective_limit = limit.min(1000); // API max is typically 1000
1081
1082        let url = self.build_api_url(&format!(
1083            "module=token&action=tokenholderlist&contractaddress={}&page=1&offset={}",
1084            token_address, effective_limit
1085        ));
1086
1087        tracing::debug!(url = %url, "Fetching token holders");
1088
1089        let response = self.client.get(&url).send().await?;
1090        let response_text = response.text().await?;
1091
1092        // Parse the response
1093        let api_response: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
1094            .map_err(|e| ScopeError::Api(format!("Failed to parse holder response: {}", e)))?;
1095
1096        if api_response.status != "1" {
1097            // Check for common error messages
1098            if api_response.message.contains("Pro")
1099                || api_response.message.contains("API")
1100                || api_response.message.contains("NOTOK")
1101            {
1102                tracing::warn!(
1103                    "Token holder API requires Pro key or is unavailable: {}",
1104                    api_response.message
1105                );
1106                return Ok(Vec::new());
1107            }
1108            return Err(ScopeError::Api(format!(
1109                "API error: {}",
1110                api_response.message
1111            )));
1112        }
1113
1114        // Parse the holder list
1115        let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
1116            .map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
1117
1118        // Calculate total supply for percentage calculation
1119        let total_balance: f64 = holders
1120            .iter()
1121            .filter_map(|h| h.quantity.parse::<f64>().ok())
1122            .sum();
1123
1124        // Convert to TokenHolder structs
1125        let token_holders: Vec<TokenHolder> = holders
1126            .into_iter()
1127            .enumerate()
1128            .map(|(i, h)| {
1129                let balance: f64 = h.quantity.parse().unwrap_or(0.0);
1130                let percentage = if total_balance > 0.0 {
1131                    (balance / total_balance) * 100.0
1132                } else {
1133                    0.0
1134                };
1135
1136                TokenHolder {
1137                    address: h.address,
1138                    balance: h.quantity.clone(),
1139                    formatted_balance: format_token_balance(&h.quantity, 18), // Default to 18 decimals
1140                    percentage,
1141                    rank: (i + 1) as u32,
1142                }
1143            })
1144            .collect();
1145
1146        Ok(token_holders)
1147    }
1148
1149    /// Gets the total holder count for a token.
1150    ///
1151    /// Uses Etherscan token holder list endpoint to estimate the count.
1152    /// If the API returns a full page at the max limit, the count is approximate.
1153    pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
1154        // First try to get a large page of holders - the page size tells us if there are more
1155        let max_page_size: u32 = 1000;
1156        let holders = self.get_token_holders(token_address, max_page_size).await?;
1157
1158        if holders.is_empty() {
1159            return Ok(0);
1160        }
1161
1162        let count = holders.len() as u64;
1163
1164        if count < max_page_size as u64 {
1165            // We got all holders - this is the exact count
1166            Ok(count)
1167        } else {
1168            // The result was capped - there are at least this many holders.
1169            // Try fetching additional pages to refine the estimate.
1170            let mut total = count;
1171            let mut page = 2u32;
1172            loop {
1173                let url = self.build_api_url(&format!(
1174                    "module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
1175                    token_address, page, max_page_size
1176                ));
1177                let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
1178                    self.client.get(&url).send().await?.json().await;
1179
1180                match response {
1181                    Ok(api_resp) if api_resp.status == "1" => {
1182                        let page_count = api_resp.result.len() as u64;
1183                        total += page_count;
1184                        if page_count < max_page_size as u64 || page >= 10 {
1185                            // Got a partial page (end of list) or hit our max pages limit
1186                            break;
1187                        }
1188                        page += 1;
1189                    }
1190                    _ => break,
1191                }
1192            }
1193            Ok(total)
1194        }
1195    }
1196}
1197
1198/// Formats a token balance with proper decimal places.
1199fn format_token_balance(balance: &str, decimals: u8) -> String {
1200    let balance_f64: f64 = balance.parse().unwrap_or(0.0);
1201    let divisor = 10_f64.powi(decimals as i32);
1202    let formatted = balance_f64 / divisor;
1203
1204    if formatted >= 1_000_000_000.0 {
1205        format!("{:.2}B", formatted / 1_000_000_000.0)
1206    } else if formatted >= 1_000_000.0 {
1207        format!("{:.2}M", formatted / 1_000_000.0)
1208    } else if formatted >= 1_000.0 {
1209        format!("{:.2}K", formatted / 1_000.0)
1210    } else {
1211        format!("{:.4}", formatted)
1212    }
1213}
1214
1215impl Default for EthereumClient {
1216    fn default() -> Self {
1217        Self {
1218            client: Client::new(),
1219            base_url: ETHERSCAN_V2_API.to_string(),
1220            chain_id: Some("1".to_string()),
1221            api_key: None,
1222            chain_name: "ethereum".to_string(),
1223            native_symbol: "ETH".to_string(),
1224            native_decimals: 18,
1225            api_type: ApiType::BlockExplorer,
1226        }
1227    }
1228}
1229
1230/// Validates an Ethereum address format.
1231fn validate_eth_address(address: &str) -> Result<()> {
1232    if !address.starts_with("0x") {
1233        return Err(ScopeError::InvalidAddress(format!(
1234            "Address must start with '0x': {}",
1235            address
1236        )));
1237    }
1238    if address.len() != 42 {
1239        return Err(ScopeError::InvalidAddress(format!(
1240            "Address must be 42 characters: {}",
1241            address
1242        )));
1243    }
1244    if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1245        return Err(ScopeError::InvalidAddress(format!(
1246            "Address contains invalid hex characters: {}",
1247            address
1248        )));
1249    }
1250    Ok(())
1251}
1252
1253/// Validates a transaction hash format.
1254fn validate_tx_hash(hash: &str) -> Result<()> {
1255    if !hash.starts_with("0x") {
1256        return Err(ScopeError::InvalidHash(format!(
1257            "Hash must start with '0x': {}",
1258            hash
1259        )));
1260    }
1261    if hash.len() != 66 {
1262        return Err(ScopeError::InvalidHash(format!(
1263            "Hash must be 66 characters: {}",
1264            hash
1265        )));
1266    }
1267    if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1268        return Err(ScopeError::InvalidHash(format!(
1269            "Hash contains invalid hex characters: {}",
1270            hash
1271        )));
1272    }
1273    Ok(())
1274}
1275
1276// ============================================================================
1277// ChainClient Trait Implementation
1278// ============================================================================
1279
1280#[async_trait]
1281impl ChainClient for EthereumClient {
1282    fn chain_name(&self) -> &str {
1283        &self.chain_name
1284    }
1285
1286    fn native_token_symbol(&self) -> &str {
1287        &self.native_symbol
1288    }
1289
1290    async fn get_balance(&self, address: &str) -> Result<Balance> {
1291        self.get_balance(address).await
1292    }
1293
1294    async fn enrich_balance_usd(&self, balance: &mut Balance) {
1295        self.enrich_balance_usd(balance).await
1296    }
1297
1298    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
1299        self.get_transaction(hash).await
1300    }
1301
1302    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
1303        self.get_transactions(address, limit).await
1304    }
1305
1306    async fn get_block_number(&self) -> Result<u64> {
1307        self.get_block_number().await
1308    }
1309
1310    async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
1311        self.get_erc20_balances(address).await
1312    }
1313
1314    async fn get_token_info(&self, address: &str) -> Result<Token> {
1315        self.get_token_info(address).await
1316    }
1317
1318    async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
1319        self.get_token_holders(address, limit).await
1320    }
1321
1322    async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
1323        self.get_token_holder_count(address).await
1324    }
1325}
1326
1327// ============================================================================
1328// Unit Tests
1329// ============================================================================
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334
1335    const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1336    const VALID_TX_HASH: &str =
1337        "0xabc123def456789012345678901234567890123456789012345678901234abcd";
1338
1339    #[test]
1340    fn test_validate_eth_address_valid() {
1341        assert!(validate_eth_address(VALID_ADDRESS).is_ok());
1342    }
1343
1344    #[test]
1345    fn test_validate_eth_address_lowercase() {
1346        let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
1347        assert!(validate_eth_address(addr).is_ok());
1348    }
1349
1350    #[test]
1351    fn test_validate_eth_address_missing_prefix() {
1352        let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1353        let result = validate_eth_address(addr);
1354        assert!(result.is_err());
1355        assert!(result.unwrap_err().to_string().contains("0x"));
1356    }
1357
1358    #[test]
1359    fn test_validate_eth_address_too_short() {
1360        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
1361        let result = validate_eth_address(addr);
1362        assert!(result.is_err());
1363        assert!(result.unwrap_err().to_string().contains("42 characters"));
1364    }
1365
1366    #[test]
1367    fn test_validate_eth_address_invalid_hex() {
1368        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
1369        let result = validate_eth_address(addr);
1370        assert!(result.is_err());
1371        assert!(result.unwrap_err().to_string().contains("invalid hex"));
1372    }
1373
1374    #[test]
1375    fn test_validate_tx_hash_valid() {
1376        assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
1377    }
1378
1379    #[test]
1380    fn test_validate_tx_hash_missing_prefix() {
1381        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
1382        let result = validate_tx_hash(hash);
1383        assert!(result.is_err());
1384    }
1385
1386    #[test]
1387    fn test_validate_tx_hash_too_short() {
1388        let hash = "0xabc123";
1389        let result = validate_tx_hash(hash);
1390        assert!(result.is_err());
1391        assert!(result.unwrap_err().to_string().contains("66 characters"));
1392    }
1393
1394    #[test]
1395    fn test_ethereum_client_default() {
1396        let client = EthereumClient::default();
1397        assert_eq!(client.chain_name(), "ethereum");
1398        assert_eq!(client.native_token_symbol(), "ETH");
1399    }
1400
1401    #[test]
1402    fn test_ethereum_client_with_base_url() {
1403        let client = EthereumClient::with_base_url("https://custom.api.com");
1404        assert_eq!(client.base_url, "https://custom.api.com");
1405    }
1406
1407    #[test]
1408    fn test_ethereum_client_for_chain_ethereum() {
1409        let config = ChainsConfig::default();
1410        let client = EthereumClient::for_chain("ethereum", &config).unwrap();
1411        assert_eq!(client.chain_name(), "ethereum");
1412        assert_eq!(client.native_token_symbol(), "ETH");
1413    }
1414
1415    #[test]
1416    fn test_ethereum_client_for_chain_polygon() {
1417        let config = ChainsConfig::default();
1418        let client = EthereumClient::for_chain("polygon", &config).unwrap();
1419        assert_eq!(client.chain_name(), "polygon");
1420        assert_eq!(client.native_token_symbol(), "MATIC");
1421        // V2 API uses unified URL with chainid parameter
1422        assert!(client.base_url.contains("etherscan.io/v2"));
1423        assert_eq!(client.chain_id, Some("137".to_string()));
1424    }
1425
1426    #[test]
1427    fn test_ethereum_client_for_chain_arbitrum() {
1428        let config = ChainsConfig::default();
1429        let client = EthereumClient::for_chain("arbitrum", &config).unwrap();
1430        assert_eq!(client.chain_name(), "arbitrum");
1431        // V2 API uses unified URL with chainid parameter
1432        assert!(client.base_url.contains("etherscan.io/v2"));
1433        assert_eq!(client.chain_id, Some("42161".to_string()));
1434    }
1435
1436    #[test]
1437    fn test_ethereum_client_for_chain_bsc() {
1438        let config = ChainsConfig::default();
1439        let client = EthereumClient::for_chain("bsc", &config).unwrap();
1440        assert_eq!(client.chain_name(), "bsc");
1441        assert_eq!(client.native_token_symbol(), "BNB");
1442        // V2 API uses unified URL with chainid parameter
1443        assert!(client.base_url.contains("etherscan.io/v2"));
1444        assert_eq!(client.chain_id, Some("56".to_string()));
1445        assert_eq!(client.api_type, ApiType::BlockExplorer);
1446    }
1447
1448    #[test]
1449    fn test_ethereum_client_for_chain_aegis() {
1450        let config = ChainsConfig::default();
1451        let client = EthereumClient::for_chain("aegis", &config).unwrap();
1452        assert_eq!(client.chain_name(), "aegis");
1453        assert_eq!(client.native_token_symbol(), "WRAITH");
1454        assert_eq!(client.api_type, ApiType::JsonRpc);
1455        // Default URL when not configured
1456        assert!(client.base_url.contains("localhost:8545"));
1457    }
1458
1459    #[test]
1460    fn test_ethereum_client_for_chain_aegis_with_config() {
1461        let config = ChainsConfig {
1462            aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
1463            ..Default::default()
1464        };
1465        let client = EthereumClient::for_chain("aegis", &config).unwrap();
1466        assert_eq!(client.base_url, "https://aegis.example.com:8545");
1467    }
1468
1469    #[test]
1470    fn test_ethereum_client_for_chain_unsupported() {
1471        let config = ChainsConfig::default();
1472        let result = EthereumClient::for_chain("bitcoin", &config);
1473        assert!(result.is_err());
1474        assert!(
1475            result
1476                .unwrap_err()
1477                .to_string()
1478                .contains("Unsupported chain")
1479        );
1480    }
1481
1482    #[test]
1483    fn test_ethereum_client_new() {
1484        let config = ChainsConfig::default();
1485        let client = EthereumClient::new(&config);
1486        assert!(client.is_ok());
1487    }
1488
1489    #[test]
1490    fn test_ethereum_client_with_api_key() {
1491        use std::collections::HashMap;
1492
1493        let mut api_keys = HashMap::new();
1494        api_keys.insert("etherscan".to_string(), "test-key".to_string());
1495
1496        let config = ChainsConfig {
1497            api_keys,
1498            ..Default::default()
1499        };
1500
1501        let client = EthereumClient::new(&config).unwrap();
1502        assert_eq!(client.api_key, Some("test-key".to_string()));
1503    }
1504
1505    #[test]
1506    fn test_api_response_deserialization() {
1507        let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
1508        let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
1509        assert_eq!(response.status, "1");
1510        assert_eq!(response.message, "OK");
1511        assert_eq!(response.result, "1000000000000000000");
1512    }
1513
1514    #[test]
1515    fn test_tx_list_item_deserialization() {
1516        let json = r#"{
1517            "hash": "0xabc",
1518            "blockNumber": "12345",
1519            "timeStamp": "1700000000",
1520            "from": "0xfrom",
1521            "to": "0xto",
1522            "value": "1000000000000000000",
1523            "gas": "21000",
1524            "gasUsed": "21000",
1525            "gasPrice": "20000000000",
1526            "nonce": "42",
1527            "input": "0x",
1528            "isError": "0"
1529        }"#;
1530
1531        let item: TxListItem = serde_json::from_str(json).unwrap();
1532        assert_eq!(item.hash, "0xabc");
1533        assert_eq!(item.block_number, "12345");
1534        assert_eq!(item.nonce, "42");
1535        assert_eq!(item.is_error, "0");
1536    }
1537
1538    // ========================================================================
1539    // Pure function tests
1540    // ========================================================================
1541
1542    #[test]
1543    fn test_parse_balance_wei_valid() {
1544        let client = EthereumClient::default();
1545        let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1546        assert_eq!(balance.symbol, "ETH");
1547        assert_eq!(balance.raw, "1000000000000000000");
1548        assert!(balance.formatted.contains("1.000000"));
1549        assert!(balance.usd_value.is_none());
1550    }
1551
1552    #[test]
1553    fn test_parse_balance_wei_zero() {
1554        let client = EthereumClient::default();
1555        let balance = client.parse_balance_wei("0").unwrap();
1556        assert!(balance.formatted.contains("0.000000"));
1557    }
1558
1559    #[test]
1560    fn test_parse_balance_wei_invalid() {
1561        let client = EthereumClient::default();
1562        let result = client.parse_balance_wei("not_a_number");
1563        assert!(result.is_err());
1564    }
1565
1566    #[test]
1567    fn test_format_token_balance_large() {
1568        assert!(format_token_balance("1000000000000000000000000000", 18).contains("B"));
1569    }
1570
1571    #[test]
1572    fn test_format_token_balance_millions() {
1573        assert!(format_token_balance("5000000000000000000000000", 18).contains("M"));
1574    }
1575
1576    #[test]
1577    fn test_format_token_balance_thousands() {
1578        assert!(format_token_balance("5000000000000000000000", 18).contains("K"));
1579    }
1580
1581    #[test]
1582    fn test_format_token_balance_small() {
1583        let formatted = format_token_balance("500000000000000000", 18);
1584        assert!(formatted.contains("0.5"));
1585    }
1586
1587    #[test]
1588    fn test_format_token_balance_zero() {
1589        let formatted = format_token_balance("0", 18);
1590        assert!(formatted.contains("0.0000"));
1591    }
1592
1593    #[test]
1594    fn test_build_api_url_with_chain_id_and_key() {
1595        use std::collections::HashMap;
1596        let mut keys = HashMap::new();
1597        keys.insert("etherscan".to_string(), "MYKEY".to_string());
1598        let config = ChainsConfig {
1599            api_keys: keys,
1600            ..Default::default()
1601        };
1602        let client = EthereumClient::new(&config).unwrap();
1603        let url = client.build_api_url("module=account&action=balance&address=0x123");
1604        assert!(url.contains("chainid=1"));
1605        assert!(url.contains("module=account"));
1606        assert!(url.contains("apikey=MYKEY"));
1607    }
1608
1609    #[test]
1610    fn test_build_api_url_no_chain_id_no_key() {
1611        let client = EthereumClient::with_base_url("https://example.com/api");
1612        let url = client.build_api_url("module=account&action=balance");
1613        assert_eq!(url, "https://example.com/api?module=account&action=balance");
1614        assert!(!url.contains("chainid"));
1615        assert!(!url.contains("apikey"));
1616    }
1617
1618    // ========================================================================
1619    // HTTP mocking tests - Block Explorer API
1620    // ========================================================================
1621
1622    #[tokio::test]
1623    async fn test_get_balance_explorer() {
1624        let mut server = mockito::Server::new_async().await;
1625        let _mock = server
1626            .mock("GET", mockito::Matcher::Any)
1627            .with_status(200)
1628            .with_header("content-type", "application/json")
1629            .with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
1630            .create_async()
1631            .await;
1632
1633        let client = EthereumClient::with_base_url(&server.url());
1634        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1635        assert_eq!(balance.raw, "2500000000000000000");
1636        assert_eq!(balance.symbol, "ETH");
1637        assert!(balance.formatted.contains("2.5"));
1638    }
1639
1640    #[tokio::test]
1641    async fn test_get_balance_explorer_api_error() {
1642        let mut server = mockito::Server::new_async().await;
1643        let _mock = server
1644            .mock("GET", mockito::Matcher::Any)
1645            .with_status(200)
1646            .with_header("content-type", "application/json")
1647            .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
1648            .create_async()
1649            .await;
1650
1651        let client = EthereumClient::with_base_url(&server.url());
1652        let result = client.get_balance(VALID_ADDRESS).await;
1653        assert!(result.is_err());
1654        assert!(result.unwrap_err().to_string().contains("API error"));
1655    }
1656
1657    #[tokio::test]
1658    async fn test_get_balance_invalid_address() {
1659        let client = EthereumClient::default();
1660        let result = client.get_balance("invalid").await;
1661        assert!(result.is_err());
1662    }
1663
1664    #[tokio::test]
1665    async fn test_get_balance_rpc() {
1666        let mut server = mockito::Server::new_async().await;
1667        let _mock = server
1668            .mock("POST", "/")
1669            .with_status(200)
1670            .with_header("content-type", "application/json")
1671            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1672            .create_async()
1673            .await;
1674
1675        let client = EthereumClient {
1676            client: Client::new(),
1677            base_url: server.url(),
1678            chain_id: None,
1679            api_key: None,
1680            chain_name: "aegis".to_string(),
1681            native_symbol: "WRAITH".to_string(),
1682            native_decimals: 18,
1683            api_type: ApiType::JsonRpc,
1684        };
1685        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1686        assert_eq!(balance.symbol, "WRAITH");
1687        assert!(balance.formatted.contains("1.000000"));
1688    }
1689
1690    #[tokio::test]
1691    async fn test_get_balance_rpc_error() {
1692        let mut server = mockito::Server::new_async().await;
1693        let _mock = server
1694            .mock("POST", "/")
1695            .with_status(200)
1696            .with_header("content-type", "application/json")
1697            .with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
1698            .create_async()
1699            .await;
1700
1701        let client = EthereumClient {
1702            client: Client::new(),
1703            base_url: server.url(),
1704            chain_id: None,
1705            api_key: None,
1706            chain_name: "aegis".to_string(),
1707            native_symbol: "WRAITH".to_string(),
1708            native_decimals: 18,
1709            api_type: ApiType::JsonRpc,
1710        };
1711        let result = client.get_balance(VALID_ADDRESS).await;
1712        assert!(result.is_err());
1713        assert!(result.unwrap_err().to_string().contains("RPC error"));
1714    }
1715
1716    #[tokio::test]
1717    async fn test_get_balance_rpc_empty_result() {
1718        let mut server = mockito::Server::new_async().await;
1719        let _mock = server
1720            .mock("POST", "/")
1721            .with_status(200)
1722            .with_header("content-type", "application/json")
1723            .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1724            .create_async()
1725            .await;
1726
1727        let client = EthereumClient {
1728            client: Client::new(),
1729            base_url: server.url(),
1730            chain_id: None,
1731            api_key: None,
1732            chain_name: "aegis".to_string(),
1733            native_symbol: "WRAITH".to_string(),
1734            native_decimals: 18,
1735            api_type: ApiType::JsonRpc,
1736        };
1737        let result = client.get_balance(VALID_ADDRESS).await;
1738        assert!(result.is_err());
1739        assert!(
1740            result
1741                .unwrap_err()
1742                .to_string()
1743                .contains("Empty RPC response")
1744        );
1745    }
1746
1747    #[tokio::test]
1748    async fn test_get_transaction_explorer() {
1749        let mut server = mockito::Server::new_async().await;
1750        // The explorer makes 3 sequential requests: tx, receipt, block
1751        let _mock = server
1752            .mock("GET", mockito::Matcher::Any)
1753            .with_status(200)
1754            .with_header("content-type", "application/json")
1755            .with_body(
1756                r#"{"jsonrpc":"2.0","id":1,"result":{
1757                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1758                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1759                "to":"0x1111111111111111111111111111111111111111",
1760                "blockNumber":"0x10",
1761                "gas":"0x5208",
1762                "gasPrice":"0x4A817C800",
1763                "nonce":"0x2A",
1764                "value":"0xDE0B6B3A7640000",
1765                "input":"0x"
1766            }}"#,
1767            )
1768            .expect_at_most(1)
1769            .create_async()
1770            .await;
1771
1772        // Receipt response
1773        let _receipt_mock = server
1774            .mock("GET", mockito::Matcher::Any)
1775            .with_status(200)
1776            .with_header("content-type", "application/json")
1777            .with_body(
1778                r#"{"jsonrpc":"2.0","id":1,"result":{
1779                "gasUsed":"0x5208",
1780                "status":"0x1"
1781            }}"#,
1782            )
1783            .expect_at_most(1)
1784            .create_async()
1785            .await;
1786
1787        // Block timestamp response
1788        let _block_mock = server
1789            .mock("GET", mockito::Matcher::Any)
1790            .with_status(200)
1791            .with_header("content-type", "application/json")
1792            .with_body(
1793                r#"{"jsonrpc":"2.0","id":1,"result":{
1794                "timestamp":"0x65A8C580"
1795            }}"#,
1796            )
1797            .create_async()
1798            .await;
1799
1800        let client = EthereumClient::with_base_url(&server.url());
1801        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1802        assert_eq!(tx.hash, VALID_TX_HASH);
1803        assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1804        assert_eq!(
1805            tx.to,
1806            Some("0x1111111111111111111111111111111111111111".to_string())
1807        );
1808        assert!(tx.gas_limit > 0);
1809        assert!(tx.nonce > 0);
1810    }
1811
1812    #[tokio::test]
1813    async fn test_get_transaction_explorer_not_found() {
1814        let mut server = mockito::Server::new_async().await;
1815        let _mock = server
1816            .mock("GET", mockito::Matcher::Any)
1817            .with_status(200)
1818            .with_header("content-type", "application/json")
1819            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
1820            .create_async()
1821            .await;
1822
1823        let client = EthereumClient::with_base_url(&server.url());
1824        let result = client.get_transaction(VALID_TX_HASH).await;
1825        assert!(result.is_err());
1826        assert!(result.unwrap_err().to_string().contains("not found"));
1827    }
1828
1829    #[tokio::test]
1830    async fn test_get_transaction_rpc() {
1831        let mut server = mockito::Server::new_async().await;
1832        // Transaction response
1833        let _tx_mock = server
1834            .mock("POST", "/")
1835            .with_status(200)
1836            .with_header("content-type", "application/json")
1837            .with_body(
1838                r#"{"jsonrpc":"2.0","id":1,"result":{
1839                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1840                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1841                "to":"0x1111111111111111111111111111111111111111",
1842                "blockNumber":"0x10",
1843                "gas":"0x5208",
1844                "gasPrice":"0x4A817C800",
1845                "nonce":"0x2A",
1846                "value":"0xDE0B6B3A7640000",
1847                "input":"0x"
1848            }}"#,
1849            )
1850            .expect_at_most(1)
1851            .create_async()
1852            .await;
1853
1854        // Receipt response
1855        let _receipt_mock = server
1856            .mock("POST", "/")
1857            .with_status(200)
1858            .with_header("content-type", "application/json")
1859            .with_body(
1860                r#"{"jsonrpc":"2.0","id":2,"result":{
1861                "gasUsed":"0x5208",
1862                "status":"0x1"
1863            }}"#,
1864            )
1865            .create_async()
1866            .await;
1867
1868        let client = EthereumClient {
1869            client: Client::new(),
1870            base_url: server.url(),
1871            chain_id: None,
1872            api_key: None,
1873            chain_name: "aegis".to_string(),
1874            native_symbol: "WRAITH".to_string(),
1875            native_decimals: 18,
1876            api_type: ApiType::JsonRpc,
1877        };
1878        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1879        assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1880        assert!(tx.status.unwrap());
1881        assert!(tx.timestamp.is_none()); // JSON-RPC doesn't fetch timestamp
1882    }
1883
1884    #[tokio::test]
1885    async fn test_get_transaction_invalid_hash() {
1886        let client = EthereumClient::default();
1887        let result = client.get_transaction("0xbad").await;
1888        assert!(result.is_err());
1889    }
1890
1891    #[tokio::test]
1892    async fn test_get_transactions() {
1893        let mut server = mockito::Server::new_async().await;
1894        let _mock = server
1895            .mock("GET", mockito::Matcher::Any)
1896            .with_status(200)
1897            .with_header("content-type", "application/json")
1898            .with_body(
1899                r#"{"status":"1","message":"OK","result":[
1900                {
1901                    "hash":"0xabc",
1902                    "blockNumber":"12345",
1903                    "timeStamp":"1700000000",
1904                    "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1905                    "to":"0x1111111111111111111111111111111111111111",
1906                    "value":"1000000000000000000",
1907                    "gas":"21000",
1908                    "gasUsed":"21000",
1909                    "gasPrice":"20000000000",
1910                    "nonce":"1",
1911                    "input":"0x",
1912                    "isError":"0"
1913                },
1914                {
1915                    "hash":"0xdef",
1916                    "blockNumber":"12346",
1917                    "timeStamp":"1700000060",
1918                    "from":"0x1111111111111111111111111111111111111111",
1919                    "to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1920                    "value":"500000000000000000",
1921                    "gas":"21000",
1922                    "gasUsed":"21000",
1923                    "gasPrice":"20000000000",
1924                    "nonce":"5",
1925                    "input":"0x",
1926                    "isError":"1"
1927                }
1928            ]}"#,
1929            )
1930            .create_async()
1931            .await;
1932
1933        let client = EthereumClient::with_base_url(&server.url());
1934        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1935        assert_eq!(txs.len(), 2);
1936        assert_eq!(txs[0].hash, "0xabc");
1937        assert!(txs[0].status.unwrap()); // isError == "0" → success
1938        assert!(!txs[1].status.unwrap()); // isError == "1" → failure
1939        assert_eq!(txs[1].nonce, 5);
1940    }
1941
1942    #[tokio::test]
1943    async fn test_get_transactions_no_transactions() {
1944        let mut server = mockito::Server::new_async().await;
1945        let _mock = server
1946            .mock("GET", mockito::Matcher::Any)
1947            .with_status(200)
1948            .with_header("content-type", "application/json")
1949            .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
1950            .create_async()
1951            .await;
1952
1953        let client = EthereumClient::with_base_url(&server.url());
1954        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1955        assert!(txs.is_empty());
1956    }
1957
1958    #[tokio::test]
1959    async fn test_get_transactions_empty_to_field() {
1960        let mut server = mockito::Server::new_async().await;
1961        let _mock = server
1962            .mock("GET", mockito::Matcher::Any)
1963            .with_status(200)
1964            .with_header("content-type", "application/json")
1965            .with_body(
1966                r#"{"status":"1","message":"OK","result":[{
1967                "hash":"0xabc",
1968                "blockNumber":"12345",
1969                "timeStamp":"1700000000",
1970                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1971                "to":"",
1972                "value":"0",
1973                "gas":"200000",
1974                "gasUsed":"150000",
1975                "gasPrice":"20000000000",
1976                "nonce":"1",
1977                "input":"0x60806040",
1978                "isError":"0"
1979            }]}"#,
1980            )
1981            .create_async()
1982            .await;
1983
1984        let client = EthereumClient::with_base_url(&server.url());
1985        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1986        assert_eq!(txs.len(), 1);
1987        assert!(txs[0].to.is_none()); // Empty "to" → contract creation
1988    }
1989
1990    #[tokio::test]
1991    async fn test_get_block_number() {
1992        let mut server = mockito::Server::new_async().await;
1993        let _mock = server
1994            .mock("GET", mockito::Matcher::Any)
1995            .with_status(200)
1996            .with_header("content-type", "application/json")
1997            .with_body(r#"{"result":"0x1234AB"}"#)
1998            .create_async()
1999            .await;
2000
2001        let client = EthereumClient::with_base_url(&server.url());
2002        let block = client.get_block_number().await.unwrap();
2003        assert_eq!(block, 0x1234AB);
2004    }
2005
2006    #[tokio::test]
2007    async fn test_get_erc20_balances() {
2008        let mut server = mockito::Server::new_async().await;
2009        // First request: token transfers
2010        let _tokentx_mock = server
2011            .mock("GET", mockito::Matcher::Any)
2012            .with_status(200)
2013            .with_header("content-type", "application/json")
2014            .with_body(
2015                r#"{"status":"1","message":"OK","result":[
2016                {
2017                    "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2018                    "tokenSymbol":"USDT",
2019                    "tokenName":"Tether USD",
2020                    "tokenDecimal":"6"
2021                },
2022                {
2023                    "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2024                    "tokenSymbol":"USDT",
2025                    "tokenName":"Tether USD",
2026                    "tokenDecimal":"6"
2027                },
2028                {
2029                    "contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2030                    "tokenSymbol":"USDC",
2031                    "tokenName":"USD Coin",
2032                    "tokenDecimal":"6"
2033                }
2034            ]}"#,
2035            )
2036            .expect_at_most(1)
2037            .create_async()
2038            .await;
2039
2040        // Second+ requests: token balances (returns for first unique token)
2041        let _balance_mock = server
2042            .mock("GET", mockito::Matcher::Any)
2043            .with_status(200)
2044            .with_header("content-type", "application/json")
2045            .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2046            .create_async()
2047            .await;
2048
2049        let client = EthereumClient::with_base_url(&server.url());
2050        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2051        // Should have 2 unique tokens (USDT deduplicated)
2052        assert!(balances.len() <= 2);
2053        if !balances.is_empty() {
2054            assert!(!balances[0].balance.is_empty());
2055        }
2056    }
2057
2058    #[tokio::test]
2059    async fn test_get_erc20_balances_empty() {
2060        let mut server = mockito::Server::new_async().await;
2061        let _mock = server
2062            .mock("GET", mockito::Matcher::Any)
2063            .with_status(200)
2064            .with_header("content-type", "application/json")
2065            .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2066            .create_async()
2067            .await;
2068
2069        let client = EthereumClient::with_base_url(&server.url());
2070        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2071        assert!(balances.is_empty());
2072    }
2073
2074    #[tokio::test]
2075    async fn test_get_token_info_success() {
2076        let mut server = mockito::Server::new_async().await;
2077        let _mock = server
2078            .mock("GET", mockito::Matcher::Any)
2079            .with_status(200)
2080            .with_header("content-type", "application/json")
2081            .with_body(
2082                r#"{"status":"1","message":"OK","result":[{
2083                "tokenName":"Tether USD",
2084                "symbol":"USDT",
2085                "divisor":"1000000",
2086                "tokenType":"ERC20",
2087                "totalSupply":"1000000000000"
2088            }]}"#,
2089            )
2090            .create_async()
2091            .await;
2092
2093        let client = EthereumClient::with_base_url(&server.url());
2094        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2095        assert_eq!(token.symbol, "USDT");
2096        assert_eq!(token.name, "Tether USD");
2097        assert_eq!(token.decimals, 6); // log10(1000000) = 6
2098    }
2099
2100    #[tokio::test]
2101    async fn test_get_token_info_fallback_to_supply() {
2102        let mut server = mockito::Server::new_async().await;
2103        // First request: tokeninfo fails
2104        let _info_mock = server
2105            .mock("GET", mockito::Matcher::Any)
2106            .with_status(200)
2107            .with_header("content-type", "application/json")
2108            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2109            .expect_at_most(1)
2110            .create_async()
2111            .await;
2112
2113        // Second request: tokensupply succeeds
2114        let _supply_mock = server
2115            .mock("GET", mockito::Matcher::Any)
2116            .with_status(200)
2117            .with_header("content-type", "application/json")
2118            .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2119            .expect_at_most(1)
2120            .create_async()
2121            .await;
2122
2123        // Third request: getsourcecode returns contract name
2124        let _source_mock = server
2125            .mock("GET", mockito::Matcher::Any)
2126            .with_status(200)
2127            .with_header("content-type", "application/json")
2128            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
2129            .create_async()
2130            .await;
2131
2132        let client = EthereumClient::with_base_url(&server.url());
2133        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2134        // Should get some token info via fallback
2135        assert!(!token.symbol.is_empty());
2136    }
2137
2138    #[tokio::test]
2139    async fn test_get_token_info_unknown() {
2140        let mut server = mockito::Server::new_async().await;
2141        let _mock = server
2142            .mock("GET", mockito::Matcher::Any)
2143            .with_status(200)
2144            .with_header("content-type", "application/json")
2145            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2146            .create_async()
2147            .await;
2148
2149        let client = EthereumClient::with_base_url(&server.url());
2150        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2151        // Fallback returns UNKNOWN or address-based placeholder
2152        assert!(!token.symbol.is_empty());
2153    }
2154
2155    #[tokio::test]
2156    async fn test_try_get_contract_name() {
2157        let mut server = mockito::Server::new_async().await;
2158        let _mock = server
2159            .mock("GET", mockito::Matcher::Any)
2160            .with_status(200)
2161            .with_header("content-type", "application/json")
2162            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
2163            .create_async()
2164            .await;
2165
2166        let client = EthereumClient::with_base_url(&server.url());
2167        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2168        assert!(result.is_some());
2169        let (symbol, name) = result.unwrap();
2170        assert_eq!(name, "USDT");
2171        assert_eq!(symbol, "USDT"); // Short name → uppercased as-is
2172    }
2173
2174    #[tokio::test]
2175    async fn test_try_get_contract_name_long_name() {
2176        let mut server = mockito::Server::new_async().await;
2177        let _mock = server
2178            .mock("GET", mockito::Matcher::Any)
2179            .with_status(200)
2180            .with_header("content-type", "application/json")
2181            .with_body(
2182                r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
2183            )
2184            .create_async()
2185            .await;
2186
2187        let client = EthereumClient::with_base_url(&server.url());
2188        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2189        assert!(result.is_some());
2190        let (symbol, name) = result.unwrap();
2191        assert_eq!(name, "TetherUSDToken");
2192        assert_eq!(symbol, "TUSDT"); // Uppercase chars extracted
2193    }
2194
2195    #[tokio::test]
2196    async fn test_try_get_contract_name_not_verified() {
2197        let mut server = mockito::Server::new_async().await;
2198        let _mock = server
2199            .mock("GET", mockito::Matcher::Any)
2200            .with_status(200)
2201            .with_header("content-type", "application/json")
2202            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2203            .create_async()
2204            .await;
2205
2206        let client = EthereumClient::with_base_url(&server.url());
2207        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2208        assert!(result.is_none());
2209    }
2210
2211    #[tokio::test]
2212    async fn test_get_token_holders() {
2213        let mut server = mockito::Server::new_async().await;
2214        let _mock = server
2215            .mock("GET", mockito::Matcher::Any)
2216            .with_status(200)
2217            .with_header("content-type", "application/json")
2218            .with_body(r#"{"status":"1","message":"OK","result":[
2219                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
2220                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
2221                {"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
2222            ]}"#)
2223            .create_async()
2224            .await;
2225
2226        let client = EthereumClient::with_base_url(&server.url());
2227        let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2228        assert_eq!(holders.len(), 3);
2229        assert_eq!(
2230            holders[0].address,
2231            "0x1111111111111111111111111111111111111111"
2232        );
2233        assert_eq!(holders[0].rank, 1);
2234        assert_eq!(holders[2].rank, 3);
2235        // Percentage should be 50%, 30%, 20%
2236        assert!((holders[0].percentage - 50.0).abs() < 0.01);
2237    }
2238
2239    #[tokio::test]
2240    async fn test_get_token_holders_pro_required() {
2241        let mut server = mockito::Server::new_async().await;
2242        let _mock = server
2243            .mock("GET", mockito::Matcher::Any)
2244            .with_status(200)
2245            .with_header("content-type", "application/json")
2246            .with_body(
2247                r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
2248            )
2249            .create_async()
2250            .await;
2251
2252        let client = EthereumClient::with_base_url(&server.url());
2253        let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2254        assert!(holders.is_empty()); // Graceful fallback
2255    }
2256
2257    #[tokio::test]
2258    async fn test_get_token_holder_count_small() {
2259        let mut server = mockito::Server::new_async().await;
2260        // Return fewer than 1000 holders → exact count
2261        let _mock = server
2262            .mock("GET", mockito::Matcher::Any)
2263            .with_status(200)
2264            .with_header("content-type", "application/json")
2265            .with_body(r#"{"status":"1","message":"OK","result":[
2266                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
2267                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
2268            ]}"#)
2269            .create_async()
2270            .await;
2271
2272        let client = EthereumClient::with_base_url(&server.url());
2273        let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2274        assert_eq!(count, 2);
2275    }
2276
2277    #[tokio::test]
2278    async fn test_get_token_holder_count_empty() {
2279        let mut server = mockito::Server::new_async().await;
2280        let _mock = server
2281            .mock("GET", mockito::Matcher::Any)
2282            .with_status(200)
2283            .with_header("content-type", "application/json")
2284            .with_body(
2285                r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
2286            )
2287            .create_async()
2288            .await;
2289
2290        let client = EthereumClient::with_base_url(&server.url());
2291        let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2292        // Pro required → empty holders → 0
2293        assert_eq!(count, 0);
2294    }
2295
2296    #[tokio::test]
2297    async fn test_get_block_timestamp() {
2298        let mut server = mockito::Server::new_async().await;
2299        let _mock = server
2300            .mock("GET", mockito::Matcher::Any)
2301            .with_status(200)
2302            .with_header("content-type", "application/json")
2303            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
2304            .create_async()
2305            .await;
2306
2307        let client = EthereumClient::with_base_url(&server.url());
2308        let ts = client.get_block_timestamp(16).await.unwrap();
2309        assert_eq!(ts, 0x65A8C580);
2310    }
2311
2312    #[tokio::test]
2313    async fn test_get_block_timestamp_not_found() {
2314        let mut server = mockito::Server::new_async().await;
2315        let _mock = server
2316            .mock("GET", mockito::Matcher::Any)
2317            .with_status(200)
2318            .with_header("content-type", "application/json")
2319            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2320            .create_async()
2321            .await;
2322
2323        let client = EthereumClient::with_base_url(&server.url());
2324        let result = client.get_block_timestamp(99999999).await;
2325        assert!(result.is_err());
2326    }
2327
2328    #[test]
2329    fn test_chain_name_and_symbol_accessors() {
2330        let client = EthereumClient::with_base_url("http://test");
2331        assert_eq!(client.chain_name(), "ethereum");
2332        assert_eq!(client.native_token_symbol(), "ETH");
2333    }
2334
2335    #[test]
2336    fn test_validate_tx_hash_invalid_hex() {
2337        let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
2338        let result = validate_tx_hash(hash);
2339        assert!(result.is_err());
2340        assert!(result.unwrap_err().to_string().contains("invalid hex"));
2341    }
2342
2343    #[tokio::test]
2344    async fn test_get_transactions_api_error() {
2345        let mut server = mockito::Server::new_async().await;
2346        let _mock = server
2347            .mock("GET", mockito::Matcher::Any)
2348            .with_status(200)
2349            .with_header("content-type", "application/json")
2350            .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
2351            .create_async()
2352            .await;
2353
2354        let client = EthereumClient::with_base_url(&server.url());
2355        let result = client.get_transactions(VALID_ADDRESS, 10).await;
2356        assert!(result.is_err());
2357    }
2358
2359    #[tokio::test]
2360    async fn test_get_token_holders_pro_key_required() {
2361        let mut server = mockito::Server::new_async().await;
2362        let _mock = server
2363            .mock("GET", mockito::Matcher::Any)
2364            .with_status(200)
2365            .with_header("content-type", "application/json")
2366            .with_body(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
2367            .create_async()
2368            .await;
2369
2370        let client = EthereumClient::with_base_url(&server.url());
2371        let holders = client
2372            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2373            .await
2374            .unwrap();
2375        assert!(holders.is_empty());
2376    }
2377
2378    #[tokio::test]
2379    async fn test_get_token_holders_api_error() {
2380        let mut server = mockito::Server::new_async().await;
2381        let _mock = server
2382            .mock("GET", mockito::Matcher::Any)
2383            .with_status(200)
2384            .with_header("content-type", "application/json")
2385            .with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
2386            .create_async()
2387            .await;
2388
2389        let client = EthereumClient::with_base_url(&server.url());
2390        let result = client
2391            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2392            .await;
2393        assert!(result.is_err());
2394    }
2395
2396    #[tokio::test]
2397    async fn test_get_token_holders_success() {
2398        let mut server = mockito::Server::new_async().await;
2399        let _mock = server
2400            .mock("GET", mockito::Matcher::Any)
2401            .with_status(200)
2402            .with_header("content-type", "application/json")
2403            .with_body(
2404                r#"{"status":"1","message":"OK","result":[
2405                {"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
2406                {"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
2407            ]}"#,
2408            )
2409            .create_async()
2410            .await;
2411
2412        let client = EthereumClient::with_base_url(&server.url());
2413        let holders = client
2414            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2415            .await
2416            .unwrap();
2417        assert_eq!(holders.len(), 2);
2418        assert_eq!(holders[0].rank, 1);
2419        assert_eq!(holders[1].rank, 2);
2420        assert!(holders[0].percentage > 0.0);
2421    }
2422
2423    #[tokio::test]
2424    async fn test_get_token_info_unknown_token() {
2425        let mut server = mockito::Server::new_async().await;
2426        // First call for tokeninfo - return empty/error
2427        let _mock = server
2428            .mock("GET", mockito::Matcher::Any)
2429            .with_status(200)
2430            .with_header("content-type", "application/json")
2431            .with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
2432            .create_async()
2433            .await;
2434
2435        let client = EthereumClient::with_base_url(&server.url());
2436        let token = client
2437            .get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
2438            .await
2439            .unwrap();
2440        assert_eq!(token.symbol, "UNKNOWN");
2441    }
2442
2443    #[tokio::test]
2444    async fn test_get_transaction_with_null_block_number() {
2445        let mut server = mockito::Server::new_async().await;
2446        let _mock = server
2447            .mock("GET", mockito::Matcher::Any)
2448            .with_status(200)
2449            .with_header("content-type", "application/json")
2450            .with_body(
2451                r#"{"jsonrpc":"2.0","id":1,"result":{
2452                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2453                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2454                "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2455                "value":"0xde0b6b3a7640000",
2456                "gas":"0x5208",
2457                "gasPrice":"0x3b9aca00",
2458                "nonce":"0x5",
2459                "input":"0x",
2460                "blockNumber":null
2461            }}"#,
2462            )
2463            .create_async()
2464            .await;
2465
2466        let client = EthereumClient::with_base_url(&server.url());
2467        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2468        // blockNumber is null, so timestamp should also be None
2469        assert!(tx.timestamp.is_none());
2470    }
2471
2472    #[tokio::test]
2473    async fn test_chain_client_trait_balance() {
2474        let mut server = mockito::Server::new_async().await;
2475        let _mock = server
2476            .mock("GET", mockito::Matcher::Any)
2477            .with_status(200)
2478            .with_header("content-type", "application/json")
2479            .with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
2480            .create_async()
2481            .await;
2482
2483        let client = EthereumClient::with_base_url(&server.url());
2484        let chain_client: &dyn ChainClient = &client;
2485        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
2486        assert_eq!(balance.symbol, "ETH");
2487    }
2488
2489    #[tokio::test]
2490    async fn test_chain_client_trait_get_transaction() {
2491        let mut server = mockito::Server::new_async().await;
2492        let _mock = server
2493            .mock("GET", mockito::Matcher::Any)
2494            .with_status(200)
2495            .with_header("content-type", "application/json")
2496            .with_body(
2497                r#"{"jsonrpc":"2.0","id":1,"result":{
2498                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2499                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2500                "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2501                "value":"0xde0b6b3a7640000",
2502                "gas":"0x5208",
2503                "gasPrice":"0x3b9aca00",
2504                "nonce":"0x5",
2505                "input":"0x",
2506                "blockNumber":"0xf4240"
2507            }}"#,
2508            )
2509            .create_async()
2510            .await;
2511
2512        let client = EthereumClient::with_base_url(&server.url());
2513        let chain_client: &dyn ChainClient = &client;
2514        let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
2515        assert!(!tx.hash.is_empty());
2516    }
2517
2518    #[tokio::test]
2519    async fn test_chain_client_trait_get_block_number() {
2520        let mut server = mockito::Server::new_async().await;
2521        let _mock = server
2522            .mock("GET", mockito::Matcher::Any)
2523            .with_status(200)
2524            .with_header("content-type", "application/json")
2525            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
2526            .create_async()
2527            .await;
2528
2529        let client = EthereumClient::with_base_url(&server.url());
2530        let chain_client: &dyn ChainClient = &client;
2531        let block = chain_client.get_block_number().await.unwrap();
2532        assert_eq!(block, 1000000);
2533    }
2534
2535    #[tokio::test]
2536    async fn test_chain_client_trait_get_transactions() {
2537        let mut server = mockito::Server::new_async().await;
2538        let _mock = server
2539            .mock("GET", mockito::Matcher::Any)
2540            .with_status(200)
2541            .with_header("content-type", "application/json")
2542            .with_body(
2543                r#"{"status":"1","message":"OK","result":[{
2544                "hash":"0xabc",
2545                "blockNumber":"12345",
2546                "timeStamp":"1700000000",
2547                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2548                "to":"0x1111111111111111111111111111111111111111",
2549                "value":"1000000000000000000",
2550                "gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
2551                "nonce":"1","input":"0x","isError":"0"
2552            }]}"#,
2553            )
2554            .create_async()
2555            .await;
2556
2557        let client = EthereumClient::with_base_url(&server.url());
2558        let chain_client: &dyn ChainClient = &client;
2559        let txs = chain_client
2560            .get_transactions(VALID_ADDRESS, 10)
2561            .await
2562            .unwrap();
2563        assert_eq!(txs.len(), 1);
2564    }
2565
2566    #[tokio::test]
2567    async fn test_chain_client_trait_get_token_balances() {
2568        let mut server = mockito::Server::new_async().await;
2569        // First call: tokentx returns tokens
2570        let _tokentx = server
2571            .mock("GET", mockito::Matcher::Any)
2572            .with_status(200)
2573            .with_header("content-type", "application/json")
2574            .with_body(
2575                r#"{"status":"1","message":"OK","result":[{
2576                "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2577                "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2578            }]}"#,
2579            )
2580            .expect_at_most(1)
2581            .create_async()
2582            .await;
2583
2584        // Second call: token balance
2585        let _balance = server
2586            .mock("GET", mockito::Matcher::Any)
2587            .with_status(200)
2588            .with_header("content-type", "application/json")
2589            .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2590            .create_async()
2591            .await;
2592
2593        let client = EthereumClient::with_base_url(&server.url());
2594        let chain_client: &dyn ChainClient = &client;
2595        let balances = chain_client
2596            .get_token_balances(VALID_ADDRESS)
2597            .await
2598            .unwrap();
2599        assert!(!balances.is_empty());
2600    }
2601
2602    #[tokio::test]
2603    async fn test_chain_client_trait_get_token_info() {
2604        let mut server = mockito::Server::new_async().await;
2605        let _mock = server
2606            .mock("GET", mockito::Matcher::Any)
2607            .with_status(200)
2608            .with_header("content-type", "application/json")
2609            .with_body(
2610                r#"{"status":"1","message":"OK","result":[{
2611                "tokenName":"Tether USD","symbol":"USDT",
2612                "divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
2613            }]}"#,
2614            )
2615            .create_async()
2616            .await;
2617
2618        let client = EthereumClient::with_base_url(&server.url());
2619        let chain_client: &dyn ChainClient = &client;
2620        let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
2621        assert_eq!(token.symbol, "USDT");
2622    }
2623
2624    #[tokio::test]
2625    async fn test_chain_client_trait_get_token_holders() {
2626        let mut server = mockito::Server::new_async().await;
2627        let _mock = server
2628            .mock("GET", mockito::Matcher::Any)
2629            .with_status(200)
2630            .with_header("content-type", "application/json")
2631            .with_body(
2632                r#"{"status":"1","message":"OK","result":[
2633                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
2634            ]}"#,
2635            )
2636            .create_async()
2637            .await;
2638
2639        let client = EthereumClient::with_base_url(&server.url());
2640        let chain_client: &dyn ChainClient = &client;
2641        let holders = chain_client
2642            .get_token_holders(VALID_ADDRESS, 10)
2643            .await
2644            .unwrap();
2645        assert_eq!(holders.len(), 1);
2646    }
2647
2648    #[tokio::test]
2649    async fn test_chain_client_trait_get_token_holder_count() {
2650        let mut server = mockito::Server::new_async().await;
2651        let _mock = server
2652            .mock("GET", mockito::Matcher::Any)
2653            .with_status(200)
2654            .with_header("content-type", "application/json")
2655            .with_body(
2656                r#"{"status":"1","message":"OK","result":[
2657                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
2658                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
2659            ]}"#,
2660            )
2661            .create_async()
2662            .await;
2663
2664        let client = EthereumClient::with_base_url(&server.url());
2665        let chain_client: &dyn ChainClient = &client;
2666        let count = chain_client
2667            .get_token_holder_count(VALID_ADDRESS)
2668            .await
2669            .unwrap();
2670        assert_eq!(count, 2);
2671    }
2672
2673    #[tokio::test]
2674    async fn test_chain_client_trait_native_token_symbol() {
2675        let client = EthereumClient::with_base_url("http://test");
2676        let chain_client: &dyn ChainClient = &client;
2677        assert_eq!(chain_client.native_token_symbol(), "ETH");
2678        assert_eq!(chain_client.chain_name(), "ethereum");
2679    }
2680
2681    #[tokio::test]
2682    async fn test_get_token_info_supply_fallback_no_contract() {
2683        let mut server = mockito::Server::new_async().await;
2684        // First request: tokeninfo fails
2685        let _info_mock = server
2686            .mock("GET", mockito::Matcher::Any)
2687            .with_status(200)
2688            .with_header("content-type", "application/json")
2689            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2690            .expect_at_most(1)
2691            .create_async()
2692            .await;
2693
2694        // Second request: tokensupply succeeds (valid ERC20)
2695        let _supply_mock = server
2696            .mock("GET", mockito::Matcher::Any)
2697            .with_status(200)
2698            .with_header("content-type", "application/json")
2699            .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2700            .expect_at_most(1)
2701            .create_async()
2702            .await;
2703
2704        // Third request: getsourcecode returns empty contract name
2705        let _source_mock = server
2706            .mock("GET", mockito::Matcher::Any)
2707            .with_status(200)
2708            .with_header("content-type", "application/json")
2709            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2710            .create_async()
2711            .await;
2712
2713        let client = EthereumClient::with_base_url(&server.url());
2714        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2715        // Should return address-based placeholder since contract name is empty
2716        assert!(token.symbol.contains("...") || !token.symbol.is_empty());
2717    }
2718
2719    #[tokio::test]
2720    async fn test_try_get_contract_name_short_lowercase() {
2721        let mut server = mockito::Server::new_async().await;
2722        let _mock = server
2723            .mock("GET", mockito::Matcher::Any)
2724            .with_status(200)
2725            .with_header("content-type", "application/json")
2726            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
2727            .create_async()
2728            .await;
2729
2730        let client = EthereumClient::with_base_url(&server.url());
2731        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2732        assert!(result.is_some());
2733        let (symbol, name) = result.unwrap();
2734        assert_eq!(name, "token");
2735        // Name <= 6 chars → uppercased directly
2736        assert_eq!(symbol, "TOKEN");
2737    }
2738
2739    #[tokio::test]
2740    async fn test_try_get_contract_name_long_lowercase() {
2741        let mut server = mockito::Server::new_async().await;
2742        let _mock = server
2743            .mock("GET", mockito::Matcher::Any)
2744            .with_status(200)
2745            .with_header("content-type", "application/json")
2746            .with_body(
2747                r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
2748            )
2749            .create_async()
2750            .await;
2751
2752        let client = EthereumClient::with_base_url(&server.url());
2753        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2754        assert!(result.is_some());
2755        let (symbol, name) = result.unwrap();
2756        assert_eq!(name, "mytokencontract");
2757        // Long name with no uppercase → extract uppercase chars gets empty string
2758        // Falls back to first 6 chars uppercased
2759        assert_eq!(symbol, "MYTOKE");
2760    }
2761
2762    #[tokio::test]
2763    async fn test_get_erc20_balances_zero_balance_skipped() {
2764        let mut server = mockito::Server::new_async().await;
2765        // First request: token transfers
2766        let _tokentx = server
2767            .mock("GET", mockito::Matcher::Any)
2768            .with_status(200)
2769            .with_header("content-type", "application/json")
2770            .with_body(
2771                r#"{"status":"1","message":"OK","result":[{
2772                "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2773                "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2774            }]}"#,
2775            )
2776            .expect_at_most(1)
2777            .create_async()
2778            .await;
2779
2780        // Second request: token balance returns zero
2781        let _balance = server
2782            .mock("GET", mockito::Matcher::Any)
2783            .with_status(200)
2784            .with_header("content-type", "application/json")
2785            .with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
2786            .create_async()
2787            .await;
2788
2789        let client = EthereumClient::with_base_url(&server.url());
2790        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2791        // Zero balance should be skipped
2792        assert!(balances.is_empty());
2793    }
2794}