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