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