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_storage_at(&self, address: &str, slot: &str) -> Result<String> {
896 validate_eth_address(address)?;
897
898 let url = self.build_api_url(&format!(
899 "module=proxy&action=eth_getStorageAt&address={}&position={}&tag=latest",
900 address, slot
901 ));
902
903 #[derive(Deserialize)]
904 struct StorageResponse {
905 result: Option<String>,
906 }
907
908 let response: StorageResponse = self.client.get(&url).send().await?.json().await?;
909 Ok(response.result.unwrap_or_else(|| {
910 "0x0000000000000000000000000000000000000000000000000000000000000000".to_string()
911 }))
912 }
913
914 pub async fn get_erc20_balances(
919 &self,
920 address: &str,
921 ) -> Result<Vec<crate::chains::TokenBalance>> {
922 validate_eth_address(address)?;
923
924 let url = self.build_api_url(&format!(
926 "module=account&action=tokentx&address={}&page=1&offset=100&sort=desc",
927 address
928 ));
929
930 tracing::debug!(url = %url, "Fetching ERC-20 token transfers");
931
932 let response = self.client.get(&url).send().await?.text().await?;
933
934 #[derive(Deserialize)]
935 struct TokenTxItem {
936 #[serde(rename = "contractAddress")]
937 contract_address: String,
938 #[serde(rename = "tokenSymbol")]
939 token_symbol: String,
940 #[serde(rename = "tokenName")]
941 token_name: String,
942 #[serde(rename = "tokenDecimal")]
943 token_decimal: String,
944 }
945
946 let parsed: std::result::Result<ApiResponse<Vec<TokenTxItem>>, _> =
947 serde_json::from_str(&response);
948
949 let token_txs = match parsed {
950 Ok(api_resp) if api_resp.status == "1" => api_resp.result,
951 _ => return Ok(vec![]),
952 };
953
954 let mut seen = std::collections::HashSet::new();
956 let unique_tokens: Vec<&TokenTxItem> = token_txs
957 .iter()
958 .filter(|tx| seen.insert(tx.contract_address.to_lowercase()))
959 .collect();
960
961 let mut balances = Vec::new();
963 for token_tx in unique_tokens.iter().take(20) {
964 let balance_url = self.build_api_url(&format!(
966 "module=account&action=tokenbalance&contractaddress={}&address={}&tag=latest",
967 token_tx.contract_address, address
968 ));
969
970 if let Ok(resp) = self.client.get(&balance_url).send().await {
971 if let Ok(bal_resp) = resp.json::<ApiResponse<String>>().await {
972 if bal_resp.status == "1" {
973 let raw_balance = bal_resp.result;
974 let decimals: u8 = token_tx.token_decimal.parse().unwrap_or(18);
975
976 if raw_balance == "0" {
978 continue;
979 }
980
981 let formatted =
982 crate::display::format_token_balance(&raw_balance, decimals);
983
984 balances.push(crate::chains::TokenBalance {
985 token: Token {
986 contract_address: token_tx.contract_address.clone(),
987 symbol: token_tx.token_symbol.clone(),
988 name: token_tx.token_name.clone(),
989 decimals,
990 },
991 balance: raw_balance,
992 formatted_balance: formatted,
993 usd_value: None,
994 });
995 }
996 }
997 }
998 }
999
1000 Ok(balances)
1001 }
1002
1003 pub async fn get_token_info(&self, token_address: &str) -> Result<Token> {
1013 validate_eth_address(token_address)?;
1014
1015 let url = self.build_api_url(&format!(
1017 "module=token&action=tokeninfo&contractaddress={}",
1018 token_address
1019 ));
1020
1021 tracing::debug!(url = %url, "Fetching token info (Pro API)");
1022
1023 let response = self.client.get(&url).send().await?;
1024 let response_text = response.text().await?;
1025
1026 if let Ok(api_response) =
1028 serde_json::from_str::<ApiResponse<Vec<TokenInfoItem>>>(&response_text)
1029 {
1030 if api_response.status == "1" && !api_response.result.is_empty() {
1031 let info = &api_response.result[0];
1032 let decimals = info
1033 .divisor
1034 .as_ref()
1035 .and_then(|d| d.parse::<u32>().ok())
1036 .map(|d| (d as f64).log10() as u8)
1037 .unwrap_or(18);
1038
1039 return Ok(Token {
1040 contract_address: token_address.to_string(),
1041 symbol: info.symbol.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
1042 name: info
1043 .token_name
1044 .clone()
1045 .unwrap_or_else(|| "Unknown Token".to_string()),
1046 decimals,
1047 });
1048 }
1049 }
1050
1051 self.get_token_info_from_supply(token_address).await
1054 }
1055
1056 async fn get_token_info_from_supply(&self, token_address: &str) -> Result<Token> {
1058 let url = self.build_api_url(&format!(
1059 "module=stats&action=tokensupply&contractaddress={}",
1060 token_address
1061 ));
1062
1063 tracing::debug!(url = %url, "Fetching token supply");
1064
1065 let response = self.client.get(&url).send().await?;
1066 let response_text = response.text().await?;
1067
1068 if let Ok(api_response) = serde_json::from_str::<ApiResponse<String>>(&response_text) {
1070 if api_response.status == "1" {
1071 if let Some(contract_info) = self.try_get_contract_name(token_address).await {
1074 return Ok(Token {
1075 contract_address: token_address.to_string(),
1076 symbol: contract_info.0,
1077 name: contract_info.1,
1078 decimals: 18,
1079 });
1080 }
1081
1082 let short_addr = format!(
1084 "{}...{}",
1085 &token_address[..6],
1086 &token_address[token_address.len() - 4..]
1087 );
1088 return Ok(Token {
1089 contract_address: token_address.to_string(),
1090 symbol: short_addr.clone(),
1091 name: format!("Token {}", short_addr),
1092 decimals: 18,
1093 });
1094 }
1095 }
1096
1097 Ok(Token {
1099 contract_address: token_address.to_string(),
1100 symbol: "UNKNOWN".to_string(),
1101 name: "Unknown Token".to_string(),
1102 decimals: 18,
1103 })
1104 }
1105
1106 async fn try_get_contract_name(&self, token_address: &str) -> Option<(String, String)> {
1108 let url = self.build_api_url(&format!(
1109 "module=contract&action=getsourcecode&address={}",
1110 token_address
1111 ));
1112
1113 let response = self.client.get(&url).send().await.ok()?;
1114 let text = response.text().await.ok()?;
1115
1116 #[derive(serde::Deserialize)]
1118 struct SourceCodeResult {
1119 #[serde(rename = "ContractName")]
1120 contract_name: Option<String>,
1121 }
1122
1123 #[derive(serde::Deserialize)]
1124 struct SourceCodeResponse {
1125 status: String,
1126 result: Vec<SourceCodeResult>,
1127 }
1128
1129 if let Ok(api_response) = serde_json::from_str::<SourceCodeResponse>(&text) {
1130 if api_response.status == "1" && !api_response.result.is_empty() {
1131 if let Some(name) = &api_response.result[0].contract_name {
1132 if !name.is_empty() {
1133 let symbol = if name.len() <= 6 {
1136 name.to_uppercase()
1137 } else {
1138 name.chars()
1140 .filter(|c| c.is_uppercase())
1141 .take(6)
1142 .collect::<String>()
1143 };
1144 let symbol = if symbol.is_empty() {
1145 name[..name.len().min(6)].to_uppercase()
1146 } else {
1147 symbol
1148 };
1149 return Some((symbol, name.clone()));
1150 }
1151 }
1152 }
1153 }
1154
1155 None
1156 }
1157
1158 pub async fn get_token_holders(
1173 &self,
1174 token_address: &str,
1175 limit: u32,
1176 ) -> Result<Vec<TokenHolder>> {
1177 validate_eth_address(token_address)?;
1178
1179 let effective_limit = limit.min(1000); let url = self.build_api_url(&format!(
1182 "module=token&action=tokenholderlist&contractaddress={}&page=1&offset={}",
1183 token_address, effective_limit
1184 ));
1185
1186 tracing::debug!(url = %url, "Fetching token holders");
1187
1188 let response = self.client.get(&url).send().await?;
1189 let response_text = response.text().await?;
1190
1191 let api_response: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
1193 .map_err(|e| ScopeError::Api(format!("Failed to parse holder response: {}", e)))?;
1194
1195 if api_response.status != "1" {
1196 if api_response.message.contains("Pro")
1198 || api_response.message.contains("API")
1199 || api_response.message.contains("NOTOK")
1200 {
1201 eprintln!(" ⚠ Holder data requires a Pro API key — skipping");
1202 tracing::debug!("Token holder API unavailable: {}", api_response.message);
1203 return Ok(Vec::new());
1204 }
1205 return Err(ScopeError::Api(format!(
1206 "API error: {}",
1207 api_response.message
1208 )));
1209 }
1210
1211 let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
1213 .map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
1214
1215 let total_balance: f64 = holders
1217 .iter()
1218 .filter_map(|h| h.quantity.parse::<f64>().ok())
1219 .sum();
1220
1221 let token_holders: Vec<TokenHolder> = holders
1223 .into_iter()
1224 .enumerate()
1225 .map(|(i, h)| {
1226 let balance: f64 = h.quantity.parse().unwrap_or(0.0);
1227 let percentage = if total_balance > 0.0 {
1228 (balance / total_balance) * 100.0
1229 } else {
1230 0.0
1231 };
1232
1233 TokenHolder {
1234 address: h.address,
1235 balance: h.quantity.clone(),
1236 formatted_balance: crate::display::format_token_balance(&h.quantity, 18), percentage,
1238 rank: (i + 1) as u32,
1239 }
1240 })
1241 .collect();
1242
1243 Ok(token_holders)
1244 }
1245
1246 pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
1251 let max_page_size: u32 = 1000;
1253 let holders = self.get_token_holders(token_address, max_page_size).await?;
1254
1255 if holders.is_empty() {
1256 return Ok(0);
1257 }
1258
1259 let count = holders.len() as u64;
1260
1261 if count < max_page_size as u64 {
1262 Ok(count)
1264 } else {
1265 let mut total = count;
1268 let mut page = 2u32;
1269 loop {
1270 let url = self.build_api_url(&format!(
1271 "module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
1272 token_address, page, max_page_size
1273 ));
1274 let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
1275 self.client.get(&url).send().await?.json().await;
1276
1277 match response {
1278 Ok(api_resp) if api_resp.status == "1" => {
1279 let page_count = api_resp.result.len() as u64;
1280 total += page_count;
1281 if page_count < max_page_size as u64 || page >= 10 {
1282 break;
1284 }
1285 page += 1;
1286 }
1287 _ => break,
1288 }
1289 }
1290 Ok(total)
1291 }
1292 }
1293}
1294
1295impl Default for EthereumClient {
1296 fn default() -> Self {
1297 Self {
1298 client: Client::new(),
1299 base_url: ETHERSCAN_V2_API.to_string(),
1300 chain_id: Some("1".to_string()),
1301 api_key: None,
1302 chain_name: "ethereum".to_string(),
1303 native_symbol: "ETH".to_string(),
1304 native_decimals: 18,
1305 api_type: ApiType::BlockExplorer,
1306 rpc_fallback_url: None,
1307 }
1308 }
1309}
1310
1311fn validate_eth_address(address: &str) -> Result<()> {
1313 if !address.starts_with("0x") {
1314 return Err(ScopeError::InvalidAddress(format!(
1315 "Address must start with '0x': {}",
1316 address
1317 )));
1318 }
1319 if address.len() != 42 {
1320 return Err(ScopeError::InvalidAddress(format!(
1321 "Address must be 42 characters: {}",
1322 address
1323 )));
1324 }
1325 if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1326 return Err(ScopeError::InvalidAddress(format!(
1327 "Address contains invalid hex characters: {}",
1328 address
1329 )));
1330 }
1331 Ok(())
1332}
1333
1334fn validate_tx_hash(hash: &str) -> Result<()> {
1336 if !hash.starts_with("0x") {
1337 return Err(ScopeError::InvalidHash(format!(
1338 "Hash must start with '0x': {}",
1339 hash
1340 )));
1341 }
1342 if hash.len() != 66 {
1343 return Err(ScopeError::InvalidHash(format!(
1344 "Hash must be 66 characters: {}",
1345 hash
1346 )));
1347 }
1348 if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1349 return Err(ScopeError::InvalidHash(format!(
1350 "Hash contains invalid hex characters: {}",
1351 hash
1352 )));
1353 }
1354 Ok(())
1355}
1356
1357#[async_trait]
1362impl ChainClient for EthereumClient {
1363 fn chain_name(&self) -> &str {
1364 &self.chain_name
1365 }
1366
1367 fn native_token_symbol(&self) -> &str {
1368 &self.native_symbol
1369 }
1370
1371 async fn get_balance(&self, address: &str) -> Result<Balance> {
1372 self.get_balance(address).await
1373 }
1374
1375 async fn enrich_balance_usd(&self, balance: &mut Balance) {
1376 self.enrich_balance_usd(balance).await
1377 }
1378
1379 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
1380 self.get_transaction(hash).await
1381 }
1382
1383 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
1384 self.get_transactions(address, limit).await
1385 }
1386
1387 async fn get_block_number(&self) -> Result<u64> {
1388 self.get_block_number().await
1389 }
1390
1391 async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
1392 self.get_erc20_balances(address).await
1393 }
1394
1395 async fn get_token_info(&self, address: &str) -> Result<Token> {
1396 self.get_token_info(address).await
1397 }
1398
1399 async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
1400 self.get_token_holders(address, limit).await
1401 }
1402
1403 async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
1404 self.get_token_holder_count(address).await
1405 }
1406
1407 async fn get_code(&self, address: &str) -> Result<String> {
1408 self.get_code(address).await
1409 }
1410
1411 async fn get_storage_at(&self, address: &str, slot: &str) -> Result<String> {
1412 self.get_storage_at(address, slot).await
1413 }
1414}
1415
1416#[cfg(test)]
1421mod tests {
1422 use super::*;
1423
1424 const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1425 const VALID_TX_HASH: &str =
1426 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
1427
1428 #[test]
1429 fn test_validate_eth_address_valid() {
1430 assert!(validate_eth_address(VALID_ADDRESS).is_ok());
1431 }
1432
1433 #[test]
1434 fn test_validate_eth_address_lowercase() {
1435 let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
1436 assert!(validate_eth_address(addr).is_ok());
1437 }
1438
1439 #[test]
1440 fn test_validate_eth_address_missing_prefix() {
1441 let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1442 let result = validate_eth_address(addr);
1443 assert!(result.is_err());
1444 assert!(result.unwrap_err().to_string().contains("0x"));
1445 }
1446
1447 #[test]
1448 fn test_validate_eth_address_too_short() {
1449 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
1450 let result = validate_eth_address(addr);
1451 assert!(result.is_err());
1452 assert!(result.unwrap_err().to_string().contains("42 characters"));
1453 }
1454
1455 #[test]
1456 fn test_validate_eth_address_invalid_hex() {
1457 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
1458 let result = validate_eth_address(addr);
1459 assert!(result.is_err());
1460 assert!(result.unwrap_err().to_string().contains("invalid hex"));
1461 }
1462
1463 #[test]
1464 fn test_validate_tx_hash_valid() {
1465 assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
1466 }
1467
1468 #[test]
1469 fn test_validate_tx_hash_missing_prefix() {
1470 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
1471 let result = validate_tx_hash(hash);
1472 assert!(result.is_err());
1473 }
1474
1475 #[test]
1476 fn test_validate_tx_hash_too_short() {
1477 let hash = "0xabc123";
1478 let result = validate_tx_hash(hash);
1479 assert!(result.is_err());
1480 assert!(result.unwrap_err().to_string().contains("66 characters"));
1481 }
1482
1483 #[test]
1484 fn test_validate_eth_address_too_long() {
1485 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2extra";
1486 let result = validate_eth_address(addr);
1487 assert!(result.is_err());
1488 assert!(result.unwrap_err().to_string().contains("42 characters"));
1489 }
1490
1491 #[test]
1492 fn test_validate_eth_address_empty() {
1493 let result = validate_eth_address("");
1494 assert!(result.is_err());
1495 }
1496
1497 #[test]
1498 fn test_validate_eth_address_only_prefix() {
1499 let result = validate_eth_address("0x");
1500 assert!(result.is_err());
1501 assert!(result.unwrap_err().to_string().contains("42 characters"));
1502 }
1503
1504 #[test]
1505 fn test_validate_tx_hash_too_long() {
1506 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcdextra";
1507 let result = validate_tx_hash(hash);
1508 assert!(result.is_err());
1509 assert!(result.unwrap_err().to_string().contains("66 characters"));
1510 }
1511
1512 #[test]
1513 fn test_validate_tx_hash_empty() {
1514 let result = validate_tx_hash("");
1515 assert!(result.is_err());
1516 }
1517
1518 #[test]
1519 fn test_validate_tx_hash_only_prefix() {
1520 let result = validate_tx_hash("0x");
1521 assert!(result.is_err());
1522 assert!(result.unwrap_err().to_string().contains("66 characters"));
1523 }
1524
1525 #[test]
1526 fn test_ethereum_client_default() {
1527 let client = EthereumClient::default();
1528 assert_eq!(client.chain_name(), "ethereum");
1529 assert_eq!(client.native_token_symbol(), "ETH");
1530 }
1531
1532 #[test]
1533 fn test_ethereum_client_with_base_url() {
1534 let client = EthereumClient::with_base_url("https://custom.api.com");
1535 assert_eq!(client.base_url, "https://custom.api.com");
1536 }
1537
1538 #[test]
1539 fn test_ethereum_client_for_chain_ethereum() {
1540 let config = ChainsConfig::default();
1541 let client = EthereumClient::for_chain("ethereum", &config).unwrap();
1542 assert_eq!(client.chain_name(), "ethereum");
1543 assert_eq!(client.native_token_symbol(), "ETH");
1544 }
1545
1546 #[test]
1547 fn test_ethereum_client_for_chain_polygon() {
1548 let config = ChainsConfig::default();
1549 let client = EthereumClient::for_chain("polygon", &config).unwrap();
1550 assert_eq!(client.chain_name(), "polygon");
1551 assert_eq!(client.native_token_symbol(), "MATIC");
1552 assert!(client.base_url.contains("etherscan.io/v2"));
1554 assert_eq!(client.chain_id, Some("137".to_string()));
1555 }
1556
1557 #[test]
1558 fn test_ethereum_client_for_chain_arbitrum() {
1559 let config = ChainsConfig::default();
1560 let client = EthereumClient::for_chain("arbitrum", &config).unwrap();
1561 assert_eq!(client.chain_name(), "arbitrum");
1562 assert!(client.base_url.contains("etherscan.io/v2"));
1564 assert_eq!(client.chain_id, Some("42161".to_string()));
1565 }
1566
1567 #[test]
1568 fn test_ethereum_client_for_chain_bsc() {
1569 let config = ChainsConfig::default();
1570 let client = EthereumClient::for_chain("bsc", &config).unwrap();
1571 assert_eq!(client.chain_name(), "bsc");
1572 assert_eq!(client.native_token_symbol(), "BNB");
1573 assert!(client.base_url.contains("etherscan.io/v2"));
1575 assert_eq!(client.chain_id, Some("56".to_string()));
1576 assert_eq!(client.api_type, ApiType::BlockExplorer);
1577 }
1578
1579 #[test]
1580 fn test_ethereum_client_for_chain_aegis() {
1581 let config = ChainsConfig::default();
1582 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1583 assert_eq!(client.chain_name(), "aegis");
1584 assert_eq!(client.native_token_symbol(), "WRAITH");
1585 assert_eq!(client.api_type, ApiType::JsonRpc);
1586 assert!(client.base_url.contains("localhost:8545"));
1588 }
1589
1590 #[test]
1591 fn test_ethereum_client_for_chain_aegis_with_config() {
1592 let config = ChainsConfig {
1593 aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
1594 ..Default::default()
1595 };
1596 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1597 assert_eq!(client.base_url, "https://aegis.example.com:8545");
1598 }
1599
1600 #[test]
1601 fn test_ethereum_client_for_chain_unsupported() {
1602 let config = ChainsConfig::default();
1603 let result = EthereumClient::for_chain("bitcoin", &config);
1604 assert!(result.is_err());
1605 assert!(
1606 result
1607 .unwrap_err()
1608 .to_string()
1609 .contains("Unsupported chain")
1610 );
1611 }
1612
1613 #[test]
1614 fn test_ethereum_client_new() {
1615 let config = ChainsConfig::default();
1616 let client = EthereumClient::new(&config);
1617 assert!(client.is_ok());
1618 }
1619
1620 #[test]
1621 fn test_ethereum_client_with_api_key() {
1622 use std::collections::HashMap;
1623
1624 let mut api_keys = HashMap::new();
1625 api_keys.insert("etherscan".to_string(), "test-key".to_string());
1626
1627 let config = ChainsConfig {
1628 api_keys,
1629 ..Default::default()
1630 };
1631
1632 let client = EthereumClient::new(&config).unwrap();
1633 assert_eq!(client.api_key, Some("test-key".to_string()));
1634 }
1635
1636 #[test]
1637 fn test_api_response_deserialization() {
1638 let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
1639 let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
1640 assert_eq!(response.status, "1");
1641 assert_eq!(response.message, "OK");
1642 assert_eq!(response.result, "1000000000000000000");
1643 }
1644
1645 #[test]
1646 fn test_tx_list_item_deserialization() {
1647 let json = r#"{
1648 "hash": "0xabc",
1649 "blockNumber": "12345",
1650 "timeStamp": "1700000000",
1651 "from": "0xfrom",
1652 "to": "0xto",
1653 "value": "1000000000000000000",
1654 "gas": "21000",
1655 "gasUsed": "21000",
1656 "gasPrice": "20000000000",
1657 "nonce": "42",
1658 "input": "0x",
1659 "isError": "0"
1660 }"#;
1661
1662 let item: TxListItem = serde_json::from_str(json).unwrap();
1663 assert_eq!(item.hash, "0xabc");
1664 assert_eq!(item.block_number, "12345");
1665 assert_eq!(item.nonce, "42");
1666 assert_eq!(item.is_error, "0");
1667 }
1668
1669 #[test]
1674 fn test_parse_balance_wei_valid() {
1675 let client = EthereumClient::default();
1676 let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1677 assert_eq!(balance.symbol, "ETH");
1678 assert_eq!(balance.raw, "1000000000000000000");
1679 assert!(balance.formatted.contains("1.000000"));
1680 assert!(balance.usd_value.is_none());
1681 }
1682
1683 #[test]
1684 fn test_parse_balance_wei_zero() {
1685 let client = EthereumClient::default();
1686 let balance = client.parse_balance_wei("0").unwrap();
1687 assert!(balance.formatted.contains("0.000000"));
1688 }
1689
1690 #[test]
1691 fn test_parse_balance_wei_invalid() {
1692 let client = EthereumClient::default();
1693 let result = client.parse_balance_wei("not_a_number");
1694 assert!(result.is_err());
1695 }
1696
1697 #[test]
1698 fn test_format_token_balance_large() {
1699 assert!(
1700 crate::display::format_token_balance("1000000000000000000000000000", 18).contains("B")
1701 );
1702 }
1703
1704 #[test]
1705 fn test_format_token_balance_millions() {
1706 assert!(
1707 crate::display::format_token_balance("5000000000000000000000000", 18).contains("M")
1708 );
1709 }
1710
1711 #[test]
1712 fn test_format_token_balance_thousands() {
1713 assert!(crate::display::format_token_balance("5000000000000000000000", 18).contains("K"));
1714 }
1715
1716 #[test]
1717 fn test_format_token_balance_small() {
1718 let formatted = crate::display::format_token_balance("500000000000000000", 18);
1719 assert!(formatted.contains("0.5"));
1720 }
1721
1722 #[test]
1723 fn test_format_token_balance_zero() {
1724 let formatted = crate::display::format_token_balance("0", 18);
1725 assert!(formatted.contains("0.0000"));
1726 }
1727
1728 #[test]
1729 fn test_build_api_url_with_chain_id_and_key() {
1730 use std::collections::HashMap;
1731 let mut keys = HashMap::new();
1732 keys.insert("etherscan".to_string(), "MYKEY".to_string());
1733 let config = ChainsConfig {
1734 api_keys: keys,
1735 ..Default::default()
1736 };
1737 let client = EthereumClient::new(&config).unwrap();
1738 let url = client.build_api_url("module=account&action=balance&address=0x123");
1739 assert!(url.contains("chainid=1"));
1740 assert!(url.contains("module=account"));
1741 assert!(url.contains("apikey=MYKEY"));
1742 }
1743
1744 #[test]
1745 fn test_build_api_url_no_chain_id_no_key() {
1746 let client = EthereumClient::with_base_url("https://example.com/api");
1747 let url = client.build_api_url("module=account&action=balance");
1748 assert_eq!(url, "https://example.com/api?module=account&action=balance");
1749 assert!(!url.contains("chainid"));
1750 assert!(!url.contains("apikey"));
1751 }
1752
1753 #[tokio::test]
1758 async fn test_get_balance_explorer() {
1759 let mut server = mockito::Server::new_async().await;
1760 let _mock = server
1761 .mock("GET", mockito::Matcher::Any)
1762 .with_status(200)
1763 .with_header("content-type", "application/json")
1764 .with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
1765 .create_async()
1766 .await;
1767
1768 let client = EthereumClient::with_base_url(&server.url());
1769 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1770 assert_eq!(balance.raw, "2500000000000000000");
1771 assert_eq!(balance.symbol, "ETH");
1772 assert!(balance.formatted.contains("2.5"));
1773 }
1774
1775 #[tokio::test]
1776 async fn test_get_balance_explorer_api_error() {
1777 let mut server = mockito::Server::new_async().await;
1778 let _mock = server
1779 .mock("GET", mockito::Matcher::Any)
1780 .with_status(200)
1781 .with_header("content-type", "application/json")
1782 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
1783 .create_async()
1784 .await;
1785
1786 let client = EthereumClient::with_base_url(&server.url());
1787 let result = client.get_balance(VALID_ADDRESS).await;
1788 assert!(result.is_err());
1789 assert!(result.unwrap_err().to_string().contains("API error"));
1790 }
1791
1792 #[tokio::test]
1793 async fn test_get_balance_explorer_api_error_result_empty() {
1794 let mut server = mockito::Server::new_async().await;
1795 let _mock = server
1796 .mock("GET", mockito::Matcher::Any)
1797 .with_status(200)
1798 .with_header("content-type", "application/json")
1799 .with_body(r#"{"status":"0","message":"Invalid API Key","result":""}"#)
1800 .create_async()
1801 .await;
1802
1803 let client = EthereumClient::with_base_url(&server.url());
1804 let result = client.get_balance(VALID_ADDRESS).await;
1805 assert!(result.is_err());
1806 let err = result.unwrap_err().to_string();
1807 assert!(err.contains("API error"));
1808 assert!(err.contains("Invalid API Key"));
1809 }
1810
1811 #[tokio::test]
1812 async fn test_get_balance_explorer_api_error_result_hex() {
1813 let mut server = mockito::Server::new_async().await;
1814 let _mock = server
1815 .mock("GET", mockito::Matcher::Any)
1816 .with_status(200)
1817 .with_header("content-type", "application/json")
1818 .with_body(r#"{"status":"0","message":"Rate limit","result":"0x"}"#)
1819 .create_async()
1820 .await;
1821
1822 let client = EthereumClient::with_base_url(&server.url());
1823 let result = client.get_balance(VALID_ADDRESS).await;
1824 assert!(result.is_err());
1825 assert!(result.unwrap_err().to_string().contains("API error"));
1826 }
1827
1828 #[test]
1829 fn test_ethereum_client_for_chain_optimism() {
1830 let config = ChainsConfig::default();
1831 let client = EthereumClient::for_chain("optimism", &config).unwrap();
1832 assert_eq!(client.chain_name(), "optimism");
1833 assert_eq!(client.native_token_symbol(), "ETH");
1834 assert_eq!(client.chain_id, Some("10".to_string()));
1835 }
1836
1837 #[test]
1838 fn test_ethereum_client_for_chain_base() {
1839 let config = ChainsConfig::default();
1840 let client = EthereumClient::for_chain("base", &config).unwrap();
1841 assert_eq!(client.chain_name(), "base");
1842 assert_eq!(client.native_token_symbol(), "ETH");
1843 assert_eq!(client.chain_id, Some("8453".to_string()));
1844 }
1845
1846 #[test]
1847 fn test_parse_balance_wei_bsc_client() {
1848 let config = ChainsConfig::default();
1849 let client = EthereumClient::for_chain("bsc", &config).unwrap();
1850 let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1851 assert_eq!(balance.symbol, "BNB");
1852 }
1853
1854 #[tokio::test]
1855 async fn test_get_balance_bsc_rpc_fallback_on_free_tier_restriction() {
1856 let mut server = mockito::Server::new_async().await;
1857 let _explorer_mock = server
1858 .mock("GET", mockito::Matcher::Any)
1859 .with_status(200)
1860 .with_header("content-type", "application/json")
1861 .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"}"#)
1862 .create_async()
1863 .await;
1864
1865 let _rpc_mock = server
1866 .mock("POST", "/")
1867 .with_status(200)
1868 .with_header("content-type", "application/json")
1869 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1870 .create_async()
1871 .await;
1872
1873 let client =
1874 EthereumClient::with_base_url_and_rpc_fallback(&server.url(), Some(server.url()));
1875 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1876 assert_eq!(balance.symbol, "BNB");
1877 assert!(balance.formatted.contains("1.000000"));
1878 }
1879
1880 #[tokio::test]
1881 async fn test_get_balance_invalid_address() {
1882 let client = EthereumClient::default();
1883 let result = client.get_balance("invalid").await;
1884 assert!(result.is_err());
1885 }
1886
1887 #[tokio::test]
1888 async fn test_get_balance_rpc() {
1889 let mut server = mockito::Server::new_async().await;
1890 let _mock = server
1891 .mock("POST", "/")
1892 .with_status(200)
1893 .with_header("content-type", "application/json")
1894 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1895 .create_async()
1896 .await;
1897
1898 let client = EthereumClient {
1899 client: Client::new(),
1900 base_url: server.url(),
1901 chain_id: None,
1902 api_key: None,
1903 chain_name: "aegis".to_string(),
1904 native_symbol: "WRAITH".to_string(),
1905 native_decimals: 18,
1906 api_type: ApiType::JsonRpc,
1907 rpc_fallback_url: None,
1908 };
1909 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1910 assert_eq!(balance.symbol, "WRAITH");
1911 assert!(balance.formatted.contains("1.000000"));
1912 }
1913
1914 #[tokio::test]
1915 async fn test_get_balance_rpc_error() {
1916 let mut server = mockito::Server::new_async().await;
1917 let _mock = server
1918 .mock("POST", "/")
1919 .with_status(200)
1920 .with_header("content-type", "application/json")
1921 .with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
1922 .create_async()
1923 .await;
1924
1925 let client = EthereumClient {
1926 client: Client::new(),
1927 base_url: server.url(),
1928 chain_id: None,
1929 api_key: None,
1930 chain_name: "aegis".to_string(),
1931 native_symbol: "WRAITH".to_string(),
1932 native_decimals: 18,
1933 api_type: ApiType::JsonRpc,
1934 rpc_fallback_url: None,
1935 };
1936 let result = client.get_balance(VALID_ADDRESS).await;
1937 assert!(result.is_err());
1938 assert!(result.unwrap_err().to_string().contains("RPC error"));
1939 }
1940
1941 #[tokio::test]
1942 async fn test_get_balance_rpc_empty_result() {
1943 let mut server = mockito::Server::new_async().await;
1944 let _mock = server
1945 .mock("POST", "/")
1946 .with_status(200)
1947 .with_header("content-type", "application/json")
1948 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1949 .create_async()
1950 .await;
1951
1952 let client = EthereumClient {
1953 client: Client::new(),
1954 base_url: server.url(),
1955 chain_id: None,
1956 api_key: None,
1957 chain_name: "aegis".to_string(),
1958 native_symbol: "WRAITH".to_string(),
1959 native_decimals: 18,
1960 api_type: ApiType::JsonRpc,
1961 rpc_fallback_url: None,
1962 };
1963 let result = client.get_balance(VALID_ADDRESS).await;
1964 assert!(result.is_err());
1965 assert!(
1966 result
1967 .unwrap_err()
1968 .to_string()
1969 .contains("Empty RPC response")
1970 );
1971 }
1972
1973 #[tokio::test]
1974 async fn test_get_transaction_explorer() {
1975 let mut server = mockito::Server::new_async().await;
1976 let _mock = server
1978 .mock("GET", mockito::Matcher::Any)
1979 .with_status(200)
1980 .with_header("content-type", "application/json")
1981 .with_body(
1982 r#"{"jsonrpc":"2.0","id":1,"result":{
1983 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1984 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1985 "to":"0x1111111111111111111111111111111111111111",
1986 "blockNumber":"0x10",
1987 "gas":"0x5208",
1988 "gasPrice":"0x4A817C800",
1989 "nonce":"0x2A",
1990 "value":"0xDE0B6B3A7640000",
1991 "input":"0x"
1992 }}"#,
1993 )
1994 .expect_at_most(1)
1995 .create_async()
1996 .await;
1997
1998 let _receipt_mock = server
2000 .mock("GET", mockito::Matcher::Any)
2001 .with_status(200)
2002 .with_header("content-type", "application/json")
2003 .with_body(
2004 r#"{"jsonrpc":"2.0","id":1,"result":{
2005 "gasUsed":"0x5208",
2006 "status":"0x1"
2007 }}"#,
2008 )
2009 .expect_at_most(1)
2010 .create_async()
2011 .await;
2012
2013 let _block_mock = server
2015 .mock("GET", mockito::Matcher::Any)
2016 .with_status(200)
2017 .with_header("content-type", "application/json")
2018 .with_body(
2019 r#"{"jsonrpc":"2.0","id":1,"result":{
2020 "timestamp":"0x65A8C580"
2021 }}"#,
2022 )
2023 .create_async()
2024 .await;
2025
2026 let client = EthereumClient::with_base_url(&server.url());
2027 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2028 assert_eq!(tx.hash, VALID_TX_HASH);
2029 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2030 assert_eq!(
2031 tx.to,
2032 Some("0x1111111111111111111111111111111111111111".to_string())
2033 );
2034 assert!(tx.gas_limit > 0);
2035 assert!(tx.nonce > 0);
2036 }
2037
2038 #[tokio::test]
2039 async fn test_get_transaction_explorer_not_found() {
2040 let mut server = mockito::Server::new_async().await;
2041 let _mock = server
2042 .mock("GET", mockito::Matcher::Any)
2043 .with_status(200)
2044 .with_header("content-type", "application/json")
2045 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2046 .create_async()
2047 .await;
2048
2049 let client = EthereumClient::with_base_url(&server.url());
2050 let result = client.get_transaction(VALID_TX_HASH).await;
2051 assert!(result.is_err());
2052 assert!(result.unwrap_err().to_string().contains("not found"));
2053 }
2054
2055 #[tokio::test]
2056 async fn test_get_transaction_rpc() {
2057 let mut server = mockito::Server::new_async().await;
2058 let _tx_mock = server
2060 .mock("POST", "/")
2061 .with_status(200)
2062 .with_header("content-type", "application/json")
2063 .with_body(
2064 r#"{"jsonrpc":"2.0","id":1,"result":{
2065 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2066 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2067 "to":"0x1111111111111111111111111111111111111111",
2068 "blockNumber":"0x10",
2069 "gas":"0x5208",
2070 "gasPrice":"0x4A817C800",
2071 "nonce":"0x2A",
2072 "value":"0xDE0B6B3A7640000",
2073 "input":"0x"
2074 }}"#,
2075 )
2076 .expect_at_most(1)
2077 .create_async()
2078 .await;
2079
2080 let _receipt_mock = server
2082 .mock("POST", "/")
2083 .with_status(200)
2084 .with_header("content-type", "application/json")
2085 .with_body(
2086 r#"{"jsonrpc":"2.0","id":2,"result":{
2087 "gasUsed":"0x5208",
2088 "status":"0x1"
2089 }}"#,
2090 )
2091 .create_async()
2092 .await;
2093
2094 let client = EthereumClient {
2095 client: Client::new(),
2096 base_url: server.url(),
2097 chain_id: None,
2098 api_key: None,
2099 chain_name: "aegis".to_string(),
2100 native_symbol: "WRAITH".to_string(),
2101 native_decimals: 18,
2102 api_type: ApiType::JsonRpc,
2103 rpc_fallback_url: None,
2104 };
2105 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2106 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2107 assert!(tx.status.unwrap());
2108 assert!(tx.timestamp.is_none()); }
2110
2111 #[tokio::test]
2112 async fn test_get_transaction_invalid_hash() {
2113 let client = EthereumClient::default();
2114 let result = client.get_transaction("0xbad").await;
2115 assert!(result.is_err());
2116 }
2117
2118 #[tokio::test]
2119 async fn test_get_transactions() {
2120 let mut server = mockito::Server::new_async().await;
2121 let _mock = server
2122 .mock("GET", mockito::Matcher::Any)
2123 .with_status(200)
2124 .with_header("content-type", "application/json")
2125 .with_body(
2126 r#"{"status":"1","message":"OK","result":[
2127 {
2128 "hash":"0xabc",
2129 "blockNumber":"12345",
2130 "timeStamp":"1700000000",
2131 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2132 "to":"0x1111111111111111111111111111111111111111",
2133 "value":"1000000000000000000",
2134 "gas":"21000",
2135 "gasUsed":"21000",
2136 "gasPrice":"20000000000",
2137 "nonce":"1",
2138 "input":"0x",
2139 "isError":"0"
2140 },
2141 {
2142 "hash":"0xdef",
2143 "blockNumber":"12346",
2144 "timeStamp":"1700000060",
2145 "from":"0x1111111111111111111111111111111111111111",
2146 "to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2147 "value":"500000000000000000",
2148 "gas":"21000",
2149 "gasUsed":"21000",
2150 "gasPrice":"20000000000",
2151 "nonce":"5",
2152 "input":"0x",
2153 "isError":"1"
2154 }
2155 ]}"#,
2156 )
2157 .create_async()
2158 .await;
2159
2160 let client = EthereumClient::with_base_url(&server.url());
2161 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2162 assert_eq!(txs.len(), 2);
2163 assert_eq!(txs[0].hash, "0xabc");
2164 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[1].nonce, 5);
2167 }
2168
2169 #[tokio::test]
2170 async fn test_get_transactions_no_transactions() {
2171 let mut server = mockito::Server::new_async().await;
2172 let _mock = server
2173 .mock("GET", mockito::Matcher::Any)
2174 .with_status(200)
2175 .with_header("content-type", "application/json")
2176 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2177 .create_async()
2178 .await;
2179
2180 let client = EthereumClient::with_base_url(&server.url());
2181 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2182 assert!(txs.is_empty());
2183 }
2184
2185 #[tokio::test]
2186 async fn test_get_transactions_empty_to_field() {
2187 let mut server = mockito::Server::new_async().await;
2188 let _mock = server
2189 .mock("GET", mockito::Matcher::Any)
2190 .with_status(200)
2191 .with_header("content-type", "application/json")
2192 .with_body(
2193 r#"{"status":"1","message":"OK","result":[{
2194 "hash":"0xabc",
2195 "blockNumber":"12345",
2196 "timeStamp":"1700000000",
2197 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2198 "to":"",
2199 "value":"0",
2200 "gas":"200000",
2201 "gasUsed":"150000",
2202 "gasPrice":"20000000000",
2203 "nonce":"1",
2204 "input":"0x60806040",
2205 "isError":"0"
2206 }]}"#,
2207 )
2208 .create_async()
2209 .await;
2210
2211 let client = EthereumClient::with_base_url(&server.url());
2212 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2213 assert_eq!(txs.len(), 1);
2214 assert!(txs[0].to.is_none()); }
2216
2217 #[tokio::test]
2218 async fn test_get_block_number() {
2219 let mut server = mockito::Server::new_async().await;
2220 let _mock = server
2221 .mock("GET", mockito::Matcher::Any)
2222 .with_status(200)
2223 .with_header("content-type", "application/json")
2224 .with_body(r#"{"result":"0x1234AB"}"#)
2225 .create_async()
2226 .await;
2227
2228 let client = EthereumClient::with_base_url(&server.url());
2229 let block = client.get_block_number().await.unwrap();
2230 assert_eq!(block, 0x1234AB);
2231 }
2232
2233 #[tokio::test]
2234 async fn test_get_erc20_balances() {
2235 let mut server = mockito::Server::new_async().await;
2236 let _tokentx_mock = server
2238 .mock("GET", mockito::Matcher::Any)
2239 .with_status(200)
2240 .with_header("content-type", "application/json")
2241 .with_body(
2242 r#"{"status":"1","message":"OK","result":[
2243 {
2244 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2245 "tokenSymbol":"USDT",
2246 "tokenName":"Tether USD",
2247 "tokenDecimal":"6"
2248 },
2249 {
2250 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2251 "tokenSymbol":"USDT",
2252 "tokenName":"Tether USD",
2253 "tokenDecimal":"6"
2254 },
2255 {
2256 "contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2257 "tokenSymbol":"USDC",
2258 "tokenName":"USD Coin",
2259 "tokenDecimal":"6"
2260 }
2261 ]}"#,
2262 )
2263 .expect_at_most(1)
2264 .create_async()
2265 .await;
2266
2267 let _balance_mock = server
2269 .mock("GET", mockito::Matcher::Any)
2270 .with_status(200)
2271 .with_header("content-type", "application/json")
2272 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2273 .create_async()
2274 .await;
2275
2276 let client = EthereumClient::with_base_url(&server.url());
2277 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2278 assert!(balances.len() <= 2);
2280 if !balances.is_empty() {
2281 assert!(!balances[0].balance.is_empty());
2282 }
2283 }
2284
2285 #[tokio::test]
2286 async fn test_get_erc20_balances_empty() {
2287 let mut server = mockito::Server::new_async().await;
2288 let _mock = server
2289 .mock("GET", mockito::Matcher::Any)
2290 .with_status(200)
2291 .with_header("content-type", "application/json")
2292 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2293 .create_async()
2294 .await;
2295
2296 let client = EthereumClient::with_base_url(&server.url());
2297 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2298 assert!(balances.is_empty());
2299 }
2300
2301 #[tokio::test]
2302 async fn test_get_token_info_success() {
2303 let mut server = mockito::Server::new_async().await;
2304 let _mock = server
2305 .mock("GET", mockito::Matcher::Any)
2306 .with_status(200)
2307 .with_header("content-type", "application/json")
2308 .with_body(
2309 r#"{"status":"1","message":"OK","result":[{
2310 "tokenName":"Tether USD",
2311 "symbol":"USDT",
2312 "divisor":"1000000",
2313 "tokenType":"ERC20",
2314 "totalSupply":"1000000000000"
2315 }]}"#,
2316 )
2317 .create_async()
2318 .await;
2319
2320 let client = EthereumClient::with_base_url(&server.url());
2321 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2322 assert_eq!(token.symbol, "USDT");
2323 assert_eq!(token.name, "Tether USD");
2324 assert_eq!(token.decimals, 6); }
2326
2327 #[tokio::test]
2328 async fn test_get_token_info_fallback_to_supply() {
2329 let mut server = mockito::Server::new_async().await;
2330 let _info_mock = server
2332 .mock("GET", mockito::Matcher::Any)
2333 .with_status(200)
2334 .with_header("content-type", "application/json")
2335 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2336 .expect_at_most(1)
2337 .create_async()
2338 .await;
2339
2340 let _supply_mock = server
2342 .mock("GET", mockito::Matcher::Any)
2343 .with_status(200)
2344 .with_header("content-type", "application/json")
2345 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2346 .expect_at_most(1)
2347 .create_async()
2348 .await;
2349
2350 let _source_mock = server
2352 .mock("GET", mockito::Matcher::Any)
2353 .with_status(200)
2354 .with_header("content-type", "application/json")
2355 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
2356 .create_async()
2357 .await;
2358
2359 let client = EthereumClient::with_base_url(&server.url());
2360 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2361 assert!(!token.symbol.is_empty());
2363 }
2364
2365 #[tokio::test]
2366 async fn test_get_token_info_unknown() {
2367 let mut server = mockito::Server::new_async().await;
2368 let _mock = server
2369 .mock("GET", mockito::Matcher::Any)
2370 .with_status(200)
2371 .with_header("content-type", "application/json")
2372 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2373 .create_async()
2374 .await;
2375
2376 let client = EthereumClient::with_base_url(&server.url());
2377 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2378 assert!(!token.symbol.is_empty());
2380 }
2381
2382 #[tokio::test]
2383 async fn test_try_get_contract_name() {
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":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
2390 .create_async()
2391 .await;
2392
2393 let client = EthereumClient::with_base_url(&server.url());
2394 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2395 assert!(result.is_some());
2396 let (symbol, name) = result.unwrap();
2397 assert_eq!(name, "USDT");
2398 assert_eq!(symbol, "USDT"); }
2400
2401 #[tokio::test]
2402 async fn test_try_get_contract_name_long_name() {
2403 let mut server = mockito::Server::new_async().await;
2404 let _mock = server
2405 .mock("GET", mockito::Matcher::Any)
2406 .with_status(200)
2407 .with_header("content-type", "application/json")
2408 .with_body(
2409 r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
2410 )
2411 .create_async()
2412 .await;
2413
2414 let client = EthereumClient::with_base_url(&server.url());
2415 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2416 assert!(result.is_some());
2417 let (symbol, name) = result.unwrap();
2418 assert_eq!(name, "TetherUSDToken");
2419 assert_eq!(symbol, "TUSDT"); }
2421
2422 #[tokio::test]
2423 async fn test_try_get_contract_name_not_verified() {
2424 let mut server = mockito::Server::new_async().await;
2425 let _mock = server
2426 .mock("GET", mockito::Matcher::Any)
2427 .with_status(200)
2428 .with_header("content-type", "application/json")
2429 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2430 .create_async()
2431 .await;
2432
2433 let client = EthereumClient::with_base_url(&server.url());
2434 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2435 assert!(result.is_none());
2436 }
2437
2438 #[tokio::test]
2439 async fn test_get_token_holders() {
2440 let mut server = mockito::Server::new_async().await;
2441 let _mock = server
2442 .mock("GET", mockito::Matcher::Any)
2443 .with_status(200)
2444 .with_header("content-type", "application/json")
2445 .with_body(r#"{"status":"1","message":"OK","result":[
2446 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
2447 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
2448 {"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
2449 ]}"#)
2450 .create_async()
2451 .await;
2452
2453 let client = EthereumClient::with_base_url(&server.url());
2454 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2455 assert_eq!(holders.len(), 3);
2456 assert_eq!(
2457 holders[0].address,
2458 "0x1111111111111111111111111111111111111111"
2459 );
2460 assert_eq!(holders[0].rank, 1);
2461 assert_eq!(holders[2].rank, 3);
2462 assert!((holders[0].percentage - 50.0).abs() < 0.01);
2464 }
2465
2466 #[tokio::test]
2467 async fn test_get_token_holders_pro_required() {
2468 let mut server = mockito::Server::new_async().await;
2469 let _mock = server
2470 .mock("GET", mockito::Matcher::Any)
2471 .with_status(200)
2472 .with_header("content-type", "application/json")
2473 .with_body(
2474 r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
2475 )
2476 .create_async()
2477 .await;
2478
2479 let client = EthereumClient::with_base_url(&server.url());
2480 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2481 assert!(holders.is_empty()); }
2483
2484 #[tokio::test]
2485 async fn test_get_token_holder_count_small() {
2486 let mut server = mockito::Server::new_async().await;
2487 let _mock = server
2489 .mock("GET", mockito::Matcher::Any)
2490 .with_status(200)
2491 .with_header("content-type", "application/json")
2492 .with_body(r#"{"status":"1","message":"OK","result":[
2493 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
2494 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
2495 ]}"#)
2496 .create_async()
2497 .await;
2498
2499 let client = EthereumClient::with_base_url(&server.url());
2500 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2501 assert_eq!(count, 2);
2502 }
2503
2504 #[tokio::test]
2505 async fn test_get_token_holder_count_empty() {
2506 let mut server = mockito::Server::new_async().await;
2507 let _mock = server
2508 .mock("GET", mockito::Matcher::Any)
2509 .with_status(200)
2510 .with_header("content-type", "application/json")
2511 .with_body(
2512 r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
2513 )
2514 .create_async()
2515 .await;
2516
2517 let client = EthereumClient::with_base_url(&server.url());
2518 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2519 assert_eq!(count, 0);
2521 }
2522
2523 #[tokio::test]
2524 async fn test_get_block_timestamp() {
2525 let mut server = mockito::Server::new_async().await;
2526 let _mock = server
2527 .mock("GET", mockito::Matcher::Any)
2528 .with_status(200)
2529 .with_header("content-type", "application/json")
2530 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
2531 .create_async()
2532 .await;
2533
2534 let client = EthereumClient::with_base_url(&server.url());
2535 let ts = client.get_block_timestamp(16).await.unwrap();
2536 assert_eq!(ts, 0x65A8C580);
2537 }
2538
2539 #[tokio::test]
2540 async fn test_get_block_timestamp_not_found() {
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(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2547 .create_async()
2548 .await;
2549
2550 let client = EthereumClient::with_base_url(&server.url());
2551 let result = client.get_block_timestamp(99999999).await;
2552 assert!(result.is_err());
2553 }
2554
2555 #[test]
2556 fn test_chain_name_and_symbol_accessors() {
2557 let client = EthereumClient::with_base_url("http://test");
2558 assert_eq!(client.chain_name(), "ethereum");
2559 assert_eq!(client.native_token_symbol(), "ETH");
2560 }
2561
2562 #[test]
2563 fn test_validate_tx_hash_invalid_hex() {
2564 let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
2565 let result = validate_tx_hash(hash);
2566 assert!(result.is_err());
2567 assert!(result.unwrap_err().to_string().contains("invalid hex"));
2568 }
2569
2570 #[tokio::test]
2571 async fn test_get_transactions_api_error() {
2572 let mut server = mockito::Server::new_async().await;
2573 let _mock = server
2574 .mock("GET", mockito::Matcher::Any)
2575 .with_status(200)
2576 .with_header("content-type", "application/json")
2577 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
2578 .create_async()
2579 .await;
2580
2581 let client = EthereumClient::with_base_url(&server.url());
2582 let result = client.get_transactions(VALID_ADDRESS, 10).await;
2583 assert!(result.is_err());
2584 }
2585
2586 #[tokio::test]
2587 async fn test_get_token_holders_pro_key_required() {
2588 let mut server = mockito::Server::new_async().await;
2589 let _mock = server
2590 .mock("GET", mockito::Matcher::Any)
2591 .with_status(200)
2592 .with_header("content-type", "application/json")
2593 .with_body(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
2594 .create_async()
2595 .await;
2596
2597 let client = EthereumClient::with_base_url(&server.url());
2598 let holders = client
2599 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2600 .await
2601 .unwrap();
2602 assert!(holders.is_empty());
2603 }
2604
2605 #[tokio::test]
2606 async fn test_get_token_holders_api_error() {
2607 let mut server = mockito::Server::new_async().await;
2608 let _mock = server
2609 .mock("GET", mockito::Matcher::Any)
2610 .with_status(200)
2611 .with_header("content-type", "application/json")
2612 .with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
2613 .create_async()
2614 .await;
2615
2616 let client = EthereumClient::with_base_url(&server.url());
2617 let result = client
2618 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2619 .await;
2620 assert!(result.is_err());
2621 }
2622
2623 #[tokio::test]
2624 async fn test_get_token_holders_success() {
2625 let mut server = mockito::Server::new_async().await;
2626 let _mock = server
2627 .mock("GET", mockito::Matcher::Any)
2628 .with_status(200)
2629 .with_header("content-type", "application/json")
2630 .with_body(
2631 r#"{"status":"1","message":"OK","result":[
2632 {"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
2633 {"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
2634 ]}"#,
2635 )
2636 .create_async()
2637 .await;
2638
2639 let client = EthereumClient::with_base_url(&server.url());
2640 let holders = client
2641 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2642 .await
2643 .unwrap();
2644 assert_eq!(holders.len(), 2);
2645 assert_eq!(holders[0].rank, 1);
2646 assert_eq!(holders[1].rank, 2);
2647 assert!(holders[0].percentage > 0.0);
2648 }
2649
2650 #[tokio::test]
2651 async fn test_get_token_info_unknown_token() {
2652 let mut server = mockito::Server::new_async().await;
2653 let _mock = server
2655 .mock("GET", mockito::Matcher::Any)
2656 .with_status(200)
2657 .with_header("content-type", "application/json")
2658 .with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
2659 .create_async()
2660 .await;
2661
2662 let client = EthereumClient::with_base_url(&server.url());
2663 let token = client
2664 .get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
2665 .await
2666 .unwrap();
2667 assert_eq!(token.symbol, "UNKNOWN");
2668 }
2669
2670 #[tokio::test]
2671 async fn test_get_transaction_with_null_block_number() {
2672 let mut server = mockito::Server::new_async().await;
2673 let _mock = server
2674 .mock("GET", mockito::Matcher::Any)
2675 .with_status(200)
2676 .with_header("content-type", "application/json")
2677 .with_body(
2678 r#"{"jsonrpc":"2.0","id":1,"result":{
2679 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2680 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2681 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2682 "value":"0xde0b6b3a7640000",
2683 "gas":"0x5208",
2684 "gasPrice":"0x3b9aca00",
2685 "nonce":"0x5",
2686 "input":"0x",
2687 "blockNumber":null
2688 }}"#,
2689 )
2690 .create_async()
2691 .await;
2692
2693 let client = EthereumClient::with_base_url(&server.url());
2694 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2695 assert!(tx.timestamp.is_none());
2697 }
2698
2699 #[tokio::test]
2700 async fn test_chain_client_trait_balance() {
2701 let mut server = mockito::Server::new_async().await;
2702 let _mock = server
2703 .mock("GET", mockito::Matcher::Any)
2704 .with_status(200)
2705 .with_header("content-type", "application/json")
2706 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
2707 .create_async()
2708 .await;
2709
2710 let client = EthereumClient::with_base_url(&server.url());
2711 let chain_client: &dyn ChainClient = &client;
2712 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
2713 assert_eq!(balance.symbol, "ETH");
2714 }
2715
2716 #[tokio::test]
2717 async fn test_chain_client_trait_get_transaction() {
2718 let mut server = mockito::Server::new_async().await;
2719 let _mock = server
2720 .mock("GET", mockito::Matcher::Any)
2721 .with_status(200)
2722 .with_header("content-type", "application/json")
2723 .with_body(
2724 r#"{"jsonrpc":"2.0","id":1,"result":{
2725 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2726 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2727 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2728 "value":"0xde0b6b3a7640000",
2729 "gas":"0x5208",
2730 "gasPrice":"0x3b9aca00",
2731 "nonce":"0x5",
2732 "input":"0x",
2733 "blockNumber":"0xf4240"
2734 }}"#,
2735 )
2736 .create_async()
2737 .await;
2738
2739 let client = EthereumClient::with_base_url(&server.url());
2740 let chain_client: &dyn ChainClient = &client;
2741 let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
2742 assert!(!tx.hash.is_empty());
2743 }
2744
2745 #[tokio::test]
2746 async fn test_chain_client_trait_get_block_number() {
2747 let mut server = mockito::Server::new_async().await;
2748 let _mock = server
2749 .mock("GET", mockito::Matcher::Any)
2750 .with_status(200)
2751 .with_header("content-type", "application/json")
2752 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
2753 .create_async()
2754 .await;
2755
2756 let client = EthereumClient::with_base_url(&server.url());
2757 let chain_client: &dyn ChainClient = &client;
2758 let block = chain_client.get_block_number().await.unwrap();
2759 assert_eq!(block, 1000000);
2760 }
2761
2762 #[tokio::test]
2763 async fn test_chain_client_trait_get_transactions() {
2764 let mut server = mockito::Server::new_async().await;
2765 let _mock = server
2766 .mock("GET", mockito::Matcher::Any)
2767 .with_status(200)
2768 .with_header("content-type", "application/json")
2769 .with_body(
2770 r#"{"status":"1","message":"OK","result":[{
2771 "hash":"0xabc",
2772 "blockNumber":"12345",
2773 "timeStamp":"1700000000",
2774 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2775 "to":"0x1111111111111111111111111111111111111111",
2776 "value":"1000000000000000000",
2777 "gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
2778 "nonce":"1","input":"0x","isError":"0"
2779 }]}"#,
2780 )
2781 .create_async()
2782 .await;
2783
2784 let client = EthereumClient::with_base_url(&server.url());
2785 let chain_client: &dyn ChainClient = &client;
2786 let txs = chain_client
2787 .get_transactions(VALID_ADDRESS, 10)
2788 .await
2789 .unwrap();
2790 assert_eq!(txs.len(), 1);
2791 }
2792
2793 #[tokio::test]
2794 async fn test_chain_client_trait_get_token_balances() {
2795 let mut server = mockito::Server::new_async().await;
2796 let _tokentx = server
2798 .mock("GET", mockito::Matcher::Any)
2799 .with_status(200)
2800 .with_header("content-type", "application/json")
2801 .with_body(
2802 r#"{"status":"1","message":"OK","result":[{
2803 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2804 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2805 }]}"#,
2806 )
2807 .expect_at_most(1)
2808 .create_async()
2809 .await;
2810
2811 let _balance = server
2813 .mock("GET", mockito::Matcher::Any)
2814 .with_status(200)
2815 .with_header("content-type", "application/json")
2816 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2817 .create_async()
2818 .await;
2819
2820 let client = EthereumClient::with_base_url(&server.url());
2821 let chain_client: &dyn ChainClient = &client;
2822 let balances = chain_client
2823 .get_token_balances(VALID_ADDRESS)
2824 .await
2825 .unwrap();
2826 assert!(!balances.is_empty());
2827 }
2828
2829 #[tokio::test]
2830 async fn test_chain_client_trait_get_token_info() {
2831 let mut server = mockito::Server::new_async().await;
2832 let _mock = server
2833 .mock("GET", mockito::Matcher::Any)
2834 .with_status(200)
2835 .with_header("content-type", "application/json")
2836 .with_body(
2837 r#"{"status":"1","message":"OK","result":[{
2838 "tokenName":"Tether USD","symbol":"USDT",
2839 "divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
2840 }]}"#,
2841 )
2842 .create_async()
2843 .await;
2844
2845 let client = EthereumClient::with_base_url(&server.url());
2846 let chain_client: &dyn ChainClient = &client;
2847 let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
2848 assert_eq!(token.symbol, "USDT");
2849 }
2850
2851 #[tokio::test]
2852 async fn test_chain_client_trait_get_token_holders() {
2853 let mut server = mockito::Server::new_async().await;
2854 let _mock = server
2855 .mock("GET", mockito::Matcher::Any)
2856 .with_status(200)
2857 .with_header("content-type", "application/json")
2858 .with_body(
2859 r#"{"status":"1","message":"OK","result":[
2860 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
2861 ]}"#,
2862 )
2863 .create_async()
2864 .await;
2865
2866 let client = EthereumClient::with_base_url(&server.url());
2867 let chain_client: &dyn ChainClient = &client;
2868 let holders = chain_client
2869 .get_token_holders(VALID_ADDRESS, 10)
2870 .await
2871 .unwrap();
2872 assert_eq!(holders.len(), 1);
2873 }
2874
2875 #[tokio::test]
2876 async fn test_chain_client_trait_get_token_holder_count() {
2877 let mut server = mockito::Server::new_async().await;
2878 let _mock = server
2879 .mock("GET", mockito::Matcher::Any)
2880 .with_status(200)
2881 .with_header("content-type", "application/json")
2882 .with_body(
2883 r#"{"status":"1","message":"OK","result":[
2884 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
2885 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
2886 ]}"#,
2887 )
2888 .create_async()
2889 .await;
2890
2891 let client = EthereumClient::with_base_url(&server.url());
2892 let chain_client: &dyn ChainClient = &client;
2893 let count = chain_client
2894 .get_token_holder_count(VALID_ADDRESS)
2895 .await
2896 .unwrap();
2897 assert_eq!(count, 2);
2898 }
2899
2900 #[tokio::test]
2901 async fn test_chain_client_trait_native_token_symbol() {
2902 let client = EthereumClient::with_base_url("http://test");
2903 let chain_client: &dyn ChainClient = &client;
2904 assert_eq!(chain_client.native_token_symbol(), "ETH");
2905 assert_eq!(chain_client.chain_name(), "ethereum");
2906 }
2907
2908 #[tokio::test]
2909 async fn test_get_token_info_supply_fallback_no_contract() {
2910 let mut server = mockito::Server::new_async().await;
2911 let _info_mock = server
2913 .mock("GET", mockito::Matcher::Any)
2914 .with_status(200)
2915 .with_header("content-type", "application/json")
2916 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2917 .expect_at_most(1)
2918 .create_async()
2919 .await;
2920
2921 let _supply_mock = 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":"1000000000000"}"#)
2927 .expect_at_most(1)
2928 .create_async()
2929 .await;
2930
2931 let _source_mock = server
2933 .mock("GET", mockito::Matcher::Any)
2934 .with_status(200)
2935 .with_header("content-type", "application/json")
2936 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2937 .create_async()
2938 .await;
2939
2940 let client = EthereumClient::with_base_url(&server.url());
2941 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2942 assert!(token.symbol.contains("...") || !token.symbol.is_empty());
2944 }
2945
2946 #[tokio::test]
2947 async fn test_try_get_contract_name_short_lowercase() {
2948 let mut server = mockito::Server::new_async().await;
2949 let _mock = server
2950 .mock("GET", mockito::Matcher::Any)
2951 .with_status(200)
2952 .with_header("content-type", "application/json")
2953 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
2954 .create_async()
2955 .await;
2956
2957 let client = EthereumClient::with_base_url(&server.url());
2958 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2959 assert!(result.is_some());
2960 let (symbol, name) = result.unwrap();
2961 assert_eq!(name, "token");
2962 assert_eq!(symbol, "TOKEN");
2964 }
2965
2966 #[tokio::test]
2967 async fn test_try_get_contract_name_long_lowercase() {
2968 let mut server = mockito::Server::new_async().await;
2969 let _mock = server
2970 .mock("GET", mockito::Matcher::Any)
2971 .with_status(200)
2972 .with_header("content-type", "application/json")
2973 .with_body(
2974 r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
2975 )
2976 .create_async()
2977 .await;
2978
2979 let client = EthereumClient::with_base_url(&server.url());
2980 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2981 assert!(result.is_some());
2982 let (symbol, name) = result.unwrap();
2983 assert_eq!(name, "mytokencontract");
2984 assert_eq!(symbol, "MYTOKE");
2987 }
2988
2989 #[tokio::test]
2990 async fn test_get_erc20_balances_zero_balance_skipped() {
2991 let mut server = mockito::Server::new_async().await;
2992 let _tokentx = server
2994 .mock("GET", mockito::Matcher::Any)
2995 .with_status(200)
2996 .with_header("content-type", "application/json")
2997 .with_body(
2998 r#"{"status":"1","message":"OK","result":[{
2999 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
3000 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
3001 }]}"#,
3002 )
3003 .expect_at_most(1)
3004 .create_async()
3005 .await;
3006
3007 let _balance = server
3009 .mock("GET", mockito::Matcher::Any)
3010 .with_status(200)
3011 .with_header("content-type", "application/json")
3012 .with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
3013 .create_async()
3014 .await;
3015
3016 let client = EthereumClient::with_base_url(&server.url());
3017 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
3018 assert!(balances.is_empty());
3020 }
3021}