pub mod dex;
pub mod ethereum;
pub mod registry;
pub mod solana;
pub mod tron;
pub use dex::{DexClient, DexDataSource, DiscoverToken, TokenSearchResult};
pub use ethereum::{ApiType, EthereumClient};
pub use solana::{SolanaClient, validate_solana_address, validate_solana_signature};
pub use tron::{TronClient, validate_tron_address, validate_tron_tx_hash};
use crate::error::Result;
use crate::http::HttpClient;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[async_trait]
pub trait ChainClient: Send + Sync {
fn chain_name(&self) -> &str;
fn native_token_symbol(&self) -> &str;
async fn get_balance(&self, address: &str) -> Result<Balance>;
async fn enrich_balance_usd(&self, balance: &mut Balance);
async fn get_transaction(&self, hash: &str) -> Result<Transaction>;
async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>>;
async fn get_block_number(&self) -> Result<u64>;
async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>>;
async fn get_token_info(&self, _address: &str) -> Result<Token> {
Err(crate::error::ScopeError::Chain(
"Token info lookup not supported on this chain".to_string(),
))
}
async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
Ok(Vec::new())
}
async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
Ok(0)
}
async fn get_code(&self, _address: &str) -> Result<String> {
Err(crate::error::ScopeError::Chain(
"Code lookup not supported on this chain".to_string(),
))
}
async fn get_storage_at(&self, _address: &str, _slot: &str) -> Result<String> {
Err(crate::error::ScopeError::Chain(
"Storage lookup not supported on this chain".to_string(),
))
}
}
pub trait ChainClientFactory: Send + Sync {
fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>>;
fn create_dex_client(&self) -> Box<dyn DexDataSource>;
fn http(&self) -> Arc<dyn HttpClient>;
}
pub struct DefaultClientFactory {
pub chains_config: crate::config::ChainsConfig,
pub http: Arc<dyn HttpClient>,
}
impl ChainClientFactory for DefaultClientFactory {
fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
match chain.to_lowercase().as_str() {
"solana" | "sol" => Ok(Box::new(SolanaClient::new_with_http(
&self.chains_config,
self.http.clone(),
)?)),
"tron" | "trx" => Ok(Box::new(TronClient::new_with_http(
&self.chains_config,
self.http.clone(),
)?)),
_ => Ok(Box::new(EthereumClient::for_chain_with_http(
chain,
&self.chains_config,
self.http.clone(),
)?)),
}
}
fn create_dex_client(&self) -> Box<dyn DexDataSource> {
Box::new(DexClient::new_with_http(self.http.clone()))
}
fn http(&self) -> Arc<dyn HttpClient> {
self.http.clone()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Balance {
pub raw: String,
pub formatted: String,
pub decimals: u8,
pub symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub usd_value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
pub hash: String,
pub block_number: Option<u64>,
pub timestamp: Option<u64>,
pub from: String,
pub to: Option<String>,
pub value: String,
pub gas_limit: u64,
pub gas_used: Option<u64>,
pub gas_price: String,
pub nonce: u64,
pub input: String,
pub status: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
pub contract_address: String,
pub symbol: String,
pub name: String,
pub decimals: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenBalance {
pub token: Token,
pub balance: String,
pub formatted_balance: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub usd_value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenHolder {
pub address: String,
pub balance: String,
pub formatted_balance: String,
pub percentage: f64,
pub rank: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricePoint {
pub timestamp: i64,
pub price: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumePoint {
pub timestamp: i64,
pub volume: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HolderCountPoint {
pub timestamp: i64,
pub count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DexPair {
pub dex_name: String,
pub pair_address: String,
pub base_token: String,
pub quote_token: String,
pub price_usd: f64,
pub volume_24h: f64,
pub liquidity_usd: f64,
#[serde(default)]
pub liquidity_base: Option<f64>,
pub price_change_24h: f64,
pub buys_24h: u64,
pub sells_24h: u64,
pub buys_6h: u64,
pub sells_6h: u64,
pub buys_1h: u64,
pub sells_1h: u64,
pub pair_created_at: Option<i64>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenAnalytics {
pub token: Token,
pub chain: String,
pub holders: Vec<TokenHolder>,
pub total_holders: u64,
pub volume_24h: f64,
pub volume_7d: f64,
pub price_usd: f64,
pub price_change_24h: f64,
pub price_change_7d: f64,
pub liquidity_usd: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub market_cap: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fdv: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_supply: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub circulating_supply: Option<String>,
pub price_history: Vec<PricePoint>,
pub volume_history: Vec<VolumePoint>,
pub holder_history: Vec<HolderCountPoint>,
pub dex_pairs: Vec<DexPair>,
pub fetched_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_10_concentration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_50_concentration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_100_concentration: Option<f64>,
pub price_change_6h: f64,
pub price_change_1h: f64,
pub total_buys_24h: u64,
pub total_sells_24h: u64,
pub total_buys_6h: u64,
pub total_sells_6h: u64,
pub total_buys_1h: u64,
pub total_sells_1h: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_age_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
pub websites: Vec<String>,
pub socials: Vec<TokenSocial>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dexscreener_url: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TokenSocial {
pub platform: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NftMetadata {
pub token_id: String,
pub name: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub token_uri: Option<String>,
pub standard: String,
pub collection_name: Option<String>,
pub attributes: Vec<NftAttribute>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NftAttribute {
pub trait_type: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GasAnalysis {
pub avg_gas_used: u64,
pub max_gas_used: u64,
pub min_gas_used: u64,
pub total_gas_cost_wei: String,
pub total_gas_cost_formatted: String,
pub tx_count: u64,
pub gas_by_function: Vec<GasByFunction>,
pub failed_tx_count: u64,
pub wasted_gas: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GasByFunction {
pub function: String,
pub call_count: u64,
pub avg_gas: u64,
pub total_gas: u64,
}
pub const DEFAULT_CHAIN: &str = "ethereum";
#[derive(Debug, Clone)]
pub struct ChainMetadata {
pub chain_id: &'static str,
pub native_symbol: &'static str,
pub native_decimals: u8,
pub explorer_token_base: &'static str,
}
pub fn chain_metadata(chain: &str) -> Option<ChainMetadata> {
let c = registry::chain(chain)?;
Some(ChainMetadata {
chain_id: c.id,
native_symbol: c.native_symbol,
native_decimals: c.native_decimals,
explorer_token_base: c.explorer_token_base?,
})
}
pub fn native_symbol(chain: &str) -> &'static str {
registry::chain(chain).map_or("???", |c| c.native_symbol)
}
pub fn infer_chain_from_address(address: &str) -> Option<&'static str> {
if address.starts_with('T') && address.len() == 34 && bs58::decode(address).into_vec().is_ok() {
return Some("tron");
}
if address.starts_with("0x")
&& address.len() == 42
&& address[2..].chars().all(|c| c.is_ascii_hexdigit())
{
return Some("ethereum");
}
if address.len() >= 32
&& address.len() <= 44
&& let Ok(decoded) = bs58::decode(address).into_vec()
&& decoded.len() == 32
{
return Some("solana");
}
None
}
pub fn infer_chain_from_hash(hash: &str) -> Option<&'static str> {
if hash.starts_with("0x")
&& hash.len() == 66
&& hash[2..].chars().all(|c| c.is_ascii_hexdigit())
{
return Some("ethereum");
}
if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Some("tron");
}
if hash.len() >= 80
&& hash.len() <= 90
&& let Ok(decoded) = bs58::decode(hash).into_vec()
&& decoded.len() == 64
{
return Some("solana");
}
None
}
pub fn analyze_gas_usage(transactions: &[Transaction]) -> GasAnalysis {
if transactions.is_empty() {
return GasAnalysis {
avg_gas_used: 0,
max_gas_used: 0,
min_gas_used: 0,
total_gas_cost_wei: "0".to_string(),
total_gas_cost_formatted: "0".to_string(),
tx_count: 0,
gas_by_function: vec![],
failed_tx_count: 0,
wasted_gas: 0,
};
}
let mut total_gas: u64 = 0;
let mut max_gas: u64 = 0;
let mut min_gas: u64 = u64::MAX;
let mut failed_count: u64 = 0;
let mut wasted_gas: u64 = 0;
let mut function_gas: std::collections::HashMap<String, (u64, u64)> =
std::collections::HashMap::new();
for tx in transactions {
let gas_used = tx.gas_used.unwrap_or(0);
total_gas += gas_used;
if gas_used > max_gas {
max_gas = gas_used;
}
if gas_used < min_gas {
min_gas = gas_used;
}
if tx.status == Some(false) {
failed_count += 1;
wasted_gas += gas_used;
}
let selector = if tx.input.len() >= 10 {
tx.input[..10].to_string()
} else if tx.input.is_empty() || tx.input == "0x" {
"transfer()".to_string()
} else {
tx.input.clone()
};
let entry = function_gas.entry(selector).or_insert((0, 0));
entry.0 += 1; entry.1 += gas_used; }
let tx_count = transactions.len() as u64;
let avg_gas = total_gas.checked_div(tx_count).unwrap_or(0);
if min_gas == u64::MAX {
min_gas = 0;
}
let mut gas_by_function: Vec<GasByFunction> = function_gas
.into_iter()
.map(|(function, (call_count, total_gas_fn))| GasByFunction {
function,
call_count,
avg_gas: total_gas_fn.checked_div(call_count).unwrap_or(0),
total_gas: total_gas_fn,
})
.collect();
gas_by_function.sort_by_key(|g| std::cmp::Reverse(g.total_gas));
let total_gas_cost_formatted = format!("{} gas units", total_gas);
GasAnalysis {
avg_gas_used: avg_gas,
max_gas_used: max_gas,
min_gas_used: min_gas,
total_gas_cost_wei: total_gas.to_string(),
total_gas_cost_formatted,
tx_count,
gas_by_function,
failed_tx_count: failed_count,
wasted_gas,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_balance_serialization() {
let balance = Balance {
raw: "1000000000000000000".to_string(),
formatted: "1.0".to_string(),
decimals: 18,
symbol: "ETH".to_string(),
usd_value: Some(3500.0),
};
let json = serde_json::to_string(&balance).unwrap();
assert!(json.contains("1000000000000000000"));
assert!(json.contains("1.0"));
assert!(json.contains("ETH"));
assert!(json.contains("3500"));
let deserialized: Balance = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.raw, balance.raw);
assert_eq!(deserialized.decimals, 18);
}
#[test]
fn test_balance_without_usd() {
let balance = Balance {
raw: "1000000000000000000".to_string(),
formatted: "1.0".to_string(),
decimals: 18,
symbol: "ETH".to_string(),
usd_value: None,
};
let json = serde_json::to_string(&balance).unwrap();
assert!(!json.contains("usd_value"));
}
#[test]
fn test_transaction_serialization() {
let tx = Transaction {
hash: "0xabc123".to_string(),
block_number: Some(12345678),
timestamp: Some(1700000000),
from: "0xfrom".to_string(),
to: Some("0xto".to_string()),
value: "1.0".to_string(),
gas_limit: 21000,
gas_used: Some(21000),
gas_price: "20000000000".to_string(),
nonce: 42,
input: "0x".to_string(),
status: Some(true),
};
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("0xabc123"));
assert!(json.contains("12345678"));
assert!(json.contains("0xfrom"));
assert!(json.contains("0xto"));
let deserialized: Transaction = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.hash, tx.hash);
assert_eq!(deserialized.nonce, 42);
}
#[test]
fn test_pending_transaction_serialization() {
let tx = Transaction {
hash: "0xpending".to_string(),
block_number: None,
timestamp: None,
from: "0xfrom".to_string(),
to: Some("0xto".to_string()),
value: "1.0".to_string(),
gas_limit: 21000,
gas_used: None,
gas_price: "20000000000".to_string(),
nonce: 0,
input: "0x".to_string(),
status: None,
};
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("0xpending"));
assert!(json.contains("null"));
let deserialized: Transaction = serde_json::from_str(&json).unwrap();
assert!(deserialized.block_number.is_none());
assert!(deserialized.status.is_none());
}
#[test]
fn test_contract_creation_transaction() {
let tx = Transaction {
hash: "0xcreate".to_string(),
block_number: Some(100),
timestamp: Some(1700000000),
from: "0xdeployer".to_string(),
to: None, value: "0".to_string(),
gas_limit: 1000000,
gas_used: Some(500000),
gas_price: "20000000000".to_string(),
nonce: 0,
input: "0x608060...".to_string(),
status: Some(true),
};
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("\"to\":null"));
}
#[test]
fn test_token_serialization() {
let token = Token {
contract_address: "0xtoken".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
decimals: 6,
};
let json = serde_json::to_string(&token).unwrap();
assert!(json.contains("USDC"));
assert!(json.contains("USD Coin"));
assert!(json.contains("\"decimals\":6"));
let deserialized: Token = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.decimals, 6);
}
#[test]
fn test_token_balance_serialization() {
let token_balance = TokenBalance {
token: Token {
contract_address: "0xtoken".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
decimals: 6,
},
balance: "1000000".to_string(),
formatted_balance: "1.0".to_string(),
usd_value: Some(1.0),
};
let json = serde_json::to_string(&token_balance).unwrap();
assert!(json.contains("USDC"));
assert!(json.contains("1000000"));
assert!(json.contains("1.0"));
}
#[test]
fn test_balance_debug() {
let balance = Balance {
raw: "1000".to_string(),
formatted: "0.001".to_string(),
decimals: 18,
symbol: "ETH".to_string(),
usd_value: None,
};
let debug_str = format!("{:?}", balance);
assert!(debug_str.contains("Balance"));
assert!(debug_str.contains("1000"));
}
#[test]
fn test_transaction_debug() {
let tx = Transaction {
hash: "0xtest".to_string(),
block_number: Some(1),
timestamp: Some(0),
from: "0x1".to_string(),
to: Some("0x2".to_string()),
value: "0".to_string(),
gas_limit: 21000,
gas_used: Some(21000),
gas_price: "0".to_string(),
nonce: 0,
input: "0x".to_string(),
status: Some(true),
};
let debug_str = format!("{:?}", tx);
assert!(debug_str.contains("Transaction"));
assert!(debug_str.contains("0xtest"));
}
#[test]
fn test_infer_chain_from_address_evm() {
assert_eq!(
super::infer_chain_from_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"),
Some("ethereum")
);
assert_eq!(
super::infer_chain_from_address("0x0000000000000000000000000000000000000000"),
Some("ethereum")
);
assert_eq!(
super::infer_chain_from_address("0xABCDEF1234567890abcdef1234567890ABCDEF12"),
Some("ethereum")
);
}
#[test]
fn test_infer_chain_from_address_tron() {
assert_eq!(
super::infer_chain_from_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
Some("tron")
);
assert_eq!(
super::infer_chain_from_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
Some("tron")
);
}
#[test]
fn test_infer_chain_from_address_solana() {
assert_eq!(
super::infer_chain_from_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"),
Some("solana")
);
assert_eq!(
super::infer_chain_from_address("11111111111111111111111111111111"),
Some("solana")
);
}
#[test]
fn test_infer_chain_from_address_invalid() {
assert_eq!(super::infer_chain_from_address("0x123"), None);
assert_eq!(super::infer_chain_from_address("not_an_address"), None);
assert_eq!(super::infer_chain_from_address(""), None);
assert_eq!(super::infer_chain_from_address("0x123456"), None);
assert_eq!(
super::infer_chain_from_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf"),
None
);
}
#[test]
fn test_infer_chain_from_hash_evm() {
assert_eq!(
super::infer_chain_from_hash(
"0xabc123def456789012345678901234567890123456789012345678901234abcd"
),
Some("ethereum")
);
assert_eq!(
super::infer_chain_from_hash(
"0x0000000000000000000000000000000000000000000000000000000000000000"
),
Some("ethereum")
);
}
#[test]
fn test_infer_chain_from_hash_tron() {
assert_eq!(
super::infer_chain_from_hash(
"abc123def456789012345678901234567890123456789012345678901234abcd"
),
Some("tron")
);
assert_eq!(
super::infer_chain_from_hash(
"0000000000000000000000000000000000000000000000000000000000000000"
),
Some("tron")
);
}
#[test]
fn test_infer_chain_from_hash_solana() {
let solana_sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
assert_eq!(super::infer_chain_from_hash(solana_sig), Some("solana"));
}
#[test]
fn test_infer_chain_from_hash_invalid() {
assert_eq!(super::infer_chain_from_hash("0x123"), None);
assert_eq!(super::infer_chain_from_hash("not_a_hash"), None);
assert_eq!(super::infer_chain_from_hash(""), None);
assert_eq!(
super::infer_chain_from_hash(
"abc123gef456789012345678901234567890123456789012345678901234abcd"
),
None
);
}
#[test]
fn test_default_client_factory_create_dex_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let dex = factory.create_dex_client();
let _ = format!("{:?}", std::mem::size_of_val(&dex));
}
#[test]
fn test_default_client_factory_create_ethereum_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("ethereum");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "ethereum");
}
#[test]
fn test_default_client_factory_create_polygon_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("polygon");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "polygon");
}
#[test]
fn test_default_client_factory_create_solana_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("solana");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "solana");
}
#[test]
fn test_default_client_factory_create_sol_alias() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("sol");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "solana");
}
#[test]
fn test_default_client_factory_create_tron_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("tron");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "tron");
}
#[test]
fn test_default_client_factory_create_trx_alias() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("trx");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "tron");
}
#[test]
fn test_default_client_factory_create_arbitrum_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("arbitrum");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "arbitrum");
}
#[test]
fn test_default_client_factory_create_optimism_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("optimism");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "optimism");
}
#[test]
fn test_default_client_factory_create_base_client() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("base");
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "base");
}
#[test]
fn test_default_client_factory_create_unsupported_chain_returns_err() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let client = factory.create_chain_client("avalanche");
match &client {
Err(e) => assert!(e.to_string().contains("Unsupported")),
Ok(_) => panic!("expected Err for unsupported chain"),
}
}
#[test]
fn test_solana_client_new_with_http() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let client = SolanaClient::new_with_http(&config, http);
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "solana");
}
#[test]
fn test_tron_client_new_with_http() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let client = TronClient::new_with_http(&config, http);
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "tron");
}
#[test]
fn test_dex_client_new_with_http() {
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let client = DexClient::new_with_http(http);
let _ = format!("{}", std::mem::size_of_val(&client));
}
#[test]
fn test_factory_shares_http_transport() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let weak = Arc::downgrade(&http);
let factory = DefaultClientFactory {
chains_config: config,
http,
};
let _eth = factory.create_chain_client("ethereum").unwrap();
let _sol = factory.create_chain_client("solana").unwrap();
let _trx = factory.create_chain_client("tron").unwrap();
let _dex = factory.create_dex_client();
assert!(weak.upgrade().is_some());
}
#[test]
fn test_ethereum_client_new_with_http() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let client = EthereumClient::new_with_http(&config, http);
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "ethereum");
}
#[test]
fn test_ethereum_client_for_chain_with_http() {
let config = crate::config::ChainsConfig::default();
let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new().unwrap());
let client = EthereumClient::for_chain_with_http("polygon", &config, http);
assert!(client.is_ok());
assert_eq!(client.unwrap().chain_name(), "polygon");
}
#[tokio::test]
async fn test_chain_client_default_get_token_info() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let result = client.get_token_info("0xsometoken").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_chain_client_default_get_token_holders() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let holders = client.get_token_holders("0xsometoken", 10).await.unwrap();
assert!(holders.is_empty());
}
#[tokio::test]
async fn test_chain_client_default_get_token_holder_count() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let count = client.get_token_holder_count("0xsometoken").await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_mock_client_factory_creates_chain_client() {
use super::mocks::MockClientFactory;
let factory = MockClientFactory::new();
let client = factory.create_chain_client("anything").unwrap();
assert_eq!(client.chain_name(), "ethereum"); }
#[tokio::test]
async fn test_mock_client_factory_creates_dex_client() {
use super::mocks::MockClientFactory;
let factory = MockClientFactory::new();
let dex = factory.create_dex_client();
let price = dex.get_token_price("ethereum", "0xtest").await;
assert_eq!(price, Some(1.0));
}
#[tokio::test]
async fn test_mock_chain_client_balance() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let balance = client.get_balance("0xtest").await.unwrap();
assert_eq!(balance.formatted, "1.0");
assert_eq!(balance.symbol, "ETH");
assert_eq!(balance.usd_value, Some(2500.0));
}
#[tokio::test]
async fn test_mock_chain_client_transaction() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let tx = client.get_transaction("0xanyhash").await.unwrap();
assert_eq!(tx.hash, "0xmocktx");
assert_eq!(tx.nonce, 42);
}
#[tokio::test]
async fn test_mock_chain_client_block_number() {
use super::mocks::MockChainClient;
let client = MockChainClient::new("ethereum", "ETH");
let block = client.get_block_number().await.unwrap();
assert_eq!(block, 12345678);
}
#[tokio::test]
async fn test_mock_dex_source_data() {
use super::mocks::MockDexSource;
let dex = MockDexSource::new();
let data = dex.get_token_data("ethereum", "0xtest").await.unwrap();
assert_eq!(data.symbol, "MOCK");
assert_eq!(data.price_usd, 1.0);
}
#[tokio::test]
async fn test_mock_dex_source_search() {
use super::mocks::MockDexSource;
let dex = MockDexSource::new();
let results = dex.search_tokens("test", None).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn test_mock_dex_source_native_price() {
use super::mocks::MockDexSource;
let dex = MockDexSource::new();
let price = dex.get_native_token_price("ethereum").await;
assert_eq!(price, Some(2500.0));
}
struct MinimalChainClient;
#[async_trait::async_trait]
impl ChainClient for MinimalChainClient {
fn chain_name(&self) -> &str {
"test"
}
fn native_token_symbol(&self) -> &str {
"TEST"
}
async fn get_balance(&self, _address: &str) -> Result<Balance> {
Ok(Balance {
raw: "0".to_string(),
formatted: "0".to_string(),
decimals: 18,
symbol: "TEST".to_string(),
usd_value: None,
})
}
async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
unimplemented!()
}
async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
Ok(Vec::new())
}
async fn get_block_number(&self) -> Result<u64> {
Ok(0)
}
async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
Ok(Vec::new())
}
async fn enrich_balance_usd(&self, _balance: &mut Balance) {}
}
#[tokio::test]
async fn test_default_get_token_info() {
let client = MinimalChainClient;
let result = client.get_token_info("0xtest").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not supported"));
}
#[tokio::test]
async fn test_default_get_token_holders() {
let client = MinimalChainClient;
let holders = client.get_token_holders("0xtest", 10).await.unwrap();
assert!(holders.is_empty());
}
#[tokio::test]
async fn test_default_get_token_holder_count() {
let client = MinimalChainClient;
let count = client.get_token_holder_count("0xtest").await.unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_chain_metadata_ethereum() {
let meta = chain_metadata("ethereum").unwrap();
assert_eq!(meta.chain_id, "ethereum");
assert_eq!(meta.native_symbol, "ETH");
assert_eq!(meta.native_decimals, 18);
assert_eq!(meta.explorer_token_base, "https://etherscan.io/token");
}
#[test]
fn test_chain_metadata_ethereum_alias() {
let meta = chain_metadata("eth").unwrap();
assert_eq!(meta.chain_id, "ethereum");
assert_eq!(meta.native_symbol, "ETH");
}
#[test]
fn test_chain_metadata_polygon() {
let meta = chain_metadata("polygon").unwrap();
assert_eq!(meta.chain_id, "polygon");
assert_eq!(meta.native_symbol, "MATIC");
assert_eq!(meta.native_decimals, 18);
assert_eq!(meta.explorer_token_base, "https://polygonscan.com/token");
}
#[test]
fn test_chain_metadata_bsc() {
let meta = chain_metadata("bsc").unwrap();
assert_eq!(meta.chain_id, "bsc");
assert_eq!(meta.native_symbol, "BNB");
assert_eq!(meta.native_decimals, 18);
assert_eq!(meta.explorer_token_base, "https://bscscan.com/token");
}
#[test]
fn test_chain_metadata_solana() {
let meta = chain_metadata("solana").unwrap();
assert_eq!(meta.chain_id, "solana");
assert_eq!(meta.native_symbol, "SOL");
assert_eq!(meta.native_decimals, 9);
assert_eq!(meta.explorer_token_base, "https://solscan.io/token");
}
#[test]
fn test_chain_metadata_solana_alias() {
let meta = chain_metadata("sol").unwrap();
assert_eq!(meta.chain_id, "solana");
assert_eq!(meta.native_symbol, "SOL");
}
#[test]
fn test_chain_metadata_tron() {
let meta = chain_metadata("tron").unwrap();
assert_eq!(meta.chain_id, "tron");
assert_eq!(meta.native_symbol, "TRX");
assert_eq!(meta.native_decimals, 6);
assert_eq!(meta.explorer_token_base, "https://tronscan.org/#/token20");
}
#[test]
fn test_chain_metadata_tron_alias() {
let meta = chain_metadata("trx").unwrap();
assert_eq!(meta.chain_id, "tron");
assert_eq!(meta.native_symbol, "TRX");
}
#[test]
fn test_chain_metadata_arbitrum() {
let meta = chain_metadata("arbitrum").unwrap();
assert_eq!(meta.chain_id, "arbitrum");
assert_eq!(meta.native_symbol, "ETH");
assert_eq!(meta.native_decimals, 18);
assert_eq!(meta.explorer_token_base, "https://arbiscan.io/token");
}
#[test]
fn test_chain_metadata_optimism() {
let meta = chain_metadata("optimism").unwrap();
assert_eq!(meta.chain_id, "optimism");
assert_eq!(meta.native_symbol, "ETH");
assert_eq!(meta.native_decimals, 18);
assert_eq!(
meta.explorer_token_base,
"https://optimistic.etherscan.io/token"
);
}
#[test]
fn test_chain_metadata_base() {
let meta = chain_metadata("base").unwrap();
assert_eq!(meta.chain_id, "base");
assert_eq!(meta.native_symbol, "ETH");
assert_eq!(meta.native_decimals, 18);
assert_eq!(meta.explorer_token_base, "https://basescan.org/token");
}
#[test]
fn test_chain_metadata_case_insensitive() {
let meta1 = chain_metadata("ETHEREUM").unwrap();
let meta2 = chain_metadata("Ethereum").unwrap();
let meta3 = chain_metadata("ethereum").unwrap();
assert_eq!(meta1.chain_id, meta2.chain_id);
assert_eq!(meta2.chain_id, meta3.chain_id);
}
#[test]
fn test_chain_metadata_unknown() {
assert!(chain_metadata("bitcoin").is_none());
assert!(chain_metadata("litecoin").is_none());
assert!(chain_metadata("unknown").is_none());
assert!(chain_metadata("").is_none());
}
#[test]
fn test_native_symbol_ethereum() {
assert_eq!(native_symbol("ethereum"), "ETH");
assert_eq!(native_symbol("eth"), "ETH");
}
#[test]
fn test_native_symbol_polygon() {
assert_eq!(native_symbol("polygon"), "MATIC");
}
#[test]
fn test_native_symbol_bsc() {
assert_eq!(native_symbol("bsc"), "BNB");
}
#[test]
fn test_native_symbol_solana() {
assert_eq!(native_symbol("solana"), "SOL");
assert_eq!(native_symbol("sol"), "SOL");
}
#[test]
fn test_native_symbol_tron() {
assert_eq!(native_symbol("tron"), "TRX");
assert_eq!(native_symbol("trx"), "TRX");
}
#[test]
fn test_native_symbol_arbitrum() {
assert_eq!(native_symbol("arbitrum"), "ETH");
}
#[test]
fn test_native_symbol_optimism() {
assert_eq!(native_symbol("optimism"), "ETH");
}
#[test]
fn test_native_symbol_base() {
assert_eq!(native_symbol("base"), "ETH");
}
#[test]
fn test_native_symbol_unknown() {
assert_eq!(native_symbol("unknown"), "???");
assert_eq!(native_symbol("bitcoin"), "???");
assert_eq!(native_symbol(""), "???");
}
#[test]
fn test_native_symbol_case_insensitive() {
assert_eq!(native_symbol("ETHEREUM"), "ETH");
assert_eq!(native_symbol("Ethereum"), "ETH");
assert_eq!(native_symbol("ethereum"), "ETH");
}
#[tokio::test]
async fn test_chain_client_default_get_code() {
let client = MinimalChainClient;
let result = client.get_code("0x1234").await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not supported"));
}
fn tx(hash: &str, gas_used: Option<u64>, input: &str, status: Option<bool>) -> Transaction {
Transaction {
hash: hash.to_string(),
block_number: Some(1),
timestamp: Some(1700000000),
from: "0xfrom".to_string(),
to: Some("0xto".to_string()),
value: "0".to_string(),
gas_limit: 21000,
gas_used,
gas_price: "20000000000".to_string(),
nonce: 0,
input: input.to_string(),
status,
}
}
#[test]
fn test_analyze_gas_usage_empty_transactions() {
let txs: Vec<Transaction> = vec![];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.avg_gas_used, 0);
assert_eq!(result.max_gas_used, 0);
assert_eq!(result.min_gas_used, 0);
assert_eq!(result.tx_count, 0);
assert_eq!(result.failed_tx_count, 0);
assert_eq!(result.wasted_gas, 0);
assert!(result.gas_by_function.is_empty());
}
#[test]
fn test_analyze_gas_usage_single_tx() {
let txs = vec![tx("0x1", Some(100_000), "0x", Some(true))];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.avg_gas_used, 100_000);
assert_eq!(result.max_gas_used, 100_000);
assert_eq!(result.min_gas_used, 100_000);
assert_eq!(result.tx_count, 1);
}
#[test]
fn test_analyze_gas_usage_multiple_txs() {
let txs = vec![
tx("0x1", Some(50_000), "0xa9059cbb", Some(true)),
tx("0x2", Some(150_000), "0xa9059cbb", Some(true)),
tx("0x3", Some(100_000), "0xa9059cbb", Some(true)),
];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.avg_gas_used, 100_000); assert_eq!(result.max_gas_used, 150_000);
assert_eq!(result.min_gas_used, 50_000);
assert_eq!(result.tx_count, 3);
}
#[test]
fn test_analyze_gas_usage_failed_tx() {
let txs = vec![
tx("0x1", Some(80_000), "0x", Some(true)),
tx("0x2", Some(120_000), "0x", Some(false)),
];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.failed_tx_count, 1);
assert_eq!(result.wasted_gas, 120_000);
}
#[test]
fn test_analyze_gas_usage_gas_by_function() {
let txs = vec![
tx("0x1", Some(100_000), "0xa9059cbb0000", Some(true)),
tx("0x2", Some(200_000), "0xa9059cbb0000", Some(true)),
tx("0x3", Some(50_000), "0x095ea7b30000", Some(true)),
];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.gas_by_function.len(), 2);
let by_sel: std::collections::HashMap<_, _> = result
.gas_by_function
.iter()
.map(|g| (g.function.as_str(), g))
.collect();
let transfer = by_sel.get("0xa9059cbb").unwrap();
assert_eq!(transfer.call_count, 2);
assert_eq!(transfer.total_gas, 300_000);
assert_eq!(transfer.avg_gas, 150_000);
let approve = by_sel.get("0x095ea7b3").unwrap();
assert_eq!(approve.call_count, 1);
assert_eq!(approve.total_gas, 50_000);
}
#[test]
fn test_analyze_gas_usage_input_0x_transfer() {
let txs = vec![tx("0x1", Some(21_000), "0x", Some(true))];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.gas_by_function.len(), 1);
assert_eq!(result.gas_by_function[0].function, "transfer()");
}
#[test]
fn test_analyze_gas_usage_input_empty_transfer() {
let txs = vec![tx("0x1", Some(21_000), "", Some(true))];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.gas_by_function.len(), 1);
assert_eq!(result.gas_by_function[0].function, "transfer()");
}
#[test]
fn test_analyze_gas_usage_gas_used_none() {
let txs = vec![tx("0x1", None, "0x", Some(true))];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.avg_gas_used, 0);
assert_eq!(result.max_gas_used, 0);
assert_eq!(result.min_gas_used, 0);
}
#[test]
fn test_analyze_gas_usage_short_input_uses_full_input_as_selector() {
let txs = vec![tx("0x1", Some(50_000), "0x1234567", Some(true))];
let result = super::analyze_gas_usage(&txs);
assert_eq!(result.gas_by_function.len(), 1);
assert_eq!(result.gas_by_function[0].function, "0x1234567");
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod mocks {
use super::*;
use crate::chains::dex::{DexDataSource, DexTokenData, TokenSearchResult};
use async_trait::async_trait;
#[derive(Debug, Clone)]
pub struct MockChainClient {
pub chain: String,
pub symbol: String,
pub balance: Balance,
pub transaction: Transaction,
pub transactions: Vec<Transaction>,
pub token_balances: Vec<TokenBalance>,
pub block_number: u64,
pub token_info: Option<Token>,
pub token_holders: Vec<TokenHolder>,
pub token_holder_count: u64,
}
impl MockChainClient {
pub fn new(chain: &str, symbol: &str) -> Self {
Self {
chain: chain.to_string(),
symbol: symbol.to_string(),
balance: Balance {
raw: "1000000000000000000".to_string(),
formatted: "1.0".to_string(),
decimals: 18,
symbol: symbol.to_string(),
usd_value: Some(2500.0),
},
transaction: Transaction {
hash: "0xmocktx".to_string(),
block_number: Some(12345678),
timestamp: Some(1700000000),
from: "0xfrom".to_string(),
to: Some("0xto".to_string()),
value: "1.0".to_string(),
gas_limit: 21000,
gas_used: Some(21000),
gas_price: "20000000000".to_string(),
nonce: 42,
input: "0x".to_string(),
status: Some(true),
},
transactions: vec![],
token_balances: vec![],
block_number: 12345678,
token_info: None,
token_holders: vec![],
token_holder_count: 0,
}
}
}
#[async_trait]
impl ChainClient for MockChainClient {
fn chain_name(&self) -> &str {
&self.chain
}
fn native_token_symbol(&self) -> &str {
&self.symbol
}
async fn get_balance(&self, _address: &str) -> Result<Balance> {
Ok(self.balance.clone())
}
async fn enrich_balance_usd(&self, _balance: &mut Balance) {
}
async fn get_transaction(&self, _hash: &str) -> Result<Transaction> {
Ok(self.transaction.clone())
}
async fn get_transactions(&self, _address: &str, _limit: u32) -> Result<Vec<Transaction>> {
Ok(self.transactions.clone())
}
async fn get_block_number(&self) -> Result<u64> {
Ok(self.block_number)
}
async fn get_token_balances(&self, _address: &str) -> Result<Vec<TokenBalance>> {
Ok(self.token_balances.clone())
}
async fn get_token_info(&self, _address: &str) -> Result<Token> {
match &self.token_info {
Some(t) => Ok(t.clone()),
None => Err(crate::error::ScopeError::Chain(
"Token info not available".to_string(),
)),
}
}
async fn get_token_holders(&self, _address: &str, _limit: u32) -> Result<Vec<TokenHolder>> {
Ok(self.token_holders.clone())
}
async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
Ok(self.token_holder_count)
}
}
#[derive(Debug, Clone)]
pub struct MockDexSource {
pub token_price: Option<f64>,
pub native_price: Option<f64>,
pub token_data: Option<DexTokenData>,
pub search_results: Vec<TokenSearchResult>,
}
impl Default for MockDexSource {
fn default() -> Self {
Self::new()
}
}
impl MockDexSource {
pub fn new() -> Self {
Self {
token_price: Some(1.0),
native_price: Some(2500.0),
token_data: Some(DexTokenData {
address: "0xmocktoken".to_string(),
symbol: "MOCK".to_string(),
name: "Mock Token".to_string(),
price_usd: 1.0,
price_change_24h: 5.0,
price_change_6h: 2.0,
price_change_1h: 0.5,
price_change_5m: 0.1,
volume_24h: 1_000_000.0,
volume_6h: 250_000.0,
volume_1h: 50_000.0,
liquidity_usd: 5_000_000.0,
market_cap: Some(100_000_000.0),
fdv: Some(200_000_000.0),
pairs: vec![],
price_history: vec![],
volume_history: vec![],
total_buys_24h: 500,
total_sells_24h: 450,
total_buys_6h: 120,
total_sells_6h: 110,
total_buys_1h: 20,
total_sells_1h: 18,
earliest_pair_created_at: Some(1690000000),
image_url: None,
websites: vec![],
socials: vec![],
dexscreener_url: None,
}),
search_results: vec![],
}
}
}
#[async_trait]
impl DexDataSource for MockDexSource {
async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
self.token_price
}
async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
self.native_price
}
async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
match &self.token_data {
Some(data) => Ok(data.clone()),
None => Err(crate::error::ScopeError::NotFound(
"No DEX data found".to_string(),
)),
}
}
async fn search_tokens(
&self,
_query: &str,
_chain: Option<&str>,
) -> Result<Vec<TokenSearchResult>> {
Ok(self.search_results.clone())
}
}
pub struct MockClientFactory {
pub mock_client: MockChainClient,
pub mock_dex: MockDexSource,
}
impl Default for MockClientFactory {
fn default() -> Self {
Self::new()
}
}
impl MockClientFactory {
pub fn new() -> Self {
Self {
mock_client: MockChainClient::new("ethereum", "ETH"),
mock_dex: MockDexSource::new(),
}
}
}
impl ChainClientFactory for MockClientFactory {
fn create_chain_client(&self, _chain: &str) -> Result<Box<dyn ChainClient>> {
Ok(Box::new(self.mock_client.clone()))
}
fn create_dex_client(&self) -> Box<dyn DexDataSource> {
Box::new(self.mock_dex.clone())
}
fn http(&self) -> Arc<dyn HttpClient> {
crate::http::shared::fallback()
}
}
}