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