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