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