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