Skip to main content

scope/chains/
ethereum.rs

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