1#![allow(clippy::collapsible_if)]
7use crate::chains::{Balance, ChainClient, Token, TokenBalance, TokenHolder, Transaction};
38use crate::config::ChainsConfig;
39use crate::error::{Result, ScopeError};
40use async_trait::async_trait;
41use reqwest::Client;
42use serde::Deserialize;
43
44const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
49
50const DEFAULT_AEGIS_RPC: &str = "http://localhost:8545";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ApiType {
56 BlockExplorer,
58 JsonRpc,
60}
61
62#[derive(Debug, Clone)]
67pub struct EthereumClient {
68 client: Client,
70
71 base_url: String,
73
74 chain_id: Option<String>,
76
77 api_key: Option<String>,
79
80 chain_name: String,
82
83 native_symbol: String,
85
86 native_decimals: u8,
88
89 api_type: ApiType,
91}
92
93#[derive(Debug, Deserialize)]
95struct ApiResponse<T> {
96 status: String,
97 message: String,
98 result: T,
99}
100
101#[derive(Debug, Deserialize)]
103#[serde(untagged)]
104#[allow(dead_code)] enum BalanceResult {
106 Balance(String),
108 Error(String),
110}
111
112#[derive(Debug, Deserialize)]
114struct TxListItem {
115 hash: String,
116 #[serde(rename = "blockNumber")]
117 block_number: String,
118 #[serde(rename = "timeStamp")]
119 timestamp: String,
120 from: String,
121 to: String,
122 value: String,
123 gas: String,
124 #[serde(rename = "gasUsed")]
125 gas_used: String,
126 #[serde(rename = "gasPrice")]
127 gas_price: String,
128 nonce: String,
129 input: String,
130 #[serde(rename = "isError")]
131 is_error: String,
132}
133
134#[derive(Debug, Deserialize)]
136struct ProxyResponse<T> {
137 result: Option<T>,
138}
139
140#[derive(Debug, Deserialize)]
142#[serde(rename_all = "camelCase")]
143struct ProxyTransaction {
144 #[serde(default)]
145 block_number: Option<String>,
146 #[serde(default)]
147 from: Option<String>,
148 #[serde(default)]
149 to: Option<String>,
150 #[serde(default)]
151 gas: Option<String>,
152 #[serde(default)]
153 gas_price: Option<String>,
154 #[serde(default)]
155 value: Option<String>,
156 #[serde(default)]
157 nonce: Option<String>,
158 #[serde(default)]
159 input: Option<String>,
160}
161
162#[derive(Debug, Deserialize)]
164#[serde(rename_all = "camelCase")]
165struct ProxyTransactionReceipt {
166 #[serde(default)]
167 gas_used: Option<String>,
168 #[serde(default)]
169 status: Option<String>,
170}
171
172#[derive(Debug, Deserialize)]
174struct TokenHolderItem {
175 #[serde(rename = "TokenHolderAddress")]
176 address: String,
177 #[serde(rename = "TokenHolderQuantity")]
178 quantity: String,
179}
180
181#[derive(Debug, Deserialize)]
183#[allow(dead_code)]
184struct TokenInfoItem {
185 #[serde(rename = "contractAddress")]
186 contract_address: Option<String>,
187 #[serde(rename = "tokenName")]
188 token_name: Option<String>,
189 #[serde(rename = "symbol")]
190 symbol: Option<String>,
191 #[serde(rename = "divisor")]
192 divisor: Option<String>,
193 #[serde(rename = "tokenType")]
194 token_type: Option<String>,
195 #[serde(rename = "totalSupply")]
196 total_supply: Option<String>,
197}
198
199impl EthereumClient {
200 pub fn new(config: &ChainsConfig) -> Result<Self> {
220 let client = Client::builder()
221 .timeout(std::time::Duration::from_secs(30))
222 .build()
223 .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
224
225 Ok(Self {
226 client,
227 base_url: ETHERSCAN_V2_API.to_string(),
228 chain_id: Some("1".to_string()),
229 api_key: config.api_keys.get("etherscan").cloned(),
230 chain_name: "ethereum".to_string(),
231 native_symbol: "ETH".to_string(),
232 native_decimals: 18,
233 api_type: ApiType::BlockExplorer,
234 })
235 }
236
237 pub fn with_base_url(base_url: &str) -> Self {
243 Self {
244 client: Client::new(),
245 base_url: base_url.to_string(),
246 chain_id: None,
247 api_key: None,
248 chain_name: "ethereum".to_string(),
249 native_symbol: "ETH".to_string(),
250 native_decimals: 18,
251 api_type: ApiType::BlockExplorer,
252 }
253 }
254
255 pub fn for_chain(chain: &str, config: &ChainsConfig) -> Result<Self> {
276 let (base_url, chain_id, api_key_name, symbol) = match chain {
279 "ethereum" => (ETHERSCAN_V2_API, "1", "etherscan", "ETH"),
280 "polygon" => (ETHERSCAN_V2_API, "137", "polygonscan", "MATIC"),
281 "arbitrum" => (ETHERSCAN_V2_API, "42161", "arbiscan", "ETH"),
282 "optimism" => (ETHERSCAN_V2_API, "10", "optimism", "ETH"),
283 "base" => (ETHERSCAN_V2_API, "8453", "basescan", "ETH"),
284 "bsc" => (ETHERSCAN_V2_API, "56", "bscscan", "BNB"),
285 "aegis" => {
286 let rpc_url = config.aegis_rpc.as_deref().unwrap_or(DEFAULT_AEGIS_RPC);
289 return Self::for_aegis(rpc_url, config);
290 }
291 _ => {
292 return Err(ScopeError::Chain(format!("Unsupported chain: {}", chain)));
293 }
294 };
295
296 let client = Client::builder()
297 .timeout(std::time::Duration::from_secs(30))
298 .build()
299 .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
300
301 Ok(Self {
302 client,
303 base_url: base_url.to_string(),
304 chain_id: Some(chain_id.to_string()),
305 api_key: config.api_keys.get(api_key_name).cloned(),
306 chain_name: chain.to_string(),
307 native_symbol: symbol.to_string(),
308 native_decimals: 18,
309 api_type: ApiType::BlockExplorer,
310 })
311 }
312
313 fn for_aegis(rpc_url: &str, _config: &ChainsConfig) -> Result<Self> {
320 let client = Client::builder()
321 .timeout(std::time::Duration::from_secs(30))
322 .build()
323 .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
324
325 Ok(Self {
326 client,
327 base_url: rpc_url.to_string(),
328 chain_id: None,
329 api_key: None,
330 chain_name: "aegis".to_string(),
331 native_symbol: "WRAITH".to_string(),
332 native_decimals: 18,
333 api_type: ApiType::JsonRpc,
334 })
335 }
336
337 pub fn chain_name(&self) -> &str {
339 &self.chain_name
340 }
341
342 pub fn native_token_symbol(&self) -> &str {
344 &self.native_symbol
345 }
346
347 fn build_api_url(&self, params: &str) -> String {
349 let mut url = format!("{}?", self.base_url);
350
351 if let Some(ref chain_id) = self.chain_id {
353 url.push_str(&format!("chainid={}&", chain_id));
354 }
355
356 url.push_str(params);
357
358 if let Some(ref key) = self.api_key {
360 url.push_str(&format!("&apikey={}", key));
361 }
362
363 url
364 }
365
366 pub async fn get_balance(&self, address: &str) -> Result<Balance> {
381 validate_eth_address(address)?;
383
384 match self.api_type {
385 ApiType::BlockExplorer => self.get_balance_explorer(address).await,
386 ApiType::JsonRpc => self.get_balance_rpc(address).await,
387 }
388 }
389
390 async fn get_balance_explorer(&self, address: &str) -> Result<Balance> {
392 let url = self.build_api_url(&format!(
393 "module=account&action=balance&address={}&tag=latest",
394 address
395 ));
396
397 tracing::debug!(url = %url, "Fetching balance via block explorer");
398
399 let response: ApiResponse<String> = self.client.get(&url).send().await?.json().await?;
400
401 if response.status != "1" {
402 return Err(ScopeError::Chain(format!(
403 "API error: {}",
404 response.message
405 )));
406 }
407
408 self.parse_balance_wei(&response.result)
409 }
410
411 async fn get_balance_rpc(&self, address: &str) -> Result<Balance> {
413 #[derive(serde::Serialize)]
414 struct RpcRequest<'a> {
415 jsonrpc: &'a str,
416 method: &'a str,
417 params: Vec<&'a str>,
418 id: u64,
419 }
420
421 #[derive(Deserialize)]
422 struct RpcResponse {
423 result: Option<String>,
424 error: Option<RpcError>,
425 }
426
427 #[derive(Deserialize)]
428 struct RpcError {
429 message: String,
430 }
431
432 let request = RpcRequest {
433 jsonrpc: "2.0",
434 method: "eth_getBalance",
435 params: vec![address, "latest"],
436 id: 1,
437 };
438
439 tracing::debug!(url = %self.base_url, address = %address, "Fetching balance via JSON-RPC");
440
441 let response: RpcResponse = self
442 .client
443 .post(&self.base_url)
444 .json(&request)
445 .send()
446 .await?
447 .json()
448 .await?;
449
450 if let Some(error) = response.error {
451 return Err(ScopeError::Chain(format!("RPC error: {}", error.message)));
452 }
453
454 let result = response
455 .result
456 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
457
458 let hex_balance = result.trim_start_matches("0x");
460 let wei = u128::from_str_radix(hex_balance, 16)
461 .map_err(|_| ScopeError::Chain("Invalid balance hex response".to_string()))?;
462
463 self.parse_balance_wei(&wei.to_string())
464 }
465
466 fn parse_balance_wei(&self, wei_str: &str) -> Result<Balance> {
468 let wei: u128 = wei_str
469 .parse()
470 .map_err(|_| ScopeError::Chain("Invalid balance response".to_string()))?;
471
472 let eth = wei as f64 / 10_f64.powi(self.native_decimals as i32);
473
474 Ok(Balance {
475 raw: wei_str.to_string(),
476 formatted: format!("{:.6} {}", eth, self.native_symbol),
477 decimals: self.native_decimals,
478 symbol: self.native_symbol.clone(),
479 usd_value: None, })
481 }
482
483 pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
485 let dex = crate::chains::DexClient::new();
486 if let Some(price) = dex.get_native_token_price(&self.chain_name).await {
487 let amount: f64 =
488 balance.raw.parse().unwrap_or(0.0) / 10_f64.powi(self.native_decimals as i32);
489 balance.usd_value = Some(amount * price);
490 }
491 }
492
493 pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
503 validate_tx_hash(hash)?;
505
506 match self.api_type {
507 ApiType::BlockExplorer => self.get_transaction_explorer(hash).await,
508 ApiType::JsonRpc => self.get_transaction_rpc(hash).await,
509 }
510 }
511
512 async fn get_transaction_explorer(&self, hash: &str) -> Result<Transaction> {
514 let tx_url = self.build_api_url(&format!(
516 "module=proxy&action=eth_getTransactionByHash&txhash={}",
517 hash
518 ));
519
520 tracing::debug!(url = %tx_url, "Fetching transaction via block explorer proxy");
521
522 let tx_response: ProxyResponse<ProxyTransaction> =
523 self.client.get(&tx_url).send().await?.json().await?;
524
525 let proxy_tx = tx_response
526 .result
527 .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
528
529 let receipt_url = self.build_api_url(&format!(
531 "module=proxy&action=eth_getTransactionReceipt&txhash={}",
532 hash
533 ));
534
535 tracing::debug!(url = %receipt_url, "Fetching transaction receipt");
536
537 let receipt_response: ProxyResponse<ProxyTransactionReceipt> =
538 self.client.get(&receipt_url).send().await?.json().await?;
539
540 let receipt = receipt_response.result;
541
542 let block_number = proxy_tx
544 .block_number
545 .as_deref()
546 .and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
547
548 let gas_limit = proxy_tx
550 .gas
551 .as_deref()
552 .and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
553 .unwrap_or(0);
554
555 let gas_price = proxy_tx
557 .gas_price
558 .as_deref()
559 .and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
560 .map(|gp| gp.to_string())
561 .unwrap_or_else(|| "0".to_string());
562
563 let nonce = proxy_tx
565 .nonce
566 .as_deref()
567 .and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
568 .unwrap_or(0);
569
570 let value = proxy_tx
572 .value
573 .as_deref()
574 .and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
575 .map(|v| v.to_string())
576 .unwrap_or_else(|| "0".to_string());
577
578 let gas_used = receipt.as_ref().and_then(|r| {
580 r.gas_used
581 .as_deref()
582 .and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
583 });
584
585 let status = receipt
586 .as_ref()
587 .and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
588
589 let timestamp = if let Some(bn) = block_number {
591 self.get_block_timestamp(bn).await.ok()
592 } else {
593 None
594 };
595
596 Ok(Transaction {
597 hash: hash.to_string(),
598 block_number,
599 timestamp,
600 from: proxy_tx.from.unwrap_or_default(),
601 to: proxy_tx.to,
602 value,
603 gas_limit,
604 gas_used,
605 gas_price,
606 nonce,
607 input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
608 status,
609 })
610 }
611
612 async fn get_transaction_rpc(&self, hash: &str) -> Result<Transaction> {
614 #[derive(serde::Serialize)]
615 struct RpcRequest<'a> {
616 jsonrpc: &'a str,
617 method: &'a str,
618 params: Vec<&'a str>,
619 id: u64,
620 }
621
622 let request = RpcRequest {
623 jsonrpc: "2.0",
624 method: "eth_getTransactionByHash",
625 params: vec![hash],
626 id: 1,
627 };
628
629 let response: ProxyResponse<ProxyTransaction> = self
630 .client
631 .post(&self.base_url)
632 .json(&request)
633 .send()
634 .await?
635 .json()
636 .await?;
637
638 let proxy_tx = response
639 .result
640 .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
641
642 let receipt_request = RpcRequest {
644 jsonrpc: "2.0",
645 method: "eth_getTransactionReceipt",
646 params: vec![hash],
647 id: 2,
648 };
649
650 let receipt_response: ProxyResponse<ProxyTransactionReceipt> = self
651 .client
652 .post(&self.base_url)
653 .json(&receipt_request)
654 .send()
655 .await?
656 .json()
657 .await?;
658
659 let receipt = receipt_response.result;
660
661 let block_number = proxy_tx
662 .block_number
663 .as_deref()
664 .and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
665
666 let gas_limit = proxy_tx
667 .gas
668 .as_deref()
669 .and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
670 .unwrap_or(0);
671
672 let gas_price = proxy_tx
673 .gas_price
674 .as_deref()
675 .and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
676 .map(|gp| gp.to_string())
677 .unwrap_or_else(|| "0".to_string());
678
679 let nonce = proxy_tx
680 .nonce
681 .as_deref()
682 .and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
683 .unwrap_or(0);
684
685 let value = proxy_tx
686 .value
687 .as_deref()
688 .and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
689 .map(|v| v.to_string())
690 .unwrap_or_else(|| "0".to_string());
691
692 let gas_used = receipt.as_ref().and_then(|r| {
693 r.gas_used
694 .as_deref()
695 .and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
696 });
697
698 let status = receipt
699 .as_ref()
700 .and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
701
702 Ok(Transaction {
703 hash: hash.to_string(),
704 block_number,
705 timestamp: None, from: proxy_tx.from.unwrap_or_default(),
707 to: proxy_tx.to,
708 value,
709 gas_limit,
710 gas_used,
711 gas_price,
712 nonce,
713 input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
714 status,
715 })
716 }
717
718 async fn get_block_timestamp(&self, block_number: u64) -> Result<u64> {
720 let hex_block = format!("0x{:x}", block_number);
721 let url = self.build_api_url(&format!(
722 "module=proxy&action=eth_getBlockByNumber&tag={}&boolean=false",
723 hex_block
724 ));
725
726 #[derive(Deserialize)]
727 struct BlockResult {
728 timestamp: Option<String>,
729 }
730
731 let response: ProxyResponse<BlockResult> =
732 self.client.get(&url).send().await?.json().await?;
733
734 let block = response
735 .result
736 .ok_or_else(|| ScopeError::Chain(format!("Block not found: {}", block_number)))?;
737
738 block
739 .timestamp
740 .as_deref()
741 .and_then(|ts| u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok())
742 .ok_or_else(|| ScopeError::Chain("Invalid block timestamp".to_string()))
743 }
744
745 pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
756 validate_eth_address(address)?;
757
758 let url = self.build_api_url(&format!(
759 "module=account&action=txlist&address={}&startblock=0&endblock=99999999&page=1&offset={}&sort=desc",
760 address, limit
761 ));
762
763 tracing::debug!(url = %url, "Fetching transactions");
764
765 let response: ApiResponse<Vec<TxListItem>> =
766 self.client.get(&url).send().await?.json().await?;
767
768 if response.status != "1" && response.message != "No transactions found" {
769 return Err(ScopeError::Chain(format!(
770 "API error: {}",
771 response.message
772 )));
773 }
774
775 let transactions = response
776 .result
777 .into_iter()
778 .map(|tx| Transaction {
779 hash: tx.hash,
780 block_number: tx.block_number.parse().ok(),
781 timestamp: tx.timestamp.parse().ok(),
782 from: tx.from,
783 to: if tx.to.is_empty() { None } else { Some(tx.to) },
784 value: tx.value,
785 gas_limit: tx.gas.parse().unwrap_or(0),
786 gas_used: tx.gas_used.parse().ok(),
787 gas_price: tx.gas_price,
788 nonce: tx.nonce.parse().unwrap_or(0),
789 input: tx.input,
790 status: Some(tx.is_error == "0"),
791 })
792 .collect();
793
794 Ok(transactions)
795 }
796
797 pub async fn get_block_number(&self) -> Result<u64> {
799 let url = self.build_api_url("module=proxy&action=eth_blockNumber");
800
801 #[derive(Deserialize)]
802 struct BlockResponse {
803 result: String,
804 }
805
806 let response: BlockResponse = self.client.get(&url).send().await?.json().await?;
807
808 let block_hex = response.result.trim_start_matches("0x");
810 let block_number = u64::from_str_radix(block_hex, 16)
811 .map_err(|_| ScopeError::Chain("Invalid block number response".to_string()))?;
812
813 Ok(block_number)
814 }
815
816 pub async fn get_erc20_balances(
821 &self,
822 address: &str,
823 ) -> Result<Vec<crate::chains::TokenBalance>> {
824 validate_eth_address(address)?;
825
826 let url = self.build_api_url(&format!(
828 "module=account&action=tokentx&address={}&page=1&offset=100&sort=desc",
829 address
830 ));
831
832 tracing::debug!(url = %url, "Fetching ERC-20 token transfers");
833
834 let response = self.client.get(&url).send().await?.text().await?;
835
836 #[derive(Deserialize)]
837 struct TokenTxItem {
838 #[serde(rename = "contractAddress")]
839 contract_address: String,
840 #[serde(rename = "tokenSymbol")]
841 token_symbol: String,
842 #[serde(rename = "tokenName")]
843 token_name: String,
844 #[serde(rename = "tokenDecimal")]
845 token_decimal: String,
846 }
847
848 let parsed: std::result::Result<ApiResponse<Vec<TokenTxItem>>, _> =
849 serde_json::from_str(&response);
850
851 let token_txs = match parsed {
852 Ok(api_resp) if api_resp.status == "1" => api_resp.result,
853 _ => return Ok(vec![]),
854 };
855
856 let mut seen = std::collections::HashSet::new();
858 let unique_tokens: Vec<&TokenTxItem> = token_txs
859 .iter()
860 .filter(|tx| seen.insert(tx.contract_address.to_lowercase()))
861 .collect();
862
863 let mut balances = Vec::new();
865 for token_tx in unique_tokens.iter().take(20) {
866 let balance_url = self.build_api_url(&format!(
868 "module=account&action=tokenbalance&contractaddress={}&address={}&tag=latest",
869 token_tx.contract_address, address
870 ));
871
872 if let Ok(resp) = self.client.get(&balance_url).send().await {
873 if let Ok(bal_resp) = resp.json::<ApiResponse<String>>().await {
874 if bal_resp.status == "1" {
875 let raw_balance = bal_resp.result;
876 let decimals: u8 = token_tx.token_decimal.parse().unwrap_or(18);
877
878 if raw_balance == "0" {
880 continue;
881 }
882
883 let formatted = format_token_balance(&raw_balance, decimals);
884
885 balances.push(crate::chains::TokenBalance {
886 token: Token {
887 contract_address: token_tx.contract_address.clone(),
888 symbol: token_tx.token_symbol.clone(),
889 name: token_tx.token_name.clone(),
890 decimals,
891 },
892 balance: raw_balance,
893 formatted_balance: formatted,
894 usd_value: None,
895 });
896 }
897 }
898 }
899 }
900
901 Ok(balances)
902 }
903
904 pub async fn get_token_info(&self, token_address: &str) -> Result<Token> {
914 validate_eth_address(token_address)?;
915
916 let url = self.build_api_url(&format!(
918 "module=token&action=tokeninfo&contractaddress={}",
919 token_address
920 ));
921
922 tracing::debug!(url = %url, "Fetching token info (Pro API)");
923
924 let response = self.client.get(&url).send().await?;
925 let response_text = response.text().await?;
926
927 if let Ok(api_response) =
929 serde_json::from_str::<ApiResponse<Vec<TokenInfoItem>>>(&response_text)
930 {
931 if api_response.status == "1" && !api_response.result.is_empty() {
932 let info = &api_response.result[0];
933 let decimals = info
934 .divisor
935 .as_ref()
936 .and_then(|d| d.parse::<u32>().ok())
937 .map(|d| (d as f64).log10() as u8)
938 .unwrap_or(18);
939
940 return Ok(Token {
941 contract_address: token_address.to_string(),
942 symbol: info.symbol.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
943 name: info
944 .token_name
945 .clone()
946 .unwrap_or_else(|| "Unknown Token".to_string()),
947 decimals,
948 });
949 }
950 }
951
952 self.get_token_info_from_supply(token_address).await
955 }
956
957 async fn get_token_info_from_supply(&self, token_address: &str) -> Result<Token> {
959 let url = self.build_api_url(&format!(
960 "module=stats&action=tokensupply&contractaddress={}",
961 token_address
962 ));
963
964 tracing::debug!(url = %url, "Fetching token supply");
965
966 let response = self.client.get(&url).send().await?;
967 let response_text = response.text().await?;
968
969 if let Ok(api_response) = serde_json::from_str::<ApiResponse<String>>(&response_text) {
971 if api_response.status == "1" {
972 if let Some(contract_info) = self.try_get_contract_name(token_address).await {
975 return Ok(Token {
976 contract_address: token_address.to_string(),
977 symbol: contract_info.0,
978 name: contract_info.1,
979 decimals: 18,
980 });
981 }
982
983 let short_addr = format!(
985 "{}...{}",
986 &token_address[..6],
987 &token_address[token_address.len() - 4..]
988 );
989 return Ok(Token {
990 contract_address: token_address.to_string(),
991 symbol: short_addr.clone(),
992 name: format!("Token {}", short_addr),
993 decimals: 18,
994 });
995 }
996 }
997
998 Ok(Token {
1000 contract_address: token_address.to_string(),
1001 symbol: "UNKNOWN".to_string(),
1002 name: "Unknown Token".to_string(),
1003 decimals: 18,
1004 })
1005 }
1006
1007 async fn try_get_contract_name(&self, token_address: &str) -> Option<(String, String)> {
1009 let url = self.build_api_url(&format!(
1010 "module=contract&action=getsourcecode&address={}",
1011 token_address
1012 ));
1013
1014 let response = self.client.get(&url).send().await.ok()?;
1015 let text = response.text().await.ok()?;
1016
1017 #[derive(serde::Deserialize)]
1019 struct SourceCodeResult {
1020 #[serde(rename = "ContractName")]
1021 contract_name: Option<String>,
1022 }
1023
1024 #[derive(serde::Deserialize)]
1025 struct SourceCodeResponse {
1026 status: String,
1027 result: Vec<SourceCodeResult>,
1028 }
1029
1030 if let Ok(api_response) = serde_json::from_str::<SourceCodeResponse>(&text) {
1031 if api_response.status == "1" && !api_response.result.is_empty() {
1032 if let Some(name) = &api_response.result[0].contract_name {
1033 if !name.is_empty() {
1034 let symbol = if name.len() <= 6 {
1037 name.to_uppercase()
1038 } else {
1039 name.chars()
1041 .filter(|c| c.is_uppercase())
1042 .take(6)
1043 .collect::<String>()
1044 };
1045 let symbol = if symbol.is_empty() {
1046 name[..name.len().min(6)].to_uppercase()
1047 } else {
1048 symbol
1049 };
1050 return Some((symbol, name.clone()));
1051 }
1052 }
1053 }
1054 }
1055
1056 None
1057 }
1058
1059 pub async fn get_token_holders(
1074 &self,
1075 token_address: &str,
1076 limit: u32,
1077 ) -> Result<Vec<TokenHolder>> {
1078 validate_eth_address(token_address)?;
1079
1080 let effective_limit = limit.min(1000); let url = self.build_api_url(&format!(
1083 "module=token&action=tokenholderlist&contractaddress={}&page=1&offset={}",
1084 token_address, effective_limit
1085 ));
1086
1087 tracing::debug!(url = %url, "Fetching token holders");
1088
1089 let response = self.client.get(&url).send().await?;
1090 let response_text = response.text().await?;
1091
1092 let api_response: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
1094 .map_err(|e| ScopeError::Api(format!("Failed to parse holder response: {}", e)))?;
1095
1096 if api_response.status != "1" {
1097 if api_response.message.contains("Pro")
1099 || api_response.message.contains("API")
1100 || api_response.message.contains("NOTOK")
1101 {
1102 tracing::warn!(
1103 "Token holder API requires Pro key or is unavailable: {}",
1104 api_response.message
1105 );
1106 return Ok(Vec::new());
1107 }
1108 return Err(ScopeError::Api(format!(
1109 "API error: {}",
1110 api_response.message
1111 )));
1112 }
1113
1114 let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
1116 .map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
1117
1118 let total_balance: f64 = holders
1120 .iter()
1121 .filter_map(|h| h.quantity.parse::<f64>().ok())
1122 .sum();
1123
1124 let token_holders: Vec<TokenHolder> = holders
1126 .into_iter()
1127 .enumerate()
1128 .map(|(i, h)| {
1129 let balance: f64 = h.quantity.parse().unwrap_or(0.0);
1130 let percentage = if total_balance > 0.0 {
1131 (balance / total_balance) * 100.0
1132 } else {
1133 0.0
1134 };
1135
1136 TokenHolder {
1137 address: h.address,
1138 balance: h.quantity.clone(),
1139 formatted_balance: format_token_balance(&h.quantity, 18), percentage,
1141 rank: (i + 1) as u32,
1142 }
1143 })
1144 .collect();
1145
1146 Ok(token_holders)
1147 }
1148
1149 pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
1154 let max_page_size: u32 = 1000;
1156 let holders = self.get_token_holders(token_address, max_page_size).await?;
1157
1158 if holders.is_empty() {
1159 return Ok(0);
1160 }
1161
1162 let count = holders.len() as u64;
1163
1164 if count < max_page_size as u64 {
1165 Ok(count)
1167 } else {
1168 let mut total = count;
1171 let mut page = 2u32;
1172 loop {
1173 let url = self.build_api_url(&format!(
1174 "module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
1175 token_address, page, max_page_size
1176 ));
1177 let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
1178 self.client.get(&url).send().await?.json().await;
1179
1180 match response {
1181 Ok(api_resp) if api_resp.status == "1" => {
1182 let page_count = api_resp.result.len() as u64;
1183 total += page_count;
1184 if page_count < max_page_size as u64 || page >= 10 {
1185 break;
1187 }
1188 page += 1;
1189 }
1190 _ => break,
1191 }
1192 }
1193 Ok(total)
1194 }
1195 }
1196}
1197
1198fn format_token_balance(balance: &str, decimals: u8) -> String {
1200 let balance_f64: f64 = balance.parse().unwrap_or(0.0);
1201 let divisor = 10_f64.powi(decimals as i32);
1202 let formatted = balance_f64 / divisor;
1203
1204 if formatted >= 1_000_000_000.0 {
1205 format!("{:.2}B", formatted / 1_000_000_000.0)
1206 } else if formatted >= 1_000_000.0 {
1207 format!("{:.2}M", formatted / 1_000_000.0)
1208 } else if formatted >= 1_000.0 {
1209 format!("{:.2}K", formatted / 1_000.0)
1210 } else {
1211 format!("{:.4}", formatted)
1212 }
1213}
1214
1215impl Default for EthereumClient {
1216 fn default() -> Self {
1217 Self {
1218 client: Client::new(),
1219 base_url: ETHERSCAN_V2_API.to_string(),
1220 chain_id: Some("1".to_string()),
1221 api_key: None,
1222 chain_name: "ethereum".to_string(),
1223 native_symbol: "ETH".to_string(),
1224 native_decimals: 18,
1225 api_type: ApiType::BlockExplorer,
1226 }
1227 }
1228}
1229
1230fn validate_eth_address(address: &str) -> Result<()> {
1232 if !address.starts_with("0x") {
1233 return Err(ScopeError::InvalidAddress(format!(
1234 "Address must start with '0x': {}",
1235 address
1236 )));
1237 }
1238 if address.len() != 42 {
1239 return Err(ScopeError::InvalidAddress(format!(
1240 "Address must be 42 characters: {}",
1241 address
1242 )));
1243 }
1244 if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1245 return Err(ScopeError::InvalidAddress(format!(
1246 "Address contains invalid hex characters: {}",
1247 address
1248 )));
1249 }
1250 Ok(())
1251}
1252
1253fn validate_tx_hash(hash: &str) -> Result<()> {
1255 if !hash.starts_with("0x") {
1256 return Err(ScopeError::InvalidHash(format!(
1257 "Hash must start with '0x': {}",
1258 hash
1259 )));
1260 }
1261 if hash.len() != 66 {
1262 return Err(ScopeError::InvalidHash(format!(
1263 "Hash must be 66 characters: {}",
1264 hash
1265 )));
1266 }
1267 if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
1268 return Err(ScopeError::InvalidHash(format!(
1269 "Hash contains invalid hex characters: {}",
1270 hash
1271 )));
1272 }
1273 Ok(())
1274}
1275
1276#[async_trait]
1281impl ChainClient for EthereumClient {
1282 fn chain_name(&self) -> &str {
1283 &self.chain_name
1284 }
1285
1286 fn native_token_symbol(&self) -> &str {
1287 &self.native_symbol
1288 }
1289
1290 async fn get_balance(&self, address: &str) -> Result<Balance> {
1291 self.get_balance(address).await
1292 }
1293
1294 async fn enrich_balance_usd(&self, balance: &mut Balance) {
1295 self.enrich_balance_usd(balance).await
1296 }
1297
1298 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
1299 self.get_transaction(hash).await
1300 }
1301
1302 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
1303 self.get_transactions(address, limit).await
1304 }
1305
1306 async fn get_block_number(&self) -> Result<u64> {
1307 self.get_block_number().await
1308 }
1309
1310 async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
1311 self.get_erc20_balances(address).await
1312 }
1313
1314 async fn get_token_info(&self, address: &str) -> Result<Token> {
1315 self.get_token_info(address).await
1316 }
1317
1318 async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
1319 self.get_token_holders(address, limit).await
1320 }
1321
1322 async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
1323 self.get_token_holder_count(address).await
1324 }
1325}
1326
1327#[cfg(test)]
1332mod tests {
1333 use super::*;
1334
1335 const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1336 const VALID_TX_HASH: &str =
1337 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
1338
1339 #[test]
1340 fn test_validate_eth_address_valid() {
1341 assert!(validate_eth_address(VALID_ADDRESS).is_ok());
1342 }
1343
1344 #[test]
1345 fn test_validate_eth_address_lowercase() {
1346 let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
1347 assert!(validate_eth_address(addr).is_ok());
1348 }
1349
1350 #[test]
1351 fn test_validate_eth_address_missing_prefix() {
1352 let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
1353 let result = validate_eth_address(addr);
1354 assert!(result.is_err());
1355 assert!(result.unwrap_err().to_string().contains("0x"));
1356 }
1357
1358 #[test]
1359 fn test_validate_eth_address_too_short() {
1360 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
1361 let result = validate_eth_address(addr);
1362 assert!(result.is_err());
1363 assert!(result.unwrap_err().to_string().contains("42 characters"));
1364 }
1365
1366 #[test]
1367 fn test_validate_eth_address_invalid_hex() {
1368 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
1369 let result = validate_eth_address(addr);
1370 assert!(result.is_err());
1371 assert!(result.unwrap_err().to_string().contains("invalid hex"));
1372 }
1373
1374 #[test]
1375 fn test_validate_tx_hash_valid() {
1376 assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
1377 }
1378
1379 #[test]
1380 fn test_validate_tx_hash_missing_prefix() {
1381 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
1382 let result = validate_tx_hash(hash);
1383 assert!(result.is_err());
1384 }
1385
1386 #[test]
1387 fn test_validate_tx_hash_too_short() {
1388 let hash = "0xabc123";
1389 let result = validate_tx_hash(hash);
1390 assert!(result.is_err());
1391 assert!(result.unwrap_err().to_string().contains("66 characters"));
1392 }
1393
1394 #[test]
1395 fn test_ethereum_client_default() {
1396 let client = EthereumClient::default();
1397 assert_eq!(client.chain_name(), "ethereum");
1398 assert_eq!(client.native_token_symbol(), "ETH");
1399 }
1400
1401 #[test]
1402 fn test_ethereum_client_with_base_url() {
1403 let client = EthereumClient::with_base_url("https://custom.api.com");
1404 assert_eq!(client.base_url, "https://custom.api.com");
1405 }
1406
1407 #[test]
1408 fn test_ethereum_client_for_chain_ethereum() {
1409 let config = ChainsConfig::default();
1410 let client = EthereumClient::for_chain("ethereum", &config).unwrap();
1411 assert_eq!(client.chain_name(), "ethereum");
1412 assert_eq!(client.native_token_symbol(), "ETH");
1413 }
1414
1415 #[test]
1416 fn test_ethereum_client_for_chain_polygon() {
1417 let config = ChainsConfig::default();
1418 let client = EthereumClient::for_chain("polygon", &config).unwrap();
1419 assert_eq!(client.chain_name(), "polygon");
1420 assert_eq!(client.native_token_symbol(), "MATIC");
1421 assert!(client.base_url.contains("etherscan.io/v2"));
1423 assert_eq!(client.chain_id, Some("137".to_string()));
1424 }
1425
1426 #[test]
1427 fn test_ethereum_client_for_chain_arbitrum() {
1428 let config = ChainsConfig::default();
1429 let client = EthereumClient::for_chain("arbitrum", &config).unwrap();
1430 assert_eq!(client.chain_name(), "arbitrum");
1431 assert!(client.base_url.contains("etherscan.io/v2"));
1433 assert_eq!(client.chain_id, Some("42161".to_string()));
1434 }
1435
1436 #[test]
1437 fn test_ethereum_client_for_chain_bsc() {
1438 let config = ChainsConfig::default();
1439 let client = EthereumClient::for_chain("bsc", &config).unwrap();
1440 assert_eq!(client.chain_name(), "bsc");
1441 assert_eq!(client.native_token_symbol(), "BNB");
1442 assert!(client.base_url.contains("etherscan.io/v2"));
1444 assert_eq!(client.chain_id, Some("56".to_string()));
1445 assert_eq!(client.api_type, ApiType::BlockExplorer);
1446 }
1447
1448 #[test]
1449 fn test_ethereum_client_for_chain_aegis() {
1450 let config = ChainsConfig::default();
1451 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1452 assert_eq!(client.chain_name(), "aegis");
1453 assert_eq!(client.native_token_symbol(), "WRAITH");
1454 assert_eq!(client.api_type, ApiType::JsonRpc);
1455 assert!(client.base_url.contains("localhost:8545"));
1457 }
1458
1459 #[test]
1460 fn test_ethereum_client_for_chain_aegis_with_config() {
1461 let config = ChainsConfig {
1462 aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
1463 ..Default::default()
1464 };
1465 let client = EthereumClient::for_chain("aegis", &config).unwrap();
1466 assert_eq!(client.base_url, "https://aegis.example.com:8545");
1467 }
1468
1469 #[test]
1470 fn test_ethereum_client_for_chain_unsupported() {
1471 let config = ChainsConfig::default();
1472 let result = EthereumClient::for_chain("bitcoin", &config);
1473 assert!(result.is_err());
1474 assert!(
1475 result
1476 .unwrap_err()
1477 .to_string()
1478 .contains("Unsupported chain")
1479 );
1480 }
1481
1482 #[test]
1483 fn test_ethereum_client_new() {
1484 let config = ChainsConfig::default();
1485 let client = EthereumClient::new(&config);
1486 assert!(client.is_ok());
1487 }
1488
1489 #[test]
1490 fn test_ethereum_client_with_api_key() {
1491 use std::collections::HashMap;
1492
1493 let mut api_keys = HashMap::new();
1494 api_keys.insert("etherscan".to_string(), "test-key".to_string());
1495
1496 let config = ChainsConfig {
1497 api_keys,
1498 ..Default::default()
1499 };
1500
1501 let client = EthereumClient::new(&config).unwrap();
1502 assert_eq!(client.api_key, Some("test-key".to_string()));
1503 }
1504
1505 #[test]
1506 fn test_api_response_deserialization() {
1507 let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
1508 let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
1509 assert_eq!(response.status, "1");
1510 assert_eq!(response.message, "OK");
1511 assert_eq!(response.result, "1000000000000000000");
1512 }
1513
1514 #[test]
1515 fn test_tx_list_item_deserialization() {
1516 let json = r#"{
1517 "hash": "0xabc",
1518 "blockNumber": "12345",
1519 "timeStamp": "1700000000",
1520 "from": "0xfrom",
1521 "to": "0xto",
1522 "value": "1000000000000000000",
1523 "gas": "21000",
1524 "gasUsed": "21000",
1525 "gasPrice": "20000000000",
1526 "nonce": "42",
1527 "input": "0x",
1528 "isError": "0"
1529 }"#;
1530
1531 let item: TxListItem = serde_json::from_str(json).unwrap();
1532 assert_eq!(item.hash, "0xabc");
1533 assert_eq!(item.block_number, "12345");
1534 assert_eq!(item.nonce, "42");
1535 assert_eq!(item.is_error, "0");
1536 }
1537
1538 #[test]
1543 fn test_parse_balance_wei_valid() {
1544 let client = EthereumClient::default();
1545 let balance = client.parse_balance_wei("1000000000000000000").unwrap();
1546 assert_eq!(balance.symbol, "ETH");
1547 assert_eq!(balance.raw, "1000000000000000000");
1548 assert!(balance.formatted.contains("1.000000"));
1549 assert!(balance.usd_value.is_none());
1550 }
1551
1552 #[test]
1553 fn test_parse_balance_wei_zero() {
1554 let client = EthereumClient::default();
1555 let balance = client.parse_balance_wei("0").unwrap();
1556 assert!(balance.formatted.contains("0.000000"));
1557 }
1558
1559 #[test]
1560 fn test_parse_balance_wei_invalid() {
1561 let client = EthereumClient::default();
1562 let result = client.parse_balance_wei("not_a_number");
1563 assert!(result.is_err());
1564 }
1565
1566 #[test]
1567 fn test_format_token_balance_large() {
1568 assert!(format_token_balance("1000000000000000000000000000", 18).contains("B"));
1569 }
1570
1571 #[test]
1572 fn test_format_token_balance_millions() {
1573 assert!(format_token_balance("5000000000000000000000000", 18).contains("M"));
1574 }
1575
1576 #[test]
1577 fn test_format_token_balance_thousands() {
1578 assert!(format_token_balance("5000000000000000000000", 18).contains("K"));
1579 }
1580
1581 #[test]
1582 fn test_format_token_balance_small() {
1583 let formatted = format_token_balance("500000000000000000", 18);
1584 assert!(formatted.contains("0.5"));
1585 }
1586
1587 #[test]
1588 fn test_format_token_balance_zero() {
1589 let formatted = format_token_balance("0", 18);
1590 assert!(formatted.contains("0.0000"));
1591 }
1592
1593 #[test]
1594 fn test_build_api_url_with_chain_id_and_key() {
1595 use std::collections::HashMap;
1596 let mut keys = HashMap::new();
1597 keys.insert("etherscan".to_string(), "MYKEY".to_string());
1598 let config = ChainsConfig {
1599 api_keys: keys,
1600 ..Default::default()
1601 };
1602 let client = EthereumClient::new(&config).unwrap();
1603 let url = client.build_api_url("module=account&action=balance&address=0x123");
1604 assert!(url.contains("chainid=1"));
1605 assert!(url.contains("module=account"));
1606 assert!(url.contains("apikey=MYKEY"));
1607 }
1608
1609 #[test]
1610 fn test_build_api_url_no_chain_id_no_key() {
1611 let client = EthereumClient::with_base_url("https://example.com/api");
1612 let url = client.build_api_url("module=account&action=balance");
1613 assert_eq!(url, "https://example.com/api?module=account&action=balance");
1614 assert!(!url.contains("chainid"));
1615 assert!(!url.contains("apikey"));
1616 }
1617
1618 #[tokio::test]
1623 async fn test_get_balance_explorer() {
1624 let mut server = mockito::Server::new_async().await;
1625 let _mock = server
1626 .mock("GET", mockito::Matcher::Any)
1627 .with_status(200)
1628 .with_header("content-type", "application/json")
1629 .with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
1630 .create_async()
1631 .await;
1632
1633 let client = EthereumClient::with_base_url(&server.url());
1634 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1635 assert_eq!(balance.raw, "2500000000000000000");
1636 assert_eq!(balance.symbol, "ETH");
1637 assert!(balance.formatted.contains("2.5"));
1638 }
1639
1640 #[tokio::test]
1641 async fn test_get_balance_explorer_api_error() {
1642 let mut server = mockito::Server::new_async().await;
1643 let _mock = server
1644 .mock("GET", mockito::Matcher::Any)
1645 .with_status(200)
1646 .with_header("content-type", "application/json")
1647 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
1648 .create_async()
1649 .await;
1650
1651 let client = EthereumClient::with_base_url(&server.url());
1652 let result = client.get_balance(VALID_ADDRESS).await;
1653 assert!(result.is_err());
1654 assert!(result.unwrap_err().to_string().contains("API error"));
1655 }
1656
1657 #[tokio::test]
1658 async fn test_get_balance_invalid_address() {
1659 let client = EthereumClient::default();
1660 let result = client.get_balance("invalid").await;
1661 assert!(result.is_err());
1662 }
1663
1664 #[tokio::test]
1665 async fn test_get_balance_rpc() {
1666 let mut server = mockito::Server::new_async().await;
1667 let _mock = server
1668 .mock("POST", "/")
1669 .with_status(200)
1670 .with_header("content-type", "application/json")
1671 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
1672 .create_async()
1673 .await;
1674
1675 let client = EthereumClient {
1676 client: Client::new(),
1677 base_url: server.url(),
1678 chain_id: None,
1679 api_key: None,
1680 chain_name: "aegis".to_string(),
1681 native_symbol: "WRAITH".to_string(),
1682 native_decimals: 18,
1683 api_type: ApiType::JsonRpc,
1684 };
1685 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1686 assert_eq!(balance.symbol, "WRAITH");
1687 assert!(balance.formatted.contains("1.000000"));
1688 }
1689
1690 #[tokio::test]
1691 async fn test_get_balance_rpc_error() {
1692 let mut server = mockito::Server::new_async().await;
1693 let _mock = server
1694 .mock("POST", "/")
1695 .with_status(200)
1696 .with_header("content-type", "application/json")
1697 .with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
1698 .create_async()
1699 .await;
1700
1701 let client = EthereumClient {
1702 client: Client::new(),
1703 base_url: server.url(),
1704 chain_id: None,
1705 api_key: None,
1706 chain_name: "aegis".to_string(),
1707 native_symbol: "WRAITH".to_string(),
1708 native_decimals: 18,
1709 api_type: ApiType::JsonRpc,
1710 };
1711 let result = client.get_balance(VALID_ADDRESS).await;
1712 assert!(result.is_err());
1713 assert!(result.unwrap_err().to_string().contains("RPC error"));
1714 }
1715
1716 #[tokio::test]
1717 async fn test_get_balance_rpc_empty_result() {
1718 let mut server = mockito::Server::new_async().await;
1719 let _mock = server
1720 .mock("POST", "/")
1721 .with_status(200)
1722 .with_header("content-type", "application/json")
1723 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1724 .create_async()
1725 .await;
1726
1727 let client = EthereumClient {
1728 client: Client::new(),
1729 base_url: server.url(),
1730 chain_id: None,
1731 api_key: None,
1732 chain_name: "aegis".to_string(),
1733 native_symbol: "WRAITH".to_string(),
1734 native_decimals: 18,
1735 api_type: ApiType::JsonRpc,
1736 };
1737 let result = client.get_balance(VALID_ADDRESS).await;
1738 assert!(result.is_err());
1739 assert!(
1740 result
1741 .unwrap_err()
1742 .to_string()
1743 .contains("Empty RPC response")
1744 );
1745 }
1746
1747 #[tokio::test]
1748 async fn test_get_transaction_explorer() {
1749 let mut server = mockito::Server::new_async().await;
1750 let _mock = server
1752 .mock("GET", mockito::Matcher::Any)
1753 .with_status(200)
1754 .with_header("content-type", "application/json")
1755 .with_body(
1756 r#"{"jsonrpc":"2.0","id":1,"result":{
1757 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1758 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1759 "to":"0x1111111111111111111111111111111111111111",
1760 "blockNumber":"0x10",
1761 "gas":"0x5208",
1762 "gasPrice":"0x4A817C800",
1763 "nonce":"0x2A",
1764 "value":"0xDE0B6B3A7640000",
1765 "input":"0x"
1766 }}"#,
1767 )
1768 .expect_at_most(1)
1769 .create_async()
1770 .await;
1771
1772 let _receipt_mock = server
1774 .mock("GET", mockito::Matcher::Any)
1775 .with_status(200)
1776 .with_header("content-type", "application/json")
1777 .with_body(
1778 r#"{"jsonrpc":"2.0","id":1,"result":{
1779 "gasUsed":"0x5208",
1780 "status":"0x1"
1781 }}"#,
1782 )
1783 .expect_at_most(1)
1784 .create_async()
1785 .await;
1786
1787 let _block_mock = server
1789 .mock("GET", mockito::Matcher::Any)
1790 .with_status(200)
1791 .with_header("content-type", "application/json")
1792 .with_body(
1793 r#"{"jsonrpc":"2.0","id":1,"result":{
1794 "timestamp":"0x65A8C580"
1795 }}"#,
1796 )
1797 .create_async()
1798 .await;
1799
1800 let client = EthereumClient::with_base_url(&server.url());
1801 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1802 assert_eq!(tx.hash, VALID_TX_HASH);
1803 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1804 assert_eq!(
1805 tx.to,
1806 Some("0x1111111111111111111111111111111111111111".to_string())
1807 );
1808 assert!(tx.gas_limit > 0);
1809 assert!(tx.nonce > 0);
1810 }
1811
1812 #[tokio::test]
1813 async fn test_get_transaction_explorer_not_found() {
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#"{"jsonrpc":"2.0","id":1,"result":null}"#)
1820 .create_async()
1821 .await;
1822
1823 let client = EthereumClient::with_base_url(&server.url());
1824 let result = client.get_transaction(VALID_TX_HASH).await;
1825 assert!(result.is_err());
1826 assert!(result.unwrap_err().to_string().contains("not found"));
1827 }
1828
1829 #[tokio::test]
1830 async fn test_get_transaction_rpc() {
1831 let mut server = mockito::Server::new_async().await;
1832 let _tx_mock = server
1834 .mock("POST", "/")
1835 .with_status(200)
1836 .with_header("content-type", "application/json")
1837 .with_body(
1838 r#"{"jsonrpc":"2.0","id":1,"result":{
1839 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
1840 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1841 "to":"0x1111111111111111111111111111111111111111",
1842 "blockNumber":"0x10",
1843 "gas":"0x5208",
1844 "gasPrice":"0x4A817C800",
1845 "nonce":"0x2A",
1846 "value":"0xDE0B6B3A7640000",
1847 "input":"0x"
1848 }}"#,
1849 )
1850 .expect_at_most(1)
1851 .create_async()
1852 .await;
1853
1854 let _receipt_mock = server
1856 .mock("POST", "/")
1857 .with_status(200)
1858 .with_header("content-type", "application/json")
1859 .with_body(
1860 r#"{"jsonrpc":"2.0","id":2,"result":{
1861 "gasUsed":"0x5208",
1862 "status":"0x1"
1863 }}"#,
1864 )
1865 .create_async()
1866 .await;
1867
1868 let client = EthereumClient {
1869 client: Client::new(),
1870 base_url: server.url(),
1871 chain_id: None,
1872 api_key: None,
1873 chain_name: "aegis".to_string(),
1874 native_symbol: "WRAITH".to_string(),
1875 native_decimals: 18,
1876 api_type: ApiType::JsonRpc,
1877 };
1878 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1879 assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1880 assert!(tx.status.unwrap());
1881 assert!(tx.timestamp.is_none()); }
1883
1884 #[tokio::test]
1885 async fn test_get_transaction_invalid_hash() {
1886 let client = EthereumClient::default();
1887 let result = client.get_transaction("0xbad").await;
1888 assert!(result.is_err());
1889 }
1890
1891 #[tokio::test]
1892 async fn test_get_transactions() {
1893 let mut server = mockito::Server::new_async().await;
1894 let _mock = server
1895 .mock("GET", mockito::Matcher::Any)
1896 .with_status(200)
1897 .with_header("content-type", "application/json")
1898 .with_body(
1899 r#"{"status":"1","message":"OK","result":[
1900 {
1901 "hash":"0xabc",
1902 "blockNumber":"12345",
1903 "timeStamp":"1700000000",
1904 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1905 "to":"0x1111111111111111111111111111111111111111",
1906 "value":"1000000000000000000",
1907 "gas":"21000",
1908 "gasUsed":"21000",
1909 "gasPrice":"20000000000",
1910 "nonce":"1",
1911 "input":"0x",
1912 "isError":"0"
1913 },
1914 {
1915 "hash":"0xdef",
1916 "blockNumber":"12346",
1917 "timeStamp":"1700000060",
1918 "from":"0x1111111111111111111111111111111111111111",
1919 "to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1920 "value":"500000000000000000",
1921 "gas":"21000",
1922 "gasUsed":"21000",
1923 "gasPrice":"20000000000",
1924 "nonce":"5",
1925 "input":"0x",
1926 "isError":"1"
1927 }
1928 ]}"#,
1929 )
1930 .create_async()
1931 .await;
1932
1933 let client = EthereumClient::with_base_url(&server.url());
1934 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1935 assert_eq!(txs.len(), 2);
1936 assert_eq!(txs[0].hash, "0xabc");
1937 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[1].nonce, 5);
1940 }
1941
1942 #[tokio::test]
1943 async fn test_get_transactions_no_transactions() {
1944 let mut server = mockito::Server::new_async().await;
1945 let _mock = server
1946 .mock("GET", mockito::Matcher::Any)
1947 .with_status(200)
1948 .with_header("content-type", "application/json")
1949 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
1950 .create_async()
1951 .await;
1952
1953 let client = EthereumClient::with_base_url(&server.url());
1954 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1955 assert!(txs.is_empty());
1956 }
1957
1958 #[tokio::test]
1959 async fn test_get_transactions_empty_to_field() {
1960 let mut server = mockito::Server::new_async().await;
1961 let _mock = server
1962 .mock("GET", mockito::Matcher::Any)
1963 .with_status(200)
1964 .with_header("content-type", "application/json")
1965 .with_body(
1966 r#"{"status":"1","message":"OK","result":[{
1967 "hash":"0xabc",
1968 "blockNumber":"12345",
1969 "timeStamp":"1700000000",
1970 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1971 "to":"",
1972 "value":"0",
1973 "gas":"200000",
1974 "gasUsed":"150000",
1975 "gasPrice":"20000000000",
1976 "nonce":"1",
1977 "input":"0x60806040",
1978 "isError":"0"
1979 }]}"#,
1980 )
1981 .create_async()
1982 .await;
1983
1984 let client = EthereumClient::with_base_url(&server.url());
1985 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1986 assert_eq!(txs.len(), 1);
1987 assert!(txs[0].to.is_none()); }
1989
1990 #[tokio::test]
1991 async fn test_get_block_number() {
1992 let mut server = mockito::Server::new_async().await;
1993 let _mock = server
1994 .mock("GET", mockito::Matcher::Any)
1995 .with_status(200)
1996 .with_header("content-type", "application/json")
1997 .with_body(r#"{"result":"0x1234AB"}"#)
1998 .create_async()
1999 .await;
2000
2001 let client = EthereumClient::with_base_url(&server.url());
2002 let block = client.get_block_number().await.unwrap();
2003 assert_eq!(block, 0x1234AB);
2004 }
2005
2006 #[tokio::test]
2007 async fn test_get_erc20_balances() {
2008 let mut server = mockito::Server::new_async().await;
2009 let _tokentx_mock = server
2011 .mock("GET", mockito::Matcher::Any)
2012 .with_status(200)
2013 .with_header("content-type", "application/json")
2014 .with_body(
2015 r#"{"status":"1","message":"OK","result":[
2016 {
2017 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2018 "tokenSymbol":"USDT",
2019 "tokenName":"Tether USD",
2020 "tokenDecimal":"6"
2021 },
2022 {
2023 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2024 "tokenSymbol":"USDT",
2025 "tokenName":"Tether USD",
2026 "tokenDecimal":"6"
2027 },
2028 {
2029 "contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2030 "tokenSymbol":"USDC",
2031 "tokenName":"USD Coin",
2032 "tokenDecimal":"6"
2033 }
2034 ]}"#,
2035 )
2036 .expect_at_most(1)
2037 .create_async()
2038 .await;
2039
2040 let _balance_mock = server
2042 .mock("GET", mockito::Matcher::Any)
2043 .with_status(200)
2044 .with_header("content-type", "application/json")
2045 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2046 .create_async()
2047 .await;
2048
2049 let client = EthereumClient::with_base_url(&server.url());
2050 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2051 assert!(balances.len() <= 2);
2053 if !balances.is_empty() {
2054 assert!(!balances[0].balance.is_empty());
2055 }
2056 }
2057
2058 #[tokio::test]
2059 async fn test_get_erc20_balances_empty() {
2060 let mut server = mockito::Server::new_async().await;
2061 let _mock = server
2062 .mock("GET", mockito::Matcher::Any)
2063 .with_status(200)
2064 .with_header("content-type", "application/json")
2065 .with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
2066 .create_async()
2067 .await;
2068
2069 let client = EthereumClient::with_base_url(&server.url());
2070 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2071 assert!(balances.is_empty());
2072 }
2073
2074 #[tokio::test]
2075 async fn test_get_token_info_success() {
2076 let mut server = mockito::Server::new_async().await;
2077 let _mock = server
2078 .mock("GET", mockito::Matcher::Any)
2079 .with_status(200)
2080 .with_header("content-type", "application/json")
2081 .with_body(
2082 r#"{"status":"1","message":"OK","result":[{
2083 "tokenName":"Tether USD",
2084 "symbol":"USDT",
2085 "divisor":"1000000",
2086 "tokenType":"ERC20",
2087 "totalSupply":"1000000000000"
2088 }]}"#,
2089 )
2090 .create_async()
2091 .await;
2092
2093 let client = EthereumClient::with_base_url(&server.url());
2094 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2095 assert_eq!(token.symbol, "USDT");
2096 assert_eq!(token.name, "Tether USD");
2097 assert_eq!(token.decimals, 6); }
2099
2100 #[tokio::test]
2101 async fn test_get_token_info_fallback_to_supply() {
2102 let mut server = mockito::Server::new_async().await;
2103 let _info_mock = server
2105 .mock("GET", mockito::Matcher::Any)
2106 .with_status(200)
2107 .with_header("content-type", "application/json")
2108 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2109 .expect_at_most(1)
2110 .create_async()
2111 .await;
2112
2113 let _supply_mock = server
2115 .mock("GET", mockito::Matcher::Any)
2116 .with_status(200)
2117 .with_header("content-type", "application/json")
2118 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2119 .expect_at_most(1)
2120 .create_async()
2121 .await;
2122
2123 let _source_mock = server
2125 .mock("GET", mockito::Matcher::Any)
2126 .with_status(200)
2127 .with_header("content-type", "application/json")
2128 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
2129 .create_async()
2130 .await;
2131
2132 let client = EthereumClient::with_base_url(&server.url());
2133 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2134 assert!(!token.symbol.is_empty());
2136 }
2137
2138 #[tokio::test]
2139 async fn test_get_token_info_unknown() {
2140 let mut server = mockito::Server::new_async().await;
2141 let _mock = server
2142 .mock("GET", mockito::Matcher::Any)
2143 .with_status(200)
2144 .with_header("content-type", "application/json")
2145 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2146 .create_async()
2147 .await;
2148
2149 let client = EthereumClient::with_base_url(&server.url());
2150 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2151 assert!(!token.symbol.is_empty());
2153 }
2154
2155 #[tokio::test]
2156 async fn test_try_get_contract_name() {
2157 let mut server = mockito::Server::new_async().await;
2158 let _mock = server
2159 .mock("GET", mockito::Matcher::Any)
2160 .with_status(200)
2161 .with_header("content-type", "application/json")
2162 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
2163 .create_async()
2164 .await;
2165
2166 let client = EthereumClient::with_base_url(&server.url());
2167 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2168 assert!(result.is_some());
2169 let (symbol, name) = result.unwrap();
2170 assert_eq!(name, "USDT");
2171 assert_eq!(symbol, "USDT"); }
2173
2174 #[tokio::test]
2175 async fn test_try_get_contract_name_long_name() {
2176 let mut server = mockito::Server::new_async().await;
2177 let _mock = server
2178 .mock("GET", mockito::Matcher::Any)
2179 .with_status(200)
2180 .with_header("content-type", "application/json")
2181 .with_body(
2182 r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
2183 )
2184 .create_async()
2185 .await;
2186
2187 let client = EthereumClient::with_base_url(&server.url());
2188 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2189 assert!(result.is_some());
2190 let (symbol, name) = result.unwrap();
2191 assert_eq!(name, "TetherUSDToken");
2192 assert_eq!(symbol, "TUSDT"); }
2194
2195 #[tokio::test]
2196 async fn test_try_get_contract_name_not_verified() {
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":"1","message":"OK","result":[{"ContractName":""}]}"#)
2203 .create_async()
2204 .await;
2205
2206 let client = EthereumClient::with_base_url(&server.url());
2207 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2208 assert!(result.is_none());
2209 }
2210
2211 #[tokio::test]
2212 async fn test_get_token_holders() {
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(r#"{"status":"1","message":"OK","result":[
2219 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
2220 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
2221 {"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
2222 ]}"#)
2223 .create_async()
2224 .await;
2225
2226 let client = EthereumClient::with_base_url(&server.url());
2227 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2228 assert_eq!(holders.len(), 3);
2229 assert_eq!(
2230 holders[0].address,
2231 "0x1111111111111111111111111111111111111111"
2232 );
2233 assert_eq!(holders[0].rank, 1);
2234 assert_eq!(holders[2].rank, 3);
2235 assert!((holders[0].percentage - 50.0).abs() < 0.01);
2237 }
2238
2239 #[tokio::test]
2240 async fn test_get_token_holders_pro_required() {
2241 let mut server = mockito::Server::new_async().await;
2242 let _mock = server
2243 .mock("GET", mockito::Matcher::Any)
2244 .with_status(200)
2245 .with_header("content-type", "application/json")
2246 .with_body(
2247 r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
2248 )
2249 .create_async()
2250 .await;
2251
2252 let client = EthereumClient::with_base_url(&server.url());
2253 let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
2254 assert!(holders.is_empty()); }
2256
2257 #[tokio::test]
2258 async fn test_get_token_holder_count_small() {
2259 let mut server = mockito::Server::new_async().await;
2260 let _mock = server
2262 .mock("GET", mockito::Matcher::Any)
2263 .with_status(200)
2264 .with_header("content-type", "application/json")
2265 .with_body(r#"{"status":"1","message":"OK","result":[
2266 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
2267 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
2268 ]}"#)
2269 .create_async()
2270 .await;
2271
2272 let client = EthereumClient::with_base_url(&server.url());
2273 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2274 assert_eq!(count, 2);
2275 }
2276
2277 #[tokio::test]
2278 async fn test_get_token_holder_count_empty() {
2279 let mut server = mockito::Server::new_async().await;
2280 let _mock = server
2281 .mock("GET", mockito::Matcher::Any)
2282 .with_status(200)
2283 .with_header("content-type", "application/json")
2284 .with_body(
2285 r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
2286 )
2287 .create_async()
2288 .await;
2289
2290 let client = EthereumClient::with_base_url(&server.url());
2291 let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
2292 assert_eq!(count, 0);
2294 }
2295
2296 #[tokio::test]
2297 async fn test_get_block_timestamp() {
2298 let mut server = mockito::Server::new_async().await;
2299 let _mock = server
2300 .mock("GET", mockito::Matcher::Any)
2301 .with_status(200)
2302 .with_header("content-type", "application/json")
2303 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
2304 .create_async()
2305 .await;
2306
2307 let client = EthereumClient::with_base_url(&server.url());
2308 let ts = client.get_block_timestamp(16).await.unwrap();
2309 assert_eq!(ts, 0x65A8C580);
2310 }
2311
2312 #[tokio::test]
2313 async fn test_get_block_timestamp_not_found() {
2314 let mut server = mockito::Server::new_async().await;
2315 let _mock = server
2316 .mock("GET", mockito::Matcher::Any)
2317 .with_status(200)
2318 .with_header("content-type", "application/json")
2319 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
2320 .create_async()
2321 .await;
2322
2323 let client = EthereumClient::with_base_url(&server.url());
2324 let result = client.get_block_timestamp(99999999).await;
2325 assert!(result.is_err());
2326 }
2327
2328 #[test]
2329 fn test_chain_name_and_symbol_accessors() {
2330 let client = EthereumClient::with_base_url("http://test");
2331 assert_eq!(client.chain_name(), "ethereum");
2332 assert_eq!(client.native_token_symbol(), "ETH");
2333 }
2334
2335 #[test]
2336 fn test_validate_tx_hash_invalid_hex() {
2337 let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
2338 let result = validate_tx_hash(hash);
2339 assert!(result.is_err());
2340 assert!(result.unwrap_err().to_string().contains("invalid hex"));
2341 }
2342
2343 #[tokio::test]
2344 async fn test_get_transactions_api_error() {
2345 let mut server = mockito::Server::new_async().await;
2346 let _mock = server
2347 .mock("GET", mockito::Matcher::Any)
2348 .with_status(200)
2349 .with_header("content-type", "application/json")
2350 .with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
2351 .create_async()
2352 .await;
2353
2354 let client = EthereumClient::with_base_url(&server.url());
2355 let result = client.get_transactions(VALID_ADDRESS, 10).await;
2356 assert!(result.is_err());
2357 }
2358
2359 #[tokio::test]
2360 async fn test_get_token_holders_pro_key_required() {
2361 let mut server = mockito::Server::new_async().await;
2362 let _mock = server
2363 .mock("GET", mockito::Matcher::Any)
2364 .with_status(200)
2365 .with_header("content-type", "application/json")
2366 .with_body(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
2367 .create_async()
2368 .await;
2369
2370 let client = EthereumClient::with_base_url(&server.url());
2371 let holders = client
2372 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2373 .await
2374 .unwrap();
2375 assert!(holders.is_empty());
2376 }
2377
2378 #[tokio::test]
2379 async fn test_get_token_holders_api_error() {
2380 let mut server = mockito::Server::new_async().await;
2381 let _mock = server
2382 .mock("GET", mockito::Matcher::Any)
2383 .with_status(200)
2384 .with_header("content-type", "application/json")
2385 .with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
2386 .create_async()
2387 .await;
2388
2389 let client = EthereumClient::with_base_url(&server.url());
2390 let result = client
2391 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2392 .await;
2393 assert!(result.is_err());
2394 }
2395
2396 #[tokio::test]
2397 async fn test_get_token_holders_success() {
2398 let mut server = mockito::Server::new_async().await;
2399 let _mock = server
2400 .mock("GET", mockito::Matcher::Any)
2401 .with_status(200)
2402 .with_header("content-type", "application/json")
2403 .with_body(
2404 r#"{"status":"1","message":"OK","result":[
2405 {"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
2406 {"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
2407 ]}"#,
2408 )
2409 .create_async()
2410 .await;
2411
2412 let client = EthereumClient::with_base_url(&server.url());
2413 let holders = client
2414 .get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
2415 .await
2416 .unwrap();
2417 assert_eq!(holders.len(), 2);
2418 assert_eq!(holders[0].rank, 1);
2419 assert_eq!(holders[1].rank, 2);
2420 assert!(holders[0].percentage > 0.0);
2421 }
2422
2423 #[tokio::test]
2424 async fn test_get_token_info_unknown_token() {
2425 let mut server = mockito::Server::new_async().await;
2426 let _mock = server
2428 .mock("GET", mockito::Matcher::Any)
2429 .with_status(200)
2430 .with_header("content-type", "application/json")
2431 .with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
2432 .create_async()
2433 .await;
2434
2435 let client = EthereumClient::with_base_url(&server.url());
2436 let token = client
2437 .get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
2438 .await
2439 .unwrap();
2440 assert_eq!(token.symbol, "UNKNOWN");
2441 }
2442
2443 #[tokio::test]
2444 async fn test_get_transaction_with_null_block_number() {
2445 let mut server = mockito::Server::new_async().await;
2446 let _mock = server
2447 .mock("GET", mockito::Matcher::Any)
2448 .with_status(200)
2449 .with_header("content-type", "application/json")
2450 .with_body(
2451 r#"{"jsonrpc":"2.0","id":1,"result":{
2452 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2453 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2454 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2455 "value":"0xde0b6b3a7640000",
2456 "gas":"0x5208",
2457 "gasPrice":"0x3b9aca00",
2458 "nonce":"0x5",
2459 "input":"0x",
2460 "blockNumber":null
2461 }}"#,
2462 )
2463 .create_async()
2464 .await;
2465
2466 let client = EthereumClient::with_base_url(&server.url());
2467 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2468 assert!(tx.timestamp.is_none());
2470 }
2471
2472 #[tokio::test]
2473 async fn test_chain_client_trait_balance() {
2474 let mut server = mockito::Server::new_async().await;
2475 let _mock = server
2476 .mock("GET", mockito::Matcher::Any)
2477 .with_status(200)
2478 .with_header("content-type", "application/json")
2479 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
2480 .create_async()
2481 .await;
2482
2483 let client = EthereumClient::with_base_url(&server.url());
2484 let chain_client: &dyn ChainClient = &client;
2485 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
2486 assert_eq!(balance.symbol, "ETH");
2487 }
2488
2489 #[tokio::test]
2490 async fn test_chain_client_trait_get_transaction() {
2491 let mut server = mockito::Server::new_async().await;
2492 let _mock = server
2493 .mock("GET", mockito::Matcher::Any)
2494 .with_status(200)
2495 .with_header("content-type", "application/json")
2496 .with_body(
2497 r#"{"jsonrpc":"2.0","id":1,"result":{
2498 "hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
2499 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2500 "to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2501 "value":"0xde0b6b3a7640000",
2502 "gas":"0x5208",
2503 "gasPrice":"0x3b9aca00",
2504 "nonce":"0x5",
2505 "input":"0x",
2506 "blockNumber":"0xf4240"
2507 }}"#,
2508 )
2509 .create_async()
2510 .await;
2511
2512 let client = EthereumClient::with_base_url(&server.url());
2513 let chain_client: &dyn ChainClient = &client;
2514 let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
2515 assert!(!tx.hash.is_empty());
2516 }
2517
2518 #[tokio::test]
2519 async fn test_chain_client_trait_get_block_number() {
2520 let mut server = mockito::Server::new_async().await;
2521 let _mock = server
2522 .mock("GET", mockito::Matcher::Any)
2523 .with_status(200)
2524 .with_header("content-type", "application/json")
2525 .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
2526 .create_async()
2527 .await;
2528
2529 let client = EthereumClient::with_base_url(&server.url());
2530 let chain_client: &dyn ChainClient = &client;
2531 let block = chain_client.get_block_number().await.unwrap();
2532 assert_eq!(block, 1000000);
2533 }
2534
2535 #[tokio::test]
2536 async fn test_chain_client_trait_get_transactions() {
2537 let mut server = mockito::Server::new_async().await;
2538 let _mock = server
2539 .mock("GET", mockito::Matcher::Any)
2540 .with_status(200)
2541 .with_header("content-type", "application/json")
2542 .with_body(
2543 r#"{"status":"1","message":"OK","result":[{
2544 "hash":"0xabc",
2545 "blockNumber":"12345",
2546 "timeStamp":"1700000000",
2547 "from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2548 "to":"0x1111111111111111111111111111111111111111",
2549 "value":"1000000000000000000",
2550 "gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
2551 "nonce":"1","input":"0x","isError":"0"
2552 }]}"#,
2553 )
2554 .create_async()
2555 .await;
2556
2557 let client = EthereumClient::with_base_url(&server.url());
2558 let chain_client: &dyn ChainClient = &client;
2559 let txs = chain_client
2560 .get_transactions(VALID_ADDRESS, 10)
2561 .await
2562 .unwrap();
2563 assert_eq!(txs.len(), 1);
2564 }
2565
2566 #[tokio::test]
2567 async fn test_chain_client_trait_get_token_balances() {
2568 let mut server = mockito::Server::new_async().await;
2569 let _tokentx = server
2571 .mock("GET", mockito::Matcher::Any)
2572 .with_status(200)
2573 .with_header("content-type", "application/json")
2574 .with_body(
2575 r#"{"status":"1","message":"OK","result":[{
2576 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2577 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2578 }]}"#,
2579 )
2580 .expect_at_most(1)
2581 .create_async()
2582 .await;
2583
2584 let _balance = server
2586 .mock("GET", mockito::Matcher::Any)
2587 .with_status(200)
2588 .with_header("content-type", "application/json")
2589 .with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
2590 .create_async()
2591 .await;
2592
2593 let client = EthereumClient::with_base_url(&server.url());
2594 let chain_client: &dyn ChainClient = &client;
2595 let balances = chain_client
2596 .get_token_balances(VALID_ADDRESS)
2597 .await
2598 .unwrap();
2599 assert!(!balances.is_empty());
2600 }
2601
2602 #[tokio::test]
2603 async fn test_chain_client_trait_get_token_info() {
2604 let mut server = mockito::Server::new_async().await;
2605 let _mock = server
2606 .mock("GET", mockito::Matcher::Any)
2607 .with_status(200)
2608 .with_header("content-type", "application/json")
2609 .with_body(
2610 r#"{"status":"1","message":"OK","result":[{
2611 "tokenName":"Tether USD","symbol":"USDT",
2612 "divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
2613 }]}"#,
2614 )
2615 .create_async()
2616 .await;
2617
2618 let client = EthereumClient::with_base_url(&server.url());
2619 let chain_client: &dyn ChainClient = &client;
2620 let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
2621 assert_eq!(token.symbol, "USDT");
2622 }
2623
2624 #[tokio::test]
2625 async fn test_chain_client_trait_get_token_holders() {
2626 let mut server = mockito::Server::new_async().await;
2627 let _mock = server
2628 .mock("GET", mockito::Matcher::Any)
2629 .with_status(200)
2630 .with_header("content-type", "application/json")
2631 .with_body(
2632 r#"{"status":"1","message":"OK","result":[
2633 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
2634 ]}"#,
2635 )
2636 .create_async()
2637 .await;
2638
2639 let client = EthereumClient::with_base_url(&server.url());
2640 let chain_client: &dyn ChainClient = &client;
2641 let holders = chain_client
2642 .get_token_holders(VALID_ADDRESS, 10)
2643 .await
2644 .unwrap();
2645 assert_eq!(holders.len(), 1);
2646 }
2647
2648 #[tokio::test]
2649 async fn test_chain_client_trait_get_token_holder_count() {
2650 let mut server = mockito::Server::new_async().await;
2651 let _mock = server
2652 .mock("GET", mockito::Matcher::Any)
2653 .with_status(200)
2654 .with_header("content-type", "application/json")
2655 .with_body(
2656 r#"{"status":"1","message":"OK","result":[
2657 {"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
2658 {"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
2659 ]}"#,
2660 )
2661 .create_async()
2662 .await;
2663
2664 let client = EthereumClient::with_base_url(&server.url());
2665 let chain_client: &dyn ChainClient = &client;
2666 let count = chain_client
2667 .get_token_holder_count(VALID_ADDRESS)
2668 .await
2669 .unwrap();
2670 assert_eq!(count, 2);
2671 }
2672
2673 #[tokio::test]
2674 async fn test_chain_client_trait_native_token_symbol() {
2675 let client = EthereumClient::with_base_url("http://test");
2676 let chain_client: &dyn ChainClient = &client;
2677 assert_eq!(chain_client.native_token_symbol(), "ETH");
2678 assert_eq!(chain_client.chain_name(), "ethereum");
2679 }
2680
2681 #[tokio::test]
2682 async fn test_get_token_info_supply_fallback_no_contract() {
2683 let mut server = mockito::Server::new_async().await;
2684 let _info_mock = server
2686 .mock("GET", mockito::Matcher::Any)
2687 .with_status(200)
2688 .with_header("content-type", "application/json")
2689 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
2690 .expect_at_most(1)
2691 .create_async()
2692 .await;
2693
2694 let _supply_mock = server
2696 .mock("GET", mockito::Matcher::Any)
2697 .with_status(200)
2698 .with_header("content-type", "application/json")
2699 .with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
2700 .expect_at_most(1)
2701 .create_async()
2702 .await;
2703
2704 let _source_mock = server
2706 .mock("GET", mockito::Matcher::Any)
2707 .with_status(200)
2708 .with_header("content-type", "application/json")
2709 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
2710 .create_async()
2711 .await;
2712
2713 let client = EthereumClient::with_base_url(&server.url());
2714 let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
2715 assert!(token.symbol.contains("...") || !token.symbol.is_empty());
2717 }
2718
2719 #[tokio::test]
2720 async fn test_try_get_contract_name_short_lowercase() {
2721 let mut server = mockito::Server::new_async().await;
2722 let _mock = server
2723 .mock("GET", mockito::Matcher::Any)
2724 .with_status(200)
2725 .with_header("content-type", "application/json")
2726 .with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
2727 .create_async()
2728 .await;
2729
2730 let client = EthereumClient::with_base_url(&server.url());
2731 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2732 assert!(result.is_some());
2733 let (symbol, name) = result.unwrap();
2734 assert_eq!(name, "token");
2735 assert_eq!(symbol, "TOKEN");
2737 }
2738
2739 #[tokio::test]
2740 async fn test_try_get_contract_name_long_lowercase() {
2741 let mut server = mockito::Server::new_async().await;
2742 let _mock = server
2743 .mock("GET", mockito::Matcher::Any)
2744 .with_status(200)
2745 .with_header("content-type", "application/json")
2746 .with_body(
2747 r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
2748 )
2749 .create_async()
2750 .await;
2751
2752 let client = EthereumClient::with_base_url(&server.url());
2753 let result = client.try_get_contract_name(VALID_ADDRESS).await;
2754 assert!(result.is_some());
2755 let (symbol, name) = result.unwrap();
2756 assert_eq!(name, "mytokencontract");
2757 assert_eq!(symbol, "MYTOKE");
2760 }
2761
2762 #[tokio::test]
2763 async fn test_get_erc20_balances_zero_balance_skipped() {
2764 let mut server = mockito::Server::new_async().await;
2765 let _tokentx = server
2767 .mock("GET", mockito::Matcher::Any)
2768 .with_status(200)
2769 .with_header("content-type", "application/json")
2770 .with_body(
2771 r#"{"status":"1","message":"OK","result":[{
2772 "contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
2773 "tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
2774 }]}"#,
2775 )
2776 .expect_at_most(1)
2777 .create_async()
2778 .await;
2779
2780 let _balance = server
2782 .mock("GET", mockito::Matcher::Any)
2783 .with_status(200)
2784 .with_header("content-type", "application/json")
2785 .with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
2786 .create_async()
2787 .await;
2788
2789 let client = EthereumClient::with_base_url(&server.url());
2790 let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
2791 assert!(balances.is_empty());
2793 }
2794}