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