Skip to main content

scope/chains/
tron.rs

1//! # Tron Client
2//!
3//! This module provides a Tron blockchain client for querying balances,
4//! transactions, and account information on the Tron network.
5//!
6//! ## Features
7//!
8//! - Balance queries via TronGrid API (with USD valuation via DexScreener)
9//! - Transaction history retrieval
10//! - Transaction details lookup by hash
11//! - TRC-20 token balance fetching from TronGrid account endpoint
12//! - T-address validation with full base58check verification (double SHA256 checksum)
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use scope::chains::TronClient;
18//! use scope::config::ChainsConfig;
19//!
20//! #[tokio::main]
21//! async fn main() -> scope::Result<()> {
22//!     let config = ChainsConfig::default();
23//!     let client = TronClient::new(&config)?;
24//!     
25//!     let balance = client.get_balance("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf").await?;
26//!     println!("Balance: {} TRX", balance.formatted);
27//!     Ok(())
28//! }
29//! ```
30
31use crate::chains::{Balance, ChainClient, Token, TokenHolder, Transaction};
32use crate::config::ChainsConfig;
33use crate::error::{Result, ScopeError};
34use crate::http::{HttpClient, Request};
35use async_trait::async_trait;
36use serde::Deserialize;
37use serde_json;
38use sha2::{Digest, Sha256};
39use std::sync::Arc;
40
41/// Default TronGrid API endpoint.
42const DEFAULT_TRON_API: &str = "https://api.trongrid.io";
43
44/// Tronscan API base for token info and holder lookups.
45const TRONSCAN_API: &str = "https://apilist.tronscanapi.com";
46
47/// DexScreener search URL for TRX/USDT price lookup.
48const DEXSCREENER_TRX_SEARCH: &str = "https://api.dexscreener.com/latest/dex/search?q=TRX%20USDT";
49
50/// Tron native token decimals (TRX uses 6 decimals, stored as "sun").
51const TRX_DECIMALS: u8 = 6;
52
53/// Tron blockchain client.
54///
55/// Uses TronGrid REST API for data retrieval.
56#[derive(Clone)]
57pub struct TronClient {
58    /// HTTP client for API requests.
59    http: Arc<dyn HttpClient>,
60
61    /// TronGrid API base URL.
62    api_url: String,
63
64    /// TronGrid API key for higher rate limits.
65    api_key: Option<String>,
66}
67
68/// Account response from TronGrid API.
69#[derive(Debug, Deserialize)]
70struct AccountResponse {
71    data: Vec<AccountData>,
72    success: bool,
73    error: Option<String>,
74}
75
76/// Account data from TronGrid.
77#[derive(Debug, Deserialize)]
78#[allow(dead_code)] // Fields used for deserialization
79struct AccountData {
80    balance: Option<u64>,
81    address: String,
82    create_time: Option<u64>,
83    #[serde(default)]
84    trc20: Vec<Trc20Balance>,
85}
86
87/// TRC20 token balance.
88#[derive(Debug, Deserialize)]
89#[allow(dead_code)] // Reserved for future TRC20 token support
90struct Trc20Balance {
91    #[serde(flatten)]
92    balances: std::collections::HashMap<String, String>,
93}
94
95/// Transaction list response from TronGrid.
96#[derive(Debug, Deserialize)]
97struct TransactionListResponse {
98    data: Vec<TronTransaction>,
99    success: bool,
100    error: Option<String>,
101}
102
103/// Tron transaction from API.
104#[derive(Debug, Deserialize)]
105struct TronTransaction {
106    #[serde(rename = "txID")]
107    tx_id: String,
108    block_number: Option<u64>,
109    block_timestamp: Option<u64>,
110    raw_data: Option<RawData>,
111    ret: Option<Vec<TransactionResult>>,
112}
113
114/// Raw transaction data.
115#[derive(Debug, Deserialize)]
116struct RawData {
117    contract: Option<Vec<Contract>>,
118}
119
120/// Contract call in transaction.
121#[derive(Debug, Deserialize)]
122#[allow(dead_code)] // Fields used for deserialization
123struct Contract {
124    parameter: Option<ContractParameter>,
125    #[serde(rename = "type")]
126    contract_type: Option<String>,
127}
128
129/// Contract parameters.
130#[derive(Debug, Deserialize)]
131struct ContractParameter {
132    value: Option<ContractValue>,
133}
134
135/// Contract value containing transfer details.
136#[derive(Debug, Deserialize)]
137struct ContractValue {
138    amount: Option<u64>,
139    owner_address: Option<String>,
140    to_address: Option<String>,
141}
142
143/// Transaction result.
144#[derive(Debug, Deserialize)]
145struct TransactionResult {
146    #[serde(rename = "contractRet")]
147    contract_ret: Option<String>,
148}
149
150impl TronClient {
151    /// Creates a new Tron client with the given configuration.
152    ///
153    /// # Arguments
154    ///
155    /// * `config` - Chain configuration containing API endpoint and keys
156    ///
157    /// # Returns
158    ///
159    /// Returns a configured [`TronClient`] instance.
160    ///
161    /// # Examples
162    ///
163    /// ```rust,no_run
164    /// use scope::chains::TronClient;
165    /// use scope::config::ChainsConfig;
166    ///
167    /// let config = ChainsConfig::default();
168    /// let client = TronClient::new(&config).unwrap();
169    /// ```
170    #[deprecated(
171        since = "0.8.0",
172        note = "pass the transport: use `TronClient::new_with_http`"
173    )]
174    pub fn new(config: &ChainsConfig) -> Result<Self> {
175        Self::new_with_http(config, crate::http::shared::fallback())
176    }
177
178    /// Creates a new Tron client with a pre-built HTTP transport.
179    pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
180        let api_url = config
181            .tron_api
182            .as_deref()
183            .unwrap_or(DEFAULT_TRON_API)
184            .to_string();
185
186        Ok(Self {
187            http,
188            api_url,
189            api_key: config.api_keys.get("tronscan").cloned(),
190        })
191    }
192
193    /// Creates a client with a custom API URL.
194    ///
195    /// # Arguments
196    ///
197    /// * `api_url` - The TronGrid API endpoint URL
198    pub fn with_api_url(api_url: &str) -> Self {
199        Self {
200            http: crate::http::shared::fallback(),
201            api_url: api_url.to_string(),
202            api_key: None,
203        }
204    }
205
206    /// Returns the chain name.
207    pub fn chain_name(&self) -> &str {
208        "tron"
209    }
210
211    /// Returns the native token symbol.
212    pub fn native_token_symbol(&self) -> &str {
213        "TRX"
214    }
215
216    /// Fetches the TRX balance for an address.
217    ///
218    /// # Arguments
219    ///
220    /// * `address` - The Tron address (T-address format)
221    ///
222    /// # Returns
223    ///
224    /// Returns a [`Balance`] struct with the balance in multiple formats.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
229    /// Returns [`ScopeError::Request`] if the API request fails.
230    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
231        // Validate address
232        validate_tron_address(address)?;
233
234        let url = format!("{}/v1/accounts/{}", self.api_url, address);
235
236        tracing::debug!(url = %url, address = %address, "Fetching Tron balance");
237
238        let mut req = Request::get(&url);
239        if let Some(ref key) = self.api_key {
240            req = req.with_header("TRON-PRO-API-KEY", key);
241        }
242
243        let response: AccountResponse = self.http.send(req).await?.json()?;
244
245        if !response.success {
246            return Err(ScopeError::Chain(format!(
247                "TronGrid API error: {}",
248                response.error.unwrap_or_else(|| "Unknown error".into())
249            )));
250        }
251
252        // Account may not exist yet (no balance)
253        let sun = response.data.first().and_then(|d| d.balance).unwrap_or(0);
254
255        let trx = sun as f64 / 10_f64.powi(TRX_DECIMALS as i32);
256
257        Ok(Balance {
258            raw: sun.to_string(),
259            formatted: format!("{:.6} TRX", trx),
260            decimals: TRX_DECIMALS,
261            symbol: "TRX".to_string(),
262            usd_value: None, // Populated by caller via enrich_balance_usd
263        })
264    }
265
266    /// Fetches TRC-20 token balances for an address.
267    ///
268    /// Uses the TronGrid `/v1/accounts/{address}` endpoint which includes
269    /// TRC-20 balances in the account data.
270    pub async fn get_trc20_balances(&self, address: &str) -> Result<Vec<Trc20TokenBalance>> {
271        validate_tron_address(address)?;
272
273        let url = format!("{}/v1/accounts/{}", self.api_url, address);
274
275        tracing::debug!(url = %url, "Fetching TRC-20 token balances");
276
277        let mut req = Request::get(&url);
278        if let Some(ref key) = self.api_key {
279            req = req.with_header("TRON-PRO-API-KEY", key);
280        }
281
282        let response: AccountResponse = self.http.send(req).await?.json()?;
283
284        if !response.success {
285            return Err(ScopeError::Chain(format!(
286                "TronGrid API error: {}",
287                response.error.unwrap_or_else(|| "Unknown error".into())
288            )));
289        }
290
291        let account = match response.data.first() {
292            Some(data) => data,
293            None => return Ok(vec![]),
294        };
295
296        let mut balances = Vec::new();
297        for trc20 in &account.trc20 {
298            for (contract_address, raw_balance) in &trc20.balances {
299                // Skip zero balances
300                if raw_balance == "0" {
301                    continue;
302                }
303                balances.push(Trc20TokenBalance {
304                    contract_address: contract_address.clone(),
305                    raw_balance: raw_balance.clone(),
306                });
307            }
308        }
309
310        Ok(balances)
311    }
312
313    /// Fetches TRC-20 token info from Tronscan API.
314    ///
315    /// Returns symbol, name, decimals, and other metadata for a TRC-20 contract.
316    pub async fn get_token_info(&self, contract_address: &str) -> Result<Token> {
317        validate_tron_address(contract_address)?;
318
319        let url = format!(
320            "{}/api/token_trc20?contract={}&showAll=1",
321            TRONSCAN_API, contract_address
322        );
323
324        tracing::debug!(url = %url, "Fetching TRC-20 token info via Tronscan");
325
326        let mut req = Request::get(&url);
327        if let Some(ref key) = self.api_key {
328            req = req.with_header("TRON-PRO-API-KEY", key);
329        }
330
331        let resp = self.http.send(req).await?;
332        let json: serde_json::Value = serde_json::from_str(&resp.body)
333            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
334
335        let tokens = json
336            .get("trc20_tokens")
337            .and_then(|v| v.as_array())
338            .ok_or_else(|| {
339                ScopeError::NotFound(format!(
340                    "No token info found for TRC-20 contract {}",
341                    contract_address
342                ))
343            })?;
344
345        let token_data = tokens.first().ok_or_else(|| {
346            ScopeError::NotFound(format!(
347                "No token info found for TRC-20 contract {}",
348                contract_address
349            ))
350        })?;
351
352        let symbol = token_data
353            .get("symbol")
354            .and_then(|v| v.as_str())
355            .unwrap_or("UNKNOWN")
356            .to_string();
357        let name = token_data
358            .get("contract_name")
359            .or_else(|| token_data.get("name"))
360            .and_then(|v| v.as_str())
361            .unwrap_or("Unknown Token")
362            .to_string();
363        let decimals = token_data
364            .get("decimals")
365            .and_then(|v| v.as_u64())
366            .unwrap_or(6) as u8;
367
368        Ok(Token {
369            contract_address: contract_address.to_string(),
370            symbol,
371            name,
372            decimals,
373        })
374    }
375
376    /// Fetches top TRC-20 token holders from Tronscan API.
377    ///
378    /// Returns holders sorted by balance (largest first).
379    pub async fn get_token_holders(
380        &self,
381        contract_address: &str,
382        limit: u32,
383    ) -> Result<Vec<TokenHolder>> {
384        validate_tron_address(contract_address)?;
385
386        let effective_limit = limit.min(100);
387        let url = format!(
388            "{}/api/token_trc20/holders?contract_address={}&start=0&limit={}",
389            TRONSCAN_API, contract_address, effective_limit
390        );
391
392        tracing::debug!(url = %url, "Fetching TRC-20 token holders via Tronscan");
393
394        let mut req = Request::get(&url);
395        if let Some(ref key) = self.api_key {
396            req = req.with_header("TRON-PRO-API-KEY", key);
397        }
398
399        let resp = self.http.send(req).await?;
400        let json: serde_json::Value = serde_json::from_str(&resp.body)
401            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan holders: {}", e)))?;
402
403        let holders_data: &[serde_json::Value] = json
404            .get("trc20_tokens")
405            .and_then(|v| v.as_array())
406            .map(|v| v.as_slice())
407            .unwrap_or(&[]);
408
409        // Get decimals for formatted balance
410        let token_info = self.get_token_info(contract_address).await;
411        let decimals = token_info.as_ref().map(|t| t.decimals).unwrap_or(6);
412
413        // Percentage is relative to sum of fetched holder balances (same as EVM chains)
414        let total_balance: f64 = holders_data
415            .iter()
416            .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
417            .filter_map(|s| s.parse::<f64>().ok())
418            .sum();
419
420        let token_holders: Vec<TokenHolder> = holders_data
421            .iter()
422            .enumerate()
423            .filter_map(|(i, h)| {
424                let holder_address = h.get("holder_address")?.as_str()?.to_string();
425                let balance_raw = h.get("balance")?.as_str()?.to_string();
426                let balance: f64 = balance_raw.parse().ok()?;
427                let percentage = if total_balance > 0.0 {
428                    (balance / total_balance) * 100.0
429                } else {
430                    0.0
431                };
432                let divisor = 10_f64.powi(decimals as i32);
433                let formatted = format!("{:.6}", balance / divisor);
434
435                Some(TokenHolder {
436                    address: holder_address,
437                    balance: balance_raw,
438                    formatted_balance: formatted,
439                    percentage,
440                    rank: (i + 1) as u32,
441                })
442            })
443            .collect();
444
445        Ok(token_holders)
446    }
447
448    /// Fetches total holder count for a TRC-20 token.
449    pub async fn get_token_holder_count(&self, contract_address: &str) -> Result<u64> {
450        validate_tron_address(contract_address)?;
451
452        let url = format!(
453            "{}/api/token_trc20/holders?contract_address={}&start=0&limit=1",
454            TRONSCAN_API, contract_address
455        );
456
457        let mut req = Request::get(&url);
458        if let Some(ref key) = self.api_key {
459            req = req.with_header("TRON-PRO-API-KEY", key);
460        }
461
462        let resp = self.http.send(req).await?;
463        let json: serde_json::Value = resp
464            .json()
465            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
466
467        let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
468
469        Ok(count)
470    }
471
472    /// Enriches a balance with a USD value using DexScreener price lookup.
473    ///
474    /// Note: Tron native token price lookup via DexScreener is not yet supported.
475    /// This is a placeholder that uses CoinGecko-style simple price API as fallback.
476    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
477        // Try to get TRX price from DexScreener search API
478        let url = DEXSCREENER_TRX_SEARCH;
479        if let Ok(resp) = self.http.send(Request::get(url)).await
480            && let Ok(search_result) = serde_json::from_str::<DexSearchResponse>(&resp.body)
481            && let Some(pairs) = search_result.pairs
482        {
483            for pair in &pairs {
484                if (pair.base_token_symbol.as_deref() == Some("TRX")
485                    || pair.base_token_symbol.as_deref() == Some("WTRX"))
486                    && let Some(price) = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok())
487                {
488                    let sun: f64 = balance.raw.parse().unwrap_or(0.0);
489                    let trx = sun / 10_f64.powi(TRX_DECIMALS as i32);
490                    balance.usd_value = Some(trx * price);
491                    return;
492                }
493            }
494        }
495    }
496
497    /// Fetches transaction details by hash.
498    ///
499    /// # Arguments
500    ///
501    /// * `hash` - The transaction hash
502    ///
503    /// # Returns
504    ///
505    /// Returns [`Transaction`] details.
506    pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
507        // Validate hash
508        validate_tron_tx_hash(hash)?;
509
510        let url = format!("{}/v1/transactions/{}", self.api_url, hash);
511
512        tracing::debug!(url = %url, hash = %hash, "Fetching Tron transaction");
513
514        let mut req = Request::get(&url);
515        if let Some(ref key) = self.api_key {
516            req = req.with_header("TRON-PRO-API-KEY", key);
517        }
518
519        let response: TransactionListResponse = self.http.send(req).await?.json()?;
520
521        if !response.success {
522            return Err(ScopeError::Chain(format!(
523                "TronGrid API error: {}",
524                response.error.unwrap_or_else(|| "Unknown error".into())
525            )));
526        }
527
528        let tx = response
529            .data
530            .into_iter()
531            .next()
532            .ok_or_else(|| ScopeError::Chain("Transaction not found".into()))?;
533
534        // Extract transfer details from contract
535        let (from, to, value) = tx
536            .raw_data
537            .and_then(|rd| rd.contract)
538            .and_then(|contracts| contracts.into_iter().next())
539            .and_then(|c| c.parameter)
540            .and_then(|p| p.value)
541            .map(|v| {
542                (
543                    v.owner_address.unwrap_or_default(),
544                    v.to_address,
545                    v.amount.unwrap_or(0).to_string(),
546                )
547            })
548            .unwrap_or_else(|| (String::new(), None, "0".to_string()));
549
550        let status = tx
551            .ret
552            .and_then(|r| r.into_iter().next())
553            .and_then(|r| r.contract_ret)
554            .map(|s| s == "SUCCESS");
555
556        Ok(Transaction {
557            hash: tx.tx_id,
558            block_number: tx.block_number,
559            timestamp: tx.block_timestamp.map(|t| t / 1000), // Convert ms to seconds
560            from,
561            to,
562            value,
563            gas_limit: 0, // Tron uses bandwidth/energy instead of gas
564            gas_used: None,
565            gas_price: "0".to_string(),
566            nonce: 0,
567            input: String::new(),
568            status,
569        })
570    }
571
572    /// Fetches recent transactions for an address.
573    ///
574    /// # Arguments
575    ///
576    /// * `address` - The address to query
577    /// * `limit` - Maximum number of transactions
578    ///
579    /// # Returns
580    ///
581    /// Returns a vector of [`Transaction`] objects.
582    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
583        validate_tron_address(address)?;
584
585        let url = format!(
586            "{}/v1/accounts/{}/transactions?limit={}",
587            self.api_url, address, limit
588        );
589
590        tracing::debug!(url = %url, address = %address, "Fetching Tron transactions");
591
592        let mut req = Request::get(&url);
593        if let Some(ref key) = self.api_key {
594            req = req.with_header("TRON-PRO-API-KEY", key);
595        }
596
597        let response: TransactionListResponse = self.http.send(req).await?.json()?;
598
599        if !response.success {
600            return Err(ScopeError::Chain(format!(
601                "TronGrid API error: {}",
602                response.error.unwrap_or_else(|| "Unknown error".into())
603            )));
604        }
605
606        let transactions = response
607            .data
608            .into_iter()
609            .map(|tx| {
610                let (from, to, value) = tx
611                    .raw_data
612                    .and_then(|rd| rd.contract)
613                    .and_then(|contracts| contracts.into_iter().next())
614                    .and_then(|c| c.parameter)
615                    .and_then(|p| p.value)
616                    .map(|v| {
617                        (
618                            v.owner_address.unwrap_or_default(),
619                            v.to_address,
620                            v.amount.unwrap_or(0).to_string(),
621                        )
622                    })
623                    .unwrap_or_else(|| (String::new(), None, "0".to_string()));
624
625                let status = tx
626                    .ret
627                    .and_then(|r| r.into_iter().next())
628                    .and_then(|r| r.contract_ret)
629                    .map(|s| s == "SUCCESS");
630
631                Transaction {
632                    hash: tx.tx_id,
633                    block_number: tx.block_number,
634                    timestamp: tx.block_timestamp.map(|t| t / 1000),
635                    from,
636                    to,
637                    value,
638                    gas_limit: 0,
639                    gas_used: None,
640                    gas_price: "0".to_string(),
641                    nonce: 0,
642                    input: String::new(),
643                    status,
644                }
645            })
646            .collect();
647
648        Ok(transactions)
649    }
650
651    /// Fetches the current block number.
652    pub async fn get_block_number(&self) -> Result<u64> {
653        let url = format!("{}/wallet/getnowblock", self.api_url);
654
655        #[derive(Deserialize)]
656        struct BlockResponse {
657            block_header: Option<BlockHeader>,
658        }
659
660        #[derive(Deserialize)]
661        struct BlockHeader {
662            raw_data: Option<BlockRawData>,
663        }
664
665        #[derive(Deserialize)]
666        struct BlockRawData {
667            number: Option<u64>,
668        }
669
670        let resp = self.http.send(Request::post_json(&url, "")).await?;
671        let response: BlockResponse = resp.json()?;
672
673        response
674            .block_header
675            .and_then(|h| h.raw_data)
676            .and_then(|d| d.number)
677            .ok_or_else(|| ScopeError::Chain("Invalid block response".into()))
678    }
679}
680
681impl Default for TronClient {
682    fn default() -> Self {
683        Self {
684            http: crate::http::shared::fallback(),
685            api_url: DEFAULT_TRON_API.to_string(),
686            api_key: None,
687        }
688    }
689}
690
691/// Validates a Tron address format (T-address, base58check encoded).
692///
693/// Tron addresses:
694/// - Start with 'T'
695/// - Are 34 characters long
696/// - Use base58check encoding (includes checksum)
697///
698/// # Arguments
699///
700/// * `address` - The address to validate
701///
702/// TRC-20 token balance result.
703#[derive(Debug, Clone)]
704pub struct Trc20TokenBalance {
705    /// Token contract address (base58).
706    pub contract_address: String,
707    /// Raw balance string.
708    pub raw_balance: String,
709}
710
711/// Minimal DexScreener search response for price lookups.
712#[derive(Debug, Deserialize)]
713struct DexSearchResponse {
714    #[serde(default)]
715    pairs: Option<Vec<DexSearchPair>>,
716}
717
718/// A pair from DexScreener search results.
719#[derive(Debug, Deserialize)]
720#[serde(rename_all = "camelCase")]
721struct DexSearchPair {
722    #[serde(default)]
723    base_token_symbol: Option<String>,
724    #[serde(default)]
725    price_usd: Option<String>,
726}
727
728/// # Returns
729///
730/// Returns `Ok(())` if valid, or an error describing the validation failure.
731pub fn validate_tron_address(address: &str) -> Result<()> {
732    if address.is_empty() {
733        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
734    }
735
736    // Tron addresses start with 'T'
737    if !address.starts_with('T') {
738        return Err(ScopeError::InvalidAddress(format!(
739            "Tron address must start with 'T': {}",
740            address
741        )));
742    }
743
744    // Tron addresses are 34 characters
745    if address.len() != 34 {
746        return Err(ScopeError::InvalidAddress(format!(
747            "Tron address must be 34 characters, got {}: {}",
748            address.len(),
749            address
750        )));
751    }
752
753    // Validate base58 encoding
754    match bs58::decode(address).into_vec() {
755        Ok(bytes) => {
756            // Should decode to 25 bytes (1 prefix + 20 address + 4 checksum)
757            if bytes.len() != 25 {
758                return Err(ScopeError::InvalidAddress(format!(
759                    "Tron address must decode to 25 bytes, got {}: {}",
760                    bytes.len(),
761                    address
762                )));
763            }
764
765            // First byte should be 0x41 (Tron mainnet prefix)
766            if bytes[0] != 0x41 {
767                return Err(ScopeError::InvalidAddress(format!(
768                    "Invalid Tron address prefix: {}",
769                    address
770                )));
771            }
772
773            // Verify checksum: last 4 bytes must equal first 4 bytes of double SHA256 of first 21 bytes
774            let payload = &bytes[0..21];
775            let hash1 = Sha256::digest(payload);
776            let hash2 = Sha256::digest(hash1);
777            let expected_checksum = &hash2[0..4];
778            let actual_checksum = &bytes[21..25];
779
780            if expected_checksum != actual_checksum {
781                return Err(ScopeError::InvalidAddress(format!(
782                    "Invalid Tron address checksum: {}",
783                    address
784                )));
785            }
786        }
787        Err(e) => {
788            return Err(ScopeError::InvalidAddress(format!(
789                "Invalid base58 encoding: {}: {}",
790                e, address
791            )));
792        }
793    }
794
795    Ok(())
796}
797
798/// Validates a Tron transaction hash format.
799///
800/// Tron transaction hashes are 64-character hex strings.
801///
802/// # Arguments
803///
804/// * `hash` - The hash to validate
805///
806/// # Returns
807///
808/// Returns `Ok(())` if valid, or an error describing the validation failure.
809pub fn validate_tron_tx_hash(hash: &str) -> Result<()> {
810    if hash.is_empty() {
811        return Err(ScopeError::InvalidHash("Hash cannot be empty".into()));
812    }
813
814    // Tron tx hashes are 64 hex characters (without 0x prefix)
815    if hash.len() != 64 {
816        return Err(ScopeError::InvalidHash(format!(
817            "Tron transaction hash must be 64 characters, got {}: {}",
818            hash.len(),
819            hash
820        )));
821    }
822
823    // Validate hex encoding
824    if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
825        return Err(ScopeError::InvalidHash(format!(
826            "Tron hash contains invalid hex characters: {}",
827            hash
828        )));
829    }
830
831    Ok(())
832}
833
834// ============================================================================
835// ChainClient Trait Implementation
836// ============================================================================
837
838#[async_trait]
839impl ChainClient for TronClient {
840    fn chain_name(&self) -> &str {
841        "tron"
842    }
843
844    fn native_token_symbol(&self) -> &str {
845        "TRX"
846    }
847
848    async fn get_balance(&self, address: &str) -> Result<Balance> {
849        self.get_balance(address).await
850    }
851
852    async fn enrich_balance_usd(&self, balance: &mut Balance) {
853        self.enrich_balance_usd(balance).await
854    }
855
856    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
857        self.get_transaction(hash).await
858    }
859
860    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
861        self.get_transactions(address, limit).await
862    }
863
864    async fn get_block_number(&self) -> Result<u64> {
865        self.get_block_number().await
866    }
867
868    async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
869        let trc20_balances = self.get_trc20_balances(address).await?;
870        let mut result = Vec::with_capacity(trc20_balances.len());
871
872        for tb in trc20_balances {
873            let token = match self.get_token_info(&tb.contract_address).await {
874                Ok(info) => info,
875                Err(e) => {
876                    tracing::debug!(
877                        contract = %tb.contract_address,
878                        error = %e,
879                        "Could not fetch TRC-20 token info, using placeholder"
880                    );
881                    Token {
882                        contract_address: tb.contract_address.clone(),
883                        symbol: "TRC20".to_string(),
884                        name: "TRC-20 Token".to_string(),
885                        decimals: 6, // Common for USDT, USDC
886                    }
887                }
888            };
889
890            let raw: f64 = tb.raw_balance.parse().unwrap_or(0.0);
891            let divisor = 10_f64.powi(token.decimals as i32);
892            let formatted = format!("{:.6}", raw / divisor);
893
894            result.push(crate::chains::TokenBalance {
895                token,
896                balance: tb.raw_balance,
897                formatted_balance: formatted,
898                usd_value: None,
899            });
900        }
901
902        Ok(result)
903    }
904
905    async fn get_token_info(&self, address: &str) -> Result<Token> {
906        self.get_token_info(address).await
907    }
908
909    async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
910        self.get_token_holders(address, limit).await
911    }
912
913    async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
914        self.get_token_holder_count(address).await
915    }
916}
917
918// ============================================================================
919// Unit Tests
920// ============================================================================
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    // Valid Tron address (Binance cold wallet)
927    const VALID_ADDRESS: &str = "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf";
928
929    // Valid Tron transaction hash
930    const VALID_TX_HASH: &str = "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
931
932    #[test]
933    fn test_validate_tron_address_valid() {
934        assert!(validate_tron_address(VALID_ADDRESS).is_ok());
935    }
936
937    #[test]
938    fn test_validate_tron_address_empty() {
939        let result = validate_tron_address("");
940        assert!(result.is_err());
941        assert!(result.unwrap_err().to_string().contains("empty"));
942    }
943
944    #[test]
945    fn test_validate_tron_address_wrong_prefix() {
946        let result = validate_tron_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
947        assert!(result.is_err());
948        assert!(result.unwrap_err().to_string().contains("start with 'T'"));
949    }
950
951    #[test]
952    fn test_validate_tron_address_too_short() {
953        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZ");
954        assert!(result.is_err());
955        assert!(result.unwrap_err().to_string().contains("34 characters"));
956    }
957
958    #[test]
959    fn test_validate_tron_address_too_long() {
960        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCfAAAA");
961        assert!(result.is_err());
962        assert!(result.unwrap_err().to_string().contains("34 characters"));
963    }
964
965    #[test]
966    fn test_validate_tron_address_invalid_base58() {
967        // Contains '0' which is not valid base58
968        let result = validate_tron_address("T0qSquXBgUCLYvYC4XZgrprLK589dkhSCf");
969        assert!(result.is_err());
970        assert!(result.unwrap_err().to_string().contains("base58"));
971    }
972
973    #[test]
974    fn test_validate_tron_tx_hash_valid() {
975        assert!(validate_tron_tx_hash(VALID_TX_HASH).is_ok());
976    }
977
978    #[test]
979    fn test_validate_tron_tx_hash_empty() {
980        let result = validate_tron_tx_hash("");
981        assert!(result.is_err());
982        assert!(result.unwrap_err().to_string().contains("empty"));
983    }
984
985    #[test]
986    fn test_validate_tron_tx_hash_too_short() {
987        let result = validate_tron_tx_hash("b3c12d62ad7e7b8b83b09a68");
988        assert!(result.is_err());
989        assert!(result.unwrap_err().to_string().contains("64 characters"));
990    }
991
992    #[test]
993    fn test_validate_tron_tx_hash_invalid_hex() {
994        let hash = "g3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
995        let result = validate_tron_tx_hash(hash);
996        assert!(result.is_err());
997        assert!(result.unwrap_err().to_string().contains("invalid hex"));
998    }
999
1000    #[test]
1001    fn test_tron_client_default() {
1002        let client = TronClient::default();
1003        assert_eq!(client.chain_name(), "tron");
1004        assert_eq!(client.native_token_symbol(), "TRX");
1005        assert!(client.api_url.contains("trongrid"));
1006    }
1007
1008    #[test]
1009    fn test_tron_client_with_api_url() {
1010        let client = TronClient::with_api_url("https://custom.tron.api");
1011        assert_eq!(client.api_url, "https://custom.tron.api");
1012    }
1013
1014    #[test]
1015    fn test_tron_client_new() {
1016        let config = ChainsConfig::default();
1017        let client = TronClient::new_with_http(&config, crate::http::test_transport());
1018        assert!(client.is_ok());
1019    }
1020
1021    #[test]
1022    fn test_tron_client_new_with_custom_api() {
1023        let config = ChainsConfig {
1024            tron_api: Some("https://my-tron-api.com".to_string()),
1025            ..Default::default()
1026        };
1027        let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1028        assert_eq!(client.api_url, "https://my-tron-api.com");
1029    }
1030
1031    #[test]
1032    fn test_tron_client_new_with_api_key() {
1033        use std::collections::HashMap;
1034
1035        let mut api_keys = HashMap::new();
1036        api_keys.insert("tronscan".to_string(), "test-key".to_string());
1037
1038        let config = ChainsConfig {
1039            api_keys,
1040            ..Default::default()
1041        };
1042
1043        let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1044        assert_eq!(client.api_key, Some("test-key".to_string()));
1045    }
1046
1047    #[test]
1048    fn test_account_response_deserialization() {
1049        let json = r#"{
1050            "data": [{
1051                "balance": 1000000,
1052                "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1053                "create_time": 1600000000000,
1054                "trc20": []
1055            }],
1056            "success": true
1057        }"#;
1058
1059        let response: AccountResponse = serde_json::from_str(json).unwrap();
1060        assert!(response.success);
1061        assert_eq!(response.data.len(), 1);
1062        assert_eq!(response.data[0].balance, Some(1_000_000));
1063    }
1064
1065    #[test]
1066    fn test_transaction_response_deserialization() {
1067        let json = r#"{
1068            "data": [{
1069                "txID": "abc123",
1070                "block_number": 12345,
1071                "block_timestamp": 1600000000000,
1072                "ret": [{"contractRet": "SUCCESS"}]
1073            }],
1074            "success": true
1075        }"#;
1076
1077        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
1078        assert!(response.success);
1079        assert_eq!(response.data.len(), 1);
1080        assert_eq!(response.data[0].tx_id, "abc123");
1081    }
1082
1083    // ========================================================================
1084    // HTTP mocking tests
1085    // ========================================================================
1086
1087    #[tokio::test]
1088    async fn test_get_balance() {
1089        let mut server = mockito::Server::new_async().await;
1090        let _mock = server
1091            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1092            .with_status(200)
1093            .with_header("content-type", "application/json")
1094            .with_body(r#"{
1095                "data": [{"balance": 5000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}],
1096                "success": true
1097            }"#)
1098            .create_async()
1099            .await;
1100
1101        let client = TronClient::with_api_url(&server.url());
1102        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1103        assert_eq!(balance.raw, "5000000");
1104        assert_eq!(balance.symbol, "TRX");
1105        assert!(balance.formatted.contains("5.000000"));
1106    }
1107
1108    #[tokio::test]
1109    async fn test_get_balance_new_account() {
1110        let mut server = mockito::Server::new_async().await;
1111        let _mock = server
1112            .mock(
1113                "GET",
1114                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1115            )
1116            .with_status(200)
1117            .with_header("content-type", "application/json")
1118            .with_body(r#"{"data": [], "success": true}"#)
1119            .create_async()
1120            .await;
1121
1122        let client = TronClient::with_api_url(&server.url());
1123        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1124        assert_eq!(balance.raw, "0");
1125        assert!(balance.formatted.contains("0.000000"));
1126    }
1127
1128    #[tokio::test]
1129    async fn test_get_balance_api_error() {
1130        let mut server = mockito::Server::new_async().await;
1131        let _mock = server
1132            .mock(
1133                "GET",
1134                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1135            )
1136            .with_status(200)
1137            .with_header("content-type", "application/json")
1138            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1139            .create_async()
1140            .await;
1141
1142        let client = TronClient::with_api_url(&server.url());
1143        let result = client.get_balance(VALID_ADDRESS).await;
1144        assert!(result.is_err());
1145        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1146    }
1147
1148    #[tokio::test]
1149    async fn test_get_balance_invalid_address() {
1150        let client = TronClient::default();
1151        let result = client.get_balance("invalid").await;
1152        assert!(result.is_err());
1153    }
1154
1155    #[tokio::test]
1156    async fn test_get_transaction() {
1157        let mut server = mockito::Server::new_async().await;
1158        let _mock = server
1159            .mock(
1160                "GET",
1161                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1162            )
1163            .with_status(200)
1164            .with_header("content-type", "application/json")
1165            .with_body(
1166                r#"{
1167                "data": [{
1168                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1169                    "block_number": 50000000,
1170                    "block_timestamp": 1700000000000,
1171                    "raw_data": {
1172                        "contract": [{
1173                            "parameter": {
1174                                "value": {
1175                                    "amount": 1000000,
1176                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1177                                    "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
1178                                }
1179                            },
1180                            "type": "TransferContract"
1181                        }]
1182                    },
1183                    "ret": [{"contractRet": "SUCCESS"}]
1184                }],
1185                "success": true
1186            }"#,
1187            )
1188            .create_async()
1189            .await;
1190
1191        let client = TronClient::with_api_url(&server.url());
1192        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1193        assert_eq!(tx.hash, VALID_TX_HASH);
1194        assert_eq!(tx.from, "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
1195        assert_eq!(
1196            tx.to,
1197            Some("TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9".to_string())
1198        );
1199        assert_eq!(tx.value, "1000000");
1200        assert_eq!(tx.block_number, Some(50000000));
1201        assert_eq!(tx.timestamp, Some(1700000000)); // ms → s
1202        assert!(tx.status.unwrap());
1203    }
1204
1205    #[tokio::test]
1206    async fn test_get_transaction_failed() {
1207        let mut server = mockito::Server::new_async().await;
1208        let _mock = server
1209            .mock(
1210                "GET",
1211                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1212            )
1213            .with_status(200)
1214            .with_header("content-type", "application/json")
1215            .with_body(
1216                r#"{
1217                "data": [{
1218                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1219                    "block_number": 50000000,
1220                    "block_timestamp": 1700000000000,
1221                    "ret": [{"contractRet": "REVERT"}]
1222                }],
1223                "success": true
1224            }"#,
1225            )
1226            .create_async()
1227            .await;
1228
1229        let client = TronClient::with_api_url(&server.url());
1230        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1231        assert!(!tx.status.unwrap()); // REVERT → failure
1232    }
1233
1234    #[tokio::test]
1235    async fn test_get_transaction_not_found() {
1236        let mut server = mockito::Server::new_async().await;
1237        let _mock = server
1238            .mock(
1239                "GET",
1240                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1241            )
1242            .with_status(200)
1243            .with_header("content-type", "application/json")
1244            .with_body(r#"{"data": [], "success": true}"#)
1245            .create_async()
1246            .await;
1247
1248        let client = TronClient::with_api_url(&server.url());
1249        let result = client.get_transaction(VALID_TX_HASH).await;
1250        assert!(result.is_err());
1251    }
1252
1253    #[tokio::test]
1254    async fn test_get_transactions() {
1255        let mut server = mockito::Server::new_async().await;
1256        let _mock = server
1257            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()))
1258            .with_status(200)
1259            .with_header("content-type", "application/json")
1260            .with_body(r#"{
1261                "data": [
1262                    {
1263                        "txID": "aaa111",
1264                        "block_number": 50000000,
1265                        "block_timestamp": 1700000000000,
1266                        "raw_data": {"contract": [{"parameter": {"value": {"amount": 500000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"}}, "type": "TransferContract"}]},
1267                        "ret": [{"contractRet": "SUCCESS"}]
1268                    },
1269                    {
1270                        "txID": "bbb222",
1271                        "block_number": 50000001,
1272                        "block_timestamp": 1700000060000,
1273                        "ret": [{"contractRet": "SUCCESS"}]
1274                    }
1275                ],
1276                "success": true
1277            }"#)
1278            .create_async()
1279            .await;
1280
1281        let client = TronClient::with_api_url(&server.url());
1282        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1283        assert_eq!(txs.len(), 2);
1284        assert_eq!(txs[0].hash, "aaa111");
1285        assert_eq!(txs[0].value, "500000");
1286        assert!(txs[0].status.unwrap());
1287        // Second tx has no contract data → defaults
1288        assert_eq!(txs[1].value, "0");
1289    }
1290
1291    #[tokio::test]
1292    async fn test_get_transactions_error() {
1293        let mut server = mockito::Server::new_async().await;
1294        let _mock = server
1295            .mock(
1296                "GET",
1297                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1298            )
1299            .with_status(200)
1300            .with_header("content-type", "application/json")
1301            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1302            .create_async()
1303            .await;
1304
1305        let client = TronClient::with_api_url(&server.url());
1306        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1307        assert!(result.is_err());
1308    }
1309
1310    #[tokio::test]
1311    async fn test_get_trc20_balances() {
1312        let mut server = mockito::Server::new_async().await;
1313        let _mock = server
1314            .mock(
1315                "GET",
1316                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1317            )
1318            .with_status(200)
1319            .with_header("content-type", "application/json")
1320            .with_body(
1321                r#"{
1322                "data": [{
1323                    "balance": 1000000,
1324                    "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1325                    "trc20": [
1326                        {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"},
1327                        {"TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8": "0"}
1328                    ]
1329                }],
1330                "success": true
1331            }"#,
1332            )
1333            .create_async()
1334            .await;
1335
1336        let client = TronClient::with_api_url(&server.url());
1337        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1338        // Zero balance filtered out
1339        assert_eq!(balances.len(), 1);
1340        assert_eq!(
1341            balances[0].contract_address,
1342            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
1343        );
1344        assert_eq!(balances[0].raw_balance, "5000000");
1345    }
1346
1347    #[tokio::test]
1348    async fn test_get_trc20_balances_empty_account() {
1349        let mut server = mockito::Server::new_async().await;
1350        let _mock = server
1351            .mock(
1352                "GET",
1353                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1354            )
1355            .with_status(200)
1356            .with_header("content-type", "application/json")
1357            .with_body(r#"{"data": [], "success": true}"#)
1358            .create_async()
1359            .await;
1360
1361        let client = TronClient::with_api_url(&server.url());
1362        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1363        assert!(balances.is_empty());
1364    }
1365
1366    #[tokio::test]
1367    async fn test_get_block_number() {
1368        let mut server = mockito::Server::new_async().await;
1369        let _mock = server
1370            .mock("POST", "/wallet/getnowblock")
1371            .with_status(200)
1372            .with_header("content-type", "application/json")
1373            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1374            .create_async()
1375            .await;
1376
1377        let client = TronClient::with_api_url(&server.url());
1378        let block = client.get_block_number().await.unwrap();
1379        assert_eq!(block, 60000000);
1380    }
1381
1382    #[tokio::test]
1383    async fn test_get_block_number_invalid_response() {
1384        let mut server = mockito::Server::new_async().await;
1385        let _mock = server
1386            .mock("POST", "/wallet/getnowblock")
1387            .with_status(200)
1388            .with_header("content-type", "application/json")
1389            .with_body(r#"{}"#)
1390            .create_async()
1391            .await;
1392
1393        let client = TronClient::with_api_url(&server.url());
1394        let result = client.get_block_number().await;
1395        assert!(result.is_err());
1396    }
1397
1398    #[test]
1399    fn test_validate_tron_address_wrong_decoded_length() {
1400        // Valid base58 but wrong number of decoded bytes
1401        let result = validate_tron_address("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT1");
1402        assert!(result.is_err());
1403    }
1404
1405    #[test]
1406    fn test_validate_tron_tx_hash_wrong_length() {
1407        let result = validate_tron_tx_hash("abc123");
1408        assert!(result.is_err());
1409        assert!(result.unwrap_err().to_string().contains("64 characters"));
1410    }
1411
1412    #[tokio::test]
1413    async fn test_get_transaction_success() {
1414        let mut server = mockito::Server::new_async().await;
1415        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1416        let _mock = server
1417            .mock(
1418                "GET",
1419                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1420            )
1421            .with_status(200)
1422            .with_header("content-type", "application/json")
1423            .with_body(
1424                r#"{"data":[{
1425                "txID":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1426                "blockNumber":60000000,
1427                "block_timestamp":1700000000000,
1428                "raw_data":{"contract":[{"parameter":{"value":{
1429                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1430                    "to_address":"TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn",
1431                    "amount":1000000
1432                }}}]},
1433                "ret":[{"contractRet":"SUCCESS"}]
1434            }],"success":true}"#,
1435            )
1436            .create_async()
1437            .await;
1438
1439        let client = TronClient::with_api_url(&server.url());
1440        let tx = client.get_transaction(valid_hash).await.unwrap();
1441        assert_eq!(tx.hash, valid_hash);
1442        assert_eq!(tx.status, Some(true));
1443    }
1444
1445    #[tokio::test]
1446    async fn test_get_transaction_api_error() {
1447        let mut server = mockito::Server::new_async().await;
1448        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1449        let _mock = server
1450            .mock(
1451                "GET",
1452                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1453            )
1454            .with_status(200)
1455            .with_header("content-type", "application/json")
1456            .with_body(r#"{"data":[],"success":false,"error":"Transaction not found"}"#)
1457            .create_async()
1458            .await;
1459
1460        let client = TronClient::with_api_url(&server.url());
1461        let result = client.get_transaction(valid_hash).await;
1462        assert!(result.is_err());
1463    }
1464
1465    #[tokio::test]
1466    async fn test_get_transactions_success() {
1467        let mut server = mockito::Server::new_async().await;
1468        let _mock = server
1469            .mock(
1470                "GET",
1471                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1472            )
1473            .with_status(200)
1474            .with_header("content-type", "application/json")
1475            .with_body(
1476                r#"{"data":[{
1477                "txID":"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1478                "blockNumber":60000000,
1479                "block_timestamp":1700000000000,
1480                "raw_data":{"contract":[{"parameter":{"value":{
1481                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1482                    "amount":500000
1483                }}}]},
1484                "ret":[{"contractRet":"SUCCESS"}]
1485            }],"success":true}"#,
1486            )
1487            .create_async()
1488            .await;
1489
1490        let client = TronClient::with_api_url(&server.url());
1491        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1492        assert_eq!(txs.len(), 1);
1493    }
1494
1495    #[tokio::test]
1496    async fn test_tron_chain_client_trait_accessors() {
1497        let client = TronClient::with_api_url("http://localhost");
1498        let chain_client: &dyn ChainClient = &client;
1499        assert_eq!(chain_client.chain_name(), "tron");
1500        assert_eq!(chain_client.native_token_symbol(), "TRX");
1501    }
1502
1503    #[tokio::test]
1504    async fn test_chain_client_trait_get_balance() {
1505        let mut server = mockito::Server::new_async().await;
1506        let _mock = server
1507            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1508            .with_status(200)
1509            .with_header("content-type", "application/json")
1510            .with_body(r#"{"data": [{"balance": 1000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#)
1511            .create_async()
1512            .await;
1513
1514        let client = TronClient::with_api_url(&server.url());
1515        let chain_client: &dyn ChainClient = &client;
1516        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1517        assert_eq!(balance.symbol, "TRX");
1518    }
1519
1520    #[tokio::test]
1521    async fn test_chain_client_trait_get_block_number() {
1522        let mut server = mockito::Server::new_async().await;
1523        let _mock = server
1524            .mock("POST", "/wallet/getnowblock")
1525            .with_status(200)
1526            .with_header("content-type", "application/json")
1527            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1528            .create_async()
1529            .await;
1530
1531        let client = TronClient::with_api_url(&server.url());
1532        let chain_client: &dyn ChainClient = &client;
1533        let block = chain_client.get_block_number().await.unwrap();
1534        assert_eq!(block, 60000000);
1535    }
1536
1537    #[tokio::test]
1538    async fn test_chain_client_trait_get_token_balances() {
1539        let mut server = mockito::Server::new_async().await;
1540        let _mock = server
1541            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1542            .with_status(200)
1543            .with_header("content-type", "application/json")
1544            .with_body(r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"}]}], "success": true}"#)
1545            .create_async()
1546            .await;
1547
1548        let client = TronClient::with_api_url(&server.url());
1549        let chain_client: &dyn ChainClient = &client;
1550        let balances = chain_client
1551            .get_token_balances(VALID_ADDRESS)
1552            .await
1553            .unwrap();
1554        assert_eq!(balances.len(), 1);
1555        // Token info enriched via Tronscan (USDT) or fallback to placeholder
1556        assert!(
1557            balances[0].token.symbol == "USDT" || balances[0].token.symbol == "TRC20",
1558            "symbol should be USDT (Tronscan) or TRC20 (fallback)"
1559        );
1560        // Tronscan returns various name formats (e.g. "TetherToken", "Tether USD");
1561        // fallback is "TRC-20 Token" or "Unknown Token"
1562        assert!(!balances[0].token.name.is_empty(), "name must be set");
1563    }
1564
1565    #[tokio::test]
1566    async fn test_chain_client_trait_get_transaction_tron() {
1567        let mut server = mockito::Server::new_async().await;
1568        let _mock = server
1569            .mock(
1570                "GET",
1571                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1572            )
1573            .with_status(200)
1574            .with_header("content-type", "application/json")
1575            .with_body(
1576                r#"{"data": [{
1577                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1578                "block_number": 50000000,
1579                "block_timestamp": 1700000000000,
1580                "raw_data": {
1581                    "contract": [{
1582                        "parameter": {
1583                            "value": {
1584                                "amount": 1000000,
1585                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1586                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1587                            }
1588                        },
1589                        "type": "TransferContract"
1590                    }]
1591                },
1592                "ret": [{"contractRet": "SUCCESS"}]
1593            }], "success": true}"#,
1594            )
1595            .create_async()
1596            .await;
1597
1598        let client = TronClient::with_api_url(&server.url());
1599        let chain_client: &dyn ChainClient = &client;
1600        let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
1601        assert_eq!(tx.hash, VALID_TX_HASH);
1602        assert!(tx.status.unwrap());
1603    }
1604
1605    #[tokio::test]
1606    async fn test_chain_client_trait_get_transactions_tron() {
1607        let mut server = mockito::Server::new_async().await;
1608        let _mock = server
1609            .mock(
1610                "GET",
1611                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1612            )
1613            .with_status(200)
1614            .with_header("content-type", "application/json")
1615            .with_body(
1616                r#"{"data": [{
1617                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1618                "block_number": 50000000,
1619                "block_timestamp": 1700000000000,
1620                "raw_data": {
1621                    "contract": [{
1622                        "parameter": {
1623                            "value": {
1624                                "amount": 2000000,
1625                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1626                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1627                            }
1628                        }
1629                    }]
1630                },
1631                "ret": [{"contractRet": "REVERT"}]
1632            }], "success": true}"#,
1633            )
1634            .create_async()
1635            .await;
1636
1637        let client = TronClient::with_api_url(&server.url());
1638        let chain_client: &dyn ChainClient = &client;
1639        let txs = chain_client
1640            .get_transactions(VALID_ADDRESS, 10)
1641            .await
1642            .unwrap();
1643        assert_eq!(txs.len(), 1);
1644        assert!(!txs[0].status.unwrap()); // REVERT means failure
1645    }
1646
1647    #[tokio::test]
1648    async fn test_get_balance_with_api_key() {
1649        let mut server = mockito::Server::new_async().await;
1650        let _mock = server
1651            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1652            .with_status(200)
1653            .with_header("content-type", "application/json")
1654            .with_body(
1655                r#"{"data": [{"balance": 10000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#,
1656            )
1657            .create_async()
1658            .await;
1659
1660        let config = ChainsConfig {
1661            tron_api: Some(server.url()),
1662            api_keys: {
1663                let mut m = std::collections::HashMap::new();
1664                m.insert("tronscan".to_string(), "test-api-key".to_string());
1665                m
1666            },
1667            ..Default::default()
1668        };
1669        let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1670        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1671        assert_eq!(balance.symbol, "TRX");
1672        assert!(balance.formatted.contains("TRX"));
1673    }
1674
1675    #[tokio::test]
1676    async fn test_get_trc20_balances_error_response() {
1677        let mut server = mockito::Server::new_async().await;
1678        let _mock = server
1679            .mock(
1680                "GET",
1681                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1682            )
1683            .with_status(200)
1684            .with_header("content-type", "application/json")
1685            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1686            .create_async()
1687            .await;
1688
1689        let client = TronClient::with_api_url(&server.url());
1690        let result = client.get_trc20_balances(VALID_ADDRESS).await;
1691        assert!(result.is_err());
1692        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1693    }
1694
1695    #[tokio::test]
1696    async fn test_get_trc20_balances_no_data() {
1697        let mut server = mockito::Server::new_async().await;
1698        let _mock = server
1699            .mock(
1700                "GET",
1701                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1702            )
1703            .with_status(200)
1704            .with_header("content-type", "application/json")
1705            .with_body(r#"{"data": [], "success": true}"#)
1706            .create_async()
1707            .await;
1708
1709        let client = TronClient::with_api_url(&server.url());
1710        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1711        assert!(balances.is_empty());
1712    }
1713
1714    #[tokio::test]
1715    async fn test_get_trc20_balances_with_api_key() {
1716        let mut server = mockito::Server::new_async().await;
1717        let _mock = server
1718            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1719            .with_status(200)
1720            .with_header("content-type", "application/json")
1721            .with_body(
1722                r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}]}], "success": true}"#,
1723            )
1724            .create_async()
1725            .await;
1726
1727        let config = ChainsConfig {
1728            tron_api: Some(server.url()),
1729            api_keys: {
1730                let mut m = std::collections::HashMap::new();
1731                m.insert("tronscan".to_string(), "my-api-key".to_string());
1732                m
1733            },
1734            ..Default::default()
1735        };
1736        let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1737        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1738        assert_eq!(balances.len(), 1);
1739    }
1740
1741    #[test]
1742    fn test_validate_tron_address_bad_checksum() {
1743        // Construct a valid-looking address with bad checksum by modifying last char
1744        // TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf -> change last char
1745        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCe");
1746        assert!(result.is_err());
1747        // Could be checksum error or base58 decode error
1748        let err_str = result.unwrap_err().to_string();
1749        assert!(
1750            err_str.contains("checksum")
1751                || err_str.contains("base58")
1752                || err_str.contains("prefix")
1753        );
1754    }
1755
1756    #[tokio::test]
1757    async fn test_get_transaction_tron_success() {
1758        let mut server = mockito::Server::new_async().await;
1759        let _mock = server
1760            .mock(
1761                "GET",
1762                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1763            )
1764            .with_status(200)
1765            .with_header("content-type", "application/json")
1766            .with_body(
1767                r#"{"data": [{
1768                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1769                "block_number": 50000000,
1770                "block_timestamp": 1700000000000,
1771                "raw_data": {
1772                    "contract": [{
1773                        "parameter": {
1774                            "value": {
1775                                "amount": 5000000,
1776                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1777                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1778                            }
1779                        }
1780                    }]
1781                },
1782                "ret": [{"contractRet": "SUCCESS"}]
1783            }], "success": true}"#,
1784            )
1785            .create_async()
1786            .await;
1787
1788        let client = TronClient::with_api_url(&server.url());
1789        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1790        assert_eq!(tx.hash, VALID_TX_HASH);
1791        assert!(tx.status.unwrap());
1792        assert_eq!(tx.value, "5000000");
1793        assert_eq!(tx.timestamp, Some(1700000000)); // Converted from ms to s
1794    }
1795
1796    #[tokio::test]
1797    async fn test_get_transaction_tron_error() {
1798        let mut server = mockito::Server::new_async().await;
1799        let _mock = server
1800            .mock(
1801                "GET",
1802                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1803            )
1804            .with_status(200)
1805            .with_header("content-type", "application/json")
1806            .with_body(r#"{"data": [], "success": false, "error": "Transaction not found"}"#)
1807            .create_async()
1808            .await;
1809
1810        let client = TronClient::with_api_url(&server.url());
1811        let result = client.get_transaction(VALID_TX_HASH).await;
1812        assert!(result.is_err());
1813    }
1814
1815    #[tokio::test]
1816    async fn test_get_transactions_tron_success() {
1817        let mut server = mockito::Server::new_async().await;
1818        let _mock = server
1819            .mock(
1820                "GET",
1821                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1822            )
1823            .with_status(200)
1824            .with_header("content-type", "application/json")
1825            .with_body(
1826                r#"{"data": [
1827                {
1828                    "txID": "aaa12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1829                    "block_number": 50000001,
1830                    "block_timestamp": 1700000003000,
1831                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 1000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"}}}]},
1832                    "ret": [{"contractRet": "SUCCESS"}]
1833                },
1834                {
1835                    "txID": "bbb12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1836                    "block_number": 50000002,
1837                    "block_timestamp": 1700000006000,
1838                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 2000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"}}}]},
1839                    "ret": [{"contractRet": "SUCCESS"}]
1840                }
1841            ], "success": true}"#,
1842            )
1843            .create_async()
1844            .await;
1845
1846        let client = TronClient::with_api_url(&server.url());
1847        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1848        assert_eq!(txs.len(), 2);
1849    }
1850
1851    #[tokio::test]
1852    async fn test_get_transactions_tron_error() {
1853        let mut server = mockito::Server::new_async().await;
1854        let _mock = server
1855            .mock(
1856                "GET",
1857                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1858            )
1859            .with_status(200)
1860            .with_header("content-type", "application/json")
1861            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1862            .create_async()
1863            .await;
1864
1865        let client = TronClient::with_api_url(&server.url());
1866        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1867        assert!(result.is_err());
1868    }
1869
1870    #[tokio::test]
1871    async fn test_get_balance_error_response() {
1872        let mut server = mockito::Server::new_async().await;
1873        let _mock = server
1874            .mock(
1875                "GET",
1876                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1877            )
1878            .with_status(200)
1879            .with_header("content-type", "application/json")
1880            .with_body(r#"{"data": [], "success": false, "error": "Account not found"}"#)
1881            .create_async()
1882            .await;
1883
1884        let client = TronClient::with_api_url(&server.url());
1885        let result = client.get_balance(VALID_ADDRESS).await;
1886        assert!(result.is_err());
1887        assert!(
1888            result
1889                .unwrap_err()
1890                .to_string()
1891                .contains("Account not found")
1892        );
1893    }
1894
1895    #[tokio::test]
1896    async fn test_get_token_info_success() {
1897        // get_token_info uses TRONSCAN_API directly (not mockable via api_url),
1898        // so we test the response parsing path that mirrors get_token_info logic
1899        let info: serde_json::Value = serde_json::from_str(
1900            r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 6}]}"#,
1901        )
1902        .unwrap();
1903        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1904        let token_data = tokens.first().unwrap();
1905        let symbol = token_data
1906            .get("symbol")
1907            .and_then(|v| v.as_str())
1908            .unwrap_or("UNKNOWN");
1909        assert_eq!(symbol, "USDT");
1910        let name = token_data
1911            .get("contract_name")
1912            .and_then(|v| v.as_str())
1913            .unwrap_or("Unknown Token");
1914        assert_eq!(name, "TetherToken");
1915        let decimals = token_data
1916            .get("decimals")
1917            .and_then(|v| v.as_u64())
1918            .unwrap_or(6) as u8;
1919        assert_eq!(decimals, 6);
1920    }
1921
1922    #[tokio::test]
1923    async fn test_get_token_info_no_tokens() {
1924        let info: serde_json::Value = serde_json::from_str(r#"{"trc20_tokens": []}"#).unwrap();
1925        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1926        assert!(tokens.is_empty());
1927    }
1928
1929    #[tokio::test]
1930    async fn test_get_token_info_missing_field() {
1931        let info: serde_json::Value =
1932            serde_json::from_str(r#"{"trc20_tokens": [{"symbol": "TEST"}]}"#).unwrap();
1933        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1934        let token_data = tokens.first().unwrap();
1935        let name = token_data
1936            .get("contract_name")
1937            .or_else(|| token_data.get("name"))
1938            .and_then(|v| v.as_str())
1939            .unwrap_or("Unknown Token");
1940        assert_eq!(name, "Unknown Token");
1941        let decimals = token_data
1942            .get("decimals")
1943            .and_then(|v| v.as_u64())
1944            .unwrap_or(6) as u8;
1945        assert_eq!(decimals, 6);
1946    }
1947
1948    #[tokio::test]
1949    async fn test_token_holder_response_parsing() {
1950        let json: serde_json::Value = serde_json::from_str(
1951            r#"{"trc20_tokens": [
1952                {"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "5000000"},
1953                {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "3000000"}
1954            ]}"#,
1955        )
1956        .unwrap();
1957        let holders_data: &[serde_json::Value] = json
1958            .get("trc20_tokens")
1959            .and_then(|v| v.as_array())
1960            .map(|v| v.as_slice())
1961            .unwrap_or(&[]);
1962        assert_eq!(holders_data.len(), 2);
1963
1964        let total_balance: f64 = holders_data
1965            .iter()
1966            .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
1967            .filter_map(|s| s.parse::<f64>().ok())
1968            .sum();
1969        assert_eq!(total_balance, 8000000.0);
1970
1971        let decimals: u8 = 6;
1972        let holders: Vec<TokenHolder> = holders_data
1973            .iter()
1974            .enumerate()
1975            .filter_map(|(i, h)| {
1976                let holder_address = h.get("holder_address")?.as_str()?.to_string();
1977                let balance_raw = h.get("balance")?.as_str()?.to_string();
1978                let balance: f64 = balance_raw.parse().ok()?;
1979                let percentage = if total_balance > 0.0 {
1980                    (balance / total_balance) * 100.0
1981                } else {
1982                    0.0
1983                };
1984                let divisor = 10_f64.powi(decimals as i32);
1985                let formatted = format!("{:.6}", balance / divisor);
1986                Some(TokenHolder {
1987                    address: holder_address,
1988                    balance: balance_raw,
1989                    formatted_balance: formatted,
1990                    percentage,
1991                    rank: (i + 1) as u32,
1992                })
1993            })
1994            .collect();
1995        assert_eq!(holders.len(), 2);
1996        assert_eq!(holders[0].rank, 1);
1997        assert_eq!(holders[1].rank, 2);
1998        assert!(holders[0].percentage > 60.0);
1999        assert!(holders[0].formatted_balance.contains("5.000000"));
2000    }
2001
2002    #[tokio::test]
2003    async fn test_token_holder_count_parsing() {
2004        let json: serde_json::Value = serde_json::from_str(r#"{"rangeTotal": 12345}"#).unwrap();
2005        let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
2006        assert_eq!(count, 12345);
2007
2008        let json_no_field: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
2009        let count2 = json_no_field
2010            .get("rangeTotal")
2011            .and_then(|v| v.as_u64())
2012            .unwrap_or(0);
2013        assert_eq!(count2, 0);
2014    }
2015
2016    #[test]
2017    fn test_dex_search_response_deserialization() {
2018        let json = r#"{"pairs":[{"baseTokenSymbol":"TRX","priceUsd":"0.08"}]}"#;
2019        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2020        assert!(result.is_ok());
2021        let resp = result.unwrap();
2022        let pairs = resp.pairs.unwrap();
2023        assert_eq!(pairs.len(), 1);
2024        assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2025    }
2026
2027    #[test]
2028    fn test_dex_search_response_empty() {
2029        let json = r#"{"pairs":[]}"#;
2030        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2031        assert!(result.is_ok());
2032        assert!(result.unwrap().pairs.unwrap().is_empty());
2033    }
2034
2035    #[test]
2036    fn test_dex_search_response_no_pairs() {
2037        let json = r#"{}"#;
2038        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2039        assert!(result.is_ok());
2040        assert!(result.unwrap().pairs.is_none());
2041    }
2042
2043    // -------------------------------------------------------------------------
2044    // Invalid input validation tests
2045    // -------------------------------------------------------------------------
2046
2047    #[tokio::test]
2048    async fn test_get_transaction_invalid_hash() {
2049        let client = TronClient::default();
2050        let result = client.get_transaction("not-a-valid-hash").await;
2051        assert!(result.is_err());
2052        assert!(result.unwrap_err().to_string().contains("64 characters"));
2053    }
2054
2055    #[tokio::test]
2056    async fn test_get_transactions_invalid_address() {
2057        let client = TronClient::default();
2058        let result = client.get_transactions("invalid-address", 10).await;
2059        assert!(result.is_err());
2060    }
2061
2062    #[tokio::test]
2063    async fn test_get_token_info_invalid_address() {
2064        let client = TronClient::default();
2065        let result = client.get_token_info("bad-address").await;
2066        assert!(result.is_err());
2067    }
2068
2069    #[tokio::test]
2070    async fn test_get_token_holders_invalid_address() {
2071        let client = TronClient::default();
2072        let result = client.get_token_holders("bad-address", 10).await;
2073        assert!(result.is_err());
2074    }
2075
2076    #[tokio::test]
2077    async fn test_get_token_holder_count_invalid_address() {
2078        let client = TronClient::default();
2079        let result = client.get_token_holder_count("bad-address").await;
2080        assert!(result.is_err());
2081    }
2082
2083    #[tokio::test]
2084    async fn test_chain_client_get_token_info_invalid_address() {
2085        let client = TronClient::default();
2086        let chain_client: &dyn ChainClient = &client;
2087        let result = chain_client.get_token_info("x").await;
2088        assert!(result.is_err());
2089    }
2090
2091    #[tokio::test]
2092    async fn test_chain_client_get_token_holders_invalid_address() {
2093        let client = TronClient::default();
2094        let chain_client: &dyn ChainClient = &client;
2095        let result = chain_client.get_token_holders("x", 10).await;
2096        assert!(result.is_err());
2097    }
2098
2099    #[tokio::test]
2100    async fn test_chain_client_get_token_holder_count_invalid_address() {
2101        let client = TronClient::default();
2102        let chain_client: &dyn ChainClient = &client;
2103        let result = chain_client.get_token_holder_count("x").await;
2104        assert!(result.is_err());
2105    }
2106
2107    // -------------------------------------------------------------------------
2108    // API error edge cases
2109    // -------------------------------------------------------------------------
2110
2111    #[tokio::test]
2112    async fn test_get_balance_api_error_unknown_error() {
2113        let mut server = mockito::Server::new_async().await;
2114        let _mock = server
2115            .mock(
2116                "GET",
2117                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2118            )
2119            .with_status(200)
2120            .with_header("content-type", "application/json")
2121            .with_body(r#"{"data": [], "success": false}"#)
2122            .create_async()
2123            .await;
2124
2125        let client = TronClient::with_api_url(&server.url());
2126        let result = client.get_balance(VALID_ADDRESS).await;
2127        assert!(result.is_err());
2128        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2129    }
2130
2131    #[tokio::test]
2132    async fn test_get_transaction_status_none() {
2133        let mut server = mockito::Server::new_async().await;
2134        let _mock = server
2135            .mock(
2136                "GET",
2137                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2138            )
2139            .with_status(200)
2140            .with_header("content-type", "application/json")
2141            .with_body(
2142                r#"{
2143                "data": [{
2144                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2145                    "block_number": 50000000,
2146                    "block_timestamp": 1700000000000,
2147                    "ret": []
2148                }],
2149                "success": true
2150            }"#,
2151            )
2152            .create_async()
2153            .await;
2154
2155        let client = TronClient::with_api_url(&server.url());
2156        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2157        assert_eq!(tx.status, None);
2158    }
2159
2160    #[tokio::test]
2161    async fn test_get_transaction_no_ret_field() {
2162        let mut server = mockito::Server::new_async().await;
2163        let _mock = server
2164            .mock(
2165                "GET",
2166                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2167            )
2168            .with_status(200)
2169            .with_header("content-type", "application/json")
2170            .with_body(
2171                r#"{
2172                "data": [{
2173                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2174                    "block_number": 50000000,
2175                    "block_timestamp": 1700000000000
2176                }],
2177                "success": true
2178            }"#,
2179            )
2180            .create_async()
2181            .await;
2182
2183        let client = TronClient::with_api_url(&server.url());
2184        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2185        assert_eq!(tx.status, None);
2186    }
2187
2188    #[tokio::test]
2189    async fn test_get_transaction_to_address_none() {
2190        let mut server = mockito::Server::new_async().await;
2191        let _mock = server
2192            .mock(
2193                "GET",
2194                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2195            )
2196            .with_status(200)
2197            .with_header("content-type", "application/json")
2198            .with_body(
2199                r#"{
2200                "data": [{
2201                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2202                    "block_number": 50000000,
2203                    "block_timestamp": 1700000000000,
2204                    "raw_data": {
2205                        "contract": [{
2206                            "parameter": {
2207                                "value": {
2208                                    "amount": 1000000,
2209                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
2210                                }
2211                            }
2212                        }]
2213                    },
2214                    "ret": [{"contractRet": "SUCCESS"}]
2215                }],
2216                "success": true
2217            }"#,
2218            )
2219            .create_async()
2220            .await;
2221
2222        let client = TronClient::with_api_url(&server.url());
2223        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2224        assert_eq!(tx.to, None);
2225        assert_eq!(tx.value, "1000000");
2226    }
2227
2228    #[tokio::test]
2229    async fn test_get_transaction_amount_none() {
2230        let mut server = mockito::Server::new_async().await;
2231        let _mock = server
2232            .mock(
2233                "GET",
2234                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2235            )
2236            .with_status(200)
2237            .with_header("content-type", "application/json")
2238            .with_body(
2239                r#"{
2240                "data": [{
2241                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2242                    "block_number": 50000000,
2243                    "block_timestamp": 1700000000000,
2244                    "raw_data": {
2245                        "contract": [{
2246                            "parameter": {
2247                                "value": {
2248                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2249                                    "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
2250                                }
2251                            }
2252                        }]
2253                    },
2254                    "ret": [{"contractRet": "SUCCESS"}]
2255                }],
2256                "success": true
2257            }"#,
2258            )
2259            .create_async()
2260            .await;
2261
2262        let client = TronClient::with_api_url(&server.url());
2263        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2264        assert_eq!(tx.value, "0");
2265    }
2266
2267    #[tokio::test]
2268    async fn test_get_transaction_no_raw_data() {
2269        let mut server = mockito::Server::new_async().await;
2270        let _mock = server
2271            .mock(
2272                "GET",
2273                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2274            )
2275            .with_status(200)
2276            .with_header("content-type", "application/json")
2277            .with_body(
2278                r#"{
2279                "data": [{
2280                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2281                    "block_number": 50000000,
2282                    "block_timestamp": 1700000000000,
2283                    "ret": [{"contractRet": "SUCCESS"}]
2284                }],
2285                "success": true
2286            }"#,
2287            )
2288            .create_async()
2289            .await;
2290
2291        let client = TronClient::with_api_url(&server.url());
2292        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2293        assert_eq!(tx.from, "");
2294        assert_eq!(tx.to, None);
2295        assert_eq!(tx.value, "0");
2296    }
2297
2298    #[tokio::test]
2299    async fn test_get_block_number_block_header_none() {
2300        let mut server = mockito::Server::new_async().await;
2301        let _mock = server
2302            .mock("POST", "/wallet/getnowblock")
2303            .with_status(200)
2304            .with_header("content-type", "application/json")
2305            .with_body(r#"{"other": "data"}"#)
2306            .create_async()
2307            .await;
2308
2309        let client = TronClient::with_api_url(&server.url());
2310        let result = client.get_block_number().await;
2311        assert!(result.is_err());
2312        assert!(result.unwrap_err().to_string().contains("Invalid block"));
2313    }
2314
2315    #[tokio::test]
2316    async fn test_get_block_number_raw_data_none() {
2317        let mut server = mockito::Server::new_async().await;
2318        let _mock = server
2319            .mock("POST", "/wallet/getnowblock")
2320            .with_status(200)
2321            .with_header("content-type", "application/json")
2322            .with_body(r#"{"block_header":{}}"#)
2323            .create_async()
2324            .await;
2325
2326        let client = TronClient::with_api_url(&server.url());
2327        let result = client.get_block_number().await;
2328        assert!(result.is_err());
2329    }
2330
2331    #[tokio::test]
2332    async fn test_get_block_number_number_none() {
2333        let mut server = mockito::Server::new_async().await;
2334        let _mock = server
2335            .mock("POST", "/wallet/getnowblock")
2336            .with_status(200)
2337            .with_header("content-type", "application/json")
2338            .with_body(r#"{"block_header":{"raw_data":{}}}"#)
2339            .create_async()
2340            .await;
2341
2342        let client = TronClient::with_api_url(&server.url());
2343        let result = client.get_block_number().await;
2344        assert!(result.is_err());
2345    }
2346
2347    #[tokio::test]
2348    async fn test_get_trc20_balances_api_error_unknown() {
2349        let mut server = mockito::Server::new_async().await;
2350        let _mock = server
2351            .mock(
2352                "GET",
2353                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2354            )
2355            .with_status(200)
2356            .with_header("content-type", "application/json")
2357            .with_body(r#"{"data": [], "success": false}"#)
2358            .create_async()
2359            .await;
2360
2361        let client = TronClient::with_api_url(&server.url());
2362        let result = client.get_trc20_balances(VALID_ADDRESS).await;
2363        assert!(result.is_err());
2364        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2365    }
2366
2367    #[tokio::test]
2368    async fn test_get_transactions_api_error_unknown() {
2369        let mut server = mockito::Server::new_async().await;
2370        let _mock = server
2371            .mock(
2372                "GET",
2373                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2374            )
2375            .with_status(200)
2376            .with_header("content-type", "application/json")
2377            .with_body(r#"{"data": [], "success": false}"#)
2378            .create_async()
2379            .await;
2380
2381        let client = TronClient::with_api_url(&server.url());
2382        let result = client.get_transactions(VALID_ADDRESS, 10).await;
2383        assert!(result.is_err());
2384        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2385    }
2386
2387    #[tokio::test]
2388    async fn test_get_transaction_api_error_unknown() {
2389        let mut server = mockito::Server::new_async().await;
2390        let _mock = server
2391            .mock(
2392                "GET",
2393                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2394            )
2395            .with_status(200)
2396            .with_header("content-type", "application/json")
2397            .with_body(r#"{"data": [], "success": false}"#)
2398            .create_async()
2399            .await;
2400
2401        let client = TronClient::with_api_url(&server.url());
2402        let result = client.get_transaction(VALID_TX_HASH).await;
2403        assert!(result.is_err());
2404    }
2405
2406    // -------------------------------------------------------------------------
2407    // Struct construction and deserialization
2408    // -------------------------------------------------------------------------
2409
2410    #[test]
2411    fn test_trc20_token_balance_struct() {
2412        let balance = Trc20TokenBalance {
2413            contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2414            raw_balance: "5000000".to_string(),
2415        };
2416        assert_eq!(
2417            balance.contract_address,
2418            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
2419        );
2420        assert_eq!(balance.raw_balance, "5000000");
2421        let debug_str = format!("{:?}", balance);
2422        assert!(debug_str.contains("Trc20TokenBalance"));
2423    }
2424
2425    #[test]
2426    fn test_account_response_with_error_field() {
2427        let json = r#"{
2428            "data": [],
2429            "success": false,
2430            "error": "Custom error message"
2431        }"#;
2432        let response: AccountResponse = serde_json::from_str(json).unwrap();
2433        assert!(!response.success);
2434        assert_eq!(response.error, Some("Custom error message".to_string()));
2435    }
2436
2437    #[test]
2438    fn test_account_response_trc20_balances() {
2439        let json = r#"{
2440            "data": [{
2441                "balance": 1000000,
2442                "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2443                "trc20": [
2444                    {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}
2445                ]
2446            }],
2447            "success": true
2448        }"#;
2449        let response: AccountResponse = serde_json::from_str(json).unwrap();
2450        assert_eq!(response.data.len(), 1);
2451        assert_eq!(response.data[0].trc20.len(), 1);
2452        let trc20 = &response.data[0].trc20[0];
2453        assert_eq!(
2454            trc20.balances.get("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
2455            Some(&"10000000".to_string())
2456        );
2457    }
2458
2459    #[test]
2460    fn test_transaction_list_response_with_error() {
2461        let json = r#"{
2462            "data": [],
2463            "success": false,
2464            "error": "Transaction not found"
2465        }"#;
2466        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2467        assert!(!response.success);
2468        assert_eq!(response.error, Some("Transaction not found".to_string()));
2469    }
2470
2471    #[test]
2472    fn test_full_transaction_deserialization() {
2473        let json = r#"{
2474            "data": [{
2475                "txID": "abc123def456",
2476                "block_number": 12345,
2477                "block_timestamp": 1600000000000,
2478                "raw_data": {
2479                    "contract": [{
2480                        "parameter": {
2481                            "value": {
2482                                "amount": 999999,
2483                                "owner_address": "TFrom123",
2484                                "to_address": "TTo456"
2485                            }
2486                        },
2487                        "type": "TransferContract"
2488                    }]
2489                },
2490                "ret": [{"contractRet": "SUCCESS"}]
2491            }],
2492            "success": true
2493        }"#;
2494        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2495        let tx = &response.data[0];
2496        assert_eq!(tx.tx_id, "abc123def456");
2497        assert_eq!(tx.block_number, Some(12345));
2498        assert_eq!(tx.block_timestamp, Some(1600000000000));
2499        let contract_value = tx
2500            .raw_data
2501            .as_ref()
2502            .and_then(|r| r.contract.as_ref())
2503            .and_then(|c| c.first())
2504            .and_then(|c| c.parameter.as_ref())
2505            .and_then(|p| p.value.as_ref())
2506            .unwrap();
2507        assert_eq!(contract_value.amount, Some(999999));
2508        assert_eq!(contract_value.owner_address.as_deref(), Some("TFrom123"));
2509        assert_eq!(contract_value.to_address.as_deref(), Some("TTo456"));
2510        assert_eq!(
2511            tx.ret
2512                .as_ref()
2513                .and_then(|r| r.first())
2514                .and_then(|r| r.contract_ret.as_deref()),
2515            Some("SUCCESS")
2516        );
2517    }
2518
2519    #[test]
2520    fn test_tron_client_default_trait() {
2521        let client = TronClient::default();
2522        assert_eq!(client.chain_name(), "tron");
2523        assert_eq!(client.native_token_symbol(), "TRX");
2524        assert_eq!(client.api_url, DEFAULT_TRON_API);
2525    }
2526
2527    #[test]
2528    fn test_dex_search_pair_wtrx() {
2529        let json = r#"{"pairs":[{"baseTokenSymbol":"WTRX","priceUsd":"0.08"}]}"#;
2530        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2531        assert!(result.is_ok());
2532        let resp = result.unwrap();
2533        let pairs = resp.pairs.unwrap();
2534        assert_eq!(pairs[0].base_token_symbol, Some("WTRX".to_string()));
2535        assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2536    }
2537
2538    #[tokio::test]
2539    async fn test_enrich_balance_usd_no_panic() {
2540        let mut balance = Balance {
2541            raw: "1000000".to_string(),
2542            formatted: "1.000000 TRX".to_string(),
2543            decimals: TRX_DECIMALS,
2544            symbol: "TRX".to_string(),
2545            usd_value: None,
2546        };
2547        let client = TronClient::default();
2548        client.enrich_balance_usd(&mut balance).await;
2549        // Should not panic; usd_value may or may not be set depending on DexScreener response
2550    }
2551
2552    #[test]
2553    fn test_trc20_token_balance_debug_format() {
2554        let b = Trc20TokenBalance {
2555            contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2556            raw_balance: "1000000".to_string(),
2557        };
2558        let s = format!("{:?}", b);
2559        assert!(s.contains("Trc20TokenBalance"));
2560        assert!(s.contains("1000000"));
2561    }
2562
2563    #[tokio::test]
2564    async fn test_get_transactions_with_minimal_contract_data() {
2565        let mut server = mockito::Server::new_async().await;
2566        let _mock = server
2567            .mock(
2568                "GET",
2569                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2570            )
2571            .with_status(200)
2572            .with_header("content-type", "application/json")
2573            .with_body(
2574                r#"{
2575                "data": [{
2576                    "txID": "c4d23e73be8f8c9c94c10b79c0c0a0c24b2c9a9c0a0c0a0c0a0c0a0c0a0c0a0c0a0c",
2577                    "block_number": 50000000,
2578                    "block_timestamp": 1700000000000,
2579                    "raw_data": {"contract": [{}]},
2580                    "ret": []
2581                }],
2582                "success": true
2583            }"#,
2584            )
2585            .create_async()
2586            .await;
2587
2588        let client = TronClient::with_api_url(&server.url());
2589        let txs = client.get_transactions(VALID_ADDRESS, 5).await.unwrap();
2590        assert_eq!(txs.len(), 1);
2591        assert_eq!(txs[0].value, "0");
2592        assert_eq!(txs[0].status, None);
2593    }
2594    // ========================================================================
2595    // Tronscan and DexScreener (fixed hosts, sent to a mock server)
2596    // ========================================================================
2597
2598    const USDT_CONTRACT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
2599
2600    /// Sends requests for the fixed Tronscan and DexScreener hosts to a local
2601    /// mock server; everything else goes out unchanged.
2602    struct ToMockServer {
2603        base: String,
2604        inner: Arc<dyn HttpClient>,
2605    }
2606
2607    #[async_trait]
2608    impl HttpClient for ToMockServer {
2609        async fn send(&self, mut req: Request) -> Result<crate::http::Response> {
2610            for host in [TRONSCAN_API, "https://api.dexscreener.com"] {
2611                if let Some(rest) = req.url.strip_prefix(host) {
2612                    req.url = format!("{}{}", self.base, rest);
2613                }
2614            }
2615            self.inner.send(req).await
2616        }
2617    }
2618
2619    /// A client whose TronGrid, Tronscan and DexScreener calls all reach `server`.
2620    fn mocked_client(server: &mockito::Server, api_key: Option<&str>) -> TronClient {
2621        let mut config = ChainsConfig {
2622            tron_api: Some(server.url()),
2623            ..Default::default()
2624        };
2625        if let Some(key) = api_key {
2626            config
2627                .api_keys
2628                .insert("tronscan".to_string(), key.to_string());
2629        }
2630        let http = Arc::new(ToMockServer {
2631            base: server.url(),
2632            inner: crate::http::test_transport(),
2633        });
2634        TronClient::new_with_http(&config, http).unwrap()
2635    }
2636
2637    fn trx_balance(raw: &str) -> Balance {
2638        Balance {
2639            raw: raw.to_string(),
2640            formatted: String::new(),
2641            decimals: TRX_DECIMALS,
2642            symbol: "TRX".to_string(),
2643            usd_value: None,
2644        }
2645    }
2646
2647    async fn mock_token_info(
2648        server: &mut mockito::Server,
2649        status: usize,
2650        body: &str,
2651    ) -> mockito::Mock {
2652        server
2653            .mock("GET", "/api/token_trc20")
2654            .match_query(mockito::Matcher::Any)
2655            .with_status(status)
2656            .with_body(body)
2657            .create_async()
2658            .await
2659    }
2660
2661    async fn mock_holders(server: &mut mockito::Server, body: &str) -> mockito::Mock {
2662        server
2663            .mock("GET", "/api/token_trc20/holders")
2664            .match_query(mockito::Matcher::Any)
2665            .with_status(200)
2666            .with_body(body)
2667            .create_async()
2668            .await
2669    }
2670
2671    async fn mock_dex_search(server: &mut mockito::Server, body: &str) -> mockito::Mock {
2672        server
2673            .mock("GET", "/latest/dex/search")
2674            .match_query(mockito::Matcher::UrlEncoded("q".into(), "TRX USDT".into()))
2675            .with_status(200)
2676            .with_body(body)
2677            .create_async()
2678            .await
2679    }
2680
2681    #[tokio::test]
2682    async fn test_get_token_info_reads_tronscan_and_sends_the_api_key() {
2683        let mut server = mockito::Server::new_async().await;
2684        let mock = server
2685            .mock("GET", "/api/token_trc20")
2686            .match_query(mockito::Matcher::AllOf(vec![
2687                mockito::Matcher::UrlEncoded("contract".into(), USDT_CONTRACT.into()),
2688                mockito::Matcher::UrlEncoded("showAll".into(), "1".into()),
2689            ]))
2690            .match_header("TRON-PRO-API-KEY", "k1")
2691            .with_status(200)
2692            .with_body(
2693                r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "name": "Tether USD", "decimals": 6}]}"#,
2694            )
2695            .create_async()
2696            .await;
2697
2698        let client = mocked_client(&server, Some("k1"));
2699        let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2700        mock.assert_async().await;
2701        assert_eq!(token.contract_address, USDT_CONTRACT);
2702        assert_eq!(token.symbol, "USDT");
2703        // contract_name wins over name
2704        assert_eq!(token.name, "TetherToken");
2705        assert_eq!(token.decimals, 6);
2706    }
2707
2708    #[tokio::test]
2709    async fn test_get_token_info_falls_back_to_name_then_defaults() {
2710        let mut server = mockito::Server::new_async().await;
2711        let client = mocked_client(&server, None);
2712
2713        let _m = mock_token_info(
2714            &mut server,
2715            200,
2716            r#"{"trc20_tokens": [{"name": "Plain Name", "decimals": 18}]}"#,
2717        )
2718        .await;
2719        let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2720        assert_eq!(token.symbol, "UNKNOWN");
2721        assert_eq!(token.name, "Plain Name");
2722        assert_eq!(token.decimals, 18);
2723
2724        server.reset();
2725        let _m = mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{}]}"#).await;
2726        let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2727        assert_eq!(token.symbol, "UNKNOWN");
2728        assert_eq!(token.name, "Unknown Token");
2729        assert_eq!(token.decimals, 6);
2730    }
2731
2732    #[tokio::test]
2733    async fn test_get_token_info_not_found_and_bad_json() {
2734        let mut server = mockito::Server::new_async().await;
2735        let client = mocked_client(&server, None);
2736        for body in [r#"{"trc20_tokens": []}"#, r#"{"other": 1}"#] {
2737            server.reset();
2738            let _m = mock_token_info(&mut server, 200, body).await;
2739            let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
2740            assert!(matches!(err, ScopeError::NotFound(_)), "{body}: {err}");
2741            assert!(err.to_string().contains(USDT_CONTRACT));
2742        }
2743
2744        server.reset();
2745        let _m = mock_token_info(&mut server, 200, "<html>not json</html>").await;
2746        let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
2747        assert!(matches!(err, ScopeError::Api(_)), "{err}");
2748        assert!(err.to_string().contains("Tronscan"));
2749    }
2750
2751    #[tokio::test]
2752    async fn test_get_token_holders_ranks_and_shares_by_fetched_total() {
2753        let mut server = mockito::Server::new_async().await;
2754        let holders = server
2755            .mock("GET", "/api/token_trc20/holders")
2756            .match_query(mockito::Matcher::AllOf(vec![
2757                mockito::Matcher::UrlEncoded("contract_address".into(), USDT_CONTRACT.into()),
2758                // The requested 500 is capped at 100.
2759                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2760            ]))
2761            .match_header("TRON-PRO-API-KEY", "k2")
2762            .with_status(200)
2763            .with_body(
2764                r#"{"trc20_tokens": [
2765                    {"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "3000000000000000000"},
2766                    {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "not-a-number"},
2767                    {"balance": "500"},
2768                    {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "1000000000000000000"}
2769                ]}"#,
2770            )
2771            .create_async()
2772            .await;
2773        let _info = mock_token_info(
2774            &mut server,
2775            200,
2776            r#"{"trc20_tokens": [{"symbol": "BIG", "decimals": 18}]}"#,
2777        )
2778        .await;
2779
2780        let client = mocked_client(&server, Some("k2"));
2781        let result = client.get_token_holders(USDT_CONTRACT, 500).await.unwrap();
2782        holders.assert_async().await;
2783
2784        // Rows without an address or a numeric balance are dropped; rank keeps
2785        // the position in Tronscan's list.
2786        assert_eq!(result.len(), 2);
2787        assert_eq!(result[0].address, VALID_ADDRESS);
2788        assert_eq!(result[0].rank, 1);
2789        assert_eq!(result[0].formatted_balance, "3.000000");
2790        assert_eq!(result[1].rank, 4);
2791        assert_eq!(result[1].formatted_balance, "1.000000");
2792        // The total counts every parseable balance, the address-less row included.
2793        let total = 4e18 + 500.0;
2794        assert!((result[0].percentage - 3e18 / total * 100.0).abs() < 1e-9);
2795        assert!((result[1].percentage - 1e18 / total * 100.0).abs() < 1e-9);
2796    }
2797
2798    #[tokio::test]
2799    async fn test_get_token_holders_without_token_info_uses_six_decimals() {
2800        let mut server = mockito::Server::new_async().await;
2801        let _holders = mock_holders(
2802            &mut server,
2803            r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "2500000"}]}"#,
2804        )
2805        .await;
2806        let _info = mock_token_info(&mut server, 200, r#"{"trc20_tokens": []}"#).await;
2807
2808        let client = mocked_client(&server, None);
2809        let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
2810        assert_eq!(result.len(), 1);
2811        assert_eq!(result[0].formatted_balance, "2.500000");
2812        assert!((result[0].percentage - 100.0).abs() < 1e-9);
2813    }
2814
2815    #[tokio::test]
2816    async fn test_get_token_holders_zero_total_gives_zero_share() {
2817        let mut server = mockito::Server::new_async().await;
2818        let _holders = mock_holders(
2819            &mut server,
2820            r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "0"}]}"#,
2821        )
2822        .await;
2823        let _info =
2824            mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
2825
2826        let client = mocked_client(&server, None);
2827        let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
2828        assert_eq!(result.len(), 1);
2829        assert_eq!(result[0].percentage, 0.0);
2830    }
2831
2832    #[tokio::test]
2833    async fn test_get_token_holders_empty_and_bad_json() {
2834        let mut server = mockito::Server::new_async().await;
2835        let client = mocked_client(&server, None);
2836        let _holders = mock_holders(&mut server, r#"{"total": 0}"#).await;
2837        let _info =
2838            mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
2839        assert!(
2840            client
2841                .get_token_holders(USDT_CONTRACT, 10)
2842                .await
2843                .unwrap()
2844                .is_empty()
2845        );
2846
2847        server.reset();
2848        let _holders = mock_holders(&mut server, "{").await;
2849        let err = client
2850            .get_token_holders(USDT_CONTRACT, 10)
2851            .await
2852            .unwrap_err();
2853        assert!(err.to_string().contains("Tronscan holders"), "{err}");
2854    }
2855
2856    #[tokio::test]
2857    async fn test_get_token_holder_count() {
2858        let mut server = mockito::Server::new_async().await;
2859        let client = mocked_client(&server, Some("k3"));
2860        let mock = server
2861            .mock("GET", "/api/token_trc20/holders")
2862            .match_query(mockito::Matcher::UrlEncoded("limit".into(), "1".into()))
2863            .match_header("TRON-PRO-API-KEY", "k3")
2864            .with_status(200)
2865            .with_body(r#"{"rangeTotal": 12345, "trc20_tokens": []}"#)
2866            .create_async()
2867            .await;
2868        assert_eq!(
2869            client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
2870            12345
2871        );
2872        mock.assert_async().await;
2873
2874        server.reset();
2875        let _m = mock_holders(&mut server, "{}").await;
2876        assert_eq!(
2877            client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
2878            0
2879        );
2880
2881        server.reset();
2882        let _m = mock_holders(&mut server, "nope").await;
2883        let err = client
2884            .get_token_holder_count(USDT_CONTRACT)
2885            .await
2886            .unwrap_err();
2887        assert!(matches!(err, ScopeError::Api(_)), "{err}");
2888    }
2889
2890    #[tokio::test]
2891    async fn test_enrich_balance_usd_uses_first_priced_trx_pair() {
2892        // The flat `baseTokenSymbol` shape that DexSearchPair parses.
2893        let mut server = mockito::Server::new_async().await;
2894        let _m = mock_dex_search(
2895            &mut server,
2896            r#"{"pairs": [
2897                {"baseTokenSymbol": "USDT", "priceUsd": "1.00"},
2898                {"baseTokenSymbol": "TRX", "priceUsd": "n/a"},
2899                {"baseTokenSymbol": "WTRX", "priceUsd": "0.25"},
2900                {"baseTokenSymbol": "TRX", "priceUsd": "9.99"}
2901            ]}"#,
2902        )
2903        .await;
2904
2905        let client = mocked_client(&server, None);
2906        let mut balance = trx_balance("4000000"); // 4 TRX
2907        client.enrich_balance_usd(&mut balance).await;
2908        assert_eq!(balance.usd_value, Some(1.0));
2909
2910        // Through the trait object too.
2911        let chain_client: &dyn ChainClient = &client;
2912        let mut balance = trx_balance("2000000");
2913        chain_client.enrich_balance_usd(&mut balance).await;
2914        assert_eq!(balance.usd_value, Some(0.5));
2915    }
2916
2917    #[tokio::test]
2918    async fn test_enrich_balance_usd_leaves_value_unset_without_a_price() {
2919        let mut server = mockito::Server::new_async().await;
2920        let client = mocked_client(&server, None);
2921        for body in [
2922            r#"{"pairs": null}"#,
2923            r#"{"pairs": [{"baseTokenSymbol": "BTC", "priceUsd": "60000"}]}"#,
2924            r#"{"pairs": [{"baseTokenSymbol": "TRX"}]}"#,
2925            "garbage",
2926        ] {
2927            server.reset();
2928            let _m = mock_dex_search(&mut server, body).await;
2929            let mut balance = trx_balance("1000000");
2930            client.enrich_balance_usd(&mut balance).await;
2931            assert_eq!(balance.usd_value, None, "{body}");
2932        }
2933    }
2934
2935    #[tokio::test]
2936    async fn test_chain_client_token_balances_use_tronscan_info_or_placeholder() {
2937        let account_body = format!(
2938            r#"{{"data": [{{"balance": 0, "address": "{VALID_ADDRESS}", "trc20": [{{"{USDT_CONTRACT}": "1234567"}}]}}], "success": true}}"#
2939        );
2940        let account_path = format!("/v1/accounts/{VALID_ADDRESS}");
2941        let mut server = mockito::Server::new_async().await;
2942        let _account = server
2943            .mock("GET", account_path.as_str())
2944            .with_status(200)
2945            .with_body(&account_body)
2946            .create_async()
2947            .await;
2948        let _info = mock_token_info(
2949            &mut server,
2950            200,
2951            r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 3}]}"#,
2952        )
2953        .await;
2954
2955        let client = mocked_client(&server, None);
2956        let chain_client: &dyn ChainClient = &client;
2957        let balances = chain_client
2958            .get_token_balances(VALID_ADDRESS)
2959            .await
2960            .unwrap();
2961        assert_eq!(balances.len(), 1);
2962        assert_eq!(balances[0].token.symbol, "USDT");
2963        assert_eq!(balances[0].token.decimals, 3);
2964        assert_eq!(balances[0].balance, "1234567");
2965        assert_eq!(balances[0].formatted_balance, "1234.567000");
2966        assert_eq!(balances[0].usd_value, None);
2967
2968        // Tronscan is down: a TRC20 placeholder with six decimals.
2969        server.reset();
2970        let _account = server
2971            .mock("GET", account_path.as_str())
2972            .with_status(200)
2973            .with_body(&account_body)
2974            .create_async()
2975            .await;
2976        let _info = mock_token_info(&mut server, 500, "down").await;
2977        let balances = chain_client
2978            .get_token_balances(VALID_ADDRESS)
2979            .await
2980            .unwrap();
2981        assert_eq!(balances[0].token.symbol, "TRC20");
2982        assert_eq!(balances[0].token.name, "TRC-20 Token");
2983        assert_eq!(balances[0].token.contract_address, USDT_CONTRACT);
2984        assert_eq!(balances[0].formatted_balance, "1.234567");
2985    }
2986
2987    #[tokio::test]
2988    async fn test_chain_client_token_queries_delegate_to_tronscan() {
2989        let mut server = mockito::Server::new_async().await;
2990        let _holders = mock_holders(
2991            &mut server,
2992            r#"{"rangeTotal": 7, "trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "1"}]}"#,
2993        )
2994        .await;
2995        let _info = mock_token_info(
2996            &mut server,
2997            200,
2998            r#"{"trc20_tokens": [{"symbol": "USDT", "decimals": 6}]}"#,
2999        )
3000        .await;
3001
3002        let client = mocked_client(&server, None);
3003        let chain_client: &dyn ChainClient = &client;
3004        assert_eq!(
3005            chain_client
3006                .get_token_info(USDT_CONTRACT)
3007                .await
3008                .unwrap()
3009                .symbol,
3010            "USDT"
3011        );
3012        assert_eq!(
3013            chain_client
3014                .get_token_holders(USDT_CONTRACT, 5)
3015                .await
3016                .unwrap()
3017                .len(),
3018            1
3019        );
3020        assert_eq!(
3021            chain_client
3022                .get_token_holder_count(USDT_CONTRACT)
3023                .await
3024                .unwrap(),
3025            7
3026        );
3027    }
3028
3029    #[tokio::test]
3030    async fn test_get_transaction_and_transactions_send_the_api_key() {
3031        let mut server = mockito::Server::new_async().await;
3032        let tx = server
3033            .mock("GET", format!("/v1/transactions/{VALID_TX_HASH}").as_str())
3034            .match_header("TRON-PRO-API-KEY", "k4")
3035            .with_status(200)
3036            .with_body(format!(
3037                r#"{{"data": [{{"txID": "{VALID_TX_HASH}"}}], "success": true}}"#
3038            ))
3039            .create_async()
3040            .await;
3041        let txs = server
3042            .mock(
3043                "GET",
3044                format!("/v1/accounts/{VALID_ADDRESS}/transactions").as_str(),
3045            )
3046            .match_query(mockito::Matcher::UrlEncoded("limit".into(), "3".into()))
3047            .match_header("TRON-PRO-API-KEY", "k4")
3048            .with_status(200)
3049            .with_body(r#"{"data": [], "success": true}"#)
3050            .create_async()
3051            .await;
3052
3053        let client = mocked_client(&server, Some("k4"));
3054        let got = client.get_transaction(VALID_TX_HASH).await.unwrap();
3055        assert_eq!(got.hash, VALID_TX_HASH);
3056        assert_eq!(got.from, "");
3057        assert_eq!(got.value, "0");
3058        assert!(
3059            client
3060                .get_transactions(VALID_ADDRESS, 3)
3061                .await
3062                .unwrap()
3063                .is_empty()
3064        );
3065        tx.assert_async().await;
3066        txs.assert_async().await;
3067    }
3068
3069    #[test]
3070    fn test_validate_tron_address_rejects_non_mainnet_prefix() {
3071        // A well-formed base58check address with version byte 0x42, not 0x41.
3072        // It still starts with 'T' and is 34 characters long.
3073        let mut bytes = vec![0x42u8];
3074        bytes.extend(0u8..20);
3075        let checksum = Sha256::digest(Sha256::digest(&bytes));
3076        bytes.extend_from_slice(&checksum[..4]);
3077        let address = bs58::encode(&bytes).into_string();
3078        assert_eq!(address, "TZJqCCFeCFdJJaGGgNhTbhcLdyjmqUrgFq");
3079
3080        let err = validate_tron_address(&address).unwrap_err();
3081        assert!(err.to_string().contains("prefix"), "{err}");
3082    }
3083
3084    #[test]
3085    #[allow(deprecated)]
3086    fn test_deprecated_new_reads_the_config() {
3087        let config = ChainsConfig {
3088            tron_api: Some("http://127.0.0.1:9".to_string()),
3089            ..Default::default()
3090        };
3091        let client = TronClient::new(&config).unwrap();
3092        assert_eq!(client.api_url, "http://127.0.0.1:9");
3093        assert!(client.api_key.is_none());
3094    }
3095}