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