use crate::chains::{Balance, ChainClient, Token, 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 serde_json;
use sha2::{Digest, Sha256};
use std::sync::Arc;
const DEFAULT_TRON_API: &str = "https://api.trongrid.io";
const TRONSCAN_API: &str = "https://apilist.tronscanapi.com";
const DEXSCREENER_TRX_SEARCH: &str = "https://api.dexscreener.com/latest/dex/search?q=TRX%20USDT";
const TRX_DECIMALS: u8 = 6;
#[derive(Clone)]
pub struct TronClient {
http: Arc<dyn HttpClient>,
api_url: String,
api_key: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AccountResponse {
data: Vec<AccountData>,
success: bool,
error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] struct AccountData {
balance: Option<u64>,
address: String,
create_time: Option<u64>,
#[serde(default)]
trc20: Vec<Trc20Balance>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] struct Trc20Balance {
#[serde(flatten)]
balances: std::collections::HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct TransactionListResponse {
data: Vec<TronTransaction>,
success: bool,
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TronTransaction {
#[serde(rename = "txID")]
tx_id: String,
block_number: Option<u64>,
block_timestamp: Option<u64>,
raw_data: Option<RawData>,
ret: Option<Vec<TransactionResult>>,
}
#[derive(Debug, Deserialize)]
struct RawData {
contract: Option<Vec<Contract>>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] struct Contract {
parameter: Option<ContractParameter>,
#[serde(rename = "type")]
contract_type: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ContractParameter {
value: Option<ContractValue>,
}
#[derive(Debug, Deserialize)]
struct ContractValue {
amount: Option<u64>,
owner_address: Option<String>,
to_address: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TransactionResult {
#[serde(rename = "contractRet")]
contract_ret: Option<String>,
}
impl TronClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `TronClient::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> {
let api_url = config
.tron_api
.as_deref()
.unwrap_or(DEFAULT_TRON_API)
.to_string();
Ok(Self {
http,
api_url,
api_key: config.api_keys.get("tronscan").cloned(),
})
}
pub fn with_api_url(api_url: &str) -> Self {
Self {
http: crate::http::shared::fallback(),
api_url: api_url.to_string(),
api_key: None,
}
}
pub fn chain_name(&self) -> &str {
"tron"
}
pub fn native_token_symbol(&self) -> &str {
"TRX"
}
pub async fn get_balance(&self, address: &str) -> Result<Balance> {
validate_tron_address(address)?;
let url = format!("{}/v1/accounts/{}", self.api_url, address);
tracing::debug!(url = %url, address = %address, "Fetching Tron balance");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let response: AccountResponse = self.http.send(req).await?.json()?;
if !response.success {
return Err(ScopeError::Chain(format!(
"TronGrid API error: {}",
response.error.unwrap_or_else(|| "Unknown error".into())
)));
}
let sun = response.data.first().and_then(|d| d.balance).unwrap_or(0);
let trx = sun as f64 / 10_f64.powi(TRX_DECIMALS as i32);
Ok(Balance {
raw: sun.to_string(),
formatted: format!("{:.6} TRX", trx),
decimals: TRX_DECIMALS,
symbol: "TRX".to_string(),
usd_value: None, })
}
pub async fn get_trc20_balances(&self, address: &str) -> Result<Vec<Trc20TokenBalance>> {
validate_tron_address(address)?;
let url = format!("{}/v1/accounts/{}", self.api_url, address);
tracing::debug!(url = %url, "Fetching TRC-20 token balances");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let response: AccountResponse = self.http.send(req).await?.json()?;
if !response.success {
return Err(ScopeError::Chain(format!(
"TronGrid API error: {}",
response.error.unwrap_or_else(|| "Unknown error".into())
)));
}
let account = match response.data.first() {
Some(data) => data,
None => return Ok(vec![]),
};
let mut balances = Vec::new();
for trc20 in &account.trc20 {
for (contract_address, raw_balance) in &trc20.balances {
if raw_balance == "0" {
continue;
}
balances.push(Trc20TokenBalance {
contract_address: contract_address.clone(),
raw_balance: raw_balance.clone(),
});
}
}
Ok(balances)
}
pub async fn get_token_info(&self, contract_address: &str) -> Result<Token> {
validate_tron_address(contract_address)?;
let url = format!(
"{}/api/token_trc20?contract={}&showAll=1",
TRONSCAN_API, contract_address
);
tracing::debug!(url = %url, "Fetching TRC-20 token info via Tronscan");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let resp = self.http.send(req).await?;
let json: serde_json::Value = serde_json::from_str(&resp.body)
.map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
let tokens = json
.get("trc20_tokens")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ScopeError::NotFound(format!(
"No token info found for TRC-20 contract {}",
contract_address
))
})?;
let token_data = tokens.first().ok_or_else(|| {
ScopeError::NotFound(format!(
"No token info found for TRC-20 contract {}",
contract_address
))
})?;
let symbol = token_data
.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_string();
let name = token_data
.get("contract_name")
.or_else(|| token_data.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown Token")
.to_string();
let decimals = token_data
.get("decimals")
.and_then(|v| v.as_u64())
.unwrap_or(6) as u8;
Ok(Token {
contract_address: contract_address.to_string(),
symbol,
name,
decimals,
})
}
pub async fn get_token_holders(
&self,
contract_address: &str,
limit: u32,
) -> Result<Vec<TokenHolder>> {
validate_tron_address(contract_address)?;
let effective_limit = limit.min(100);
let url = format!(
"{}/api/token_trc20/holders?contract_address={}&start=0&limit={}",
TRONSCAN_API, contract_address, effective_limit
);
tracing::debug!(url = %url, "Fetching TRC-20 token holders via Tronscan");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let resp = self.http.send(req).await?;
let json: serde_json::Value = serde_json::from_str(&resp.body)
.map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan holders: {}", e)))?;
let holders_data: &[serde_json::Value] = json
.get("trc20_tokens")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or(&[]);
let token_info = self.get_token_info(contract_address).await;
let decimals = token_info.as_ref().map(|t| t.decimals).unwrap_or(6);
let total_balance: f64 = holders_data
.iter()
.filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
.filter_map(|s| s.parse::<f64>().ok())
.sum();
let token_holders: Vec<TokenHolder> = holders_data
.iter()
.enumerate()
.filter_map(|(i, h)| {
let holder_address = h.get("holder_address")?.as_str()?.to_string();
let balance_raw = h.get("balance")?.as_str()?.to_string();
let balance: f64 = balance_raw.parse().ok()?;
let percentage = if total_balance > 0.0 {
(balance / total_balance) * 100.0
} else {
0.0
};
let divisor = 10_f64.powi(decimals as i32);
let formatted = format!("{:.6}", balance / divisor);
Some(TokenHolder {
address: holder_address,
balance: balance_raw,
formatted_balance: formatted,
percentage,
rank: (i + 1) as u32,
})
})
.collect();
Ok(token_holders)
}
pub async fn get_token_holder_count(&self, contract_address: &str) -> Result<u64> {
validate_tron_address(contract_address)?;
let url = format!(
"{}/api/token_trc20/holders?contract_address={}&start=0&limit=1",
TRONSCAN_API, contract_address
);
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let resp = self.http.send(req).await?;
let json: serde_json::Value = resp
.json()
.map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
Ok(count)
}
pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
let url = DEXSCREENER_TRX_SEARCH;
if let Ok(resp) = self.http.send(Request::get(url)).await
&& let Ok(search_result) = serde_json::from_str::<DexSearchResponse>(&resp.body)
&& let Some(pairs) = search_result.pairs
{
for pair in &pairs {
if (pair.base_token_symbol.as_deref() == Some("TRX")
|| pair.base_token_symbol.as_deref() == Some("WTRX"))
&& let Some(price) = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok())
{
let sun: f64 = balance.raw.parse().unwrap_or(0.0);
let trx = sun / 10_f64.powi(TRX_DECIMALS as i32);
balance.usd_value = Some(trx * price);
return;
}
}
}
}
pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
validate_tron_tx_hash(hash)?;
let url = format!("{}/v1/transactions/{}", self.api_url, hash);
tracing::debug!(url = %url, hash = %hash, "Fetching Tron transaction");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let response: TransactionListResponse = self.http.send(req).await?.json()?;
if !response.success {
return Err(ScopeError::Chain(format!(
"TronGrid API error: {}",
response.error.unwrap_or_else(|| "Unknown error".into())
)));
}
let tx = response
.data
.into_iter()
.next()
.ok_or_else(|| ScopeError::Chain("Transaction not found".into()))?;
let (from, to, value) = tx
.raw_data
.and_then(|rd| rd.contract)
.and_then(|contracts| contracts.into_iter().next())
.and_then(|c| c.parameter)
.and_then(|p| p.value)
.map(|v| {
(
v.owner_address.unwrap_or_default(),
v.to_address,
v.amount.unwrap_or(0).to_string(),
)
})
.unwrap_or_else(|| (String::new(), None, "0".to_string()));
let status = tx
.ret
.and_then(|r| r.into_iter().next())
.and_then(|r| r.contract_ret)
.map(|s| s == "SUCCESS");
Ok(Transaction {
hash: tx.tx_id,
block_number: tx.block_number,
timestamp: tx.block_timestamp.map(|t| t / 1000), from,
to,
value,
gas_limit: 0, gas_used: None,
gas_price: "0".to_string(),
nonce: 0,
input: String::new(),
status,
})
}
pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
validate_tron_address(address)?;
let url = format!(
"{}/v1/accounts/{}/transactions?limit={}",
self.api_url, address, limit
);
tracing::debug!(url = %url, address = %address, "Fetching Tron transactions");
let mut req = Request::get(&url);
if let Some(ref key) = self.api_key {
req = req.with_header("TRON-PRO-API-KEY", key);
}
let response: TransactionListResponse = self.http.send(req).await?.json()?;
if !response.success {
return Err(ScopeError::Chain(format!(
"TronGrid API error: {}",
response.error.unwrap_or_else(|| "Unknown error".into())
)));
}
let transactions = response
.data
.into_iter()
.map(|tx| {
let (from, to, value) = tx
.raw_data
.and_then(|rd| rd.contract)
.and_then(|contracts| contracts.into_iter().next())
.and_then(|c| c.parameter)
.and_then(|p| p.value)
.map(|v| {
(
v.owner_address.unwrap_or_default(),
v.to_address,
v.amount.unwrap_or(0).to_string(),
)
})
.unwrap_or_else(|| (String::new(), None, "0".to_string()));
let status = tx
.ret
.and_then(|r| r.into_iter().next())
.and_then(|r| r.contract_ret)
.map(|s| s == "SUCCESS");
Transaction {
hash: tx.tx_id,
block_number: tx.block_number,
timestamp: tx.block_timestamp.map(|t| t / 1000),
from,
to,
value,
gas_limit: 0,
gas_used: None,
gas_price: "0".to_string(),
nonce: 0,
input: String::new(),
status,
}
})
.collect();
Ok(transactions)
}
pub async fn get_block_number(&self) -> Result<u64> {
let url = format!("{}/wallet/getnowblock", self.api_url);
#[derive(Deserialize)]
struct BlockResponse {
block_header: Option<BlockHeader>,
}
#[derive(Deserialize)]
struct BlockHeader {
raw_data: Option<BlockRawData>,
}
#[derive(Deserialize)]
struct BlockRawData {
number: Option<u64>,
}
let resp = self.http.send(Request::post_json(&url, "")).await?;
let response: BlockResponse = resp.json()?;
response
.block_header
.and_then(|h| h.raw_data)
.and_then(|d| d.number)
.ok_or_else(|| ScopeError::Chain("Invalid block response".into()))
}
}
impl Default for TronClient {
fn default() -> Self {
Self {
http: crate::http::shared::fallback(),
api_url: DEFAULT_TRON_API.to_string(),
api_key: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Trc20TokenBalance {
pub contract_address: String,
pub raw_balance: String,
}
#[derive(Debug, Deserialize)]
struct DexSearchResponse {
#[serde(default)]
pairs: Option<Vec<DexSearchPair>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DexSearchPair {
#[serde(default)]
base_token_symbol: Option<String>,
#[serde(default)]
price_usd: Option<String>,
}
pub fn validate_tron_address(address: &str) -> Result<()> {
if address.is_empty() {
return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
}
if !address.starts_with('T') {
return Err(ScopeError::InvalidAddress(format!(
"Tron address must start with 'T': {}",
address
)));
}
if address.len() != 34 {
return Err(ScopeError::InvalidAddress(format!(
"Tron address must be 34 characters, got {}: {}",
address.len(),
address
)));
}
match bs58::decode(address).into_vec() {
Ok(bytes) => {
if bytes.len() != 25 {
return Err(ScopeError::InvalidAddress(format!(
"Tron address must decode to 25 bytes, got {}: {}",
bytes.len(),
address
)));
}
if bytes[0] != 0x41 {
return Err(ScopeError::InvalidAddress(format!(
"Invalid Tron address prefix: {}",
address
)));
}
let payload = &bytes[0..21];
let hash1 = Sha256::digest(payload);
let hash2 = Sha256::digest(hash1);
let expected_checksum = &hash2[0..4];
let actual_checksum = &bytes[21..25];
if expected_checksum != actual_checksum {
return Err(ScopeError::InvalidAddress(format!(
"Invalid Tron address checksum: {}",
address
)));
}
}
Err(e) => {
return Err(ScopeError::InvalidAddress(format!(
"Invalid base58 encoding: {}: {}",
e, address
)));
}
}
Ok(())
}
pub fn validate_tron_tx_hash(hash: &str) -> Result<()> {
if hash.is_empty() {
return Err(ScopeError::InvalidHash("Hash cannot be empty".into()));
}
if hash.len() != 64 {
return Err(ScopeError::InvalidHash(format!(
"Tron transaction hash must be 64 characters, got {}: {}",
hash.len(),
hash
)));
}
if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ScopeError::InvalidHash(format!(
"Tron hash contains invalid hex characters: {}",
hash
)));
}
Ok(())
}
#[async_trait]
impl ChainClient for TronClient {
fn chain_name(&self) -> &str {
"tron"
}
fn native_token_symbol(&self) -> &str {
"TRX"
}
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<crate::chains::TokenBalance>> {
let trc20_balances = self.get_trc20_balances(address).await?;
let mut result = Vec::with_capacity(trc20_balances.len());
for tb in trc20_balances {
let token = match self.get_token_info(&tb.contract_address).await {
Ok(info) => info,
Err(e) => {
tracing::debug!(
contract = %tb.contract_address,
error = %e,
"Could not fetch TRC-20 token info, using placeholder"
);
Token {
contract_address: tb.contract_address.clone(),
symbol: "TRC20".to_string(),
name: "TRC-20 Token".to_string(),
decimals: 6, }
}
};
let raw: f64 = tb.raw_balance.parse().unwrap_or(0.0);
let divisor = 10_f64.powi(token.decimals as i32);
let formatted = format!("{:.6}", raw / divisor);
result.push(crate::chains::TokenBalance {
token,
balance: tb.raw_balance,
formatted_balance: formatted,
usd_value: None,
});
}
Ok(result)
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_ADDRESS: &str = "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf";
const VALID_TX_HASH: &str = "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
#[test]
fn test_validate_tron_address_valid() {
assert!(validate_tron_address(VALID_ADDRESS).is_ok());
}
#[test]
fn test_validate_tron_address_empty() {
let result = validate_tron_address("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_validate_tron_address_wrong_prefix() {
let result = validate_tron_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("start with 'T'"));
}
#[test]
fn test_validate_tron_address_too_short() {
let result = validate_tron_address("TDqSquXBgUCLYvYC4XZ");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("34 characters"));
}
#[test]
fn test_validate_tron_address_too_long() {
let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCfAAAA");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("34 characters"));
}
#[test]
fn test_validate_tron_address_invalid_base58() {
let result = validate_tron_address("T0qSquXBgUCLYvYC4XZgrprLK589dkhSCf");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("base58"));
}
#[test]
fn test_validate_tron_tx_hash_valid() {
assert!(validate_tron_tx_hash(VALID_TX_HASH).is_ok());
}
#[test]
fn test_validate_tron_tx_hash_empty() {
let result = validate_tron_tx_hash("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_validate_tron_tx_hash_too_short() {
let result = validate_tron_tx_hash("b3c12d62ad7e7b8b83b09a68");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("64 characters"));
}
#[test]
fn test_validate_tron_tx_hash_invalid_hex() {
let hash = "g3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
let result = validate_tron_tx_hash(hash);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid hex"));
}
#[test]
fn test_tron_client_default() {
let client = TronClient::default();
assert_eq!(client.chain_name(), "tron");
assert_eq!(client.native_token_symbol(), "TRX");
assert!(client.api_url.contains("trongrid"));
}
#[test]
fn test_tron_client_with_api_url() {
let client = TronClient::with_api_url("https://custom.tron.api");
assert_eq!(client.api_url, "https://custom.tron.api");
}
#[test]
fn test_tron_client_new() {
let config = ChainsConfig::default();
let client = TronClient::new_with_http(&config, crate::http::test_transport());
assert!(client.is_ok());
}
#[test]
fn test_tron_client_new_with_custom_api() {
let config = ChainsConfig {
tron_api: Some("https://my-tron-api.com".to_string()),
..Default::default()
};
let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
assert_eq!(client.api_url, "https://my-tron-api.com");
}
#[test]
fn test_tron_client_new_with_api_key() {
use std::collections::HashMap;
let mut api_keys = HashMap::new();
api_keys.insert("tronscan".to_string(), "test-key".to_string());
let config = ChainsConfig {
api_keys,
..Default::default()
};
let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
assert_eq!(client.api_key, Some("test-key".to_string()));
}
#[test]
fn test_account_response_deserialization() {
let json = r#"{
"data": [{
"balance": 1000000,
"address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"create_time": 1600000000000,
"trc20": []
}],
"success": true
}"#;
let response: AccountResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert_eq!(response.data.len(), 1);
assert_eq!(response.data[0].balance, Some(1_000_000));
}
#[test]
fn test_transaction_response_deserialization() {
let json = r#"{
"data": [{
"txID": "abc123",
"block_number": 12345,
"block_timestamp": 1600000000000,
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#;
let response: TransactionListResponse = serde_json::from_str(json).unwrap();
assert!(response.success);
assert_eq!(response.data.len(), 1);
assert_eq!(response.data[0].tx_id, "abc123");
}
#[tokio::test]
async fn test_get_balance() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{
"data": [{"balance": 5000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}],
"success": true
}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.raw, "5000000");
assert_eq!(balance.symbol, "TRX");
assert!(balance.formatted.contains("5.000000"));
}
#[tokio::test]
async fn test_get_balance_new_account() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.raw, "0");
assert!(balance.formatted.contains("0.000000"));
}
#[tokio::test]
async fn test_get_balance_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Rate limit"));
}
#[tokio::test]
async fn test_get_balance_invalid_address() {
let client = TronClient::default();
let result = client.get_balance("invalid").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transaction() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 1000000,
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
}
},
"type": "TransferContract"
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.hash, VALID_TX_HASH);
assert_eq!(tx.from, "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
assert_eq!(
tx.to,
Some("TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9".to_string())
);
assert_eq!(tx.value, "1000000");
assert_eq!(tx.block_number, Some(50000000));
assert_eq!(tx.timestamp, Some(1700000000)); assert!(tx.status.unwrap());
}
#[tokio::test]
async fn test_get_transaction_failed() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"ret": [{"contractRet": "REVERT"}]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert!(!tx.status.unwrap()); }
#[tokio::test]
async fn test_get_transaction_not_found() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transaction(VALID_TX_HASH).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::Regex(r"/v1/accounts/.*/transactions.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{
"data": [
{
"txID": "aaa111",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {"contract": [{"parameter": {"value": {"amount": 500000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"}}, "type": "TransferContract"}]},
"ret": [{"contractRet": "SUCCESS"}]
},
{
"txID": "bbb222",
"block_number": 50000001,
"block_timestamp": 1700000060000,
"ret": [{"contractRet": "SUCCESS"}]
}
],
"success": true
}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 2);
assert_eq!(txs[0].hash, "aaa111");
assert_eq!(txs[0].value, "500000");
assert!(txs[0].status.unwrap());
assert_eq!(txs[1].value, "0");
}
#[tokio::test]
async fn test_get_transactions_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transactions(VALID_ADDRESS, 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_trc20_balances() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"balance": 1000000,
"address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"trc20": [
{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"},
{"TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8": "0"}
]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
assert_eq!(balances.len(), 1);
assert_eq!(
balances[0].contract_address,
"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
);
assert_eq!(balances[0].raw_balance, "5000000");
}
#[tokio::test]
async fn test_get_trc20_balances_empty_account() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
assert!(balances.is_empty());
}
#[tokio::test]
async fn test_get_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let block = client.get_block_number().await.unwrap();
assert_eq!(block, 60000000);
}
#[tokio::test]
async fn test_get_block_number_invalid_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_block_number().await;
assert!(result.is_err());
}
#[test]
fn test_validate_tron_address_wrong_decoded_length() {
let result = validate_tron_address("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT1");
assert!(result.is_err());
}
#[test]
fn test_validate_tron_tx_hash_wrong_length() {
let result = validate_tron_tx_hash("abc123");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("64 characters"));
}
#[tokio::test]
async fn test_get_transaction_success() {
let mut server = mockito::Server::new_async().await;
let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":[{
"txID":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"blockNumber":60000000,
"block_timestamp":1700000000000,
"raw_data":{"contract":[{"parameter":{"value":{
"owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address":"TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn",
"amount":1000000
}}}]},
"ret":[{"contractRet":"SUCCESS"}]
}],"success":true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(valid_hash).await.unwrap();
assert_eq!(tx.hash, valid_hash);
assert_eq!(tx.status, Some(true));
}
#[tokio::test]
async fn test_get_transaction_api_error() {
let mut server = mockito::Server::new_async().await;
let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[],"success":false,"error":"Transaction not found"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transaction(valid_hash).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transactions_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":[{
"txID":"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
"blockNumber":60000000,
"block_timestamp":1700000000000,
"raw_data":{"contract":[{"parameter":{"value":{
"owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"amount":500000
}}}]},
"ret":[{"contractRet":"SUCCESS"}]
}],"success":true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 1);
}
#[tokio::test]
async fn test_tron_chain_client_trait_accessors() {
let client = TronClient::with_api_url("http://localhost");
let chain_client: &dyn ChainClient = &client;
assert_eq!(chain_client.chain_name(), "tron");
assert_eq!(chain_client.native_token_symbol(), "TRX");
}
#[tokio::test]
async fn test_chain_client_trait_get_balance() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [{"balance": 1000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "TRX");
}
#[tokio::test]
async fn test_chain_client_trait_get_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let block = chain_client.get_block_number().await.unwrap();
assert_eq!(block, 60000000);
}
#[tokio::test]
async fn test_chain_client_trait_get_token_balances() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"}]}], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balances = chain_client
.get_token_balances(VALID_ADDRESS)
.await
.unwrap();
assert_eq!(balances.len(), 1);
assert!(
balances[0].token.symbol == "USDT" || balances[0].token.symbol == "TRC20",
"symbol should be USDT (Tronscan) or TRC20 (fallback)"
);
assert!(!balances[0].token.name.is_empty(), "name must be set");
}
#[tokio::test]
async fn test_chain_client_trait_get_transaction_tron() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 1000000,
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
}
},
"type": "TransferContract"
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}], "success": true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.hash, VALID_TX_HASH);
assert!(tx.status.unwrap());
}
#[tokio::test]
async fn test_chain_client_trait_get_transactions_tron() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 2000000,
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
}
}
}]
},
"ret": [{"contractRet": "REVERT"}]
}], "success": true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_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);
assert!(!txs[0].status.unwrap()); }
#[tokio::test]
async fn test_get_balance_with_api_key() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [{"balance": 10000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#,
)
.create_async()
.await;
let config = ChainsConfig {
tron_api: Some(server.url()),
api_keys: {
let mut m = std::collections::HashMap::new();
m.insert("tronscan".to_string(), "test-api-key".to_string());
m
},
..Default::default()
};
let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "TRX");
assert!(balance.formatted.contains("TRX"));
}
#[tokio::test]
async fn test_get_trc20_balances_error_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_trc20_balances(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Rate limit"));
}
#[tokio::test]
async fn test_get_trc20_balances_no_data() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": true}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
assert!(balances.is_empty());
}
#[tokio::test]
async fn test_get_trc20_balances_with_api_key() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}]}], "success": true}"#,
)
.create_async()
.await;
let config = ChainsConfig {
tron_api: Some(server.url()),
api_keys: {
let mut m = std::collections::HashMap::new();
m.insert("tronscan".to_string(), "my-api-key".to_string());
m
},
..Default::default()
};
let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
assert_eq!(balances.len(), 1);
}
#[test]
fn test_validate_tron_address_bad_checksum() {
let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCe");
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("checksum")
|| err_str.contains("base58")
|| err_str.contains("prefix")
);
}
#[tokio::test]
async fn test_get_transaction_tron_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 5000000,
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
}
}
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}], "success": true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.hash, VALID_TX_HASH);
assert!(tx.status.unwrap());
assert_eq!(tx.value, "5000000");
assert_eq!(tx.timestamp, Some(1700000000)); }
#[tokio::test]
async fn test_get_transaction_tron_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Transaction not found"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transaction(VALID_TX_HASH).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transactions_tron_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data": [
{
"txID": "aaa12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000001,
"block_timestamp": 1700000003000,
"raw_data": {"contract": [{"parameter": {"value": {"amount": 1000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"}}}]},
"ret": [{"contractRet": "SUCCESS"}]
},
{
"txID": "bbb12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000002,
"block_timestamp": 1700000006000,
"raw_data": {"contract": [{"parameter": {"value": {"amount": 2000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"}}}]},
"ret": [{"contractRet": "SUCCESS"}]
}
], "success": true}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 2);
}
#[tokio::test]
async fn test_get_transactions_tron_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transactions(VALID_ADDRESS, 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_balance_error_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false, "error": "Account not found"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Account not found")
);
}
#[tokio::test]
async fn test_get_token_info_success() {
let info: serde_json::Value = serde_json::from_str(
r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 6}]}"#,
)
.unwrap();
let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
let token_data = tokens.first().unwrap();
let symbol = token_data
.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN");
assert_eq!(symbol, "USDT");
let name = token_data
.get("contract_name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Token");
assert_eq!(name, "TetherToken");
let decimals = token_data
.get("decimals")
.and_then(|v| v.as_u64())
.unwrap_or(6) as u8;
assert_eq!(decimals, 6);
}
#[tokio::test]
async fn test_get_token_info_no_tokens() {
let info: serde_json::Value = serde_json::from_str(r#"{"trc20_tokens": []}"#).unwrap();
let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
assert!(tokens.is_empty());
}
#[tokio::test]
async fn test_get_token_info_missing_field() {
let info: serde_json::Value =
serde_json::from_str(r#"{"trc20_tokens": [{"symbol": "TEST"}]}"#).unwrap();
let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
let token_data = tokens.first().unwrap();
let name = token_data
.get("contract_name")
.or_else(|| token_data.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown Token");
assert_eq!(name, "Unknown Token");
let decimals = token_data
.get("decimals")
.and_then(|v| v.as_u64())
.unwrap_or(6) as u8;
assert_eq!(decimals, 6);
}
#[tokio::test]
async fn test_token_holder_response_parsing() {
let json: serde_json::Value = serde_json::from_str(
r#"{"trc20_tokens": [
{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "5000000"},
{"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "3000000"}
]}"#,
)
.unwrap();
let holders_data: &[serde_json::Value] = json
.get("trc20_tokens")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or(&[]);
assert_eq!(holders_data.len(), 2);
let total_balance: f64 = holders_data
.iter()
.filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
.filter_map(|s| s.parse::<f64>().ok())
.sum();
assert_eq!(total_balance, 8000000.0);
let decimals: u8 = 6;
let holders: Vec<TokenHolder> = holders_data
.iter()
.enumerate()
.filter_map(|(i, h)| {
let holder_address = h.get("holder_address")?.as_str()?.to_string();
let balance_raw = h.get("balance")?.as_str()?.to_string();
let balance: f64 = balance_raw.parse().ok()?;
let percentage = if total_balance > 0.0 {
(balance / total_balance) * 100.0
} else {
0.0
};
let divisor = 10_f64.powi(decimals as i32);
let formatted = format!("{:.6}", balance / divisor);
Some(TokenHolder {
address: holder_address,
balance: balance_raw,
formatted_balance: formatted,
percentage,
rank: (i + 1) as u32,
})
})
.collect();
assert_eq!(holders.len(), 2);
assert_eq!(holders[0].rank, 1);
assert_eq!(holders[1].rank, 2);
assert!(holders[0].percentage > 60.0);
assert!(holders[0].formatted_balance.contains("5.000000"));
}
#[tokio::test]
async fn test_token_holder_count_parsing() {
let json: serde_json::Value = serde_json::from_str(r#"{"rangeTotal": 12345}"#).unwrap();
let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
assert_eq!(count, 12345);
let json_no_field: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
let count2 = json_no_field
.get("rangeTotal")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(count2, 0);
}
#[test]
fn test_dex_search_response_deserialization() {
let json = r#"{"pairs":[{"baseTokenSymbol":"TRX","priceUsd":"0.08"}]}"#;
let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
assert!(result.is_ok());
let resp = result.unwrap();
let pairs = resp.pairs.unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
}
#[test]
fn test_dex_search_response_empty() {
let json = r#"{"pairs":[]}"#;
let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
assert!(result.is_ok());
assert!(result.unwrap().pairs.unwrap().is_empty());
}
#[test]
fn test_dex_search_response_no_pairs() {
let json = r#"{}"#;
let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
assert!(result.is_ok());
assert!(result.unwrap().pairs.is_none());
}
#[tokio::test]
async fn test_get_transaction_invalid_hash() {
let client = TronClient::default();
let result = client.get_transaction("not-a-valid-hash").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("64 characters"));
}
#[tokio::test]
async fn test_get_transactions_invalid_address() {
let client = TronClient::default();
let result = client.get_transactions("invalid-address", 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_info_invalid_address() {
let client = TronClient::default();
let result = client.get_token_info("bad-address").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_holders_invalid_address() {
let client = TronClient::default();
let result = client.get_token_holders("bad-address", 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_holder_count_invalid_address() {
let client = TronClient::default();
let result = client.get_token_holder_count("bad-address").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_chain_client_get_token_info_invalid_address() {
let client = TronClient::default();
let chain_client: &dyn ChainClient = &client;
let result = chain_client.get_token_info("x").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_chain_client_get_token_holders_invalid_address() {
let client = TronClient::default();
let chain_client: &dyn ChainClient = &client;
let result = chain_client.get_token_holders("x", 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_chain_client_get_token_holder_count_invalid_address() {
let client = TronClient::default();
let chain_client: &dyn ChainClient = &client;
let result = chain_client.get_token_holder_count("x").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_balance_api_error_unknown_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown error"));
}
#[tokio::test]
async fn test_get_transaction_status_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"ret": []
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.status, None);
}
#[tokio::test]
async fn test_get_transaction_no_ret_field() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.status, None);
}
#[tokio::test]
async fn test_get_transaction_to_address_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 1000000,
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
}
}
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.to, None);
assert_eq!(tx.value, "1000000");
}
#[tokio::test]
async fn test_get_transaction_amount_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
}
}
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.value, "0");
}
#[tokio::test]
async fn test_get_transaction_no_raw_data() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(tx.from, "");
assert_eq!(tx.to, None);
assert_eq!(tx.value, "0");
}
#[tokio::test]
async fn test_get_block_number_block_header_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"other": "data"}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_block_number().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid block"));
}
#[tokio::test]
async fn test_get_block_number_raw_data_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"block_header":{}}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_block_number().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_block_number_number_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/wallet/getnowblock")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"block_header":{"raw_data":{}}}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_block_number().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_trc20_balances_api_error_unknown() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_trc20_balances(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown error"));
}
#[tokio::test]
async fn test_get_transactions_api_error_unknown() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transactions(VALID_ADDRESS, 10).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown error"));
}
#[tokio::test]
async fn test_get_transaction_api_error_unknown() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data": [], "success": false}"#)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let result = client.get_transaction(VALID_TX_HASH).await;
assert!(result.is_err());
}
#[test]
fn test_trc20_token_balance_struct() {
let balance = Trc20TokenBalance {
contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
raw_balance: "5000000".to_string(),
};
assert_eq!(
balance.contract_address,
"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
);
assert_eq!(balance.raw_balance, "5000000");
let debug_str = format!("{:?}", balance);
assert!(debug_str.contains("Trc20TokenBalance"));
}
#[test]
fn test_account_response_with_error_field() {
let json = r#"{
"data": [],
"success": false,
"error": "Custom error message"
}"#;
let response: AccountResponse = serde_json::from_str(json).unwrap();
assert!(!response.success);
assert_eq!(response.error, Some("Custom error message".to_string()));
}
#[test]
fn test_account_response_trc20_balances() {
let json = r#"{
"data": [{
"balance": 1000000,
"address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
"trc20": [
{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}
]
}],
"success": true
}"#;
let response: AccountResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.data.len(), 1);
assert_eq!(response.data[0].trc20.len(), 1);
let trc20 = &response.data[0].trc20[0];
assert_eq!(
trc20.balances.get("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
Some(&"10000000".to_string())
);
}
#[test]
fn test_transaction_list_response_with_error() {
let json = r#"{
"data": [],
"success": false,
"error": "Transaction not found"
}"#;
let response: TransactionListResponse = serde_json::from_str(json).unwrap();
assert!(!response.success);
assert_eq!(response.error, Some("Transaction not found".to_string()));
}
#[test]
fn test_full_transaction_deserialization() {
let json = r#"{
"data": [{
"txID": "abc123def456",
"block_number": 12345,
"block_timestamp": 1600000000000,
"raw_data": {
"contract": [{
"parameter": {
"value": {
"amount": 999999,
"owner_address": "TFrom123",
"to_address": "TTo456"
}
},
"type": "TransferContract"
}]
},
"ret": [{"contractRet": "SUCCESS"}]
}],
"success": true
}"#;
let response: TransactionListResponse = serde_json::from_str(json).unwrap();
let tx = &response.data[0];
assert_eq!(tx.tx_id, "abc123def456");
assert_eq!(tx.block_number, Some(12345));
assert_eq!(tx.block_timestamp, Some(1600000000000));
let contract_value = tx
.raw_data
.as_ref()
.and_then(|r| r.contract.as_ref())
.and_then(|c| c.first())
.and_then(|c| c.parameter.as_ref())
.and_then(|p| p.value.as_ref())
.unwrap();
assert_eq!(contract_value.amount, Some(999999));
assert_eq!(contract_value.owner_address.as_deref(), Some("TFrom123"));
assert_eq!(contract_value.to_address.as_deref(), Some("TTo456"));
assert_eq!(
tx.ret
.as_ref()
.and_then(|r| r.first())
.and_then(|r| r.contract_ret.as_deref()),
Some("SUCCESS")
);
}
#[test]
fn test_tron_client_default_trait() {
let client = TronClient::default();
assert_eq!(client.chain_name(), "tron");
assert_eq!(client.native_token_symbol(), "TRX");
assert_eq!(client.api_url, DEFAULT_TRON_API);
}
#[test]
fn test_dex_search_pair_wtrx() {
let json = r#"{"pairs":[{"baseTokenSymbol":"WTRX","priceUsd":"0.08"}]}"#;
let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
assert!(result.is_ok());
let resp = result.unwrap();
let pairs = resp.pairs.unwrap();
assert_eq!(pairs[0].base_token_symbol, Some("WTRX".to_string()));
assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
}
#[tokio::test]
async fn test_enrich_balance_usd_no_panic() {
let mut balance = Balance {
raw: "1000000".to_string(),
formatted: "1.000000 TRX".to_string(),
decimals: TRX_DECIMALS,
symbol: "TRX".to_string(),
usd_value: None,
};
let client = TronClient::default();
client.enrich_balance_usd(&mut balance).await;
}
#[test]
fn test_trc20_token_balance_debug_format() {
let b = Trc20TokenBalance {
contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
raw_balance: "1000000".to_string(),
};
let s = format!("{:?}", b);
assert!(s.contains("Trc20TokenBalance"));
assert!(s.contains("1000000"));
}
#[tokio::test]
async fn test_get_transactions_with_minimal_contract_data() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock(
"GET",
mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"data": [{
"txID": "c4d23e73be8f8c9c94c10b79c0c0a0c24b2c9a9c0a0c0a0c0a0c0a0c0a0c0a0c0a0c",
"block_number": 50000000,
"block_timestamp": 1700000000000,
"raw_data": {"contract": [{}]},
"ret": []
}],
"success": true
}"#,
)
.create_async()
.await;
let client = TronClient::with_api_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 5).await.unwrap();
assert_eq!(txs.len(), 1);
assert_eq!(txs[0].value, "0");
assert_eq!(txs[0].status, None);
}
const USDT_CONTRACT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
struct ToMockServer {
base: String,
inner: Arc<dyn HttpClient>,
}
#[async_trait]
impl HttpClient for ToMockServer {
async fn send(&self, mut req: Request) -> Result<crate::http::Response> {
for host in [TRONSCAN_API, "https://api.dexscreener.com"] {
if let Some(rest) = req.url.strip_prefix(host) {
req.url = format!("{}{}", self.base, rest);
}
}
self.inner.send(req).await
}
}
fn mocked_client(server: &mockito::Server, api_key: Option<&str>) -> TronClient {
let mut config = ChainsConfig {
tron_api: Some(server.url()),
..Default::default()
};
if let Some(key) = api_key {
config
.api_keys
.insert("tronscan".to_string(), key.to_string());
}
let http = Arc::new(ToMockServer {
base: server.url(),
inner: crate::http::test_transport(),
});
TronClient::new_with_http(&config, http).unwrap()
}
fn trx_balance(raw: &str) -> Balance {
Balance {
raw: raw.to_string(),
formatted: String::new(),
decimals: TRX_DECIMALS,
symbol: "TRX".to_string(),
usd_value: None,
}
}
async fn mock_token_info(
server: &mut mockito::Server,
status: usize,
body: &str,
) -> mockito::Mock {
server
.mock("GET", "/api/token_trc20")
.match_query(mockito::Matcher::Any)
.with_status(status)
.with_body(body)
.create_async()
.await
}
async fn mock_holders(server: &mut mockito::Server, body: &str) -> mockito::Mock {
server
.mock("GET", "/api/token_trc20/holders")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_body(body)
.create_async()
.await
}
async fn mock_dex_search(server: &mut mockito::Server, body: &str) -> mockito::Mock {
server
.mock("GET", "/latest/dex/search")
.match_query(mockito::Matcher::UrlEncoded("q".into(), "TRX USDT".into()))
.with_status(200)
.with_body(body)
.create_async()
.await
}
#[tokio::test]
async fn test_get_token_info_reads_tronscan_and_sends_the_api_key() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/token_trc20")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("contract".into(), USDT_CONTRACT.into()),
mockito::Matcher::UrlEncoded("showAll".into(), "1".into()),
]))
.match_header("TRON-PRO-API-KEY", "k1")
.with_status(200)
.with_body(
r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "name": "Tether USD", "decimals": 6}]}"#,
)
.create_async()
.await;
let client = mocked_client(&server, Some("k1"));
let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
mock.assert_async().await;
assert_eq!(token.contract_address, USDT_CONTRACT);
assert_eq!(token.symbol, "USDT");
assert_eq!(token.name, "TetherToken");
assert_eq!(token.decimals, 6);
}
#[tokio::test]
async fn test_get_token_info_falls_back_to_name_then_defaults() {
let mut server = mockito::Server::new_async().await;
let client = mocked_client(&server, None);
let _m = mock_token_info(
&mut server,
200,
r#"{"trc20_tokens": [{"name": "Plain Name", "decimals": 18}]}"#,
)
.await;
let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
assert_eq!(token.symbol, "UNKNOWN");
assert_eq!(token.name, "Plain Name");
assert_eq!(token.decimals, 18);
server.reset();
let _m = mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{}]}"#).await;
let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
assert_eq!(token.symbol, "UNKNOWN");
assert_eq!(token.name, "Unknown Token");
assert_eq!(token.decimals, 6);
}
#[tokio::test]
async fn test_get_token_info_not_found_and_bad_json() {
let mut server = mockito::Server::new_async().await;
let client = mocked_client(&server, None);
for body in [r#"{"trc20_tokens": []}"#, r#"{"other": 1}"#] {
server.reset();
let _m = mock_token_info(&mut server, 200, body).await;
let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
assert!(matches!(err, ScopeError::NotFound(_)), "{body}: {err}");
assert!(err.to_string().contains(USDT_CONTRACT));
}
server.reset();
let _m = mock_token_info(&mut server, 200, "<html>not json</html>").await;
let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
assert!(matches!(err, ScopeError::Api(_)), "{err}");
assert!(err.to_string().contains("Tronscan"));
}
#[tokio::test]
async fn test_get_token_holders_ranks_and_shares_by_fetched_total() {
let mut server = mockito::Server::new_async().await;
let holders = server
.mock("GET", "/api/token_trc20/holders")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("contract_address".into(), USDT_CONTRACT.into()),
mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
]))
.match_header("TRON-PRO-API-KEY", "k2")
.with_status(200)
.with_body(
r#"{"trc20_tokens": [
{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "3000000000000000000"},
{"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "not-a-number"},
{"balance": "500"},
{"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "1000000000000000000"}
]}"#,
)
.create_async()
.await;
let _info = mock_token_info(
&mut server,
200,
r#"{"trc20_tokens": [{"symbol": "BIG", "decimals": 18}]}"#,
)
.await;
let client = mocked_client(&server, Some("k2"));
let result = client.get_token_holders(USDT_CONTRACT, 500).await.unwrap();
holders.assert_async().await;
assert_eq!(result.len(), 2);
assert_eq!(result[0].address, VALID_ADDRESS);
assert_eq!(result[0].rank, 1);
assert_eq!(result[0].formatted_balance, "3.000000");
assert_eq!(result[1].rank, 4);
assert_eq!(result[1].formatted_balance, "1.000000");
let total = 4e18 + 500.0;
assert!((result[0].percentage - 3e18 / total * 100.0).abs() < 1e-9);
assert!((result[1].percentage - 1e18 / total * 100.0).abs() < 1e-9);
}
#[tokio::test]
async fn test_get_token_holders_without_token_info_uses_six_decimals() {
let mut server = mockito::Server::new_async().await;
let _holders = mock_holders(
&mut server,
r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "2500000"}]}"#,
)
.await;
let _info = mock_token_info(&mut server, 200, r#"{"trc20_tokens": []}"#).await;
let client = mocked_client(&server, None);
let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].formatted_balance, "2.500000");
assert!((result[0].percentage - 100.0).abs() < 1e-9);
}
#[tokio::test]
async fn test_get_token_holders_zero_total_gives_zero_share() {
let mut server = mockito::Server::new_async().await;
let _holders = mock_holders(
&mut server,
r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "0"}]}"#,
)
.await;
let _info =
mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
let client = mocked_client(&server, None);
let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].percentage, 0.0);
}
#[tokio::test]
async fn test_get_token_holders_empty_and_bad_json() {
let mut server = mockito::Server::new_async().await;
let client = mocked_client(&server, None);
let _holders = mock_holders(&mut server, r#"{"total": 0}"#).await;
let _info =
mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
assert!(
client
.get_token_holders(USDT_CONTRACT, 10)
.await
.unwrap()
.is_empty()
);
server.reset();
let _holders = mock_holders(&mut server, "{").await;
let err = client
.get_token_holders(USDT_CONTRACT, 10)
.await
.unwrap_err();
assert!(err.to_string().contains("Tronscan holders"), "{err}");
}
#[tokio::test]
async fn test_get_token_holder_count() {
let mut server = mockito::Server::new_async().await;
let client = mocked_client(&server, Some("k3"));
let mock = server
.mock("GET", "/api/token_trc20/holders")
.match_query(mockito::Matcher::UrlEncoded("limit".into(), "1".into()))
.match_header("TRON-PRO-API-KEY", "k3")
.with_status(200)
.with_body(r#"{"rangeTotal": 12345, "trc20_tokens": []}"#)
.create_async()
.await;
assert_eq!(
client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
12345
);
mock.assert_async().await;
server.reset();
let _m = mock_holders(&mut server, "{}").await;
assert_eq!(
client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
0
);
server.reset();
let _m = mock_holders(&mut server, "nope").await;
let err = client
.get_token_holder_count(USDT_CONTRACT)
.await
.unwrap_err();
assert!(matches!(err, ScopeError::Api(_)), "{err}");
}
#[tokio::test]
async fn test_enrich_balance_usd_uses_first_priced_trx_pair() {
let mut server = mockito::Server::new_async().await;
let _m = mock_dex_search(
&mut server,
r#"{"pairs": [
{"baseTokenSymbol": "USDT", "priceUsd": "1.00"},
{"baseTokenSymbol": "TRX", "priceUsd": "n/a"},
{"baseTokenSymbol": "WTRX", "priceUsd": "0.25"},
{"baseTokenSymbol": "TRX", "priceUsd": "9.99"}
]}"#,
)
.await;
let client = mocked_client(&server, None);
let mut balance = trx_balance("4000000"); client.enrich_balance_usd(&mut balance).await;
assert_eq!(balance.usd_value, Some(1.0));
let chain_client: &dyn ChainClient = &client;
let mut balance = trx_balance("2000000");
chain_client.enrich_balance_usd(&mut balance).await;
assert_eq!(balance.usd_value, Some(0.5));
}
#[tokio::test]
async fn test_enrich_balance_usd_leaves_value_unset_without_a_price() {
let mut server = mockito::Server::new_async().await;
let client = mocked_client(&server, None);
for body in [
r#"{"pairs": null}"#,
r#"{"pairs": [{"baseTokenSymbol": "BTC", "priceUsd": "60000"}]}"#,
r#"{"pairs": [{"baseTokenSymbol": "TRX"}]}"#,
"garbage",
] {
server.reset();
let _m = mock_dex_search(&mut server, body).await;
let mut balance = trx_balance("1000000");
client.enrich_balance_usd(&mut balance).await;
assert_eq!(balance.usd_value, None, "{body}");
}
}
#[tokio::test]
async fn test_chain_client_token_balances_use_tronscan_info_or_placeholder() {
let account_body = format!(
r#"{{"data": [{{"balance": 0, "address": "{VALID_ADDRESS}", "trc20": [{{"{USDT_CONTRACT}": "1234567"}}]}}], "success": true}}"#
);
let account_path = format!("/v1/accounts/{VALID_ADDRESS}");
let mut server = mockito::Server::new_async().await;
let _account = server
.mock("GET", account_path.as_str())
.with_status(200)
.with_body(&account_body)
.create_async()
.await;
let _info = mock_token_info(
&mut server,
200,
r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 3}]}"#,
)
.await;
let client = mocked_client(&server, None);
let chain_client: &dyn ChainClient = &client;
let balances = chain_client
.get_token_balances(VALID_ADDRESS)
.await
.unwrap();
assert_eq!(balances.len(), 1);
assert_eq!(balances[0].token.symbol, "USDT");
assert_eq!(balances[0].token.decimals, 3);
assert_eq!(balances[0].balance, "1234567");
assert_eq!(balances[0].formatted_balance, "1234.567000");
assert_eq!(balances[0].usd_value, None);
server.reset();
let _account = server
.mock("GET", account_path.as_str())
.with_status(200)
.with_body(&account_body)
.create_async()
.await;
let _info = mock_token_info(&mut server, 500, "down").await;
let balances = chain_client
.get_token_balances(VALID_ADDRESS)
.await
.unwrap();
assert_eq!(balances[0].token.symbol, "TRC20");
assert_eq!(balances[0].token.name, "TRC-20 Token");
assert_eq!(balances[0].token.contract_address, USDT_CONTRACT);
assert_eq!(balances[0].formatted_balance, "1.234567");
}
#[tokio::test]
async fn test_chain_client_token_queries_delegate_to_tronscan() {
let mut server = mockito::Server::new_async().await;
let _holders = mock_holders(
&mut server,
r#"{"rangeTotal": 7, "trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "1"}]}"#,
)
.await;
let _info = mock_token_info(
&mut server,
200,
r#"{"trc20_tokens": [{"symbol": "USDT", "decimals": 6}]}"#,
)
.await;
let client = mocked_client(&server, None);
let chain_client: &dyn ChainClient = &client;
assert_eq!(
chain_client
.get_token_info(USDT_CONTRACT)
.await
.unwrap()
.symbol,
"USDT"
);
assert_eq!(
chain_client
.get_token_holders(USDT_CONTRACT, 5)
.await
.unwrap()
.len(),
1
);
assert_eq!(
chain_client
.get_token_holder_count(USDT_CONTRACT)
.await
.unwrap(),
7
);
}
#[tokio::test]
async fn test_get_transaction_and_transactions_send_the_api_key() {
let mut server = mockito::Server::new_async().await;
let tx = server
.mock("GET", format!("/v1/transactions/{VALID_TX_HASH}").as_str())
.match_header("TRON-PRO-API-KEY", "k4")
.with_status(200)
.with_body(format!(
r#"{{"data": [{{"txID": "{VALID_TX_HASH}"}}], "success": true}}"#
))
.create_async()
.await;
let txs = server
.mock(
"GET",
format!("/v1/accounts/{VALID_ADDRESS}/transactions").as_str(),
)
.match_query(mockito::Matcher::UrlEncoded("limit".into(), "3".into()))
.match_header("TRON-PRO-API-KEY", "k4")
.with_status(200)
.with_body(r#"{"data": [], "success": true}"#)
.create_async()
.await;
let client = mocked_client(&server, Some("k4"));
let got = client.get_transaction(VALID_TX_HASH).await.unwrap();
assert_eq!(got.hash, VALID_TX_HASH);
assert_eq!(got.from, "");
assert_eq!(got.value, "0");
assert!(
client
.get_transactions(VALID_ADDRESS, 3)
.await
.unwrap()
.is_empty()
);
tx.assert_async().await;
txs.assert_async().await;
}
#[test]
fn test_validate_tron_address_rejects_non_mainnet_prefix() {
let mut bytes = vec![0x42u8];
bytes.extend(0u8..20);
let checksum = Sha256::digest(Sha256::digest(&bytes));
bytes.extend_from_slice(&checksum[..4]);
let address = bs58::encode(&bytes).into_string();
assert_eq!(address, "TZJqCCFeCFdJJaGGgNhTbhcLdyjmqUrgFq");
let err = validate_tron_address(&address).unwrap_err();
assert!(err.to_string().contains("prefix"), "{err}");
}
#[test]
#[allow(deprecated)]
fn test_deprecated_new_reads_the_config() {
let config = ChainsConfig {
tron_api: Some("http://127.0.0.1:9".to_string()),
..Default::default()
};
let client = TronClient::new(&config).unwrap();
assert_eq!(client.api_url, "http://127.0.0.1:9");
assert!(client.api_key.is_none());
}
}