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    /// Reads a storage slot value at a contract address (eth_getStorageAt).
895    pub async fn get_storage_at(&self, address: &str, slot: &str) -> Result<String> {
896        validate_eth_address(address)?;
897
898        let url = self.build_api_url(&format!(
899            "module=proxy&action=eth_getStorageAt&address={}&position={}&tag=latest",
900            address, slot
901        ));
902
903        #[derive(Deserialize)]
904        struct StorageResponse {
905            result: Option<String>,
906        }
907
908        let response: StorageResponse = self.client.get(&url).send().await?.json().await?;
909        Ok(response.result.unwrap_or_else(|| {
910            "0x0000000000000000000000000000000000000000000000000000000000000000".to_string()
911        }))
912    }
913
914    /// Fetches ERC-20 token balances for an address.
915    ///
916    /// Uses Etherscan's tokentx endpoint to find unique tokens the address
917    /// has interacted with, then fetches current balances for each.
918    pub async fn get_erc20_balances(
919        &self,
920        address: &str,
921    ) -> Result<Vec<crate::chains::TokenBalance>> {
922        validate_eth_address(address)?;
923
924        // Step 1: Get recent ERC-20 token transfers to find unique tokens
925        let url = self.build_api_url(&format!(
926            "module=account&action=tokentx&address={}&page=1&offset=100&sort=desc",
927            address
928        ));
929
930        tracing::debug!(url = %url, "Fetching ERC-20 token transfers");
931
932        let response = self.client.get(&url).send().await?.text().await?;
933
934        #[derive(Deserialize)]
935        struct TokenTxItem {
936            #[serde(rename = "contractAddress")]
937            contract_address: String,
938            #[serde(rename = "tokenSymbol")]
939            token_symbol: String,
940            #[serde(rename = "tokenName")]
941            token_name: String,
942            #[serde(rename = "tokenDecimal")]
943            token_decimal: String,
944        }
945
946        let parsed: std::result::Result<ApiResponse<Vec<TokenTxItem>>, _> =
947            serde_json::from_str(&response);
948
949        let token_txs = match parsed {
950            Ok(api_resp) if api_resp.status == "1" => api_resp.result,
951            _ => return Ok(vec![]),
952        };
953
954        // Step 2: Deduplicate by contract address
955        let mut seen = std::collections::HashSet::new();
956        let unique_tokens: Vec<&TokenTxItem> = token_txs
957            .iter()
958            .filter(|tx| seen.insert(tx.contract_address.to_lowercase()))
959            .collect();
960
961        // Step 3: Fetch current balance for each unique token
962        let mut balances = Vec::new();
963        for token_tx in unique_tokens.iter().take(20) {
964            // Cap at 20 to avoid rate limits
965            let balance_url = self.build_api_url(&format!(
966                "module=account&action=tokenbalance&contractaddress={}&address={}&tag=latest",
967                token_tx.contract_address, address
968            ));
969
970            if let Ok(resp) = self.client.get(&balance_url).send().await {
971                if let Ok(bal_resp) = resp.json::<ApiResponse<String>>().await {
972                    if bal_resp.status == "1" {
973                        let raw_balance = bal_resp.result;
974                        let decimals: u8 = token_tx.token_decimal.parse().unwrap_or(18);
975
976                        // Skip zero balances
977                        if raw_balance == "0" {
978                            continue;
979                        }
980
981                        let formatted =
982                            crate::display::format_token_balance(&raw_balance, decimals);
983
984                        balances.push(crate::chains::TokenBalance {
985                            token: Token {
986                                contract_address: token_tx.contract_address.clone(),
987                                symbol: token_tx.token_symbol.clone(),
988                                name: token_tx.token_name.clone(),
989                                decimals,
990                            },
991                            balance: raw_balance,
992                            formatted_balance: formatted,
993                            usd_value: None,
994                        });
995                    }
996                }
997            }
998        }
999
1000        Ok(balances)
1001    }
1002
1003    /// Fetches token information for a contract address.
1004    ///
1005    /// # Arguments
1006    ///
1007    /// * `token_address` - The token contract address
1008    ///
1009    /// # Returns
1010    ///
1011    /// Returns [`Token`] information including name, symbol, and decimals.
1012    pub async fn get_token_info(&self, token_address: &str) -> Result<Token> {
1013        validate_eth_address(token_address)?;
1014
1015        // Try the Pro API tokeninfo endpoint first
1016        let url = self.build_api_url(&format!(
1017            "module=token&action=tokeninfo&contractaddress={}",
1018            token_address
1019        ));
1020
1021        tracing::debug!(url = %url, "Fetching token info (Pro API)");
1022
1023        let response = self.client.get(&url).send().await?;
1024        let response_text = response.text().await?;
1025
1026        // Try to parse as successful response
1027        if let Ok(api_response) =
1028            serde_json::from_str::<ApiResponse<Vec<TokenInfoItem>>>(&response_text)
1029        {
1030            if api_response.status == "1" && !api_response.result.is_empty() {
1031                let info = &api_response.result[0];
1032                let decimals = info
1033                    .divisor
1034                    .as_ref()
1035                    .and_then(|d| d.parse::<u32>().ok())
1036                    .map(|d| (d as f64).log10() as u8)
1037                    .unwrap_or(18);
1038
1039                return Ok(Token {
1040                    contract_address: token_address.to_string(),
1041                    symbol: info.symbol.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
1042                    name: info
1043                        .token_name
1044                        .clone()
1045                        .unwrap_or_else(|| "Unknown Token".to_string()),
1046                    decimals,
1047                });
1048            }
1049        }
1050
1051        // Fall back to tokensupply endpoint (free) to verify it's a valid ERC20
1052        // and get the total supply
1053        self.get_token_info_from_supply(token_address).await
1054    }
1055
1056    /// Gets basic token info using the tokensupply endpoint.
1057    async fn get_token_info_from_supply(&self, token_address: &str) -> Result<Token> {
1058        let url = self.build_api_url(&format!(
1059            "module=stats&action=tokensupply&contractaddress={}",
1060            token_address
1061        ));
1062
1063        tracing::debug!(url = %url, "Fetching token supply");
1064
1065        let response = self.client.get(&url).send().await?;
1066        let response_text = response.text().await?;
1067
1068        // Check if the token supply call succeeded (indicates valid ERC20)
1069        if let Ok(api_response) = serde_json::from_str::<ApiResponse<String>>(&response_text) {
1070            if api_response.status == "1" {
1071                // Valid ERC20 token, but we don't have name/symbol
1072                // Try to get them from contract source if verified
1073                if let Some(contract_info) = self.try_get_contract_name(token_address).await {
1074                    return Ok(Token {
1075                        contract_address: token_address.to_string(),
1076                        symbol: contract_info.0,
1077                        name: contract_info.1,
1078                        decimals: 18,
1079                    });
1080                }
1081
1082                // Return with address-based placeholder
1083                let short_addr = format!(
1084                    "{}...{}",
1085                    &token_address[..6],
1086                    &token_address[token_address.len() - 4..]
1087                );
1088                return Ok(Token {
1089                    contract_address: token_address.to_string(),
1090                    symbol: short_addr.clone(),
1091                    name: format!("Token {}", short_addr),
1092                    decimals: 18,
1093                });
1094            }
1095        }
1096
1097        // Not a valid ERC20 token
1098        Ok(Token {
1099            contract_address: token_address.to_string(),
1100            symbol: "UNKNOWN".to_string(),
1101            name: "Unknown Token".to_string(),
1102            decimals: 18,
1103        })
1104    }
1105
1106    /// Tries to get contract name from verified source code.
1107    async fn try_get_contract_name(&self, token_address: &str) -> Option<(String, String)> {
1108        let url = self.build_api_url(&format!(
1109            "module=contract&action=getsourcecode&address={}",
1110            token_address
1111        ));
1112
1113        let response = self.client.get(&url).send().await.ok()?;
1114        let text = response.text().await.ok()?;
1115
1116        // Parse the response to get ContractName
1117        #[derive(serde::Deserialize)]
1118        struct SourceCodeResult {
1119            #[serde(rename = "ContractName")]
1120            contract_name: Option<String>,
1121        }
1122
1123        #[derive(serde::Deserialize)]
1124        struct SourceCodeResponse {
1125            status: String,
1126            result: Vec<SourceCodeResult>,
1127        }
1128
1129        if let Ok(api_response) = serde_json::from_str::<SourceCodeResponse>(&text) {
1130            if api_response.status == "1" && !api_response.result.is_empty() {
1131                if let Some(name) = &api_response.result[0].contract_name {
1132                    if !name.is_empty() {
1133                        // Use contract name as both symbol and name
1134                        // Try to extract symbol from name (often in format "NameToken" or "NAME")
1135                        let symbol = if name.len() <= 6 {
1136                            name.to_uppercase()
1137                        } else {
1138                            // Take first letters that are uppercase
1139                            name.chars()
1140                                .filter(|c| c.is_uppercase())
1141                                .take(6)
1142                                .collect::<String>()
1143                        };
1144                        let symbol = if symbol.is_empty() {
1145                            name[..name.len().min(6)].to_uppercase()
1146                        } else {
1147                            symbol
1148                        };
1149                        return Some((symbol, name.clone()));
1150                    }
1151                }
1152            }
1153        }
1154
1155        None
1156    }
1157
1158    /// Fetches the top token holders for a given token.
1159    ///
1160    /// # Arguments
1161    ///
1162    /// * `token_address` - The token contract address
1163    /// * `limit` - Maximum number of holders to return (max 1000 for most APIs)
1164    ///
1165    /// # Returns
1166    ///
1167    /// Returns a vector of [`TokenHolder`] objects sorted by balance.
1168    ///
1169    /// # Note
1170    ///
1171    /// This requires an Etherscan Pro API key for most networks.
1172    pub async fn get_token_holders(
1173        &self,
1174        token_address: &str,
1175        limit: u32,
1176    ) -> Result<Vec<TokenHolder>> {
1177        validate_eth_address(token_address)?;
1178
1179        let effective_limit = limit.min(1000); // API max is typically 1000
1180
1181        let url = self.build_api_url(&format!(
1182            "module=token&action=tokenholderlist&contractaddress={}&page=1&offset={}",
1183            token_address, effective_limit
1184        ));
1185
1186        tracing::debug!(url = %url, "Fetching token holders");
1187
1188        let response = self.client.get(&url).send().await?;
1189        let response_text = response.text().await?;
1190
1191        // Parse the response
1192        let api_response: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
1193            .map_err(|e| ScopeError::Api(format!("Failed to parse holder response: {}", e)))?;
1194
1195        if api_response.status != "1" {
1196            // Check for common error messages
1197            if api_response.message.contains("Pro")
1198                || api_response.message.contains("API")
1199                || api_response.message.contains("NOTOK")
1200            {
1201                eprintln!("  ⚠ Holder data requires a Pro API key — skipping");
1202                tracing::debug!("Token holder API unavailable: {}", api_response.message);
1203                return Ok(Vec::new());
1204            }
1205            return Err(ScopeError::Api(format!(
1206                "API error: {}",
1207                api_response.message
1208            )));
1209        }
1210
1211        // Parse the holder list
1212        let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
1213            .map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
1214
1215        // Calculate total supply for percentage calculation
1216        let total_balance: f64 = holders
1217            .iter()
1218            .filter_map(|h| h.quantity.parse::<f64>().ok())
1219            .sum();
1220
1221        // Convert to TokenHolder structs
1222        let token_holders: Vec<TokenHolder> = holders
1223            .into_iter()
1224            .enumerate()
1225            .map(|(i, h)| {
1226                let balance: f64 = h.quantity.parse().unwrap_or(0.0);
1227                let percentage = if total_balance > 0.0 {
1228                    (balance / total_balance) * 100.0
1229                } else {
1230                    0.0
1231                };
1232
1233                TokenHolder {
1234                    address: h.address,
1235                    balance: h.quantity.clone(),
1236                    formatted_balance: crate::display::format_token_balance(&h.quantity, 18), // Default to 18 decimals
1237                    percentage,
1238                    rank: (i + 1) as u32,
1239                }
1240            })
1241            .collect();
1242
1243        Ok(token_holders)
1244    }
1245
1246    /// Gets the total holder count for a token.
1247    ///
1248    /// Uses Etherscan token holder list endpoint to estimate the count.
1249    /// If the API returns a full page at the max limit, the count is approximate.
1250    pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
1251        // First try to get a large page of holders - the page size tells us if there are more
1252        let max_page_size: u32 = 1000;
1253        let holders = self.get_token_holders(token_address, max_page_size).await?;
1254
1255        if holders.is_empty() {
1256            return Ok(0);
1257        }
1258
1259        let count = holders.len() as u64;
1260
1261        if count < max_page_size as u64 {
1262            // We got all holders - this is the exact count
1263            Ok(count)
1264        } else {
1265            // The result was capped - there are at least this many holders.
1266            // Try fetching additional pages to refine the estimate.
1267            let mut total = count;
1268            let mut page = 2u32;
1269            loop {
1270                let url = self.build_api_url(&format!(
1271                    "module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
1272                    token_address, page, max_page_size
1273                ));
1274                let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
1275                    self.client.get(&url).send().await?.json().await;
1276
1277                match response {
1278                    Ok(api_resp) if api_resp.status == "1" => {
1279                        let page_count = api_resp.result.len() as u64;
1280                        total += page_count;
1281                        if page_count < max_page_size as u64 || page >= 10 {
1282                            // Got a partial page (end of list) or hit our max pages limit
1283                            break;
1284                        }
1285                        page += 1;
1286                    }
1287                    _ => break,
1288                }
1289            }
1290            Ok(total)
1291        }
1292    }
1293}
1294
1295impl Default for EthereumClient {
1296    fn default() -> Self {
1297        Self {
1298            client: Client::new(),
1299            base_url: ETHERSCAN_V2_API.to_string(),
1300            chain_id: Some("1".to_string()),
1301            api_key: None,
1302            chain_name: "ethereum".to_string(),
1303            native_symbol: "ETH".to_string(),
1304            native_decimals: 18,
1305            api_type: ApiType::BlockExplorer,
1306            rpc_fallback_url: None,
1307        }
1308    }
1309}
1310
1311/// Validates an Ethereum address format.
1312fn validate_eth_address(address: &str) -> Result<()> {
1313    if !address.starts_with("0x") {
1314        return Err(ScopeError::InvalidAddress(format!(
1315            "Address must start with '0x': {}",
1316            address
1317        )));
1318    }
1319    if address.len() != 42 {
1320        return Err(ScopeError::InvalidAddress(format!(
1321            "Address must be 42 characters: {}",
1322            address
1323        )));
1324    }
1325    if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1326        return Err(ScopeError::InvalidAddress(format!(
1327            "Address contains invalid hex characters: {}",
1328            address
1329        )));
1330    }
1331    Ok(())
1332}
1333
1334/// Validates a transaction hash format.
1335fn validate_tx_hash(hash: &str) -> Result<()> {
1336    if !hash.starts_with("0x") {
1337        return Err(ScopeError::InvalidHash(format!(
1338            "Hash must start with '0x': {}",
1339            hash
1340        )));
1341    }
1342    if hash.len() != 66 {
1343        return Err(ScopeError::InvalidHash(format!(
1344            "Hash must be 66 characters: {}",
1345            hash
1346        )));
1347    }
1348    if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1349        return Err(ScopeError::InvalidHash(format!(
1350            "Hash contains invalid hex characters: {}",
1351            hash
1352        )));
1353    }
1354    Ok(())
1355}
1356
1357// ============================================================================
1358// ChainClient Trait Implementation
1359// ============================================================================
1360
1361#[async_trait]
1362impl ChainClient for EthereumClient {
1363    fn chain_name(&self) -> &str {
1364        &self.chain_name
1365    }
1366
1367    fn native_token_symbol(&self) -> &str {
1368        &self.native_symbol
1369    }
1370
1371    async fn get_balance(&self, address: &str) -> Result<Balance> {
1372        self.get_balance(address).await
1373    }
1374
1375    async fn enrich_balance_usd(&self, balance: &mut Balance) {
1376        self.enrich_balance_usd(balance).await
1377    }
1378
1379    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
1380        self.get_transaction(hash).await
1381    }
1382
1383    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
1384        self.get_transactions(address, limit).await
1385    }
1386
1387    async fn get_block_number(&self) -> Result<u64> {
1388        self.get_block_number().await
1389    }
1390
1391    async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
1392        self.get_erc20_balances(address).await
1393    }
1394
1395    async fn get_token_info(&self, address: &str) -> Result<Token> {
1396        self.get_token_info(address).await
1397    }
1398
1399    async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
1400        self.get_token_holders(address, limit).await
1401    }
1402
1403    async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
1404        self.get_token_holder_count(address).await
1405    }
1406
1407    async fn get_code(&self, address: &str) -> Result<String> {
1408        self.get_code(address).await
1409    }
1410
1411    async fn get_storage_at(&self, address: &str, slot: &str) -> Result<String> {
1412        self.get_storage_at(address, slot).await
1413    }
1414}
1415
1416// ============================================================================
1417// Unit Tests
1418// ============================================================================
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423
1424    const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1425    const VALID_TX_HASH: &str =
1426        "0xabc123def456789012345678901234567890123456789012345678901234abcd";
1427
1428    #[test]
1429    fn test_validate_eth_address_valid() {
1430        assert!(validate_eth_address(VALID_ADDRESS).is_ok());
1431    }
1432
1433    #[test]
1434    fn test_validate_eth_address_lowercase() {
1435        let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
1436        assert!(validate_eth_address(addr).is_ok());
1437    }
1438
1439    #[test]
1440    fn test_validate_eth_address_missing_prefix() {
1441        let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1442        let result = validate_eth_address(addr);
1443        assert!(result.is_err());
1444        assert!(result.unwrap_err().to_string().contains("0x"));
1445    }
1446
1447    #[test]
1448    fn test_validate_eth_address_too_short() {
1449        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
1450        let result = validate_eth_address(addr);
1451        assert!(result.is_err());
1452        assert!(result.unwrap_err().to_string().contains("42 characters"));
1453    }
1454
1455    #[test]
1456    fn test_validate_eth_address_invalid_hex() {
1457        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
1458        let result = validate_eth_address(addr);
1459        assert!(result.is_err());
1460        assert!(result.unwrap_err().to_string().contains("invalid hex"));
1461    }
1462
1463    #[test]
1464    fn test_validate_tx_hash_valid() {
1465        assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
1466    }
1467
1468    #[test]
1469    fn test_validate_tx_hash_missing_prefix() {
1470        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
1471        let result = validate_tx_hash(hash);
1472        assert!(result.is_err());
1473    }
1474
1475    #[test]
1476    fn test_validate_tx_hash_too_short() {
1477        let hash = "0xabc123";
1478        let result = validate_tx_hash(hash);
1479        assert!(result.is_err());
1480        assert!(result.unwrap_err().to_string().contains("66 characters"));
1481    }
1482
1483    #[test]
1484    fn test_validate_eth_address_too_long() {
1485        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2extra";
1486        let result = validate_eth_address(addr);
1487        assert!(result.is_err());
1488        assert!(result.unwrap_err().to_string().contains("42 characters"));
1489    }
1490
1491    #[test]
1492    fn test_validate_eth_address_empty() {
1493        let result = validate_eth_address("");
1494        assert!(result.is_err());
1495    }
1496
1497    #[test]
1498    fn test_validate_eth_address_only_prefix() {
1499        let result = validate_eth_address("0x");
1500        assert!(result.is_err());
1501        assert!(result.unwrap_err().to_string().contains("42 characters"));
1502    }
1503
1504    #[test]
1505    fn test_validate_tx_hash_too_long() {
1506        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcdextra";
1507        let result = validate_tx_hash(hash);
1508        assert!(result.is_err());
1509        assert!(result.unwrap_err().to_string().contains("66 characters"));
1510    }
1511
1512    #[test]
1513    fn test_validate_tx_hash_empty() {
1514        let result = validate_tx_hash("");
1515        assert!(result.is_err());
1516    }
1517
1518    #[test]
1519    fn test_validate_tx_hash_only_prefix() {
1520        let result = validate_tx_hash("0x");
1521        assert!(result.is_err());
1522        assert!(result.unwrap_err().to_string().contains("66 characters"));
1523    }
1524
1525    #[test]
1526    fn test_ethereum_client_default() {
1527        let client = EthereumClient::default();
1528        assert_eq!(client.chain_name(), "ethereum");
1529        assert_eq!(client.native_token_symbol(), "ETH");
1530    }
1531
1532    #[test]
1533    fn test_ethereum_client_with_base_url() {
1534        let client = EthereumClient::with_base_url("https://custom.api.com");
1535        assert_eq!(client.base_url, "https://custom.api.com");
1536    }
1537
1538    #[test]
1539    fn test_ethereum_client_for_chain_ethereum() {
1540        let config = ChainsConfig::default();
1541        let client = EthereumClient::for_chain("ethereum", &config).unwrap();
1542        assert_eq!(client.chain_name(), "ethereum");
1543        assert_eq!(client.native_token_symbol(), "ETH");
1544    }
1545
1546    #[test]
1547    fn test_ethereum_client_for_chain_polygon() {
1548        let config = ChainsConfig::default();
1549        let client = EthereumClient::for_chain("polygon", &config).unwrap();
1550        assert_eq!(client.chain_name(), "polygon");
1551        assert_eq!(client.native_token_symbol(), "MATIC");
1552        // V2 API uses unified URL with chainid parameter
1553        assert!(client.base_url.contains("etherscan.io/v2"));
1554        assert_eq!(client.chain_id, Some("137".to_string()));
1555    }
1556
1557    #[test]
1558    fn test_ethereum_client_for_chain_arbitrum() {
1559        let config = ChainsConfig::default();
1560        let client = EthereumClient::for_chain("arbitrum", &config).unwrap();
1561        assert_eq!(client.chain_name(), "arbitrum");
1562        // V2 API uses unified URL with chainid parameter
1563        assert!(client.base_url.contains("etherscan.io/v2"));
1564        assert_eq!(client.chain_id, Some("42161".to_string()));
1565    }
1566
1567    #[test]
1568    fn test_ethereum_client_for_chain_bsc() {
1569        let config = ChainsConfig::default();
1570        let client = EthereumClient::for_chain("bsc", &config).unwrap();
1571        assert_eq!(client.chain_name(), "bsc");
1572        assert_eq!(client.native_token_symbol(), "BNB");
1573        // V2 API uses unified URL with chainid parameter
1574        assert!(client.base_url.contains("etherscan.io/v2"));
1575        assert_eq!(client.chain_id, Some("56".to_string()));
1576        assert_eq!(client.api_type, ApiType::BlockExplorer);
1577    }
1578
1579    #[test]
1580    fn test_ethereum_client_for_chain_aegis() {
1581        let config = ChainsConfig::default();
1582        let client = EthereumClient::for_chain("aegis", &config).unwrap();
1583        assert_eq!(client.chain_name(), "aegis");
1584        assert_eq!(client.native_token_symbol(), "WRAITH");
1585        assert_eq!(client.api_type, ApiType::JsonRpc);
1586        // Default URL when not configured
1587        assert!(client.base_url.contains("localhost:8545"));
1588    }
1589
1590    #[test]
1591    fn test_ethereum_client_for_chain_aegis_with_config() {
1592        let config = ChainsConfig {
1593            aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
1594            ..Default::default()
1595        };
1596        let client = EthereumClient::for_chain("aegis", &config).unwrap();
1597        assert_eq!(client.base_url, "https://aegis.example.com:8545");
1598    }
1599
1600    #[test]
1601    fn test_ethereum_client_for_chain_unsupported() {
1602        let config = ChainsConfig::default();
1603        let result = EthereumClient::for_chain("bitcoin", &config);
1604        assert!(result.is_err());
1605        assert!(
1606            result
1607                .unwrap_err()
1608                .to_string()
1609                .contains("Unsupported chain")
1610        );
1611    }
1612
1613    #[test]
1614    fn test_ethereum_client_new() {
1615        let config = ChainsConfig::default();
1616        let client = EthereumClient::new(&config);
1617        assert!(client.is_ok());
1618    }
1619
1620    #[test]
1621    fn test_ethereum_client_with_api_key() {
1622        use std::collections::HashMap;
1623
1624        let mut api_keys = HashMap::new();
1625        api_keys.insert("etherscan".to_string(), "test-key".to_string());
1626
1627        let config = ChainsConfig {
1628            api_keys,
1629            ..Default::default()
1630        };
1631
1632        let client = EthereumClient::new(&config).unwrap();
1633        assert_eq!(client.api_key, Some("test-key".to_string()));
1634    }
1635
1636    #[test]
1637    fn test_api_response_deserialization() {
1638        let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
1639        let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
1640        assert_eq!(response.status, "1");
1641        assert_eq!(response.message, "OK");
1642        assert_eq!(response.result, "1000000000000000000");
1643    }
1644
1645    #[test]
1646    fn test_tx_list_item_deserialization() {
1647        let json = r#"{
1648            "hash": "0xabc",
1649            "blockNumber": "12345",
1650            "timeStamp": "1700000000",
1651            "from": "0xfrom",
1652            "to": "0xto",
1653            "value": "1000000000000000000",
1654            "gas": "21000",
1655            "gasUsed": "21000",
1656            "gasPrice": "20000000000",
1657            "nonce": "42",
1658            "input": "0x",
1659            "isError": "0"
1660        }"#;
1661
1662        let item: TxListItem = serde_json::from_str(json).unwrap();
1663        assert_eq!(item.hash, "0xabc");
1664        assert_eq!(item.block_number, "12345");
1665        assert_eq!(item.nonce, "42");
1666        assert_eq!(item.is_error, "0");
1667    }
1668
1669    // ========================================================================
1670    // Pure function tests
1671    // ========================================================================
1672
1673    #[test]
1674    fn test_parse_balance_wei_valid() {
1675        let client = EthereumClient::default();
1676        let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1677        assert_eq!(balance.symbol, "ETH");
1678        assert_eq!(balance.raw, "1000000000000000000");
1679        assert!(balance.formatted.contains("1.000000"));
1680        assert!(balance.usd_value.is_none());
1681    }
1682
1683    #[test]
1684    fn test_parse_balance_wei_zero() {
1685        let client = EthereumClient::default();
1686        let balance = client.parse_balance_wei("0").unwrap();
1687        assert!(balance.formatted.contains("0.000000"));
1688    }
1689
1690    #[test]
1691    fn test_parse_balance_wei_invalid() {
1692        let client = EthereumClient::default();
1693        let result = client.parse_balance_wei("not_a_number");
1694        assert!(result.is_err());
1695    }
1696
1697    #[test]
1698    fn test_format_token_balance_large() {
1699        assert!(
1700            crate::display::format_token_balance("1000000000000000000000000000", 18).contains("B")
1701        );
1702    }
1703
1704    #[test]
1705    fn test_format_token_balance_millions() {
1706        assert!(
1707            crate::display::format_token_balance("5000000000000000000000000", 18).contains("M")
1708        );
1709    }
1710
1711    #[test]
1712    fn test_format_token_balance_thousands() {
1713        assert!(crate::display::format_token_balance("5000000000000000000000", 18).contains("K"));
1714    }
1715
1716    #[test]
1717    fn test_format_token_balance_small() {
1718        let formatted = crate::display::format_token_balance("500000000000000000", 18);
1719        assert!(formatted.contains("0.5"));
1720    }
1721
1722    #[test]
1723    fn test_format_token_balance_zero() {
1724        let formatted = crate::display::format_token_balance("0", 18);
1725        assert!(formatted.contains("0.0000"));
1726    }
1727
1728    #[test]
1729    fn test_build_api_url_with_chain_id_and_key() {
1730        use std::collections::HashMap;
1731        let mut keys = HashMap::new();
1732        keys.insert("etherscan".to_string(), "MYKEY".to_string());
1733        let config = ChainsConfig {
1734            api_keys: keys,
1735            ..Default::default()
1736        };
1737        let client = EthereumClient::new(&config).unwrap();
1738        let url = client.build_api_url("module=account&action=balance&address=0x123");
1739        assert!(url.contains("chainid=1"));
1740        assert!(url.contains("module=account"));
1741        assert!(url.contains("apikey=MYKEY"));
1742    }
1743
1744    #[test]
1745    fn test_build_api_url_no_chain_id_no_key() {
1746        let client = EthereumClient::with_base_url("https://example.com/api");
1747        let url = client.build_api_url("module=account&action=balance");
1748        assert_eq!(url, "https://example.com/api?module=account&action=balance");
1749        assert!(!url.contains("chainid"));
1750        assert!(!url.contains("apikey"));
1751    }
1752
1753    // ========================================================================
1754    // HTTP mocking tests - Block Explorer API
1755    // ========================================================================
1756
1757    #[tokio::test]
1758    async fn test_get_balance_explorer() {
1759        let mut server = mockito::Server::new_async().await;
1760        let _mock = server
1761            .mock("GET", mockito::Matcher::Any)
1762            .with_status(200)
1763            .with_header("content-type", "application/json")
1764            .with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
1765            .create_async()
1766            .await;
1767
1768        let client = EthereumClient::with_base_url(&server.url());
1769        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1770        assert_eq!(balance.raw, "2500000000000000000");
1771        assert_eq!(balance.symbol, "ETH");
1772        assert!(balance.formatted.contains("2.5"));
1773    }
1774
1775    #[tokio::test]
1776    async fn test_get_balance_explorer_api_error() {
1777        let mut server = mockito::Server::new_async().await;
1778        let _mock = server
1779            .mock("GET", mockito::Matcher::Any)
1780            .with_status(200)
1781            .with_header("content-type", "application/json")
1782            .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
1783            .create_async()
1784            .await;
1785
1786        let client = EthereumClient::with_base_url(&server.url());
1787        let result = client.get_balance(VALID_ADDRESS).await;
1788        assert!(result.is_err());
1789        assert!(result.unwrap_err().to_string().contains("API error"));
1790    }
1791
1792    #[tokio::test]
1793    async fn test_get_balance_explorer_api_error_result_empty() {
1794        let mut server = mockito::Server::new_async().await;
1795        let _mock = server
1796            .mock("GET", mockito::Matcher::Any)
1797            .with_status(200)
1798            .with_header("content-type", "application/json")
1799            .with_body(r#"{"status":"0","message":"Invalid API Key","result":""}"#)
1800            .create_async()
1801            .await;
1802
1803        let client = EthereumClient::with_base_url(&server.url());
1804        let result = client.get_balance(VALID_ADDRESS).await;
1805        assert!(result.is_err());
1806        let err = result.unwrap_err().to_string();
1807        assert!(err.contains("API error"));
1808        assert!(err.contains("Invalid API Key"));
1809    }
1810
1811    #[tokio::test]
1812    async fn test_get_balance_explorer_api_error_result_hex() {
1813        let mut server = mockito::Server::new_async().await;
1814        let _mock = server
1815            .mock("GET", mockito::Matcher::Any)
1816            .with_status(200)
1817            .with_header("content-type", "application/json")
1818            .with_body(r#"{"status":"0","message":"Rate limit","result":"0x"}"#)
1819            .create_async()
1820            .await;
1821
1822        let client = EthereumClient::with_base_url(&server.url());
1823        let result = client.get_balance(VALID_ADDRESS).await;
1824        assert!(result.is_err());
1825        assert!(result.unwrap_err().to_string().contains("API error"));
1826    }
1827
1828    #[test]
1829    fn test_ethereum_client_for_chain_optimism() {
1830        let config = ChainsConfig::default();
1831        let client = EthereumClient::for_chain("optimism", &config).unwrap();
1832        assert_eq!(client.chain_name(), "optimism");
1833        assert_eq!(client.native_token_symbol(), "ETH");
1834        assert_eq!(client.chain_id, Some("10".to_string()));
1835    }
1836
1837    #[test]
1838    fn test_ethereum_client_for_chain_base() {
1839        let config = ChainsConfig::default();
1840        let client = EthereumClient::for_chain("base", &config).unwrap();
1841        assert_eq!(client.chain_name(), "base");
1842        assert_eq!(client.native_token_symbol(), "ETH");
1843        assert_eq!(client.chain_id, Some("8453".to_string()));
1844    }
1845
1846    #[test]
1847    fn test_parse_balance_wei_bsc_client() {
1848        let config = ChainsConfig::default();
1849        let client = EthereumClient::for_chain("bsc", &config).unwrap();
1850        let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1851        assert_eq!(balance.symbol, "BNB");
1852    }
1853
1854    #[tokio::test]
1855    async fn test_get_balance_bsc_rpc_fallback_on_free_tier_restriction() {
1856        let mut server = mockito::Server::new_async().await;
1857        let _explorer_mock = server
1858            .mock("GET", mockito::Matcher::Any)
1859            .with_status(200)
1860            .with_header("content-type", "application/json")
1861            .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"}"#)
1862            .create_async()
1863            .await;
1864
1865        let _rpc_mock = server
1866            .mock("POST", "/")
1867            .with_status(200)
1868            .with_header("content-type", "application/json")
1869            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1870            .create_async()
1871            .await;
1872
1873        let client =
1874            EthereumClient::with_base_url_and_rpc_fallback(&server.url(), Some(server.url()));
1875        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1876        assert_eq!(balance.symbol, "BNB");
1877        assert!(balance.formatted.contains("1.000000"));
1878    }
1879
1880    #[tokio::test]
1881    async fn test_get_balance_invalid_address() {
1882        let client = EthereumClient::default();
1883        let result = client.get_balance("invalid").await;
1884        assert!(result.is_err());
1885    }
1886
1887    #[tokio::test]
1888    async fn test_get_balance_rpc() {
1889        let mut server = mockito::Server::new_async().await;
1890        let _mock = server
1891            .mock("POST", "/")
1892            .with_status(200)
1893            .with_header("content-type", "application/json")
1894            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1895            .create_async()
1896            .await;
1897
1898        let client = EthereumClient {
1899            client: Client::new(),
1900            base_url: server.url(),
1901            chain_id: None,
1902            api_key: None,
1903            chain_name: "aegis".to_string(),
1904            native_symbol: "WRAITH".to_string(),
1905            native_decimals: 18,
1906            api_type: ApiType::JsonRpc,
1907            rpc_fallback_url: None,
1908        };
1909        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1910        assert_eq!(balance.symbol, "WRAITH");
1911        assert!(balance.formatted.contains("1.000000"));
1912    }
1913
1914    #[tokio::test]
1915    async fn test_get_balance_rpc_error() {
1916        let mut server = mockito::Server::new_async().await;
1917        let _mock = server
1918            .mock("POST", "/")
1919            .with_status(200)
1920            .with_header("content-type", "application/json")
1921            .with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
1922            .create_async()
1923            .await;
1924
1925        let client = EthereumClient {
1926            client: Client::new(),
1927            base_url: server.url(),
1928            chain_id: None,
1929            api_key: None,
1930            chain_name: "aegis".to_string(),
1931            native_symbol: "WRAITH".to_string(),
1932            native_decimals: 18,
1933            api_type: ApiType::JsonRpc,
1934            rpc_fallback_url: None,
1935        };
1936        let result = client.get_balance(VALID_ADDRESS).await;
1937        assert!(result.is_err());
1938        assert!(result.unwrap_err().to_string().contains("RPC error"));
1939    }
1940
1941    #[tokio::test]
1942    async fn test_get_balance_rpc_empty_result() {
1943        let mut server = mockito::Server::new_async().await;
1944        let _mock = server
1945            .mock("POST", "/")
1946            .with_status(200)
1947            .with_header("content-type", "application/json")
1948            .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1949            .create_async()
1950            .await;
1951
1952        let client = EthereumClient {
1953            client: Client::new(),
1954            base_url: server.url(),
1955            chain_id: None,
1956            api_key: None,
1957            chain_name: "aegis".to_string(),
1958            native_symbol: "WRAITH".to_string(),
1959            native_decimals: 18,
1960            api_type: ApiType::JsonRpc,
1961            rpc_fallback_url: None,
1962        };
1963        let result = client.get_balance(VALID_ADDRESS).await;
1964        assert!(result.is_err());
1965        assert!(
1966            result
1967                .unwrap_err()
1968                .to_string()
1969                .contains("Empty RPC response")
1970        );
1971    }
1972
1973    #[tokio::test]
1974    async fn test_get_transaction_explorer() {
1975        let mut server = mockito::Server::new_async().await;
1976        // The explorer makes 3 sequential requests: tx, receipt, block
1977        let _mock = server
1978            .mock("GET", mockito::Matcher::Any)
1979            .with_status(200)
1980            .with_header("content-type", "application/json")
1981            .with_body(
1982                r#"{"jsonrpc":"2.0","id":1,"result":{
1983                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1984                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1985                "to":"0x1111111111111111111111111111111111111111",
1986                "blockNumber":"0x10",
1987                "gas":"0x5208",
1988                "gasPrice":"0x4A817C800",
1989                "nonce":"0x2A",
1990                "value":"0xDE0B6B3A7640000",
1991                "input":"0x"
1992            }}"#,
1993            )
1994            .expect_at_most(1)
1995            .create_async()
1996            .await;
1997
1998        // Receipt response
1999        let _receipt_mock = server
2000            .mock("GET", mockito::Matcher::Any)
2001            .with_status(200)
2002            .with_header("content-type", "application/json")
2003            .with_body(
2004                r#"{"jsonrpc":"2.0","id":1,"result":{
2005                "gasUsed":"0x5208",
2006                "status":"0x1"
2007            }}"#,
2008            )
2009            .expect_at_most(1)
2010            .create_async()
2011            .await;
2012
2013        // Block timestamp response
2014        let _block_mock = server
2015            .mock("GET", mockito::Matcher::Any)
2016            .with_status(200)
2017            .with_header("content-type", "application/json")
2018            .with_body(
2019                r#"{"jsonrpc":"2.0","id":1,"result":{
2020                "timestamp":"0x65A8C580"
2021            }}"#,
2022            )
2023            .create_async()
2024            .await;
2025
2026        let client = EthereumClient::with_base_url(&server.url());
2027        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2028        assert_eq!(tx.hash, VALID_TX_HASH);
2029        assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2030        assert_eq!(
2031            tx.to,
2032            Some("0x1111111111111111111111111111111111111111".to_string())
2033        );
2034        assert!(tx.gas_limit > 0);
2035        assert!(tx.nonce > 0);
2036    }
2037
2038    #[tokio::test]
2039    async fn test_get_transaction_explorer_not_found() {
2040        let mut server = mockito::Server::new_async().await;
2041        let _mock = server
2042            .mock("GET", mockito::Matcher::Any)
2043            .with_status(200)
2044            .with_header("content-type", "application/json")
2045            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2046            .create_async()
2047            .await;
2048
2049        let client = EthereumClient::with_base_url(&server.url());
2050        let result = client.get_transaction(VALID_TX_HASH).await;
2051        assert!(result.is_err());
2052        assert!(result.unwrap_err().to_string().contains("not found"));
2053    }
2054
2055    #[tokio::test]
2056    async fn test_get_transaction_rpc() {
2057        let mut server = mockito::Server::new_async().await;
2058        // Transaction response
2059        let _tx_mock = server
2060            .mock("POST", "/")
2061            .with_status(200)
2062            .with_header("content-type", "application/json")
2063            .with_body(
2064                r#"{"jsonrpc":"2.0","id":1,"result":{
2065                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2066                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2067                "to":"0x1111111111111111111111111111111111111111",
2068                "blockNumber":"0x10",
2069                "gas":"0x5208",
2070                "gasPrice":"0x4A817C800",
2071                "nonce":"0x2A",
2072                "value":"0xDE0B6B3A7640000",
2073                "input":"0x"
2074            }}"#,
2075            )
2076            .expect_at_most(1)
2077            .create_async()
2078            .await;
2079
2080        // Receipt response
2081        let _receipt_mock = server
2082            .mock("POST", "/")
2083            .with_status(200)
2084            .with_header("content-type", "application/json")
2085            .with_body(
2086                r#"{"jsonrpc":"2.0","id":2,"result":{
2087                "gasUsed":"0x5208",
2088                "status":"0x1"
2089            }}"#,
2090            )
2091            .create_async()
2092            .await;
2093
2094        let client = EthereumClient {
2095            client: Client::new(),
2096            base_url: server.url(),
2097            chain_id: None,
2098            api_key: None,
2099            chain_name: "aegis".to_string(),
2100            native_symbol: "WRAITH".to_string(),
2101            native_decimals: 18,
2102            api_type: ApiType::JsonRpc,
2103            rpc_fallback_url: None,
2104        };
2105        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2106        assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2107        assert!(tx.status.unwrap());
2108        assert!(tx.timestamp.is_none()); // JSON-RPC doesn't fetch timestamp
2109    }
2110
2111    #[tokio::test]
2112    async fn test_get_transaction_invalid_hash() {
2113        let client = EthereumClient::default();
2114        let result = client.get_transaction("0xbad").await;
2115        assert!(result.is_err());
2116    }
2117
2118    #[tokio::test]
2119    async fn test_get_transactions() {
2120        let mut server = mockito::Server::new_async().await;
2121        let _mock = server
2122            .mock("GET", mockito::Matcher::Any)
2123            .with_status(200)
2124            .with_header("content-type", "application/json")
2125            .with_body(
2126                r#"{"status":"1","message":"OK","result":[
2127                {
2128                    "hash":"0xabc",
2129                    "blockNumber":"12345",
2130                    "timeStamp":"1700000000",
2131                    "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2132                    "to":"0x1111111111111111111111111111111111111111",
2133                    "value":"1000000000000000000",
2134                    "gas":"21000",
2135                    "gasUsed":"21000",
2136                    "gasPrice":"20000000000",
2137                    "nonce":"1",
2138                    "input":"0x",
2139                    "isError":"0"
2140                },
2141                {
2142                    "hash":"0xdef",
2143                    "blockNumber":"12346",
2144                    "timeStamp":"1700000060",
2145                    "from":"0x1111111111111111111111111111111111111111",
2146                    "to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2147                    "value":"500000000000000000",
2148                    "gas":"21000",
2149                    "gasUsed":"21000",
2150                    "gasPrice":"20000000000",
2151                    "nonce":"5",
2152                    "input":"0x",
2153                    "isError":"1"
2154                }
2155            ]}"#,
2156            )
2157            .create_async()
2158            .await;
2159
2160        let client = EthereumClient::with_base_url(&server.url());
2161        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2162        assert_eq!(txs.len(), 2);
2163        assert_eq!(txs[0].hash, "0xabc");
2164        assert!(txs[0].status.unwrap()); // isError == "0" → success
2165        assert!(!txs[1].status.unwrap()); // isError == "1" → failure
2166        assert_eq!(txs[1].nonce, 5);
2167    }
2168
2169    #[tokio::test]
2170    async fn test_get_transactions_no_transactions() {
2171        let mut server = mockito::Server::new_async().await;
2172        let _mock = server
2173            .mock("GET", mockito::Matcher::Any)
2174            .with_status(200)
2175            .with_header("content-type", "application/json")
2176            .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2177            .create_async()
2178            .await;
2179
2180        let client = EthereumClient::with_base_url(&server.url());
2181        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2182        assert!(txs.is_empty());
2183    }
2184
2185    #[tokio::test]
2186    async fn test_get_transactions_empty_to_field() {
2187        let mut server = mockito::Server::new_async().await;
2188        let _mock = server
2189            .mock("GET", mockito::Matcher::Any)
2190            .with_status(200)
2191            .with_header("content-type", "application/json")
2192            .with_body(
2193                r#"{"status":"1","message":"OK","result":[{
2194                "hash":"0xabc",
2195                "blockNumber":"12345",
2196                "timeStamp":"1700000000",
2197                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2198                "to":"",
2199                "value":"0",
2200                "gas":"200000",
2201                "gasUsed":"150000",
2202                "gasPrice":"20000000000",
2203                "nonce":"1",
2204                "input":"0x60806040",
2205                "isError":"0"
2206            }]}"#,
2207            )
2208            .create_async()
2209            .await;
2210
2211        let client = EthereumClient::with_base_url(&server.url());
2212        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2213        assert_eq!(txs.len(), 1);
2214        assert!(txs[0].to.is_none()); // Empty "to" → contract creation
2215    }
2216
2217    #[tokio::test]
2218    async fn test_get_block_number() {
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(r#"{"result":"0x1234AB"}"#)
2225            .create_async()
2226            .await;
2227
2228        let client = EthereumClient::with_base_url(&server.url());
2229        let block = client.get_block_number().await.unwrap();
2230        assert_eq!(block, 0x1234AB);
2231    }
2232
2233    #[tokio::test]
2234    async fn test_get_erc20_balances() {
2235        let mut server = mockito::Server::new_async().await;
2236        // First request: token transfers
2237        let _tokentx_mock = server
2238            .mock("GET", mockito::Matcher::Any)
2239            .with_status(200)
2240            .with_header("content-type", "application/json")
2241            .with_body(
2242                r#"{"status":"1","message":"OK","result":[
2243                {
2244                    "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2245                    "tokenSymbol":"USDT",
2246                    "tokenName":"Tether USD",
2247                    "tokenDecimal":"6"
2248                },
2249                {
2250                    "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2251                    "tokenSymbol":"USDT",
2252                    "tokenName":"Tether USD",
2253                    "tokenDecimal":"6"
2254                },
2255                {
2256                    "contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2257                    "tokenSymbol":"USDC",
2258                    "tokenName":"USD Coin",
2259                    "tokenDecimal":"6"
2260                }
2261            ]}"#,
2262            )
2263            .expect_at_most(1)
2264            .create_async()
2265            .await;
2266
2267        // Second+ requests: token balances (returns for first unique token)
2268        let _balance_mock = server
2269            .mock("GET", mockito::Matcher::Any)
2270            .with_status(200)
2271            .with_header("content-type", "application/json")
2272            .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2273            .create_async()
2274            .await;
2275
2276        let client = EthereumClient::with_base_url(&server.url());
2277        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2278        // Should have 2 unique tokens (USDT deduplicated)
2279        assert!(balances.len() <= 2);
2280        if !balances.is_empty() {
2281            assert!(!balances[0].balance.is_empty());
2282        }
2283    }
2284
2285    #[tokio::test]
2286    async fn test_get_erc20_balances_empty() {
2287        let mut server = mockito::Server::new_async().await;
2288        let _mock = server
2289            .mock("GET", mockito::Matcher::Any)
2290            .with_status(200)
2291            .with_header("content-type", "application/json")
2292            .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2293            .create_async()
2294            .await;
2295
2296        let client = EthereumClient::with_base_url(&server.url());
2297        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2298        assert!(balances.is_empty());
2299    }
2300
2301    #[tokio::test]
2302    async fn test_get_token_info_success() {
2303        let mut server = mockito::Server::new_async().await;
2304        let _mock = server
2305            .mock("GET", mockito::Matcher::Any)
2306            .with_status(200)
2307            .with_header("content-type", "application/json")
2308            .with_body(
2309                r#"{"status":"1","message":"OK","result":[{
2310                "tokenName":"Tether USD",
2311                "symbol":"USDT",
2312                "divisor":"1000000",
2313                "tokenType":"ERC20",
2314                "totalSupply":"1000000000000"
2315            }]}"#,
2316            )
2317            .create_async()
2318            .await;
2319
2320        let client = EthereumClient::with_base_url(&server.url());
2321        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2322        assert_eq!(token.symbol, "USDT");
2323        assert_eq!(token.name, "Tether USD");
2324        assert_eq!(token.decimals, 6); // log10(1000000) = 6
2325    }
2326
2327    #[tokio::test]
2328    async fn test_get_token_info_fallback_to_supply() {
2329        let mut server = mockito::Server::new_async().await;
2330        // First request: tokeninfo fails
2331        let _info_mock = server
2332            .mock("GET", mockito::Matcher::Any)
2333            .with_status(200)
2334            .with_header("content-type", "application/json")
2335            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2336            .expect_at_most(1)
2337            .create_async()
2338            .await;
2339
2340        // Second request: tokensupply succeeds
2341        let _supply_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":"1000000000000"}"#)
2346            .expect_at_most(1)
2347            .create_async()
2348            .await;
2349
2350        // Third request: getsourcecode returns contract name
2351        let _source_mock = server
2352            .mock("GET", mockito::Matcher::Any)
2353            .with_status(200)
2354            .with_header("content-type", "application/json")
2355            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
2356            .create_async()
2357            .await;
2358
2359        let client = EthereumClient::with_base_url(&server.url());
2360        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2361        // Should get some token info via fallback
2362        assert!(!token.symbol.is_empty());
2363    }
2364
2365    #[tokio::test]
2366    async fn test_get_token_info_unknown() {
2367        let mut server = mockito::Server::new_async().await;
2368        let _mock = server
2369            .mock("GET", mockito::Matcher::Any)
2370            .with_status(200)
2371            .with_header("content-type", "application/json")
2372            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2373            .create_async()
2374            .await;
2375
2376        let client = EthereumClient::with_base_url(&server.url());
2377        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2378        // Fallback returns UNKNOWN or address-based placeholder
2379        assert!(!token.symbol.is_empty());
2380    }
2381
2382    #[tokio::test]
2383    async fn test_try_get_contract_name() {
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(r#"{"status":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
2390            .create_async()
2391            .await;
2392
2393        let client = EthereumClient::with_base_url(&server.url());
2394        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2395        assert!(result.is_some());
2396        let (symbol, name) = result.unwrap();
2397        assert_eq!(name, "USDT");
2398        assert_eq!(symbol, "USDT"); // Short name → uppercased as-is
2399    }
2400
2401    #[tokio::test]
2402    async fn test_try_get_contract_name_long_name() {
2403        let mut server = mockito::Server::new_async().await;
2404        let _mock = server
2405            .mock("GET", mockito::Matcher::Any)
2406            .with_status(200)
2407            .with_header("content-type", "application/json")
2408            .with_body(
2409                r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
2410            )
2411            .create_async()
2412            .await;
2413
2414        let client = EthereumClient::with_base_url(&server.url());
2415        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2416        assert!(result.is_some());
2417        let (symbol, name) = result.unwrap();
2418        assert_eq!(name, "TetherUSDToken");
2419        assert_eq!(symbol, "TUSDT"); // Uppercase chars extracted
2420    }
2421
2422    #[tokio::test]
2423    async fn test_try_get_contract_name_not_verified() {
2424        let mut server = mockito::Server::new_async().await;
2425        let _mock = server
2426            .mock("GET", mockito::Matcher::Any)
2427            .with_status(200)
2428            .with_header("content-type", "application/json")
2429            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2430            .create_async()
2431            .await;
2432
2433        let client = EthereumClient::with_base_url(&server.url());
2434        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2435        assert!(result.is_none());
2436    }
2437
2438    #[tokio::test]
2439    async fn test_get_token_holders() {
2440        let mut server = mockito::Server::new_async().await;
2441        let _mock = server
2442            .mock("GET", mockito::Matcher::Any)
2443            .with_status(200)
2444            .with_header("content-type", "application/json")
2445            .with_body(r#"{"status":"1","message":"OK","result":[
2446                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
2447                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
2448                {"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
2449            ]}"#)
2450            .create_async()
2451            .await;
2452
2453        let client = EthereumClient::with_base_url(&server.url());
2454        let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2455        assert_eq!(holders.len(), 3);
2456        assert_eq!(
2457            holders[0].address,
2458            "0x1111111111111111111111111111111111111111"
2459        );
2460        assert_eq!(holders[0].rank, 1);
2461        assert_eq!(holders[2].rank, 3);
2462        // Percentage should be 50%, 30%, 20%
2463        assert!((holders[0].percentage - 50.0).abs() < 0.01);
2464    }
2465
2466    #[tokio::test]
2467    async fn test_get_token_holders_pro_required() {
2468        let mut server = mockito::Server::new_async().await;
2469        let _mock = server
2470            .mock("GET", mockito::Matcher::Any)
2471            .with_status(200)
2472            .with_header("content-type", "application/json")
2473            .with_body(
2474                r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
2475            )
2476            .create_async()
2477            .await;
2478
2479        let client = EthereumClient::with_base_url(&server.url());
2480        let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2481        assert!(holders.is_empty()); // Graceful fallback
2482    }
2483
2484    #[tokio::test]
2485    async fn test_get_token_holder_count_small() {
2486        let mut server = mockito::Server::new_async().await;
2487        // Return fewer than 1000 holders → exact count
2488        let _mock = server
2489            .mock("GET", mockito::Matcher::Any)
2490            .with_status(200)
2491            .with_header("content-type", "application/json")
2492            .with_body(r#"{"status":"1","message":"OK","result":[
2493                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
2494                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
2495            ]}"#)
2496            .create_async()
2497            .await;
2498
2499        let client = EthereumClient::with_base_url(&server.url());
2500        let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2501        assert_eq!(count, 2);
2502    }
2503
2504    #[tokio::test]
2505    async fn test_get_token_holder_count_empty() {
2506        let mut server = mockito::Server::new_async().await;
2507        let _mock = server
2508            .mock("GET", mockito::Matcher::Any)
2509            .with_status(200)
2510            .with_header("content-type", "application/json")
2511            .with_body(
2512                r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
2513            )
2514            .create_async()
2515            .await;
2516
2517        let client = EthereumClient::with_base_url(&server.url());
2518        let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2519        // Pro required → empty holders → 0
2520        assert_eq!(count, 0);
2521    }
2522
2523    #[tokio::test]
2524    async fn test_get_block_timestamp() {
2525        let mut server = mockito::Server::new_async().await;
2526        let _mock = server
2527            .mock("GET", mockito::Matcher::Any)
2528            .with_status(200)
2529            .with_header("content-type", "application/json")
2530            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
2531            .create_async()
2532            .await;
2533
2534        let client = EthereumClient::with_base_url(&server.url());
2535        let ts = client.get_block_timestamp(16).await.unwrap();
2536        assert_eq!(ts, 0x65A8C580);
2537    }
2538
2539    #[tokio::test]
2540    async fn test_get_block_timestamp_not_found() {
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(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2547            .create_async()
2548            .await;
2549
2550        let client = EthereumClient::with_base_url(&server.url());
2551        let result = client.get_block_timestamp(99999999).await;
2552        assert!(result.is_err());
2553    }
2554
2555    #[test]
2556    fn test_chain_name_and_symbol_accessors() {
2557        let client = EthereumClient::with_base_url("http://test");
2558        assert_eq!(client.chain_name(), "ethereum");
2559        assert_eq!(client.native_token_symbol(), "ETH");
2560    }
2561
2562    #[test]
2563    fn test_validate_tx_hash_invalid_hex() {
2564        let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
2565        let result = validate_tx_hash(hash);
2566        assert!(result.is_err());
2567        assert!(result.unwrap_err().to_string().contains("invalid hex"));
2568    }
2569
2570    #[tokio::test]
2571    async fn test_get_transactions_api_error() {
2572        let mut server = mockito::Server::new_async().await;
2573        let _mock = server
2574            .mock("GET", mockito::Matcher::Any)
2575            .with_status(200)
2576            .with_header("content-type", "application/json")
2577            .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
2578            .create_async()
2579            .await;
2580
2581        let client = EthereumClient::with_base_url(&server.url());
2582        let result = client.get_transactions(VALID_ADDRESS, 10).await;
2583        assert!(result.is_err());
2584    }
2585
2586    #[tokio::test]
2587    async fn test_get_token_holders_pro_key_required() {
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(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
2594            .create_async()
2595            .await;
2596
2597        let client = EthereumClient::with_base_url(&server.url());
2598        let holders = client
2599            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2600            .await
2601            .unwrap();
2602        assert!(holders.is_empty());
2603    }
2604
2605    #[tokio::test]
2606    async fn test_get_token_holders_api_error() {
2607        let mut server = mockito::Server::new_async().await;
2608        let _mock = server
2609            .mock("GET", mockito::Matcher::Any)
2610            .with_status(200)
2611            .with_header("content-type", "application/json")
2612            .with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
2613            .create_async()
2614            .await;
2615
2616        let client = EthereumClient::with_base_url(&server.url());
2617        let result = client
2618            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2619            .await;
2620        assert!(result.is_err());
2621    }
2622
2623    #[tokio::test]
2624    async fn test_get_token_holders_success() {
2625        let mut server = mockito::Server::new_async().await;
2626        let _mock = server
2627            .mock("GET", mockito::Matcher::Any)
2628            .with_status(200)
2629            .with_header("content-type", "application/json")
2630            .with_body(
2631                r#"{"status":"1","message":"OK","result":[
2632                {"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
2633                {"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
2634            ]}"#,
2635            )
2636            .create_async()
2637            .await;
2638
2639        let client = EthereumClient::with_base_url(&server.url());
2640        let holders = client
2641            .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2642            .await
2643            .unwrap();
2644        assert_eq!(holders.len(), 2);
2645        assert_eq!(holders[0].rank, 1);
2646        assert_eq!(holders[1].rank, 2);
2647        assert!(holders[0].percentage > 0.0);
2648    }
2649
2650    #[tokio::test]
2651    async fn test_get_token_info_unknown_token() {
2652        let mut server = mockito::Server::new_async().await;
2653        // First call for tokeninfo - return empty/error
2654        let _mock = server
2655            .mock("GET", mockito::Matcher::Any)
2656            .with_status(200)
2657            .with_header("content-type", "application/json")
2658            .with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
2659            .create_async()
2660            .await;
2661
2662        let client = EthereumClient::with_base_url(&server.url());
2663        let token = client
2664            .get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
2665            .await
2666            .unwrap();
2667        assert_eq!(token.symbol, "UNKNOWN");
2668    }
2669
2670    #[tokio::test]
2671    async fn test_get_transaction_with_null_block_number() {
2672        let mut server = mockito::Server::new_async().await;
2673        let _mock = server
2674            .mock("GET", mockito::Matcher::Any)
2675            .with_status(200)
2676            .with_header("content-type", "application/json")
2677            .with_body(
2678                r#"{"jsonrpc":"2.0","id":1,"result":{
2679                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2680                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2681                "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2682                "value":"0xde0b6b3a7640000",
2683                "gas":"0x5208",
2684                "gasPrice":"0x3b9aca00",
2685                "nonce":"0x5",
2686                "input":"0x",
2687                "blockNumber":null
2688            }}"#,
2689            )
2690            .create_async()
2691            .await;
2692
2693        let client = EthereumClient::with_base_url(&server.url());
2694        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2695        // blockNumber is null, so timestamp should also be None
2696        assert!(tx.timestamp.is_none());
2697    }
2698
2699    #[tokio::test]
2700    async fn test_chain_client_trait_balance() {
2701        let mut server = mockito::Server::new_async().await;
2702        let _mock = server
2703            .mock("GET", mockito::Matcher::Any)
2704            .with_status(200)
2705            .with_header("content-type", "application/json")
2706            .with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
2707            .create_async()
2708            .await;
2709
2710        let client = EthereumClient::with_base_url(&server.url());
2711        let chain_client: &dyn ChainClient = &client;
2712        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
2713        assert_eq!(balance.symbol, "ETH");
2714    }
2715
2716    #[tokio::test]
2717    async fn test_chain_client_trait_get_transaction() {
2718        let mut server = mockito::Server::new_async().await;
2719        let _mock = server
2720            .mock("GET", mockito::Matcher::Any)
2721            .with_status(200)
2722            .with_header("content-type", "application/json")
2723            .with_body(
2724                r#"{"jsonrpc":"2.0","id":1,"result":{
2725                "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2726                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2727                "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2728                "value":"0xde0b6b3a7640000",
2729                "gas":"0x5208",
2730                "gasPrice":"0x3b9aca00",
2731                "nonce":"0x5",
2732                "input":"0x",
2733                "blockNumber":"0xf4240"
2734            }}"#,
2735            )
2736            .create_async()
2737            .await;
2738
2739        let client = EthereumClient::with_base_url(&server.url());
2740        let chain_client: &dyn ChainClient = &client;
2741        let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
2742        assert!(!tx.hash.is_empty());
2743    }
2744
2745    #[tokio::test]
2746    async fn test_chain_client_trait_get_block_number() {
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(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
2753            .create_async()
2754            .await;
2755
2756        let client = EthereumClient::with_base_url(&server.url());
2757        let chain_client: &dyn ChainClient = &client;
2758        let block = chain_client.get_block_number().await.unwrap();
2759        assert_eq!(block, 1000000);
2760    }
2761
2762    #[tokio::test]
2763    async fn test_chain_client_trait_get_transactions() {
2764        let mut server = mockito::Server::new_async().await;
2765        let _mock = server
2766            .mock("GET", mockito::Matcher::Any)
2767            .with_status(200)
2768            .with_header("content-type", "application/json")
2769            .with_body(
2770                r#"{"status":"1","message":"OK","result":[{
2771                "hash":"0xabc",
2772                "blockNumber":"12345",
2773                "timeStamp":"1700000000",
2774                "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2775                "to":"0x1111111111111111111111111111111111111111",
2776                "value":"1000000000000000000",
2777                "gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
2778                "nonce":"1","input":"0x","isError":"0"
2779            }]}"#,
2780            )
2781            .create_async()
2782            .await;
2783
2784        let client = EthereumClient::with_base_url(&server.url());
2785        let chain_client: &dyn ChainClient = &client;
2786        let txs = chain_client
2787            .get_transactions(VALID_ADDRESS, 10)
2788            .await
2789            .unwrap();
2790        assert_eq!(txs.len(), 1);
2791    }
2792
2793    #[tokio::test]
2794    async fn test_chain_client_trait_get_token_balances() {
2795        let mut server = mockito::Server::new_async().await;
2796        // First call: tokentx returns tokens
2797        let _tokentx = server
2798            .mock("GET", mockito::Matcher::Any)
2799            .with_status(200)
2800            .with_header("content-type", "application/json")
2801            .with_body(
2802                r#"{"status":"1","message":"OK","result":[{
2803                "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2804                "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2805            }]}"#,
2806            )
2807            .expect_at_most(1)
2808            .create_async()
2809            .await;
2810
2811        // Second call: token balance
2812        let _balance = server
2813            .mock("GET", mockito::Matcher::Any)
2814            .with_status(200)
2815            .with_header("content-type", "application/json")
2816            .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2817            .create_async()
2818            .await;
2819
2820        let client = EthereumClient::with_base_url(&server.url());
2821        let chain_client: &dyn ChainClient = &client;
2822        let balances = chain_client
2823            .get_token_balances(VALID_ADDRESS)
2824            .await
2825            .unwrap();
2826        assert!(!balances.is_empty());
2827    }
2828
2829    #[tokio::test]
2830    async fn test_chain_client_trait_get_token_info() {
2831        let mut server = mockito::Server::new_async().await;
2832        let _mock = server
2833            .mock("GET", mockito::Matcher::Any)
2834            .with_status(200)
2835            .with_header("content-type", "application/json")
2836            .with_body(
2837                r#"{"status":"1","message":"OK","result":[{
2838                "tokenName":"Tether USD","symbol":"USDT",
2839                "divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
2840            }]}"#,
2841            )
2842            .create_async()
2843            .await;
2844
2845        let client = EthereumClient::with_base_url(&server.url());
2846        let chain_client: &dyn ChainClient = &client;
2847        let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
2848        assert_eq!(token.symbol, "USDT");
2849    }
2850
2851    #[tokio::test]
2852    async fn test_chain_client_trait_get_token_holders() {
2853        let mut server = mockito::Server::new_async().await;
2854        let _mock = server
2855            .mock("GET", mockito::Matcher::Any)
2856            .with_status(200)
2857            .with_header("content-type", "application/json")
2858            .with_body(
2859                r#"{"status":"1","message":"OK","result":[
2860                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
2861            ]}"#,
2862            )
2863            .create_async()
2864            .await;
2865
2866        let client = EthereumClient::with_base_url(&server.url());
2867        let chain_client: &dyn ChainClient = &client;
2868        let holders = chain_client
2869            .get_token_holders(VALID_ADDRESS, 10)
2870            .await
2871            .unwrap();
2872        assert_eq!(holders.len(), 1);
2873    }
2874
2875    #[tokio::test]
2876    async fn test_chain_client_trait_get_token_holder_count() {
2877        let mut server = mockito::Server::new_async().await;
2878        let _mock = server
2879            .mock("GET", mockito::Matcher::Any)
2880            .with_status(200)
2881            .with_header("content-type", "application/json")
2882            .with_body(
2883                r#"{"status":"1","message":"OK","result":[
2884                {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
2885                {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
2886            ]}"#,
2887            )
2888            .create_async()
2889            .await;
2890
2891        let client = EthereumClient::with_base_url(&server.url());
2892        let chain_client: &dyn ChainClient = &client;
2893        let count = chain_client
2894            .get_token_holder_count(VALID_ADDRESS)
2895            .await
2896            .unwrap();
2897        assert_eq!(count, 2);
2898    }
2899
2900    #[tokio::test]
2901    async fn test_chain_client_trait_native_token_symbol() {
2902        let client = EthereumClient::with_base_url("http://test");
2903        let chain_client: &dyn ChainClient = &client;
2904        assert_eq!(chain_client.native_token_symbol(), "ETH");
2905        assert_eq!(chain_client.chain_name(), "ethereum");
2906    }
2907
2908    #[tokio::test]
2909    async fn test_get_token_info_supply_fallback_no_contract() {
2910        let mut server = mockito::Server::new_async().await;
2911        // First request: tokeninfo fails
2912        let _info_mock = server
2913            .mock("GET", mockito::Matcher::Any)
2914            .with_status(200)
2915            .with_header("content-type", "application/json")
2916            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2917            .expect_at_most(1)
2918            .create_async()
2919            .await;
2920
2921        // Second request: tokensupply succeeds (valid ERC20)
2922        let _supply_mock = server
2923            .mock("GET", mockito::Matcher::Any)
2924            .with_status(200)
2925            .with_header("content-type", "application/json")
2926            .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2927            .expect_at_most(1)
2928            .create_async()
2929            .await;
2930
2931        // Third request: getsourcecode returns empty contract name
2932        let _source_mock = server
2933            .mock("GET", mockito::Matcher::Any)
2934            .with_status(200)
2935            .with_header("content-type", "application/json")
2936            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2937            .create_async()
2938            .await;
2939
2940        let client = EthereumClient::with_base_url(&server.url());
2941        let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2942        // Should return address-based placeholder since contract name is empty
2943        assert!(token.symbol.contains("...") || !token.symbol.is_empty());
2944    }
2945
2946    #[tokio::test]
2947    async fn test_try_get_contract_name_short_lowercase() {
2948        let mut server = mockito::Server::new_async().await;
2949        let _mock = server
2950            .mock("GET", mockito::Matcher::Any)
2951            .with_status(200)
2952            .with_header("content-type", "application/json")
2953            .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
2954            .create_async()
2955            .await;
2956
2957        let client = EthereumClient::with_base_url(&server.url());
2958        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2959        assert!(result.is_some());
2960        let (symbol, name) = result.unwrap();
2961        assert_eq!(name, "token");
2962        // Name <= 6 chars → uppercased directly
2963        assert_eq!(symbol, "TOKEN");
2964    }
2965
2966    #[tokio::test]
2967    async fn test_try_get_contract_name_long_lowercase() {
2968        let mut server = mockito::Server::new_async().await;
2969        let _mock = server
2970            .mock("GET", mockito::Matcher::Any)
2971            .with_status(200)
2972            .with_header("content-type", "application/json")
2973            .with_body(
2974                r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
2975            )
2976            .create_async()
2977            .await;
2978
2979        let client = EthereumClient::with_base_url(&server.url());
2980        let result = client.try_get_contract_name(VALID_ADDRESS).await;
2981        assert!(result.is_some());
2982        let (symbol, name) = result.unwrap();
2983        assert_eq!(name, "mytokencontract");
2984        // Long name with no uppercase → extract uppercase chars gets empty string
2985        // Falls back to first 6 chars uppercased
2986        assert_eq!(symbol, "MYTOKE");
2987    }
2988
2989    #[tokio::test]
2990    async fn test_get_erc20_balances_zero_balance_skipped() {
2991        let mut server = mockito::Server::new_async().await;
2992        // First request: token transfers
2993        let _tokentx = server
2994            .mock("GET", mockito::Matcher::Any)
2995            .with_status(200)
2996            .with_header("content-type", "application/json")
2997            .with_body(
2998                r#"{"status":"1","message":"OK","result":[{
2999                "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
3000                "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
3001            }]}"#,
3002            )
3003            .expect_at_most(1)
3004            .create_async()
3005            .await;
3006
3007        // Second request: token balance returns zero
3008        let _balance = server
3009            .mock("GET", mockito::Matcher::Any)
3010            .with_status(200)
3011            .with_header("content-type", "application/json")
3012            .with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
3013            .create_async()
3014            .await;
3015
3016        let client = EthereumClient::with_base_url(&server.url());
3017        let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
3018        // Zero balance should be skipped
3019        assert!(balances.is_empty());
3020    }
3021}