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 tracing::warn!(
1182 "Token holder API requires Pro key or is unavailable: {}",
1183 api_response.message
1184 );
1185 return Ok(Vec::new());
1186 }
1187 return Err(ScopeError::Api(format!(
1188 "API error: {}",
1189 api_response.message
1190 )));
1191 }
1192
1193 let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
1195 .map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
1196
1197 let total_balance: f64 = holders
1199 .iter()
1200 .filter_map(|h| h.quantity.parse::<f64>().ok())
1201 .sum();
1202
1203 let token_holders: Vec<TokenHolder> = holders
1205 .into_iter()
1206 .enumerate()
1207 .map(|(i, h)| {
1208 let balance: f64 = h.quantity.parse().unwrap_or(0.0);
1209 let percentage = if total_balance > 0.0 {
1210 (balance / total_balance) * 100.0
1211 } else {
1212 0.0
1213 };
1214
1215 TokenHolder {
1216 address: h.address,
1217 balance: h.quantity.clone(),
1218 formatted_balance: crate::display::format_token_balance(&h.quantity, 18), percentage,
1220 rank: (i + 1) as u32,
1221 }
1222 })
1223 .collect();
1224
1225 Ok(token_holders)
1226 }
1227
1228 pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
1233 let max_page_size: u32 = 1000;
1235 let holders = self.get_token_holders(token_address, max_page_size).await?;
1236
1237 if holders.is_empty() {
1238 return Ok(0);
1239 }
1240
1241 let count = holders.len() as u64;
1242
1243 if count < max_page_size as u64 {
1244 Ok(count)
1246 } else {
1247 let mut total = count;
1250 let mut page = 2u32;
1251 loop {
1252 let url = self.build_api_url(&format!(
1253 "module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
1254 token_address, page, max_page_size
1255 ));
1256 let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
1257 self.client.get(&url).send().await?.json().await;
1258
1259 match response {
1260 Ok(api_resp) if api_resp.status == "1" => {
1261 let page_count = api_resp.result.len() as u64;
1262 total += page_count;
1263 if page_count < max_page_size as u64 || page >= 10 {
1264 break;
1266 }
1267 page += 1;
1268 }
1269 _ => break,
1270 }
1271 }
1272 Ok(total)
1273 }
1274 }
1275}
1276
1277impl Default for EthereumClient {
1278 fn default() -> Self {
1279 Self {
1280 client: Client::new(),
1281 base_url: ETHERSCAN_V2_API.to_string(),
1282 chain_id: Some("1".to_string()),
1283 api_key: None,
1284 chain_name: "ethereum".to_string(),
1285 native_symbol: "ETH".to_string(),
1286 native_decimals: 18,
1287 api_type: ApiType::BlockExplorer,
1288 rpc_fallback_url: None,
1289 }
1290 }
1291}
1292
1293fn validate_eth_address(address: &str) -> Result<()> {
1295 if !address.starts_with("0x") {
1296 return Err(ScopeError::InvalidAddress(format!(
1297 "Address must start with '0x': {}",
1298 address
1299 )));
1300 }
1301 if address.len() != 42 {
1302 return Err(ScopeError::InvalidAddress(format!(
1303 "Address must be 42 characters: {}",
1304 address
1305 )));
1306 }
1307 if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1308 return Err(ScopeError::InvalidAddress(format!(
1309 "Address contains invalid hex characters: {}",
1310 address
1311 )));
1312 }
1313 Ok(())
1314}
1315
1316fn validate_tx_hash(hash: &str) -> Result<()> {
1318 if !hash.starts_with("0x") {
1319 return Err(ScopeError::InvalidHash(format!(
1320 "Hash must start with '0x': {}",
1321 hash
1322 )));
1323 }
1324 if hash.len() != 66 {
1325 return Err(ScopeError::InvalidHash(format!(
1326 "Hash must be 66 characters: {}",
1327 hash
1328 )));
1329 }
1330 if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1331 return Err(ScopeError::InvalidHash(format!(
1332 "Hash contains invalid hex characters: {}",
1333 hash
1334 )));
1335 }
1336 Ok(())
1337}
1338
1339#[async_trait]
1344impl ChainClient for EthereumClient {
1345 fn chain_name(&self) -> &str {
1346 &self.chain_name
1347 }
1348
1349 fn native_token_symbol(&self) -> &str {
1350 &self.native_symbol
1351 }
1352
1353 async fn get_balance(&self, address: &str) -> Result<Balance> {
1354 self.get_balance(address).await
1355 }
1356
1357 async fn enrich_balance_usd(&self, balance: &mut Balance) {
1358 self.enrich_balance_usd(balance).await
1359 }
1360
1361 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
1362 self.get_transaction(hash).await
1363 }
1364
1365 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
1366 self.get_transactions(address, limit).await
1367 }
1368
1369 async fn get_block_number(&self) -> Result<u64> {
1370 self.get_block_number().await
1371 }
1372
1373 async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
1374 self.get_erc20_balances(address).await
1375 }
1376
1377 async fn get_token_info(&self, address: &str) -> Result<Token> {
1378 self.get_token_info(address).await
1379 }
1380
1381 async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
1382 self.get_token_holders(address, limit).await
1383 }
1384
1385 async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
1386 self.get_token_holder_count(address).await
1387 }
1388
1389 async fn get_code(&self, address: &str) -> Result<String> {
1390 self.get_code(address).await
1391 }
1392}
1393
1394#[cfg(test)]
1399mod tests {
1400 use super::*;
1401
1402 const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1403 const VALID_TX_HASH: &str =
1404 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
1405
1406 #[test]
1407 fn test_validate_eth_address_valid() {
1408 assert!(validate_eth_address(VALID_ADDRESS).is_ok());
1409 }
1410
1411 #[test]
1412 fn test_validate_eth_address_lowercase() {
1413 let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
1414 assert!(validate_eth_address(addr).is_ok());
1415 }
1416
1417 #[test]
1418 fn test_validate_eth_address_missing_prefix() {
1419 let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1420 let result = validate_eth_address(addr);
1421 assert!(result.is_err());
1422 assert!(result.unwrap_err().to_string().contains("0x"));
1423 }
1424
1425 #[test]
1426 fn test_validate_eth_address_too_short() {
1427 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
1428 let result = validate_eth_address(addr);
1429 assert!(result.is_err());
1430 assert!(result.unwrap_err().to_string().contains("42 characters"));
1431 }
1432
1433 #[test]
1434 fn test_validate_eth_address_invalid_hex() {
1435 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
1436 let result = validate_eth_address(addr);
1437 assert!(result.is_err());
1438 assert!(result.unwrap_err().to_string().contains("invalid hex"));
1439 }
1440
1441 #[test]
1442 fn test_validate_tx_hash_valid() {
1443 assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
1444 }
1445
1446 #[test]
1447 fn test_validate_tx_hash_missing_prefix() {
1448 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
1449 let result = validate_tx_hash(hash);
1450 assert!(result.is_err());
1451 }
1452
1453 #[test]
1454 fn test_validate_tx_hash_too_short() {
1455 let hash = "0xabc123";
1456 let result = validate_tx_hash(hash);
1457 assert!(result.is_err());
1458 assert!(result.unwrap_err().to_string().contains("66 characters"));
1459 }
1460
1461 #[test]
1462 fn test_validate_eth_address_too_long() {
1463 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2extra";
1464 let result = validate_eth_address(addr);
1465 assert!(result.is_err());
1466 assert!(result.unwrap_err().to_string().contains("42 characters"));
1467 }
1468
1469 #[test]
1470 fn test_validate_eth_address_empty() {
1471 let result = validate_eth_address("");
1472 assert!(result.is_err());
1473 }
1474
1475 #[test]
1476 fn test_validate_eth_address_only_prefix() {
1477 let result = validate_eth_address("0x");
1478 assert!(result.is_err());
1479 assert!(result.unwrap_err().to_string().contains("42 characters"));
1480 }
1481
1482 #[test]
1483 fn test_validate_tx_hash_too_long() {
1484 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcdextra";
1485 let result = validate_tx_hash(hash);
1486 assert!(result.is_err());
1487 assert!(result.unwrap_err().to_string().contains("66 characters"));
1488 }
1489
1490 #[test]
1491 fn test_validate_tx_hash_empty() {
1492 let result = validate_tx_hash("");
1493 assert!(result.is_err());
1494 }
1495
1496 #[test]
1497 fn test_validate_tx_hash_only_prefix() {
1498 let result = validate_tx_hash("0x");
1499 assert!(result.is_err());
1500 assert!(result.unwrap_err().to_string().contains("66 characters"));
1501 }
1502
1503 #[test]
1504 fn test_ethereum_client_default() {
1505 let client = EthereumClient::default();
1506 assert_eq!(client.chain_name(), "ethereum");
1507 assert_eq!(client.native_token_symbol(), "ETH");
1508 }
1509
1510 #[test]
1511 fn test_ethereum_client_with_base_url() {
1512 let client = EthereumClient::with_base_url("https://custom.api.com");
1513 assert_eq!(client.base_url, "https://custom.api.com");
1514 }
1515
1516 #[test]
1517 fn test_ethereum_client_for_chain_ethereum() {
1518 let config = ChainsConfig::default();
1519 let client = EthereumClient::for_chain("ethereum", &config).unwrap();
1520 assert_eq!(client.chain_name(), "ethereum");
1521 assert_eq!(client.native_token_symbol(), "ETH");
1522 }
1523
1524 #[test]
1525 fn test_ethereum_client_for_chain_polygon() {
1526 let config = ChainsConfig::default();
1527 let client = EthereumClient::for_chain("polygon", &config).unwrap();
1528 assert_eq!(client.chain_name(), "polygon");
1529 assert_eq!(client.native_token_symbol(), "MATIC");
1530 assert!(client.base_url.contains("etherscan.io/v2"));
1532 assert_eq!(client.chain_id, Some("137".to_string()));
1533 }
1534
1535 #[test]
1536 fn test_ethereum_client_for_chain_arbitrum() {
1537 let config = ChainsConfig::default();
1538 let client = EthereumClient::for_chain("arbitrum", &config).unwrap();
1539 assert_eq!(client.chain_name(), "arbitrum");
1540 assert!(client.base_url.contains("etherscan.io/v2"));
1542 assert_eq!(client.chain_id, Some("42161".to_string()));
1543 }
1544
1545 #[test]
1546 fn test_ethereum_client_for_chain_bsc() {
1547 let config = ChainsConfig::default();
1548 let client = EthereumClient::for_chain("bsc", &config).unwrap();
1549 assert_eq!(client.chain_name(), "bsc");
1550 assert_eq!(client.native_token_symbol(), "BNB");
1551 assert!(client.base_url.contains("etherscan.io/v2"));
1553 assert_eq!(client.chain_id, Some("56".to_string()));
1554 assert_eq!(client.api_type, ApiType::BlockExplorer);
1555 }
1556
1557 #[test]
1558 fn test_ethereum_client_for_chain_aegis() {
1559 let config = ChainsConfig::default();
1560 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1561 assert_eq!(client.chain_name(), "aegis");
1562 assert_eq!(client.native_token_symbol(), "WRAITH");
1563 assert_eq!(client.api_type, ApiType::JsonRpc);
1564 assert!(client.base_url.contains("localhost:8545"));
1566 }
1567
1568 #[test]
1569 fn test_ethereum_client_for_chain_aegis_with_config() {
1570 let config = ChainsConfig {
1571 aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
1572 ..Default::default()
1573 };
1574 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1575 assert_eq!(client.base_url, "https://aegis.example.com:8545");
1576 }
1577
1578 #[test]
1579 fn test_ethereum_client_for_chain_unsupported() {
1580 let config = ChainsConfig::default();
1581 let result = EthereumClient::for_chain("bitcoin", &config);
1582 assert!(result.is_err());
1583 assert!(
1584 result
1585 .unwrap_err()
1586 .to_string()
1587 .contains("Unsupported chain")
1588 );
1589 }
1590
1591 #[test]
1592 fn test_ethereum_client_new() {
1593 let config = ChainsConfig::default();
1594 let client = EthereumClient::new(&config);
1595 assert!(client.is_ok());
1596 }
1597
1598 #[test]
1599 fn test_ethereum_client_with_api_key() {
1600 use std::collections::HashMap;
1601
1602 let mut api_keys = HashMap::new();
1603 api_keys.insert("etherscan".to_string(), "test-key".to_string());
1604
1605 let config = ChainsConfig {
1606 api_keys,
1607 ..Default::default()
1608 };
1609
1610 let client = EthereumClient::new(&config).unwrap();
1611 assert_eq!(client.api_key, Some("test-key".to_string()));
1612 }
1613
1614 #[test]
1615 fn test_api_response_deserialization() {
1616 let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
1617 let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
1618 assert_eq!(response.status, "1");
1619 assert_eq!(response.message, "OK");
1620 assert_eq!(response.result, "1000000000000000000");
1621 }
1622
1623 #[test]
1624 fn test_tx_list_item_deserialization() {
1625 let json = r#"{
1626 "hash": "0xabc",
1627 "blockNumber": "12345",
1628 "timeStamp": "1700000000",
1629 "from": "0xfrom",
1630 "to": "0xto",
1631 "value": "1000000000000000000",
1632 "gas": "21000",
1633 "gasUsed": "21000",
1634 "gasPrice": "20000000000",
1635 "nonce": "42",
1636 "input": "0x",
1637 "isError": "0"
1638 }"#;
1639
1640 let item: TxListItem = serde_json::from_str(json).unwrap();
1641 assert_eq!(item.hash, "0xabc");
1642 assert_eq!(item.block_number, "12345");
1643 assert_eq!(item.nonce, "42");
1644 assert_eq!(item.is_error, "0");
1645 }
1646
1647 #[test]
1652 fn test_parse_balance_wei_valid() {
1653 let client = EthereumClient::default();
1654 let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1655 assert_eq!(balance.symbol, "ETH");
1656 assert_eq!(balance.raw, "1000000000000000000");
1657 assert!(balance.formatted.contains("1.000000"));
1658 assert!(balance.usd_value.is_none());
1659 }
1660
1661 #[test]
1662 fn test_parse_balance_wei_zero() {
1663 let client = EthereumClient::default();
1664 let balance = client.parse_balance_wei("0").unwrap();
1665 assert!(balance.formatted.contains("0.000000"));
1666 }
1667
1668 #[test]
1669 fn test_parse_balance_wei_invalid() {
1670 let client = EthereumClient::default();
1671 let result = client.parse_balance_wei("not_a_number");
1672 assert!(result.is_err());
1673 }
1674
1675 #[test]
1676 fn test_format_token_balance_large() {
1677 assert!(
1678 crate::display::format_token_balance("1000000000000000000000000000", 18).contains("B")
1679 );
1680 }
1681
1682 #[test]
1683 fn test_format_token_balance_millions() {
1684 assert!(
1685 crate::display::format_token_balance("5000000000000000000000000", 18).contains("M")
1686 );
1687 }
1688
1689 #[test]
1690 fn test_format_token_balance_thousands() {
1691 assert!(crate::display::format_token_balance("5000000000000000000000", 18).contains("K"));
1692 }
1693
1694 #[test]
1695 fn test_format_token_balance_small() {
1696 let formatted = crate::display::format_token_balance("500000000000000000", 18);
1697 assert!(formatted.contains("0.5"));
1698 }
1699
1700 #[test]
1701 fn test_format_token_balance_zero() {
1702 let formatted = crate::display::format_token_balance("0", 18);
1703 assert!(formatted.contains("0.0000"));
1704 }
1705
1706 #[test]
1707 fn test_build_api_url_with_chain_id_and_key() {
1708 use std::collections::HashMap;
1709 let mut keys = HashMap::new();
1710 keys.insert("etherscan".to_string(), "MYKEY".to_string());
1711 let config = ChainsConfig {
1712 api_keys: keys,
1713 ..Default::default()
1714 };
1715 let client = EthereumClient::new(&config).unwrap();
1716 let url = client.build_api_url("module=account&action=balance&address=0x123");
1717 assert!(url.contains("chainid=1"));
1718 assert!(url.contains("module=account"));
1719 assert!(url.contains("apikey=MYKEY"));
1720 }
1721
1722 #[test]
1723 fn test_build_api_url_no_chain_id_no_key() {
1724 let client = EthereumClient::with_base_url("https://example.com/api");
1725 let url = client.build_api_url("module=account&action=balance");
1726 assert_eq!(url, "https://example.com/api?module=account&action=balance");
1727 assert!(!url.contains("chainid"));
1728 assert!(!url.contains("apikey"));
1729 }
1730
1731 #[tokio::test]
1736 async fn test_get_balance_explorer() {
1737 let mut server = mockito::Server::new_async().await;
1738 let _mock = server
1739 .mock("GET", mockito::Matcher::Any)
1740 .with_status(200)
1741 .with_header("content-type", "application/json")
1742 .with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
1743 .create_async()
1744 .await;
1745
1746 let client = EthereumClient::with_base_url(&server.url());
1747 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1748 assert_eq!(balance.raw, "2500000000000000000");
1749 assert_eq!(balance.symbol, "ETH");
1750 assert!(balance.formatted.contains("2.5"));
1751 }
1752
1753 #[tokio::test]
1754 async fn test_get_balance_explorer_api_error() {
1755 let mut server = mockito::Server::new_async().await;
1756 let _mock = server
1757 .mock("GET", mockito::Matcher::Any)
1758 .with_status(200)
1759 .with_header("content-type", "application/json")
1760 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
1761 .create_async()
1762 .await;
1763
1764 let client = EthereumClient::with_base_url(&server.url());
1765 let result = client.get_balance(VALID_ADDRESS).await;
1766 assert!(result.is_err());
1767 assert!(result.unwrap_err().to_string().contains("API error"));
1768 }
1769
1770 #[tokio::test]
1771 async fn test_get_balance_bsc_rpc_fallback_on_free_tier_restriction() {
1772 let mut server = mockito::Server::new_async().await;
1773 let _explorer_mock = server
1774 .mock("GET", mockito::Matcher::Any)
1775 .with_status(200)
1776 .with_header("content-type", "application/json")
1777 .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"}"#)
1778 .create_async()
1779 .await;
1780
1781 let _rpc_mock = server
1782 .mock("POST", "/")
1783 .with_status(200)
1784 .with_header("content-type", "application/json")
1785 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1786 .create_async()
1787 .await;
1788
1789 let client =
1790 EthereumClient::with_base_url_and_rpc_fallback(&server.url(), Some(server.url()));
1791 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1792 assert_eq!(balance.symbol, "BNB");
1793 assert!(balance.formatted.contains("1.000000"));
1794 }
1795
1796 #[tokio::test]
1797 async fn test_get_balance_invalid_address() {
1798 let client = EthereumClient::default();
1799 let result = client.get_balance("invalid").await;
1800 assert!(result.is_err());
1801 }
1802
1803 #[tokio::test]
1804 async fn test_get_balance_rpc() {
1805 let mut server = mockito::Server::new_async().await;
1806 let _mock = server
1807 .mock("POST", "/")
1808 .with_status(200)
1809 .with_header("content-type", "application/json")
1810 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1811 .create_async()
1812 .await;
1813
1814 let client = EthereumClient {
1815 client: Client::new(),
1816 base_url: server.url(),
1817 chain_id: None,
1818 api_key: None,
1819 chain_name: "aegis".to_string(),
1820 native_symbol: "WRAITH".to_string(),
1821 native_decimals: 18,
1822 api_type: ApiType::JsonRpc,
1823 rpc_fallback_url: None,
1824 };
1825 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1826 assert_eq!(balance.symbol, "WRAITH");
1827 assert!(balance.formatted.contains("1.000000"));
1828 }
1829
1830 #[tokio::test]
1831 async fn test_get_balance_rpc_error() {
1832 let mut server = mockito::Server::new_async().await;
1833 let _mock = server
1834 .mock("POST", "/")
1835 .with_status(200)
1836 .with_header("content-type", "application/json")
1837 .with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
1838 .create_async()
1839 .await;
1840
1841 let client = EthereumClient {
1842 client: Client::new(),
1843 base_url: server.url(),
1844 chain_id: None,
1845 api_key: None,
1846 chain_name: "aegis".to_string(),
1847 native_symbol: "WRAITH".to_string(),
1848 native_decimals: 18,
1849 api_type: ApiType::JsonRpc,
1850 rpc_fallback_url: None,
1851 };
1852 let result = client.get_balance(VALID_ADDRESS).await;
1853 assert!(result.is_err());
1854 assert!(result.unwrap_err().to_string().contains("RPC error"));
1855 }
1856
1857 #[tokio::test]
1858 async fn test_get_balance_rpc_empty_result() {
1859 let mut server = mockito::Server::new_async().await;
1860 let _mock = server
1861 .mock("POST", "/")
1862 .with_status(200)
1863 .with_header("content-type", "application/json")
1864 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1865 .create_async()
1866 .await;
1867
1868 let client = EthereumClient {
1869 client: Client::new(),
1870 base_url: server.url(),
1871 chain_id: None,
1872 api_key: None,
1873 chain_name: "aegis".to_string(),
1874 native_symbol: "WRAITH".to_string(),
1875 native_decimals: 18,
1876 api_type: ApiType::JsonRpc,
1877 rpc_fallback_url: None,
1878 };
1879 let result = client.get_balance(VALID_ADDRESS).await;
1880 assert!(result.is_err());
1881 assert!(
1882 result
1883 .unwrap_err()
1884 .to_string()
1885 .contains("Empty RPC response")
1886 );
1887 }
1888
1889 #[tokio::test]
1890 async fn test_get_transaction_explorer() {
1891 let mut server = mockito::Server::new_async().await;
1892 let _mock = server
1894 .mock("GET", mockito::Matcher::Any)
1895 .with_status(200)
1896 .with_header("content-type", "application/json")
1897 .with_body(
1898 r#"{"jsonrpc":"2.0","id":1,"result":{
1899 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1900 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1901 "to":"0x1111111111111111111111111111111111111111",
1902 "blockNumber":"0x10",
1903 "gas":"0x5208",
1904 "gasPrice":"0x4A817C800",
1905 "nonce":"0x2A",
1906 "value":"0xDE0B6B3A7640000",
1907 "input":"0x"
1908 }}"#,
1909 )
1910 .expect_at_most(1)
1911 .create_async()
1912 .await;
1913
1914 let _receipt_mock = server
1916 .mock("GET", mockito::Matcher::Any)
1917 .with_status(200)
1918 .with_header("content-type", "application/json")
1919 .with_body(
1920 r#"{"jsonrpc":"2.0","id":1,"result":{
1921 "gasUsed":"0x5208",
1922 "status":"0x1"
1923 }}"#,
1924 )
1925 .expect_at_most(1)
1926 .create_async()
1927 .await;
1928
1929 let _block_mock = server
1931 .mock("GET", mockito::Matcher::Any)
1932 .with_status(200)
1933 .with_header("content-type", "application/json")
1934 .with_body(
1935 r#"{"jsonrpc":"2.0","id":1,"result":{
1936 "timestamp":"0x65A8C580"
1937 }}"#,
1938 )
1939 .create_async()
1940 .await;
1941
1942 let client = EthereumClient::with_base_url(&server.url());
1943 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1944 assert_eq!(tx.hash, VALID_TX_HASH);
1945 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1946 assert_eq!(
1947 tx.to,
1948 Some("0x1111111111111111111111111111111111111111".to_string())
1949 );
1950 assert!(tx.gas_limit > 0);
1951 assert!(tx.nonce > 0);
1952 }
1953
1954 #[tokio::test]
1955 async fn test_get_transaction_explorer_not_found() {
1956 let mut server = mockito::Server::new_async().await;
1957 let _mock = server
1958 .mock("GET", mockito::Matcher::Any)
1959 .with_status(200)
1960 .with_header("content-type", "application/json")
1961 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
1962 .create_async()
1963 .await;
1964
1965 let client = EthereumClient::with_base_url(&server.url());
1966 let result = client.get_transaction(VALID_TX_HASH).await;
1967 assert!(result.is_err());
1968 assert!(result.unwrap_err().to_string().contains("not found"));
1969 }
1970
1971 #[tokio::test]
1972 async fn test_get_transaction_rpc() {
1973 let mut server = mockito::Server::new_async().await;
1974 let _tx_mock = server
1976 .mock("POST", "/")
1977 .with_status(200)
1978 .with_header("content-type", "application/json")
1979 .with_body(
1980 r#"{"jsonrpc":"2.0","id":1,"result":{
1981 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1982 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1983 "to":"0x1111111111111111111111111111111111111111",
1984 "blockNumber":"0x10",
1985 "gas":"0x5208",
1986 "gasPrice":"0x4A817C800",
1987 "nonce":"0x2A",
1988 "value":"0xDE0B6B3A7640000",
1989 "input":"0x"
1990 }}"#,
1991 )
1992 .expect_at_most(1)
1993 .create_async()
1994 .await;
1995
1996 let _receipt_mock = server
1998 .mock("POST", "/")
1999 .with_status(200)
2000 .with_header("content-type", "application/json")
2001 .with_body(
2002 r#"{"jsonrpc":"2.0","id":2,"result":{
2003 "gasUsed":"0x5208",
2004 "status":"0x1"
2005 }}"#,
2006 )
2007 .create_async()
2008 .await;
2009
2010 let client = EthereumClient {
2011 client: Client::new(),
2012 base_url: server.url(),
2013 chain_id: None,
2014 api_key: None,
2015 chain_name: "aegis".to_string(),
2016 native_symbol: "WRAITH".to_string(),
2017 native_decimals: 18,
2018 api_type: ApiType::JsonRpc,
2019 rpc_fallback_url: None,
2020 };
2021 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2022 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2023 assert!(tx.status.unwrap());
2024 assert!(tx.timestamp.is_none()); }
2026
2027 #[tokio::test]
2028 async fn test_get_transaction_invalid_hash() {
2029 let client = EthereumClient::default();
2030 let result = client.get_transaction("0xbad").await;
2031 assert!(result.is_err());
2032 }
2033
2034 #[tokio::test]
2035 async fn test_get_transactions() {
2036 let mut server = mockito::Server::new_async().await;
2037 let _mock = server
2038 .mock("GET", mockito::Matcher::Any)
2039 .with_status(200)
2040 .with_header("content-type", "application/json")
2041 .with_body(
2042 r#"{"status":"1","message":"OK","result":[
2043 {
2044 "hash":"0xabc",
2045 "blockNumber":"12345",
2046 "timeStamp":"1700000000",
2047 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2048 "to":"0x1111111111111111111111111111111111111111",
2049 "value":"1000000000000000000",
2050 "gas":"21000",
2051 "gasUsed":"21000",
2052 "gasPrice":"20000000000",
2053 "nonce":"1",
2054 "input":"0x",
2055 "isError":"0"
2056 },
2057 {
2058 "hash":"0xdef",
2059 "blockNumber":"12346",
2060 "timeStamp":"1700000060",
2061 "from":"0x1111111111111111111111111111111111111111",
2062 "to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2063 "value":"500000000000000000",
2064 "gas":"21000",
2065 "gasUsed":"21000",
2066 "gasPrice":"20000000000",
2067 "nonce":"5",
2068 "input":"0x",
2069 "isError":"1"
2070 }
2071 ]}"#,
2072 )
2073 .create_async()
2074 .await;
2075
2076 let client = EthereumClient::with_base_url(&server.url());
2077 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2078 assert_eq!(txs.len(), 2);
2079 assert_eq!(txs[0].hash, "0xabc");
2080 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[1].nonce, 5);
2083 }
2084
2085 #[tokio::test]
2086 async fn test_get_transactions_no_transactions() {
2087 let mut server = mockito::Server::new_async().await;
2088 let _mock = server
2089 .mock("GET", mockito::Matcher::Any)
2090 .with_status(200)
2091 .with_header("content-type", "application/json")
2092 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2093 .create_async()
2094 .await;
2095
2096 let client = EthereumClient::with_base_url(&server.url());
2097 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2098 assert!(txs.is_empty());
2099 }
2100
2101 #[tokio::test]
2102 async fn test_get_transactions_empty_to_field() {
2103 let mut server = mockito::Server::new_async().await;
2104 let _mock = server
2105 .mock("GET", mockito::Matcher::Any)
2106 .with_status(200)
2107 .with_header("content-type", "application/json")
2108 .with_body(
2109 r#"{"status":"1","message":"OK","result":[{
2110 "hash":"0xabc",
2111 "blockNumber":"12345",
2112 "timeStamp":"1700000000",
2113 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2114 "to":"",
2115 "value":"0",
2116 "gas":"200000",
2117 "gasUsed":"150000",
2118 "gasPrice":"20000000000",
2119 "nonce":"1",
2120 "input":"0x60806040",
2121 "isError":"0"
2122 }]}"#,
2123 )
2124 .create_async()
2125 .await;
2126
2127 let client = EthereumClient::with_base_url(&server.url());
2128 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
2129 assert_eq!(txs.len(), 1);
2130 assert!(txs[0].to.is_none()); }
2132
2133 #[tokio::test]
2134 async fn test_get_block_number() {
2135 let mut server = mockito::Server::new_async().await;
2136 let _mock = server
2137 .mock("GET", mockito::Matcher::Any)
2138 .with_status(200)
2139 .with_header("content-type", "application/json")
2140 .with_body(r#"{"result":"0x1234AB"}"#)
2141 .create_async()
2142 .await;
2143
2144 let client = EthereumClient::with_base_url(&server.url());
2145 let block = client.get_block_number().await.unwrap();
2146 assert_eq!(block, 0x1234AB);
2147 }
2148
2149 #[tokio::test]
2150 async fn test_get_erc20_balances() {
2151 let mut server = mockito::Server::new_async().await;
2152 let _tokentx_mock = server
2154 .mock("GET", mockito::Matcher::Any)
2155 .with_status(200)
2156 .with_header("content-type", "application/json")
2157 .with_body(
2158 r#"{"status":"1","message":"OK","result":[
2159 {
2160 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2161 "tokenSymbol":"USDT",
2162 "tokenName":"Tether USD",
2163 "tokenDecimal":"6"
2164 },
2165 {
2166 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2167 "tokenSymbol":"USDT",
2168 "tokenName":"Tether USD",
2169 "tokenDecimal":"6"
2170 },
2171 {
2172 "contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2173 "tokenSymbol":"USDC",
2174 "tokenName":"USD Coin",
2175 "tokenDecimal":"6"
2176 }
2177 ]}"#,
2178 )
2179 .expect_at_most(1)
2180 .create_async()
2181 .await;
2182
2183 let _balance_mock = server
2185 .mock("GET", mockito::Matcher::Any)
2186 .with_status(200)
2187 .with_header("content-type", "application/json")
2188 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2189 .create_async()
2190 .await;
2191
2192 let client = EthereumClient::with_base_url(&server.url());
2193 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2194 assert!(balances.len() <= 2);
2196 if !balances.is_empty() {
2197 assert!(!balances[0].balance.is_empty());
2198 }
2199 }
2200
2201 #[tokio::test]
2202 async fn test_get_erc20_balances_empty() {
2203 let mut server = mockito::Server::new_async().await;
2204 let _mock = server
2205 .mock("GET", mockito::Matcher::Any)
2206 .with_status(200)
2207 .with_header("content-type", "application/json")
2208 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2209 .create_async()
2210 .await;
2211
2212 let client = EthereumClient::with_base_url(&server.url());
2213 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2214 assert!(balances.is_empty());
2215 }
2216
2217 #[tokio::test]
2218 async fn test_get_token_info_success() {
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(
2225 r#"{"status":"1","message":"OK","result":[{
2226 "tokenName":"Tether USD",
2227 "symbol":"USDT",
2228 "divisor":"1000000",
2229 "tokenType":"ERC20",
2230 "totalSupply":"1000000000000"
2231 }]}"#,
2232 )
2233 .create_async()
2234 .await;
2235
2236 let client = EthereumClient::with_base_url(&server.url());
2237 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2238 assert_eq!(token.symbol, "USDT");
2239 assert_eq!(token.name, "Tether USD");
2240 assert_eq!(token.decimals, 6); }
2242
2243 #[tokio::test]
2244 async fn test_get_token_info_fallback_to_supply() {
2245 let mut server = mockito::Server::new_async().await;
2246 let _info_mock = server
2248 .mock("GET", mockito::Matcher::Any)
2249 .with_status(200)
2250 .with_header("content-type", "application/json")
2251 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2252 .expect_at_most(1)
2253 .create_async()
2254 .await;
2255
2256 let _supply_mock = server
2258 .mock("GET", mockito::Matcher::Any)
2259 .with_status(200)
2260 .with_header("content-type", "application/json")
2261 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2262 .expect_at_most(1)
2263 .create_async()
2264 .await;
2265
2266 let _source_mock = server
2268 .mock("GET", mockito::Matcher::Any)
2269 .with_status(200)
2270 .with_header("content-type", "application/json")
2271 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
2272 .create_async()
2273 .await;
2274
2275 let client = EthereumClient::with_base_url(&server.url());
2276 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2277 assert!(!token.symbol.is_empty());
2279 }
2280
2281 #[tokio::test]
2282 async fn test_get_token_info_unknown() {
2283 let mut server = mockito::Server::new_async().await;
2284 let _mock = server
2285 .mock("GET", mockito::Matcher::Any)
2286 .with_status(200)
2287 .with_header("content-type", "application/json")
2288 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2289 .create_async()
2290 .await;
2291
2292 let client = EthereumClient::with_base_url(&server.url());
2293 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2294 assert!(!token.symbol.is_empty());
2296 }
2297
2298 #[tokio::test]
2299 async fn test_try_get_contract_name() {
2300 let mut server = mockito::Server::new_async().await;
2301 let _mock = server
2302 .mock("GET", mockito::Matcher::Any)
2303 .with_status(200)
2304 .with_header("content-type", "application/json")
2305 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
2306 .create_async()
2307 .await;
2308
2309 let client = EthereumClient::with_base_url(&server.url());
2310 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2311 assert!(result.is_some());
2312 let (symbol, name) = result.unwrap();
2313 assert_eq!(name, "USDT");
2314 assert_eq!(symbol, "USDT"); }
2316
2317 #[tokio::test]
2318 async fn test_try_get_contract_name_long_name() {
2319 let mut server = mockito::Server::new_async().await;
2320 let _mock = server
2321 .mock("GET", mockito::Matcher::Any)
2322 .with_status(200)
2323 .with_header("content-type", "application/json")
2324 .with_body(
2325 r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
2326 )
2327 .create_async()
2328 .await;
2329
2330 let client = EthereumClient::with_base_url(&server.url());
2331 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2332 assert!(result.is_some());
2333 let (symbol, name) = result.unwrap();
2334 assert_eq!(name, "TetherUSDToken");
2335 assert_eq!(symbol, "TUSDT"); }
2337
2338 #[tokio::test]
2339 async fn test_try_get_contract_name_not_verified() {
2340 let mut server = mockito::Server::new_async().await;
2341 let _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":[{"ContractName":""}]}"#)
2346 .create_async()
2347 .await;
2348
2349 let client = EthereumClient::with_base_url(&server.url());
2350 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2351 assert!(result.is_none());
2352 }
2353
2354 #[tokio::test]
2355 async fn test_get_token_holders() {
2356 let mut server = mockito::Server::new_async().await;
2357 let _mock = server
2358 .mock("GET", mockito::Matcher::Any)
2359 .with_status(200)
2360 .with_header("content-type", "application/json")
2361 .with_body(r#"{"status":"1","message":"OK","result":[
2362 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
2363 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
2364 {"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
2365 ]}"#)
2366 .create_async()
2367 .await;
2368
2369 let client = EthereumClient::with_base_url(&server.url());
2370 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2371 assert_eq!(holders.len(), 3);
2372 assert_eq!(
2373 holders[0].address,
2374 "0x1111111111111111111111111111111111111111"
2375 );
2376 assert_eq!(holders[0].rank, 1);
2377 assert_eq!(holders[2].rank, 3);
2378 assert!((holders[0].percentage - 50.0).abs() < 0.01);
2380 }
2381
2382 #[tokio::test]
2383 async fn test_get_token_holders_pro_required() {
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(
2390 r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
2391 )
2392 .create_async()
2393 .await;
2394
2395 let client = EthereumClient::with_base_url(&server.url());
2396 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2397 assert!(holders.is_empty()); }
2399
2400 #[tokio::test]
2401 async fn test_get_token_holder_count_small() {
2402 let mut server = mockito::Server::new_async().await;
2403 let _mock = server
2405 .mock("GET", mockito::Matcher::Any)
2406 .with_status(200)
2407 .with_header("content-type", "application/json")
2408 .with_body(r#"{"status":"1","message":"OK","result":[
2409 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
2410 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
2411 ]}"#)
2412 .create_async()
2413 .await;
2414
2415 let client = EthereumClient::with_base_url(&server.url());
2416 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2417 assert_eq!(count, 2);
2418 }
2419
2420 #[tokio::test]
2421 async fn test_get_token_holder_count_empty() {
2422 let mut server = mockito::Server::new_async().await;
2423 let _mock = server
2424 .mock("GET", mockito::Matcher::Any)
2425 .with_status(200)
2426 .with_header("content-type", "application/json")
2427 .with_body(
2428 r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
2429 )
2430 .create_async()
2431 .await;
2432
2433 let client = EthereumClient::with_base_url(&server.url());
2434 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2435 assert_eq!(count, 0);
2437 }
2438
2439 #[tokio::test]
2440 async fn test_get_block_timestamp() {
2441 let mut server = mockito::Server::new_async().await;
2442 let _mock = server
2443 .mock("GET", mockito::Matcher::Any)
2444 .with_status(200)
2445 .with_header("content-type", "application/json")
2446 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
2447 .create_async()
2448 .await;
2449
2450 let client = EthereumClient::with_base_url(&server.url());
2451 let ts = client.get_block_timestamp(16).await.unwrap();
2452 assert_eq!(ts, 0x65A8C580);
2453 }
2454
2455 #[tokio::test]
2456 async fn test_get_block_timestamp_not_found() {
2457 let mut server = mockito::Server::new_async().await;
2458 let _mock = server
2459 .mock("GET", mockito::Matcher::Any)
2460 .with_status(200)
2461 .with_header("content-type", "application/json")
2462 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2463 .create_async()
2464 .await;
2465
2466 let client = EthereumClient::with_base_url(&server.url());
2467 let result = client.get_block_timestamp(99999999).await;
2468 assert!(result.is_err());
2469 }
2470
2471 #[test]
2472 fn test_chain_name_and_symbol_accessors() {
2473 let client = EthereumClient::with_base_url("http://test");
2474 assert_eq!(client.chain_name(), "ethereum");
2475 assert_eq!(client.native_token_symbol(), "ETH");
2476 }
2477
2478 #[test]
2479 fn test_validate_tx_hash_invalid_hex() {
2480 let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
2481 let result = validate_tx_hash(hash);
2482 assert!(result.is_err());
2483 assert!(result.unwrap_err().to_string().contains("invalid hex"));
2484 }
2485
2486 #[tokio::test]
2487 async fn test_get_transactions_api_error() {
2488 let mut server = mockito::Server::new_async().await;
2489 let _mock = server
2490 .mock("GET", mockito::Matcher::Any)
2491 .with_status(200)
2492 .with_header("content-type", "application/json")
2493 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
2494 .create_async()
2495 .await;
2496
2497 let client = EthereumClient::with_base_url(&server.url());
2498 let result = client.get_transactions(VALID_ADDRESS, 10).await;
2499 assert!(result.is_err());
2500 }
2501
2502 #[tokio::test]
2503 async fn test_get_token_holders_pro_key_required() {
2504 let mut server = mockito::Server::new_async().await;
2505 let _mock = server
2506 .mock("GET", mockito::Matcher::Any)
2507 .with_status(200)
2508 .with_header("content-type", "application/json")
2509 .with_body(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
2510 .create_async()
2511 .await;
2512
2513 let client = EthereumClient::with_base_url(&server.url());
2514 let holders = client
2515 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2516 .await
2517 .unwrap();
2518 assert!(holders.is_empty());
2519 }
2520
2521 #[tokio::test]
2522 async fn test_get_token_holders_api_error() {
2523 let mut server = mockito::Server::new_async().await;
2524 let _mock = server
2525 .mock("GET", mockito::Matcher::Any)
2526 .with_status(200)
2527 .with_header("content-type", "application/json")
2528 .with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
2529 .create_async()
2530 .await;
2531
2532 let client = EthereumClient::with_base_url(&server.url());
2533 let result = client
2534 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2535 .await;
2536 assert!(result.is_err());
2537 }
2538
2539 #[tokio::test]
2540 async fn test_get_token_holders_success() {
2541 let mut server = mockito::Server::new_async().await;
2542 let _mock = server
2543 .mock("GET", mockito::Matcher::Any)
2544 .with_status(200)
2545 .with_header("content-type", "application/json")
2546 .with_body(
2547 r#"{"status":"1","message":"OK","result":[
2548 {"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
2549 {"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
2550 ]}"#,
2551 )
2552 .create_async()
2553 .await;
2554
2555 let client = EthereumClient::with_base_url(&server.url());
2556 let holders = client
2557 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2558 .await
2559 .unwrap();
2560 assert_eq!(holders.len(), 2);
2561 assert_eq!(holders[0].rank, 1);
2562 assert_eq!(holders[1].rank, 2);
2563 assert!(holders[0].percentage > 0.0);
2564 }
2565
2566 #[tokio::test]
2567 async fn test_get_token_info_unknown_token() {
2568 let mut server = mockito::Server::new_async().await;
2569 let _mock = server
2571 .mock("GET", mockito::Matcher::Any)
2572 .with_status(200)
2573 .with_header("content-type", "application/json")
2574 .with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
2575 .create_async()
2576 .await;
2577
2578 let client = EthereumClient::with_base_url(&server.url());
2579 let token = client
2580 .get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
2581 .await
2582 .unwrap();
2583 assert_eq!(token.symbol, "UNKNOWN");
2584 }
2585
2586 #[tokio::test]
2587 async fn test_get_transaction_with_null_block_number() {
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(
2594 r#"{"jsonrpc":"2.0","id":1,"result":{
2595 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2596 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2597 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2598 "value":"0xde0b6b3a7640000",
2599 "gas":"0x5208",
2600 "gasPrice":"0x3b9aca00",
2601 "nonce":"0x5",
2602 "input":"0x",
2603 "blockNumber":null
2604 }}"#,
2605 )
2606 .create_async()
2607 .await;
2608
2609 let client = EthereumClient::with_base_url(&server.url());
2610 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2611 assert!(tx.timestamp.is_none());
2613 }
2614
2615 #[tokio::test]
2616 async fn test_chain_client_trait_balance() {
2617 let mut server = mockito::Server::new_async().await;
2618 let _mock = server
2619 .mock("GET", mockito::Matcher::Any)
2620 .with_status(200)
2621 .with_header("content-type", "application/json")
2622 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
2623 .create_async()
2624 .await;
2625
2626 let client = EthereumClient::with_base_url(&server.url());
2627 let chain_client: &dyn ChainClient = &client;
2628 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
2629 assert_eq!(balance.symbol, "ETH");
2630 }
2631
2632 #[tokio::test]
2633 async fn test_chain_client_trait_get_transaction() {
2634 let mut server = mockito::Server::new_async().await;
2635 let _mock = server
2636 .mock("GET", mockito::Matcher::Any)
2637 .with_status(200)
2638 .with_header("content-type", "application/json")
2639 .with_body(
2640 r#"{"jsonrpc":"2.0","id":1,"result":{
2641 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2642 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2643 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2644 "value":"0xde0b6b3a7640000",
2645 "gas":"0x5208",
2646 "gasPrice":"0x3b9aca00",
2647 "nonce":"0x5",
2648 "input":"0x",
2649 "blockNumber":"0xf4240"
2650 }}"#,
2651 )
2652 .create_async()
2653 .await;
2654
2655 let client = EthereumClient::with_base_url(&server.url());
2656 let chain_client: &dyn ChainClient = &client;
2657 let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
2658 assert!(!tx.hash.is_empty());
2659 }
2660
2661 #[tokio::test]
2662 async fn test_chain_client_trait_get_block_number() {
2663 let mut server = mockito::Server::new_async().await;
2664 let _mock = server
2665 .mock("GET", mockito::Matcher::Any)
2666 .with_status(200)
2667 .with_header("content-type", "application/json")
2668 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
2669 .create_async()
2670 .await;
2671
2672 let client = EthereumClient::with_base_url(&server.url());
2673 let chain_client: &dyn ChainClient = &client;
2674 let block = chain_client.get_block_number().await.unwrap();
2675 assert_eq!(block, 1000000);
2676 }
2677
2678 #[tokio::test]
2679 async fn test_chain_client_trait_get_transactions() {
2680 let mut server = mockito::Server::new_async().await;
2681 let _mock = server
2682 .mock("GET", mockito::Matcher::Any)
2683 .with_status(200)
2684 .with_header("content-type", "application/json")
2685 .with_body(
2686 r#"{"status":"1","message":"OK","result":[{
2687 "hash":"0xabc",
2688 "blockNumber":"12345",
2689 "timeStamp":"1700000000",
2690 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2691 "to":"0x1111111111111111111111111111111111111111",
2692 "value":"1000000000000000000",
2693 "gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
2694 "nonce":"1","input":"0x","isError":"0"
2695 }]}"#,
2696 )
2697 .create_async()
2698 .await;
2699
2700 let client = EthereumClient::with_base_url(&server.url());
2701 let chain_client: &dyn ChainClient = &client;
2702 let txs = chain_client
2703 .get_transactions(VALID_ADDRESS, 10)
2704 .await
2705 .unwrap();
2706 assert_eq!(txs.len(), 1);
2707 }
2708
2709 #[tokio::test]
2710 async fn test_chain_client_trait_get_token_balances() {
2711 let mut server = mockito::Server::new_async().await;
2712 let _tokentx = server
2714 .mock("GET", mockito::Matcher::Any)
2715 .with_status(200)
2716 .with_header("content-type", "application/json")
2717 .with_body(
2718 r#"{"status":"1","message":"OK","result":[{
2719 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2720 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2721 }]}"#,
2722 )
2723 .expect_at_most(1)
2724 .create_async()
2725 .await;
2726
2727 let _balance = server
2729 .mock("GET", mockito::Matcher::Any)
2730 .with_status(200)
2731 .with_header("content-type", "application/json")
2732 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2733 .create_async()
2734 .await;
2735
2736 let client = EthereumClient::with_base_url(&server.url());
2737 let chain_client: &dyn ChainClient = &client;
2738 let balances = chain_client
2739 .get_token_balances(VALID_ADDRESS)
2740 .await
2741 .unwrap();
2742 assert!(!balances.is_empty());
2743 }
2744
2745 #[tokio::test]
2746 async fn test_chain_client_trait_get_token_info() {
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(
2753 r#"{"status":"1","message":"OK","result":[{
2754 "tokenName":"Tether USD","symbol":"USDT",
2755 "divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
2756 }]}"#,
2757 )
2758 .create_async()
2759 .await;
2760
2761 let client = EthereumClient::with_base_url(&server.url());
2762 let chain_client: &dyn ChainClient = &client;
2763 let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
2764 assert_eq!(token.symbol, "USDT");
2765 }
2766
2767 #[tokio::test]
2768 async fn test_chain_client_trait_get_token_holders() {
2769 let mut server = mockito::Server::new_async().await;
2770 let _mock = server
2771 .mock("GET", mockito::Matcher::Any)
2772 .with_status(200)
2773 .with_header("content-type", "application/json")
2774 .with_body(
2775 r#"{"status":"1","message":"OK","result":[
2776 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
2777 ]}"#,
2778 )
2779 .create_async()
2780 .await;
2781
2782 let client = EthereumClient::with_base_url(&server.url());
2783 let chain_client: &dyn ChainClient = &client;
2784 let holders = chain_client
2785 .get_token_holders(VALID_ADDRESS, 10)
2786 .await
2787 .unwrap();
2788 assert_eq!(holders.len(), 1);
2789 }
2790
2791 #[tokio::test]
2792 async fn test_chain_client_trait_get_token_holder_count() {
2793 let mut server = mockito::Server::new_async().await;
2794 let _mock = server
2795 .mock("GET", mockito::Matcher::Any)
2796 .with_status(200)
2797 .with_header("content-type", "application/json")
2798 .with_body(
2799 r#"{"status":"1","message":"OK","result":[
2800 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
2801 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
2802 ]}"#,
2803 )
2804 .create_async()
2805 .await;
2806
2807 let client = EthereumClient::with_base_url(&server.url());
2808 let chain_client: &dyn ChainClient = &client;
2809 let count = chain_client
2810 .get_token_holder_count(VALID_ADDRESS)
2811 .await
2812 .unwrap();
2813 assert_eq!(count, 2);
2814 }
2815
2816 #[tokio::test]
2817 async fn test_chain_client_trait_native_token_symbol() {
2818 let client = EthereumClient::with_base_url("http://test");
2819 let chain_client: &dyn ChainClient = &client;
2820 assert_eq!(chain_client.native_token_symbol(), "ETH");
2821 assert_eq!(chain_client.chain_name(), "ethereum");
2822 }
2823
2824 #[tokio::test]
2825 async fn test_get_token_info_supply_fallback_no_contract() {
2826 let mut server = mockito::Server::new_async().await;
2827 let _info_mock = server
2829 .mock("GET", mockito::Matcher::Any)
2830 .with_status(200)
2831 .with_header("content-type", "application/json")
2832 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2833 .expect_at_most(1)
2834 .create_async()
2835 .await;
2836
2837 let _supply_mock = server
2839 .mock("GET", mockito::Matcher::Any)
2840 .with_status(200)
2841 .with_header("content-type", "application/json")
2842 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2843 .expect_at_most(1)
2844 .create_async()
2845 .await;
2846
2847 let _source_mock = server
2849 .mock("GET", mockito::Matcher::Any)
2850 .with_status(200)
2851 .with_header("content-type", "application/json")
2852 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2853 .create_async()
2854 .await;
2855
2856 let client = EthereumClient::with_base_url(&server.url());
2857 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2858 assert!(token.symbol.contains("...") || !token.symbol.is_empty());
2860 }
2861
2862 #[tokio::test]
2863 async fn test_try_get_contract_name_short_lowercase() {
2864 let mut server = mockito::Server::new_async().await;
2865 let _mock = server
2866 .mock("GET", mockito::Matcher::Any)
2867 .with_status(200)
2868 .with_header("content-type", "application/json")
2869 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
2870 .create_async()
2871 .await;
2872
2873 let client = EthereumClient::with_base_url(&server.url());
2874 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2875 assert!(result.is_some());
2876 let (symbol, name) = result.unwrap();
2877 assert_eq!(name, "token");
2878 assert_eq!(symbol, "TOKEN");
2880 }
2881
2882 #[tokio::test]
2883 async fn test_try_get_contract_name_long_lowercase() {
2884 let mut server = mockito::Server::new_async().await;
2885 let _mock = server
2886 .mock("GET", mockito::Matcher::Any)
2887 .with_status(200)
2888 .with_header("content-type", "application/json")
2889 .with_body(
2890 r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
2891 )
2892 .create_async()
2893 .await;
2894
2895 let client = EthereumClient::with_base_url(&server.url());
2896 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2897 assert!(result.is_some());
2898 let (symbol, name) = result.unwrap();
2899 assert_eq!(name, "mytokencontract");
2900 assert_eq!(symbol, "MYTOKE");
2903 }
2904
2905 #[tokio::test]
2906 async fn test_get_erc20_balances_zero_balance_skipped() {
2907 let mut server = mockito::Server::new_async().await;
2908 let _tokentx = server
2910 .mock("GET", mockito::Matcher::Any)
2911 .with_status(200)
2912 .with_header("content-type", "application/json")
2913 .with_body(
2914 r#"{"status":"1","message":"OK","result":[{
2915 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2916 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2917 }]}"#,
2918 )
2919 .expect_at_most(1)
2920 .create_async()
2921 .await;
2922
2923 let _balance = server
2925 .mock("GET", mockito::Matcher::Any)
2926 .with_status(200)
2927 .with_header("content-type", "application/json")
2928 .with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
2929 .create_async()
2930 .await;
2931
2932 let client = EthereumClient::with_base_url(&server.url());
2933 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2934 assert!(balances.is_empty());
2936 }
2937}