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