#![allow(clippy::collapsible_if)]
use crate::chains::{Balance, ChainClient, Token, TokenBalance, TokenHolder, Transaction};
use crate::config::ChainsConfig;
use crate::error::{Result, ScopeError};
use crate::http::{HttpClient, Request};
use async_trait::async_trait;
use serde::Deserialize;
use std::sync::Arc;
const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
const DEFAULT_BSC_RPC: &str = "https://bsc-dataseed.binance.org";
const DEFAULT_AEGIS_RPC: &str = "http://localhost:8545";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiType {
BlockExplorer,
JsonRpc,
}
#[derive(Clone)]
pub struct EthereumClient {
http: Arc<dyn HttpClient>,
base_url: String,
chain_id: Option<String>,
api_key: Option<String>,
chain_name: String,
native_symbol: String,
native_decimals: u8,
api_type: ApiType,
rpc_fallback_url: Option<String>,
}
impl std::fmt::Debug for EthereumClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EthereumClient")
.field("base_url", &self.base_url)
.field("chain_name", &self.chain_name)
.field("api_type", &self.api_type)
.finish_non_exhaustive()
}
}
#[derive(Debug, Deserialize)]
struct ApiResponse<T> {
status: String,
message: String,
result: T,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)] enum BalanceResult {
Balance(String),
Error(String),
}
#[derive(Debug, Deserialize)]
struct TxListItem {
hash: String,
#[serde(rename = "blockNumber")]
block_number: String,
#[serde(rename = "timeStamp")]
timestamp: String,
from: String,
to: String,
value: String,
gas: String,
#[serde(rename = "gasUsed")]
gas_used: String,
#[serde(rename = "gasPrice")]
gas_price: String,
nonce: String,
input: String,
#[serde(rename = "isError")]
is_error: String,
}
#[derive(Debug, Deserialize)]
struct ProxyResponse<T> {
result: Option<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTransaction {
#[serde(default)]
block_number: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
to: Option<String>,
#[serde(default)]
gas: Option<String>,
#[serde(default)]
gas_price: Option<String>,
#[serde(default)]
value: Option<String>,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
input: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTransactionReceipt {
#[serde(default)]
gas_used: Option<String>,
#[serde(default)]
status: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenHolderItem {
#[serde(rename = "TokenHolderAddress")]
address: String,
#[serde(rename = "TokenHolderQuantity")]
quantity: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenInfoItem {
#[serde(rename = "contractAddress")]
contract_address: Option<String>,
#[serde(rename = "tokenName")]
token_name: Option<String>,
#[serde(rename = "symbol")]
symbol: Option<String>,
#[serde(rename = "divisor")]
divisor: Option<String>,
#[serde(rename = "tokenType")]
token_type: Option<String>,
#[serde(rename = "totalSupply")]
total_supply: Option<String>,
}
impl EthereumClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `EthereumClient::new_with_http`"
)]
pub fn new(config: &ChainsConfig) -> Result<Self> {
Self::new_with_http(config, crate::http::shared::fallback())
}
pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
Ok(Self {
http,
base_url: ETHERSCAN_V2_API.to_string(),
chain_id: Some("1".to_string()),
api_key: config.api_keys.get("etherscan").cloned(),
chain_name: "ethereum".to_string(),
native_symbol: "ETH".to_string(),
native_decimals: 18,
api_type: ApiType::BlockExplorer,
rpc_fallback_url: None,
})
}
pub fn with_base_url(base_url: &str) -> Self {
Self {
http: crate::http::shared::fallback(),
base_url: base_url.to_string(),
chain_id: None,
api_key: None,
chain_name: "ethereum".to_string(),
native_symbol: "ETH".to_string(),
native_decimals: 18,
api_type: ApiType::BlockExplorer,
rpc_fallback_url: None,
}
}
#[cfg(test)]
fn with_base_url_and_rpc_fallback(base_url: &str, rpc_fallback_url: Option<String>) -> Self {
Self {
http: Arc::new(
crate::http::NativeHttpClient::new().expect("failed to build reqwest client"),
),
base_url: base_url.to_string(),
chain_id: Some("56".to_string()),
api_key: None,
chain_name: "bsc".to_string(),
native_symbol: "BNB".to_string(),
native_decimals: 18,
api_type: ApiType::BlockExplorer,
rpc_fallback_url,
}
}
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `EthereumClient::for_chain_with_http`"
)]
pub fn for_chain(chain: &str, config: &ChainsConfig) -> Result<Self> {
Self::for_chain_with_http(chain, config, crate::http::shared::fallback())
}
pub fn for_chain_with_http(
chain: &str,
config: &ChainsConfig,
http: Arc<dyn HttpClient>,
) -> Result<Self> {
let unsupported = || ScopeError::InvalidInput(format!("Unsupported chain: {chain}"));
let c = super::registry::chain(chain)
.filter(|c| c.kind == super::registry::ChainKind::Evm)
.ok_or_else(unsupported)?;
let (Some(chain_id), Some(api_key_name)) = (c.evm_chain_id, c.api_key_name) else {
let rpc_url = config.aegis_rpc.as_deref().unwrap_or(DEFAULT_AEGIS_RPC);
return Self::for_aegis(rpc_url, http);
};
let (base_url, symbol, chain) = (ETHERSCAN_V2_API, c.native_symbol, c.id);
let rpc_fallback_url = if chain == "bsc" {
Some(
config
.bsc_rpc
.clone()
.unwrap_or_else(|| DEFAULT_BSC_RPC.to_string()),
)
} else {
None
};
Ok(Self {
http,
base_url: base_url.to_string(),
chain_id: Some(chain_id.to_string()),
api_key: config.api_keys.get(api_key_name).cloned(),
chain_name: chain.to_string(),
native_symbol: symbol.to_string(),
native_decimals: 18,
api_type: ApiType::BlockExplorer,
rpc_fallback_url,
})
}
fn for_aegis(rpc_url: &str, http: Arc<dyn HttpClient>) -> Result<Self> {
Ok(Self {
http,
base_url: rpc_url.to_string(),
chain_id: None,
api_key: None,
chain_name: "aegis".to_string(),
native_symbol: "WRAITH".to_string(),
native_decimals: 18,
api_type: ApiType::JsonRpc,
rpc_fallback_url: None,
})
}
pub fn chain_name(&self) -> &str {
&self.chain_name
}
pub fn native_token_symbol(&self) -> &str {
&self.native_symbol
}
fn build_api_url(&self, params: &str) -> String {
let mut url = format!("{}?", self.base_url);
if let Some(ref chain_id) = self.chain_id {
url.push_str(&format!("chainid={}&", chain_id));
}
url.push_str(params);
if let Some(ref key) = self.api_key {
url.push_str(&format!("&apikey={}", key));
}
url
}
pub async fn get_balance(&self, address: &str) -> Result<Balance> {
validate_eth_address(address)?;
match self.api_type {
ApiType::BlockExplorer => {
let result = self.get_balance_explorer(address).await;
if let (Err(e), Some(rpc_url)) = (&result, &self.rpc_fallback_url) {
let msg = e.to_string();
if msg.contains("Free API access is not supported for this chain") {
tracing::debug!(
url = %rpc_url,
"Falling back to RPC for balance (block explorer free tier restriction)"
);
return self.get_balance_via_rpc(rpc_url, address).await;
}
}
result
}
ApiType::JsonRpc => self.get_balance_rpc(address).await,
}
}
async fn get_balance_explorer(&self, address: &str) -> Result<Balance> {
let url = self.build_api_url(&format!(
"module=account&action=balance&address={}&tag=latest",
address
));
tracing::debug!(url = %url, "Fetching balance via block explorer");
let response: ApiResponse<String> = self.http.send(Request::get(&url)).await?.json()?;
if response.status != "1" {
let detail = if response.result.is_empty() || response.result.starts_with("0x") {
response.message.clone()
} else {
format!("{} — {}", response.message, response.result)
};
return Err(ScopeError::Chain(format!("API error: {}", detail)));
}
self.parse_balance_wei(&response.result)
}
async fn get_balance_rpc(&self, address: &str) -> Result<Balance> {
self.get_balance_via_rpc(&self.base_url, address).await
}
async fn get_balance_via_rpc(&self, rpc_url: &str, address: &str) -> Result<Balance> {
#[derive(serde::Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
method: &'a str,
params: Vec<&'a str>,
id: u64,
}
#[derive(Deserialize)]
struct RpcResponse {
result: Option<String>,
error: Option<RpcError>,
}
#[derive(Deserialize)]
struct RpcError {
message: String,
}
let request = RpcRequest {
jsonrpc: "2.0",
method: "eth_getBalance",
params: vec![address, "latest"],
id: 1,
};
tracing::debug!(url = %rpc_url, address = %address, "Fetching balance via JSON-RPC");
let response: RpcResponse = self
.http
.send(Request::post_json(
rpc_url,
serde_json::to_string(&request)?,
))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!("RPC error: {}", error.message)));
}
let result = response
.result
.ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
let hex_balance = result.trim_start_matches("0x");
let wei = u128::from_str_radix(hex_balance, 16)
.map_err(|_| ScopeError::Chain("Invalid balance hex response".to_string()))?;
self.parse_balance_wei(&wei.to_string())
}
fn parse_balance_wei(&self, wei_str: &str) -> Result<Balance> {
let wei: u128 = wei_str
.parse()
.map_err(|_| ScopeError::Chain("Invalid balance response".to_string()))?;
let eth = wei as f64 / 10_f64.powi(self.native_decimals as i32);
Ok(Balance {
raw: wei_str.to_string(),
formatted: format!("{:.6} {}", eth, self.native_symbol),
decimals: self.native_decimals,
symbol: self.native_symbol.clone(),
usd_value: None, })
}
pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
let dex = crate::chains::DexClient::new_with_http(self.http.clone());
if let Some(price) = dex.get_native_token_price(&self.chain_name).await {
let amount: f64 =
balance.raw.parse().unwrap_or(0.0) / 10_f64.powi(self.native_decimals as i32);
balance.usd_value = Some(amount * price);
}
}
pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
validate_tx_hash(hash)?;
match self.api_type {
ApiType::BlockExplorer => self.get_transaction_explorer(hash).await,
ApiType::JsonRpc => self.get_transaction_rpc(hash).await,
}
}
async fn get_transaction_explorer(&self, hash: &str) -> Result<Transaction> {
let tx_url = self.build_api_url(&format!(
"module=proxy&action=eth_getTransactionByHash&txhash={}",
hash
));
tracing::debug!(url = %tx_url, "Fetching transaction via block explorer proxy");
let tx_response: ProxyResponse<ProxyTransaction> =
self.http.send(Request::get(&tx_url)).await?.json()?;
let proxy_tx = tx_response
.result
.ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
let receipt_url = self.build_api_url(&format!(
"module=proxy&action=eth_getTransactionReceipt&txhash={}",
hash
));
tracing::debug!(url = %receipt_url, "Fetching transaction receipt");
let receipt_response: ProxyResponse<ProxyTransactionReceipt> =
self.http.send(Request::get(&receipt_url)).await?.json()?;
let receipt = receipt_response.result;
let block_number = proxy_tx
.block_number
.as_deref()
.and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
let gas_limit = proxy_tx
.gas
.as_deref()
.and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
let gas_price = proxy_tx
.gas_price
.as_deref()
.and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
.map(|gp| gp.to_string())
.unwrap_or_else(|| "0".to_string());
let nonce = proxy_tx
.nonce
.as_deref()
.and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
let value = proxy_tx
.value
.as_deref()
.and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
.map(|v| v.to_string())
.unwrap_or_else(|| "0".to_string());
let gas_used = receipt.as_ref().and_then(|r| {
r.gas_used
.as_deref()
.and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
});
let status = receipt
.as_ref()
.and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
let timestamp = if let Some(bn) = block_number {
self.get_block_timestamp(bn).await.ok()
} else {
None
};
Ok(Transaction {
hash: hash.to_string(),
block_number,
timestamp,
from: proxy_tx.from.unwrap_or_default(),
to: proxy_tx.to,
value,
gas_limit,
gas_used,
gas_price,
nonce,
input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
status,
})
}
async fn get_transaction_rpc(&self, hash: &str) -> Result<Transaction> {
#[derive(serde::Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
method: &'a str,
params: Vec<&'a str>,
id: u64,
}
let request = RpcRequest {
jsonrpc: "2.0",
method: "eth_getTransactionByHash",
params: vec![hash],
id: 1,
};
let response: ProxyResponse<ProxyTransaction> = self
.http
.send(Request::post_json(
&self.base_url,
serde_json::to_string(&request)?,
))
.await?
.json()?;
let proxy_tx = response
.result
.ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", hash)))?;
let receipt_request = RpcRequest {
jsonrpc: "2.0",
method: "eth_getTransactionReceipt",
params: vec![hash],
id: 2,
};
let receipt_response: ProxyResponse<ProxyTransactionReceipt> = self
.http
.send(Request::post_json(
&self.base_url,
serde_json::to_string(&receipt_request)?,
))
.await?
.json()?;
let receipt = receipt_response.result;
let block_number = proxy_tx
.block_number
.as_deref()
.and_then(|bn| u64::from_str_radix(bn.trim_start_matches("0x"), 16).ok());
let gas_limit = proxy_tx
.gas
.as_deref()
.and_then(|g| u64::from_str_radix(g.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
let gas_price = proxy_tx
.gas_price
.as_deref()
.and_then(|gp| u128::from_str_radix(gp.trim_start_matches("0x"), 16).ok())
.map(|gp| gp.to_string())
.unwrap_or_else(|| "0".to_string());
let nonce = proxy_tx
.nonce
.as_deref()
.and_then(|n| u64::from_str_radix(n.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
let value = proxy_tx
.value
.as_deref()
.and_then(|v| u128::from_str_radix(v.trim_start_matches("0x"), 16).ok())
.map(|v| v.to_string())
.unwrap_or_else(|| "0".to_string());
let gas_used = receipt.as_ref().and_then(|r| {
r.gas_used
.as_deref()
.and_then(|gu| u64::from_str_radix(gu.trim_start_matches("0x"), 16).ok())
});
let status = receipt
.as_ref()
.and_then(|r| r.status.as_deref().map(|s| s == "0x1"));
Ok(Transaction {
hash: hash.to_string(),
block_number,
timestamp: None, from: proxy_tx.from.unwrap_or_default(),
to: proxy_tx.to,
value,
gas_limit,
gas_used,
gas_price,
nonce,
input: proxy_tx.input.unwrap_or_else(|| "0x".to_string()),
status,
})
}
async fn get_block_timestamp(&self, block_number: u64) -> Result<u64> {
let hex_block = format!("0x{:x}", block_number);
let url = self.build_api_url(&format!(
"module=proxy&action=eth_getBlockByNumber&tag={}&boolean=false",
hex_block
));
#[derive(Deserialize)]
struct BlockResult {
timestamp: Option<String>,
}
let response: ProxyResponse<BlockResult> =
self.http.send(Request::get(&url)).await?.json()?;
let block = response
.result
.ok_or_else(|| ScopeError::Chain(format!("Block not found: {}", block_number)))?;
block
.timestamp
.as_deref()
.and_then(|ts| u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok())
.ok_or_else(|| ScopeError::Chain("Invalid block timestamp".to_string()))
}
pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
validate_eth_address(address)?;
let url = self.build_api_url(&format!(
"module=account&action=txlist&address={}&startblock=0&endblock=99999999&page=1&offset={}&sort=desc",
address, limit
));
tracing::debug!(url = %url, "Fetching transactions");
let response: ApiResponse<Vec<TxListItem>> =
self.http.send(Request::get(&url)).await?.json()?;
if response.status != "1" && response.message != "No transactions found" {
return Err(ScopeError::Chain(format!(
"API error: {}",
response.message
)));
}
let transactions = response
.result
.into_iter()
.map(|tx| Transaction {
hash: tx.hash,
block_number: tx.block_number.parse().ok(),
timestamp: tx.timestamp.parse().ok(),
from: tx.from,
to: if tx.to.is_empty() { None } else { Some(tx.to) },
value: tx.value,
gas_limit: tx.gas.parse().unwrap_or(0),
gas_used: tx.gas_used.parse().ok(),
gas_price: tx.gas_price,
nonce: tx.nonce.parse().unwrap_or(0),
input: tx.input,
status: Some(tx.is_error == "0"),
})
.collect();
Ok(transactions)
}
pub async fn get_block_number(&self) -> Result<u64> {
let url = self.build_api_url("module=proxy&action=eth_blockNumber");
#[derive(Deserialize)]
struct BlockResponse {
result: String,
}
let response: BlockResponse = self.http.send(Request::get(&url)).await?.json()?;
let block_hex = response.result.trim_start_matches("0x");
let block_number = u64::from_str_radix(block_hex, 16)
.map_err(|_| ScopeError::Chain("Invalid block number response".to_string()))?;
Ok(block_number)
}
pub async fn get_code(&self, address: &str) -> Result<String> {
validate_eth_address(address)?;
let url = self.build_api_url(&format!(
"module=proxy&action=eth_getCode&address={}&tag=latest",
address
));
#[derive(Deserialize)]
struct CodeResponse {
result: Option<String>,
}
let response: CodeResponse = self.http.send(Request::get(&url)).await?.json()?;
Ok(response.result.unwrap_or_else(|| "0x".to_string()))
}
pub async fn get_storage_at(&self, address: &str, slot: &str) -> Result<String> {
validate_eth_address(address)?;
let url = self.build_api_url(&format!(
"module=proxy&action=eth_getStorageAt&address={}&position={}&tag=latest",
address, slot
));
#[derive(Deserialize)]
struct StorageResponse {
result: Option<String>,
}
let response: StorageResponse = self.http.send(Request::get(&url)).await?.json()?;
Ok(response.result.unwrap_or_else(|| {
"0x0000000000000000000000000000000000000000000000000000000000000000".to_string()
}))
}
pub async fn get_erc20_balances(
&self,
address: &str,
) -> Result<Vec<crate::chains::TokenBalance>> {
validate_eth_address(address)?;
let url = self.build_api_url(&format!(
"module=account&action=tokentx&address={}&page=1&offset=100&sort=desc",
address
));
tracing::debug!(url = %url, "Fetching ERC-20 token transfers");
let response = self.http.send(Request::get(&url)).await?.body;
#[derive(Deserialize)]
struct TokenTxItem {
#[serde(rename = "contractAddress")]
contract_address: String,
#[serde(rename = "tokenSymbol")]
token_symbol: String,
#[serde(rename = "tokenName")]
token_name: String,
#[serde(rename = "tokenDecimal")]
token_decimal: String,
}
let parsed: std::result::Result<ApiResponse<Vec<TokenTxItem>>, _> =
serde_json::from_str(&response);
let token_txs = match parsed {
Ok(api_resp) if api_resp.status == "1" => api_resp.result,
_ => return Ok(vec![]),
};
let mut seen = std::collections::HashSet::new();
let unique_tokens: Vec<&TokenTxItem> = token_txs
.iter()
.filter(|tx| seen.insert(tx.contract_address.to_lowercase()))
.collect();
let mut balances = Vec::new();
for token_tx in unique_tokens.iter().take(20) {
let balance_url = self.build_api_url(&format!(
"module=account&action=tokenbalance&contractaddress={}&address={}&tag=latest",
token_tx.contract_address, address
));
if let Ok(resp) = self.http.send(Request::get(&balance_url)).await {
if let Ok(bal_resp) = resp.json::<ApiResponse<String>>() {
if bal_resp.status == "1" {
let raw_balance = bal_resp.result;
let decimals: u8 = token_tx.token_decimal.parse().unwrap_or(18);
if raw_balance == "0" {
continue;
}
let formatted =
crate::display::format_token_balance(&raw_balance, decimals);
balances.push(crate::chains::TokenBalance {
token: Token {
contract_address: token_tx.contract_address.clone(),
symbol: token_tx.token_symbol.clone(),
name: token_tx.token_name.clone(),
decimals,
},
balance: raw_balance,
formatted_balance: formatted,
usd_value: None,
});
}
}
}
}
Ok(balances)
}
pub async fn get_token_info(&self, token_address: &str) -> Result<Token> {
validate_eth_address(token_address)?;
let url = self.build_api_url(&format!(
"module=token&action=tokeninfo&contractaddress={}",
token_address
));
tracing::debug!(url = %url, "Fetching token info (Pro API)");
let response = self.http.send(Request::get(&url)).await?;
let response_text = response.body;
if let Ok(api_response) =
serde_json::from_str::<ApiResponse<Vec<TokenInfoItem>>>(&response_text)
{
if api_response.status == "1" && !api_response.result.is_empty() {
let info = &api_response.result[0];
let decimals = info
.divisor
.as_ref()
.and_then(|d| d.parse::<u32>().ok())
.map(|d| (d as f64).log10() as u8)
.unwrap_or(18);
return Ok(Token {
contract_address: token_address.to_string(),
symbol: info.symbol.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
name: info
.token_name
.clone()
.unwrap_or_else(|| "Unknown Token".to_string()),
decimals,
});
}
}
self.get_token_info_from_supply(token_address).await
}
async fn get_token_info_from_supply(&self, token_address: &str) -> Result<Token> {
let url = self.build_api_url(&format!(
"module=stats&action=tokensupply&contractaddress={}",
token_address
));
tracing::debug!(url = %url, "Fetching token supply");
let response = self.http.send(Request::get(&url)).await?;
let response_text = response.body;
if let Ok(api_response) = serde_json::from_str::<ApiResponse<String>>(&response_text) {
if api_response.status == "1" {
if let Some(contract_info) = self.try_get_contract_name(token_address).await {
return Ok(Token {
contract_address: token_address.to_string(),
symbol: contract_info.0,
name: contract_info.1,
decimals: 18,
});
}
let short_addr = crate::display::text::abbreviate(token_address);
return Ok(Token {
contract_address: token_address.to_string(),
symbol: short_addr.clone(),
name: format!("Token {}", short_addr),
decimals: 18,
});
}
}
Ok(Token {
contract_address: token_address.to_string(),
symbol: "UNKNOWN".to_string(),
name: "Unknown Token".to_string(),
decimals: 18,
})
}
async fn try_get_contract_name(&self, token_address: &str) -> Option<(String, String)> {
let url = self.build_api_url(&format!(
"module=contract&action=getsourcecode&address={}",
token_address
));
let response = self.http.send(Request::get(&url)).await.ok()?;
let text = response.body;
#[derive(serde::Deserialize)]
struct SourceCodeResult {
#[serde(rename = "ContractName")]
contract_name: Option<String>,
}
#[derive(serde::Deserialize)]
struct SourceCodeResponse {
status: String,
result: Vec<SourceCodeResult>,
}
if let Ok(api_response) = serde_json::from_str::<SourceCodeResponse>(&text) {
if api_response.status == "1" && !api_response.result.is_empty() {
if let Some(name) = &api_response.result[0].contract_name {
if !name.is_empty() {
let symbol = if name.len() <= 6 {
name.to_uppercase()
} else {
name.chars()
.filter(|c| c.is_uppercase())
.take(6)
.collect::<String>()
};
let symbol = if symbol.is_empty() {
name[..name.len().min(6)].to_uppercase()
} else {
symbol
};
return Some((symbol, name.clone()));
}
}
}
}
None
}
pub async fn get_token_holders(
&self,
token_address: &str,
limit: u32,
) -> Result<Vec<TokenHolder>> {
validate_eth_address(token_address)?;
let effective_limit = limit.min(1000);
let url = self.build_api_url(&format!(
"module=token&action=tokenholderlist&contractaddress={}&page=1&offset={}",
token_address, effective_limit
));
tracing::debug!(url = %url, "Fetching token holders");
let response = self.http.send(Request::get(&url)).await?;
let response_text = response.body;
let api_response: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
.map_err(|e| ScopeError::Api(format!("Failed to parse holder response: {}", e)))?;
if api_response.status != "1" {
if api_response.message.contains("Pro")
|| api_response.message.contains("API")
|| api_response.message.contains("NOTOK")
{
crate::notice!(" ⚠ Holder data requires a Pro API key — skipping");
tracing::debug!("Token holder API unavailable: {}", api_response.message);
return Ok(Vec::new());
}
return Err(ScopeError::Api(format!(
"API error: {}",
api_response.message
)));
}
let holders: Vec<TokenHolderItem> = serde_json::from_value(api_response.result)
.map_err(|e| ScopeError::Api(format!("Failed to parse holder list: {}", e)))?;
let total_balance: f64 = holders
.iter()
.filter_map(|h| h.quantity.parse::<f64>().ok())
.sum();
let token_holders: Vec<TokenHolder> = holders
.into_iter()
.enumerate()
.map(|(i, h)| {
let balance: f64 = h.quantity.parse().unwrap_or(0.0);
let percentage = if total_balance > 0.0 {
(balance / total_balance) * 100.0
} else {
0.0
};
TokenHolder {
address: h.address,
balance: h.quantity.clone(),
formatted_balance: crate::display::format_token_balance(&h.quantity, 18), percentage,
rank: (i + 1) as u32,
}
})
.collect();
Ok(token_holders)
}
pub async fn get_token_holder_count(&self, token_address: &str) -> Result<u64> {
let max_page_size: u32 = 1000;
let holders = self.get_token_holders(token_address, max_page_size).await?;
if holders.is_empty() {
return Ok(0);
}
let count = holders.len() as u64;
if count < max_page_size as u64 {
Ok(count)
} else {
let mut total = count;
let mut page = 2u32;
loop {
let url = self.build_api_url(&format!(
"module=token&action=tokenholderlist&contractaddress={}&page={}&offset={}",
token_address, page, max_page_size
));
let response: std::result::Result<ApiResponse<Vec<TokenHolderItem>>, _> =
self.http.send(Request::get(&url)).await?.json();
match response {
Ok(api_resp) if api_resp.status == "1" => {
let page_count = api_resp.result.len() as u64;
total += page_count;
if page_count < max_page_size as u64 || page >= 10 {
break;
}
page += 1;
}
_ => break,
}
}
Ok(total)
}
}
}
impl Default for EthereumClient {
fn default() -> Self {
Self {
http: crate::http::shared::fallback(),
base_url: ETHERSCAN_V2_API.to_string(),
chain_id: Some("1".to_string()),
api_key: None,
chain_name: "ethereum".to_string(),
native_symbol: "ETH".to_string(),
native_decimals: 18,
api_type: ApiType::BlockExplorer,
rpc_fallback_url: None,
}
}
}
fn validate_eth_address(address: &str) -> Result<()> {
if !address.starts_with("0x") {
return Err(ScopeError::InvalidAddress(format!(
"Address must start with '0x': {}",
address
)));
}
if address.len() != 42 {
return Err(ScopeError::InvalidAddress(format!(
"Address must be 42 characters: {}",
address
)));
}
if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ScopeError::InvalidAddress(format!(
"Address contains invalid hex characters: {}",
address
)));
}
Ok(())
}
fn validate_tx_hash(hash: &str) -> Result<()> {
if !hash.starts_with("0x") {
return Err(ScopeError::InvalidHash(format!(
"Hash must start with '0x': {}",
hash
)));
}
if hash.len() != 66 {
return Err(ScopeError::InvalidHash(format!(
"Hash must be 66 characters: {}",
hash
)));
}
if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ScopeError::InvalidHash(format!(
"Hash contains invalid hex characters: {}",
hash
)));
}
Ok(())
}
#[async_trait]
impl ChainClient for EthereumClient {
fn chain_name(&self) -> &str {
&self.chain_name
}
fn native_token_symbol(&self) -> &str {
&self.native_symbol
}
async fn get_balance(&self, address: &str) -> Result<Balance> {
self.get_balance(address).await
}
async fn enrich_balance_usd(&self, balance: &mut Balance) {
self.enrich_balance_usd(balance).await
}
async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
self.get_transaction(hash).await
}
async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
self.get_transactions(address, limit).await
}
async fn get_block_number(&self) -> Result<u64> {
self.get_block_number().await
}
async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
self.get_erc20_balances(address).await
}
async fn get_token_info(&self, address: &str) -> Result<Token> {
self.get_token_info(address).await
}
async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
self.get_token_holders(address, limit).await
}
async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
self.get_token_holder_count(address).await
}
async fn get_code(&self, address: &str) -> Result<String> {
self.get_code(address).await
}
async fn get_storage_at(&self, address: &str, slot: &str) -> Result<String> {
self.get_storage_at(address, slot).await
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_ADDRESS: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
const VALID_TX_HASH: &str =
"0xabc123def456789012345678901234567890123456789012345678901234abcd";
#[test]
fn test_validate_eth_address_valid() {
assert!(validate_eth_address(VALID_ADDRESS).is_ok());
}
#[test]
fn test_validate_eth_address_lowercase() {
let addr = "0x742d35cc6634c0532925a3b844bc9e7595f1b3c2";
assert!(validate_eth_address(addr).is_ok());
}
#[test]
fn test_validate_eth_address_missing_prefix() {
let addr = "742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
let result = validate_eth_address(addr);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("0x"));
}
#[test]
fn test_validate_eth_address_too_short() {
let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3";
let result = validate_eth_address(addr);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("42 characters"));
}
#[test]
fn test_validate_eth_address_invalid_hex() {
let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ";
let result = validate_eth_address(addr);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid hex"));
}
#[test]
fn test_validate_tx_hash_valid() {
assert!(validate_tx_hash(VALID_TX_HASH).is_ok());
}
#[test]
fn test_validate_tx_hash_missing_prefix() {
let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
let result = validate_tx_hash(hash);
assert!(result.is_err());
}
#[test]
fn test_validate_tx_hash_too_short() {
let hash = "0xabc123";
let result = validate_tx_hash(hash);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("66 characters"));
}
#[test]
fn test_validate_eth_address_too_long() {
let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2extra";
let result = validate_eth_address(addr);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("42 characters"));
}
#[test]
fn test_validate_eth_address_empty() {
let result = validate_eth_address("");
assert!(result.is_err());
}
#[test]
fn test_validate_eth_address_only_prefix() {
let result = validate_eth_address("0x");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("42 characters"));
}
#[test]
fn test_validate_tx_hash_too_long() {
let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcdextra";
let result = validate_tx_hash(hash);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("66 characters"));
}
#[test]
fn test_validate_tx_hash_empty() {
let result = validate_tx_hash("");
assert!(result.is_err());
}
#[test]
fn test_validate_tx_hash_only_prefix() {
let result = validate_tx_hash("0x");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("66 characters"));
}
#[test]
fn test_ethereum_client_default() {
let client = EthereumClient::default();
assert_eq!(client.chain_name(), "ethereum");
assert_eq!(client.native_token_symbol(), "ETH");
}
#[test]
fn test_ethereum_client_with_base_url() {
let client = EthereumClient::with_base_url("https://custom.api.com");
assert_eq!(client.base_url, "https://custom.api.com");
}
#[test]
fn test_ethereum_client_for_chain_ethereum() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("ethereum", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "ethereum");
assert_eq!(client.native_token_symbol(), "ETH");
}
#[test]
fn test_ethereum_client_for_chain_polygon() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("polygon", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "polygon");
assert_eq!(client.native_token_symbol(), "MATIC");
assert!(client.base_url.contains("etherscan.io/v2"));
assert_eq!(client.chain_id, Some("137".to_string()));
}
#[test]
fn test_ethereum_client_for_chain_arbitrum() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("arbitrum", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "arbitrum");
assert!(client.base_url.contains("etherscan.io/v2"));
assert_eq!(client.chain_id, Some("42161".to_string()));
}
#[test]
fn test_ethereum_client_for_chain_bsc() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("bsc", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "bsc");
assert_eq!(client.native_token_symbol(), "BNB");
assert!(client.base_url.contains("etherscan.io/v2"));
assert_eq!(client.chain_id, Some("56".to_string()));
assert_eq!(client.api_type, ApiType::BlockExplorer);
}
#[test]
fn test_ethereum_client_for_chain_aegis() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("aegis", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "aegis");
assert_eq!(client.native_token_symbol(), "WRAITH");
assert_eq!(client.api_type, ApiType::JsonRpc);
assert!(client.base_url.contains("localhost:8545"));
}
#[test]
fn test_ethereum_client_for_chain_aegis_with_config() {
let config = ChainsConfig {
aegis_rpc: Some("https://aegis.example.com:8545".to_string()),
..Default::default()
};
let client =
EthereumClient::for_chain_with_http("aegis", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.base_url, "https://aegis.example.com:8545");
}
#[test]
fn test_ethereum_client_for_chain_unsupported() {
let config = ChainsConfig::default();
let result =
EthereumClient::for_chain_with_http("bitcoin", &config, crate::http::test_transport());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported chain")
);
}
#[test]
fn test_ethereum_client_new() {
let config = ChainsConfig::default();
let client = EthereumClient::new_with_http(&config, crate::http::test_transport());
assert!(client.is_ok());
}
#[test]
fn test_ethereum_client_with_api_key() {
use std::collections::HashMap;
let mut api_keys = HashMap::new();
api_keys.insert("etherscan".to_string(), "test-key".to_string());
let config = ChainsConfig {
api_keys,
..Default::default()
};
let client = EthereumClient::new_with_http(&config, crate::http::test_transport()).unwrap();
assert_eq!(client.api_key, Some("test-key".to_string()));
}
#[test]
fn test_api_response_deserialization() {
let json = r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#;
let response: ApiResponse<String> = serde_json::from_str(json).unwrap();
assert_eq!(response.status, "1");
assert_eq!(response.message, "OK");
assert_eq!(response.result, "1000000000000000000");
}
#[test]
fn test_tx_list_item_deserialization() {
let json = r#"{
"hash": "0xabc",
"blockNumber": "12345",
"timeStamp": "1700000000",
"from": "0xfrom",
"to": "0xto",
"value": "1000000000000000000",
"gas": "21000",
"gasUsed": "21000",
"gasPrice": "20000000000",
"nonce": "42",
"input": "0x",
"isError": "0"
}"#;
let item: TxListItem = serde_json::from_str(json).unwrap();
assert_eq!(item.hash, "0xabc");
assert_eq!(item.block_number, "12345");
assert_eq!(item.nonce, "42");
assert_eq!(item.is_error, "0");
}
#[test]
fn test_parse_balance_wei_valid() {
let client = EthereumClient::default();
let balance = client.parse_balance_wei("1000000000000000000").unwrap();
assert_eq!(balance.symbol, "ETH");
assert_eq!(balance.raw, "1000000000000000000");
assert!(balance.formatted.contains("1.000000"));
assert!(balance.usd_value.is_none());
}
#[test]
fn test_parse_balance_wei_zero() {
let client = EthereumClient::default();
let balance = client.parse_balance_wei("0").unwrap();
assert!(balance.formatted.contains("0.000000"));
}
#[test]
fn test_parse_balance_wei_invalid() {
let client = EthereumClient::default();
let result = client.parse_balance_wei("not_a_number");
assert!(result.is_err());
}
#[test]
fn test_format_token_balance_large() {
assert!(
crate::display::format_token_balance("1000000000000000000000000000", 18).contains("B")
);
}
#[test]
fn test_format_token_balance_millions() {
assert!(
crate::display::format_token_balance("5000000000000000000000000", 18).contains("M")
);
}
#[test]
fn test_format_token_balance_thousands() {
assert!(crate::display::format_token_balance("5000000000000000000000", 18).contains("K"));
}
#[test]
fn test_format_token_balance_small() {
let formatted = crate::display::format_token_balance("500000000000000000", 18);
assert!(formatted.contains("0.5"));
}
#[test]
fn test_format_token_balance_zero() {
let formatted = crate::display::format_token_balance("0", 18);
assert!(formatted.contains("0.0000"));
}
#[test]
fn test_build_api_url_with_chain_id_and_key() {
use std::collections::HashMap;
let mut keys = HashMap::new();
keys.insert("etherscan".to_string(), "MYKEY".to_string());
let config = ChainsConfig {
api_keys: keys,
..Default::default()
};
let client = EthereumClient::new_with_http(&config, crate::http::test_transport()).unwrap();
let url = client.build_api_url("module=account&action=balance&address=0x123");
assert!(url.contains("chainid=1"));
assert!(url.contains("module=account"));
assert!(url.contains("apikey=MYKEY"));
}
#[test]
fn test_build_api_url_no_chain_id_no_key() {
let client = EthereumClient::with_base_url("https://example.com/api");
let url = client.build_api_url("module=account&action=balance");
assert_eq!(url, "https://example.com/api?module=account&action=balance");
assert!(!url.contains("chainid"));
assert!(!url.contains("apikey"));
}
#[tokio::test]
async fn test_get_balance_explorer() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"2500000000000000000"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.raw, "2500000000000000000");
assert_eq!(balance.symbol, "ETH");
assert!(balance.formatted.contains("2.5"));
}
#[tokio::test]
async fn test_get_balance_explorer_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("API error"));
}
#[tokio::test]
async fn test_get_balance_explorer_api_error_result_empty() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"Invalid API Key","result":""}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("API error"));
assert!(err.contains("Invalid API Key"));
}
#[tokio::test]
async fn test_get_balance_explorer_api_error_result_hex() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"Rate limit","result":"0x"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("API error"));
}
#[test]
fn test_ethereum_client_for_chain_optimism() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("optimism", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "optimism");
assert_eq!(client.native_token_symbol(), "ETH");
assert_eq!(client.chain_id, Some("10".to_string()));
}
#[test]
fn test_ethereum_client_for_chain_base() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("base", &config, crate::http::test_transport())
.unwrap();
assert_eq!(client.chain_name(), "base");
assert_eq!(client.native_token_symbol(), "ETH");
assert_eq!(client.chain_id, Some("8453".to_string()));
}
#[test]
fn test_parse_balance_wei_bsc_client() {
let config = ChainsConfig::default();
let client =
EthereumClient::for_chain_with_http("bsc", &config, crate::http::test_transport())
.unwrap();
let balance = client.parse_balance_wei("1000000000000000000").unwrap();
assert_eq!(balance.symbol, "BNB");
}
#[tokio::test]
async fn test_get_balance_bsc_rpc_fallback_on_free_tier_restriction() {
let mut server = mockito::Server::new_async().await;
let _explorer_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Free API access is not supported for this chain. Please upgrade your api plan for full chain coverage. https://etherscan.io/apis"}"#)
.create_async()
.await;
let _rpc_mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
.create_async()
.await;
let client =
EthereumClient::with_base_url_and_rpc_fallback(&server.url(), Some(server.url()));
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "BNB");
assert!(balance.formatted.contains("1.000000"));
}
#[tokio::test]
async fn test_get_balance_invalid_address() {
let client = EthereumClient::default();
let result = client.get_balance("invalid").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_balance_rpc() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xDE0B6B3A7640000"}"#)
.create_async()
.await;
let client = EthereumClient {
http: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
base_url: server.url(),
chain_id: None,
api_key: None,
chain_name: "aegis".to_string(),
native_symbol: "WRAITH".to_string(),
native_decimals: 18,
api_type: ApiType::JsonRpc,
rpc_fallback_url: None,
};
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "WRAITH");
assert!(balance.formatted.contains("1.000000"));
}
#[tokio::test]
async fn test_get_balance_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"execution reverted"}}"#)
.create_async()
.await;
let client = EthereumClient {
http: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
base_url: server.url(),
chain_id: None,
api_key: None,
chain_name: "aegis".to_string(),
native_symbol: "WRAITH".to_string(),
native_decimals: 18,
api_type: ApiType::JsonRpc,
rpc_fallback_url: None,
};
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("RPC error"));
}
#[tokio::test]
async fn test_get_balance_rpc_empty_result() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1}"#)
.create_async()
.await;
let client = EthereumClient {
http: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
base_url: server.url(),
chain_id: None,
api_key: None,
chain_name: "aegis".to_string(),
native_symbol: "WRAITH".to_string(),
native_decimals: 18,
api_type: ApiType::JsonRpc,
rpc_fallback_url: None,
};
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Empty RPC response")
);
}
#[tokio::test]
async fn test_get_transaction_explorer() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0x1111111111111111111111111111111111111111",
"blockNumber":"0x10",
"gas":"0x5208",
"gasPrice":"0x4A817C800",
"nonce":"0x2A",
"value":"0xDE0B6B3A7640000",
"input":"0x"
}}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _receipt_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"gasUsed":"0x5208",
"status":"0x1"
}}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _block_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"timestamp":"0x65A8C580"
}}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.hash, VALID_TX_HASH);
assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
assert_eq!(
tx.to,
Some("0x1111111111111111111111111111111111111111".to_string())
);
assert!(tx.gas_limit > 0);
assert!(tx.nonce > 0);
}
#[tokio::test]
async fn test_get_transaction_explorer_not_found() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_transaction(VALID_TX_HASH).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_get_transaction_rpc() {
let mut server = mockito::Server::new_async().await;
let _tx_mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0x1111111111111111111111111111111111111111",
"blockNumber":"0x10",
"gas":"0x5208",
"gasPrice":"0x4A817C800",
"nonce":"0x2A",
"value":"0xDE0B6B3A7640000",
"input":"0x"
}}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _receipt_mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":2,"result":{
"gasUsed":"0x5208",
"status":"0x1"
}}"#,
)
.create_async()
.await;
let client = EthereumClient {
http: Arc::new(crate::http::NativeHttpClient::new().unwrap()),
base_url: server.url(),
chain_id: None,
api_key: None,
chain_name: "aegis".to_string(),
native_symbol: "WRAITH".to_string(),
native_decimals: 18,
api_type: ApiType::JsonRpc,
rpc_fallback_url: None,
};
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.from, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
assert!(tx.status.unwrap());
assert!(tx.timestamp.is_none()); }
#[tokio::test]
async fn test_get_transaction_invalid_hash() {
let client = EthereumClient::default();
let result = client.get_transaction("0xbad").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transactions() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[
{
"hash":"0xabc",
"blockNumber":"12345",
"timeStamp":"1700000000",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0x1111111111111111111111111111111111111111",
"value":"1000000000000000000",
"gas":"21000",
"gasUsed":"21000",
"gasPrice":"20000000000",
"nonce":"1",
"input":"0x",
"isError":"0"
},
{
"hash":"0xdef",
"blockNumber":"12346",
"timeStamp":"1700000060",
"from":"0x1111111111111111111111111111111111111111",
"to":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"value":"500000000000000000",
"gas":"21000",
"gasUsed":"21000",
"gasPrice":"20000000000",
"nonce":"5",
"input":"0x",
"isError":"1"
}
]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 2);
assert_eq!(txs[0].hash, "0xabc");
assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[1].nonce, 5);
}
#[tokio::test]
async fn test_get_transactions_no_transactions() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert!(txs.is_empty());
}
#[tokio::test]
async fn test_get_transactions_empty_to_field() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"hash":"0xabc",
"blockNumber":"12345",
"timeStamp":"1700000000",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"",
"value":"0",
"gas":"200000",
"gasUsed":"150000",
"gasPrice":"20000000000",
"nonce":"1",
"input":"0x60806040",
"isError":"0"
}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 1);
assert!(txs[0].to.is_none()); }
#[tokio::test]
async fn test_get_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"result":"0x1234AB"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let block = client.get_block_number().await.unwrap();
assert_eq!(block, 0x1234AB);
}
#[tokio::test]
async fn test_get_erc20_balances() {
let mut server = mockito::Server::new_async().await;
let _tokentx_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[
{
"contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"tokenSymbol":"USDT",
"tokenName":"Tether USD",
"tokenDecimal":"6"
},
{
"contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"tokenSymbol":"USDT",
"tokenName":"Tether USD",
"tokenDecimal":"6"
},
{
"contractAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"tokenSymbol":"USDC",
"tokenName":"USD Coin",
"tokenDecimal":"6"
}
]}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _balance_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
assert!(balances.len() <= 2);
if !balances.is_empty() {
assert!(!balances[0].balance.is_empty());
}
}
#[tokio::test]
async fn test_get_erc20_balances_empty() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"No transactions found","result":[]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
assert!(balances.is_empty());
}
#[tokio::test]
async fn test_get_token_info_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"tokenName":"Tether USD",
"symbol":"USDT",
"divisor":"1000000",
"tokenType":"ERC20",
"totalSupply":"1000000000000"
}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
assert_eq!(token.symbol, "USDT");
assert_eq!(token.name, "Tether USD");
assert_eq!(token.decimals, 6); }
#[tokio::test]
async fn test_get_token_info_fallback_to_supply() {
let mut server = mockito::Server::new_async().await;
let _info_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
.expect_at_most(1)
.create_async()
.await;
let _supply_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
.expect_at_most(1)
.create_async()
.await;
let _source_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherToken"}]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
assert!(!token.symbol.is_empty());
}
#[tokio::test]
async fn test_get_token_info_unknown() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
assert!(!token.symbol.is_empty());
}
#[tokio::test]
async fn test_try_get_contract_name() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"USDT"}]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.try_get_contract_name(VALID_ADDRESS).await;
assert!(result.is_some());
let (symbol, name) = result.unwrap();
assert_eq!(name, "USDT");
assert_eq!(symbol, "USDT"); }
#[tokio::test]
async fn test_try_get_contract_name_long_name() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{"ContractName":"TetherUSDToken"}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.try_get_contract_name(VALID_ADDRESS).await;
assert!(result.is_some());
let (symbol, name) = result.unwrap();
assert_eq!(name, "TetherUSDToken");
assert_eq!(symbol, "TUSDT"); }
#[tokio::test]
async fn test_try_get_contract_name_not_verified() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.try_get_contract_name(VALID_ADDRESS).await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_get_token_holders() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[
{"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000000000000000000000"},
{"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000000000000000000000"},
{"TokenHolderAddress":"0x3333333333333333333333333333333333333333","TokenHolderQuantity":"2000000000000000000000"}
]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(holders.len(), 3);
assert_eq!(
holders[0].address,
"0x1111111111111111111111111111111111111111"
);
assert_eq!(holders[0].rank, 1);
assert_eq!(holders[2].rank, 3);
assert!((holders[0].percentage - 50.0).abs() < 0.01);
}
#[tokio::test]
async fn test_get_token_holders_pro_required() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"0","message":"This endpoint requires a Pro API key","result":[]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let holders = client.get_token_holders(VALID_ADDRESS, 10).await.unwrap();
assert!(holders.is_empty()); }
#[tokio::test]
async fn test_get_token_holder_count_small() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[
{"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"1000"},
{"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"500"}
]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
assert_eq!(count, 2);
}
#[tokio::test]
async fn test_get_token_holder_count_empty() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"0","message":"NOTOK - Missing or invalid API Pro key","result":[]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let count = client.get_token_holder_count(VALID_ADDRESS).await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_get_block_timestamp() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x65A8C580"}}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let ts = client.get_block_timestamp(16).await.unwrap();
assert_eq!(ts, 0x65A8C580);
}
#[tokio::test]
async fn test_get_block_timestamp_not_found() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":null}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_block_timestamp(99999999).await;
assert!(result.is_err());
}
#[test]
fn test_chain_name_and_symbol_accessors() {
let client = EthereumClient::with_base_url("http://test");
assert_eq!(client.chain_name(), "ethereum");
assert_eq!(client.native_token_symbol(), "ETH");
}
#[test]
fn test_validate_tx_hash_invalid_hex() {
let hash = "0xZZZZ23def456789012345678901234567890123456789012345678901234abcd";
let result = validate_tx_hash(hash);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid hex"));
}
#[tokio::test]
async fn test_get_transactions_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.get_transactions(VALID_ADDRESS, 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_holders_pro_key_required() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"Pro API key required","result":[]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let holders = client
.get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
.await
.unwrap();
assert!(holders.is_empty());
}
#[tokio::test]
async fn test_get_token_holders_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"Some other error","result":[]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client
.get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_holders_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[
{"TokenHolderAddress":"0xHolder1","TokenHolderQuantity":"1000000000000000000"},
{"TokenHolderAddress":"0xHolder2","TokenHolderQuantity":"500000000000000000"}
]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let holders = client
.get_token_holders("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 10)
.await
.unwrap();
assert_eq!(holders.len(), 2);
assert_eq!(holders[0].rank, 1);
assert_eq!(holders[1].rank, 2);
assert!(holders[0].percentage > 0.0);
}
#[tokio::test]
async fn test_get_token_info_unknown_token() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"No data found","result":[]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let token = client
.get_token_info("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
.await
.unwrap();
assert_eq!(token.symbol, "UNKNOWN");
}
#[tokio::test]
async fn test_get_transaction_with_null_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"value":"0xde0b6b3a7640000",
"gas":"0x5208",
"gasPrice":"0x3b9aca00",
"nonce":"0x5",
"input":"0x",
"blockNumber":null
}}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert!(tx.timestamp.is_none());
}
#[tokio::test]
async fn test_chain_client_trait_balance() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"1000000000000000000"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "ETH");
}
#[tokio::test]
async fn test_chain_client_trait_get_transaction() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","id":1,"result":{
"hash":"0xabc123def456789012345678901234567890123456789012345678901234abcd",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0xB0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"value":"0xde0b6b3a7640000",
"gas":"0x5208",
"gasPrice":"0x3b9aca00",
"nonce":"0x5",
"input":"0x",
"blockNumber":"0xf4240"
}}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
assert!(!tx.hash.is_empty());
}
#[tokio::test]
async fn test_chain_client_trait_get_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1,"result":"0xf4240"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let block = chain_client.get_block_number().await.unwrap();
assert_eq!(block, 1000000);
}
#[tokio::test]
async fn test_chain_client_trait_get_transactions() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"hash":"0xabc",
"blockNumber":"12345",
"timeStamp":"1700000000",
"from":"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
"to":"0x1111111111111111111111111111111111111111",
"value":"1000000000000000000",
"gas":"21000","gasUsed":"21000","gasPrice":"20000000000",
"nonce":"1","input":"0x","isError":"0"
}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let txs = chain_client
.get_transactions(VALID_ADDRESS, 10)
.await
.unwrap();
assert_eq!(txs.len(), 1);
}
#[tokio::test]
async fn test_chain_client_trait_get_token_balances() {
let mut server = mockito::Server::new_async().await;
let _tokentx = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
}]}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _balance = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"5000000000"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balances = chain_client
.get_token_balances(VALID_ADDRESS)
.await
.unwrap();
assert!(!balances.is_empty());
}
#[tokio::test]
async fn test_chain_client_trait_get_token_info() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"tokenName":"Tether USD","symbol":"USDT",
"divisor":"1000000","tokenType":"ERC20","totalSupply":"1000000000000"
}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let token = chain_client.get_token_info(VALID_ADDRESS).await.unwrap();
assert_eq!(token.symbol, "USDT");
}
#[tokio::test]
async fn test_chain_client_trait_get_token_holders() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[
{"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"}
]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let holders = chain_client
.get_token_holders(VALID_ADDRESS, 10)
.await
.unwrap();
assert_eq!(holders.len(), 1);
}
#[tokio::test]
async fn test_chain_client_trait_get_token_holder_count() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[
{"TokenHolderAddress":"0x1111111111111111111111111111111111111111","TokenHolderQuantity":"5000"},
{"TokenHolderAddress":"0x2222222222222222222222222222222222222222","TokenHolderQuantity":"3000"}
]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let count = chain_client
.get_token_holder_count(VALID_ADDRESS)
.await
.unwrap();
assert_eq!(count, 2);
}
#[tokio::test]
async fn test_chain_client_trait_native_token_symbol() {
let client = EthereumClient::with_base_url("http://test");
let chain_client: &dyn ChainClient = &client;
assert_eq!(chain_client.native_token_symbol(), "ETH");
assert_eq!(chain_client.chain_name(), "ethereum");
}
#[tokio::test]
async fn test_get_token_info_supply_fallback_no_contract() {
let mut server = mockito::Server::new_async().await;
let _info_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
.expect_at_most(1)
.create_async()
.await;
let _supply_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"1000000000000"}"#)
.expect_at_most(1)
.create_async()
.await;
let _source_mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":""}]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let token = client.get_token_info(VALID_ADDRESS).await.unwrap();
assert!(token.symbol.contains("...") || !token.symbol.is_empty());
}
#[tokio::test]
async fn test_try_get_contract_name_short_lowercase() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":[{"ContractName":"token"}]}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.try_get_contract_name(VALID_ADDRESS).await;
assert!(result.is_some());
let (symbol, name) = result.unwrap();
assert_eq!(name, "token");
assert_eq!(symbol, "TOKEN");
}
#[tokio::test]
async fn test_try_get_contract_name_long_lowercase() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{"ContractName":"mytokencontract"}]}"#,
)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let result = client.try_get_contract_name(VALID_ADDRESS).await;
assert!(result.is_some());
let (symbol, name) = result.unwrap();
assert_eq!(name, "mytokencontract");
assert_eq!(symbol, "MYTOKE");
}
#[tokio::test]
async fn test_get_erc20_balances_zero_balance_skipped() {
let mut server = mockito::Server::new_async().await;
let _tokentx = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"status":"1","message":"OK","result":[{
"contractAddress":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"tokenSymbol":"USDT","tokenName":"Tether USD","tokenDecimal":"6"
}]}"#,
)
.expect_at_most(1)
.create_async()
.await;
let _balance = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"1","message":"OK","result":"0"}"#)
.create_async()
.await;
let client = EthereumClient::with_base_url(&server.url());
let balances = client.get_erc20_balances(VALID_ADDRESS).await.unwrap();
assert!(balances.is_empty());
}
}