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    pub fn new(config: &ChainsConfig) -> Result<Self> {
171        Self::new_with_http(config, crate::http::transport())
172    }
173
174    /// Creates a new Tron client with a pre-built HTTP transport.
175    pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
176        let api_url = config
177            .tron_api
178            .as_deref()
179            .unwrap_or(DEFAULT_TRON_API)
180            .to_string();
181
182        Ok(Self {
183            http,
184            api_url,
185            api_key: config.api_keys.get("tronscan").cloned(),
186        })
187    }
188
189    /// Creates a client with a custom API URL.
190    ///
191    /// # Arguments
192    ///
193    /// * `api_url` - The TronGrid API endpoint URL
194    pub fn with_api_url(api_url: &str) -> Self {
195        Self {
196            http: crate::http::transport(),
197            api_url: api_url.to_string(),
198            api_key: None,
199        }
200    }
201
202    /// Returns the chain name.
203    pub fn chain_name(&self) -> &str {
204        "tron"
205    }
206
207    /// Returns the native token symbol.
208    pub fn native_token_symbol(&self) -> &str {
209        "TRX"
210    }
211
212    /// Fetches the TRX balance for an address.
213    ///
214    /// # Arguments
215    ///
216    /// * `address` - The Tron address (T-address format)
217    ///
218    /// # Returns
219    ///
220    /// Returns a [`Balance`] struct with the balance in multiple formats.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
225    /// Returns [`ScopeError::Request`] if the API request fails.
226    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
227        // Validate address
228        validate_tron_address(address)?;
229
230        let url = format!("{}/v1/accounts/{}", self.api_url, address);
231
232        tracing::debug!(url = %url, address = %address, "Fetching Tron balance");
233
234        let mut req = Request::get(&url);
235        if let Some(ref key) = self.api_key {
236            req = req.with_header("TRON-PRO-API-KEY", key);
237        }
238
239        let response: AccountResponse = self.http.send(req).await?.json()?;
240
241        if !response.success {
242            return Err(ScopeError::Chain(format!(
243                "TronGrid API error: {}",
244                response.error.unwrap_or_else(|| "Unknown error".into())
245            )));
246        }
247
248        // Account may not exist yet (no balance)
249        let sun = response.data.first().and_then(|d| d.balance).unwrap_or(0);
250
251        let trx = sun as f64 / 10_f64.powi(TRX_DECIMALS as i32);
252
253        Ok(Balance {
254            raw: sun.to_string(),
255            formatted: format!("{:.6} TRX", trx),
256            decimals: TRX_DECIMALS,
257            symbol: "TRX".to_string(),
258            usd_value: None, // Populated by caller via enrich_balance_usd
259        })
260    }
261
262    /// Fetches TRC-20 token balances for an address.
263    ///
264    /// Uses the TronGrid `/v1/accounts/{address}` endpoint which includes
265    /// TRC-20 balances in the account data.
266    pub async fn get_trc20_balances(&self, address: &str) -> Result<Vec<Trc20TokenBalance>> {
267        validate_tron_address(address)?;
268
269        let url = format!("{}/v1/accounts/{}", self.api_url, address);
270
271        tracing::debug!(url = %url, "Fetching TRC-20 token balances");
272
273        let mut req = Request::get(&url);
274        if let Some(ref key) = self.api_key {
275            req = req.with_header("TRON-PRO-API-KEY", key);
276        }
277
278        let response: AccountResponse = self.http.send(req).await?.json()?;
279
280        if !response.success {
281            return Err(ScopeError::Chain(format!(
282                "TronGrid API error: {}",
283                response.error.unwrap_or_else(|| "Unknown error".into())
284            )));
285        }
286
287        let account = match response.data.first() {
288            Some(data) => data,
289            None => return Ok(vec![]),
290        };
291
292        let mut balances = Vec::new();
293        for trc20 in &account.trc20 {
294            for (contract_address, raw_balance) in &trc20.balances {
295                // Skip zero balances
296                if raw_balance == "0" {
297                    continue;
298                }
299                balances.push(Trc20TokenBalance {
300                    contract_address: contract_address.clone(),
301                    raw_balance: raw_balance.clone(),
302                });
303            }
304        }
305
306        Ok(balances)
307    }
308
309    /// Fetches TRC-20 token info from Tronscan API.
310    ///
311    /// Returns symbol, name, decimals, and other metadata for a TRC-20 contract.
312    pub async fn get_token_info(&self, contract_address: &str) -> Result<Token> {
313        validate_tron_address(contract_address)?;
314
315        let url = format!(
316            "{}/api/token_trc20?contract={}&showAll=1",
317            TRONSCAN_API, contract_address
318        );
319
320        tracing::debug!(url = %url, "Fetching TRC-20 token info via Tronscan");
321
322        let mut req = Request::get(&url);
323        if let Some(ref key) = self.api_key {
324            req = req.with_header("TRON-PRO-API-KEY", key);
325        }
326
327        let resp = self.http.send(req).await?;
328        let json: serde_json::Value = serde_json::from_str(&resp.body)
329            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
330
331        let tokens = json
332            .get("trc20_tokens")
333            .and_then(|v| v.as_array())
334            .ok_or_else(|| {
335                ScopeError::NotFound(format!(
336                    "No token info found for TRC-20 contract {}",
337                    contract_address
338                ))
339            })?;
340
341        let token_data = tokens.first().ok_or_else(|| {
342            ScopeError::NotFound(format!(
343                "No token info found for TRC-20 contract {}",
344                contract_address
345            ))
346        })?;
347
348        let symbol = token_data
349            .get("symbol")
350            .and_then(|v| v.as_str())
351            .unwrap_or("UNKNOWN")
352            .to_string();
353        let name = token_data
354            .get("contract_name")
355            .or_else(|| token_data.get("name"))
356            .and_then(|v| v.as_str())
357            .unwrap_or("Unknown Token")
358            .to_string();
359        let decimals = token_data
360            .get("decimals")
361            .and_then(|v| v.as_u64())
362            .unwrap_or(6) as u8;
363
364        Ok(Token {
365            contract_address: contract_address.to_string(),
366            symbol,
367            name,
368            decimals,
369        })
370    }
371
372    /// Fetches top TRC-20 token holders from Tronscan API.
373    ///
374    /// Returns holders sorted by balance (largest first).
375    pub async fn get_token_holders(
376        &self,
377        contract_address: &str,
378        limit: u32,
379    ) -> Result<Vec<TokenHolder>> {
380        validate_tron_address(contract_address)?;
381
382        let effective_limit = limit.min(100);
383        let url = format!(
384            "{}/api/token_trc20/holders?contract_address={}&start=0&limit={}",
385            TRONSCAN_API, contract_address, effective_limit
386        );
387
388        tracing::debug!(url = %url, "Fetching TRC-20 token holders via Tronscan");
389
390        let mut req = Request::get(&url);
391        if let Some(ref key) = self.api_key {
392            req = req.with_header("TRON-PRO-API-KEY", key);
393        }
394
395        let resp = self.http.send(req).await?;
396        let json: serde_json::Value = serde_json::from_str(&resp.body)
397            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan holders: {}", e)))?;
398
399        let holders_data: &[serde_json::Value] = json
400            .get("trc20_tokens")
401            .and_then(|v| v.as_array())
402            .map(|v| v.as_slice())
403            .unwrap_or(&[]);
404
405        // Get decimals for formatted balance
406        let token_info = self.get_token_info(contract_address).await;
407        let decimals = token_info.as_ref().map(|t| t.decimals).unwrap_or(6);
408
409        // Percentage is relative to sum of fetched holder balances (same as EVM chains)
410        let total_balance: f64 = holders_data
411            .iter()
412            .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
413            .filter_map(|s| s.parse::<f64>().ok())
414            .sum();
415
416        let token_holders: Vec<TokenHolder> = holders_data
417            .iter()
418            .enumerate()
419            .filter_map(|(i, h)| {
420                let holder_address = h.get("holder_address")?.as_str()?.to_string();
421                let balance_raw = h.get("balance")?.as_str()?.to_string();
422                let balance: f64 = balance_raw.parse().ok()?;
423                let percentage = if total_balance > 0.0 {
424                    (balance / total_balance) * 100.0
425                } else {
426                    0.0
427                };
428                let divisor = 10_f64.powi(decimals as i32);
429                let formatted = format!("{:.6}", balance / divisor);
430
431                Some(TokenHolder {
432                    address: holder_address,
433                    balance: balance_raw,
434                    formatted_balance: formatted,
435                    percentage,
436                    rank: (i + 1) as u32,
437                })
438            })
439            .collect();
440
441        Ok(token_holders)
442    }
443
444    /// Fetches total holder count for a TRC-20 token.
445    pub async fn get_token_holder_count(&self, contract_address: &str) -> Result<u64> {
446        validate_tron_address(contract_address)?;
447
448        let url = format!(
449            "{}/api/token_trc20/holders?contract_address={}&start=0&limit=1",
450            TRONSCAN_API, contract_address
451        );
452
453        let mut req = Request::get(&url);
454        if let Some(ref key) = self.api_key {
455            req = req.with_header("TRON-PRO-API-KEY", key);
456        }
457
458        let resp = self.http.send(req).await?;
459        let json: serde_json::Value = resp
460            .json()
461            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
462
463        let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
464
465        Ok(count)
466    }
467
468    /// Enriches a balance with a USD value using DexScreener price lookup.
469    ///
470    /// Note: Tron native token price lookup via DexScreener is not yet supported.
471    /// This is a placeholder that uses CoinGecko-style simple price API as fallback.
472    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
473        // Try to get TRX price from DexScreener search API
474        let url = DEXSCREENER_TRX_SEARCH;
475        if let Ok(resp) = self.http.send(Request::get(url)).await
476            && let Ok(search_result) = serde_json::from_str::<DexSearchResponse>(&resp.body)
477            && let Some(pairs) = search_result.pairs
478        {
479            for pair in &pairs {
480                if (pair.base_token_symbol.as_deref() == Some("TRX")
481                    || pair.base_token_symbol.as_deref() == Some("WTRX"))
482                    && let Some(price) = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok())
483                {
484                    let sun: f64 = balance.raw.parse().unwrap_or(0.0);
485                    let trx = sun / 10_f64.powi(TRX_DECIMALS as i32);
486                    balance.usd_value = Some(trx * price);
487                    return;
488                }
489            }
490        }
491    }
492
493    /// Fetches transaction details by hash.
494    ///
495    /// # Arguments
496    ///
497    /// * `hash` - The transaction hash
498    ///
499    /// # Returns
500    ///
501    /// Returns [`Transaction`] details.
502    pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
503        // Validate hash
504        validate_tron_tx_hash(hash)?;
505
506        let url = format!("{}/v1/transactions/{}", self.api_url, hash);
507
508        tracing::debug!(url = %url, hash = %hash, "Fetching Tron transaction");
509
510        let mut req = Request::get(&url);
511        if let Some(ref key) = self.api_key {
512            req = req.with_header("TRON-PRO-API-KEY", key);
513        }
514
515        let response: TransactionListResponse = self.http.send(req).await?.json()?;
516
517        if !response.success {
518            return Err(ScopeError::Chain(format!(
519                "TronGrid API error: {}",
520                response.error.unwrap_or_else(|| "Unknown error".into())
521            )));
522        }
523
524        let tx = response
525            .data
526            .into_iter()
527            .next()
528            .ok_or_else(|| ScopeError::Chain("Transaction not found".into()))?;
529
530        // Extract transfer details from contract
531        let (from, to, value) = tx
532            .raw_data
533            .and_then(|rd| rd.contract)
534            .and_then(|contracts| contracts.into_iter().next())
535            .and_then(|c| c.parameter)
536            .and_then(|p| p.value)
537            .map(|v| {
538                (
539                    v.owner_address.unwrap_or_default(),
540                    v.to_address,
541                    v.amount.unwrap_or(0).to_string(),
542                )
543            })
544            .unwrap_or_else(|| (String::new(), None, "0".to_string()));
545
546        let status = tx
547            .ret
548            .and_then(|r| r.into_iter().next())
549            .and_then(|r| r.contract_ret)
550            .map(|s| s == "SUCCESS");
551
552        Ok(Transaction {
553            hash: tx.tx_id,
554            block_number: tx.block_number,
555            timestamp: tx.block_timestamp.map(|t| t / 1000), // Convert ms to seconds
556            from,
557            to,
558            value,
559            gas_limit: 0, // Tron uses bandwidth/energy instead of gas
560            gas_used: None,
561            gas_price: "0".to_string(),
562            nonce: 0,
563            input: String::new(),
564            status,
565        })
566    }
567
568    /// Fetches recent transactions for an address.
569    ///
570    /// # Arguments
571    ///
572    /// * `address` - The address to query
573    /// * `limit` - Maximum number of transactions
574    ///
575    /// # Returns
576    ///
577    /// Returns a vector of [`Transaction`] objects.
578    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
579        validate_tron_address(address)?;
580
581        let url = format!(
582            "{}/v1/accounts/{}/transactions?limit={}",
583            self.api_url, address, limit
584        );
585
586        tracing::debug!(url = %url, address = %address, "Fetching Tron transactions");
587
588        let mut req = Request::get(&url);
589        if let Some(ref key) = self.api_key {
590            req = req.with_header("TRON-PRO-API-KEY", key);
591        }
592
593        let response: TransactionListResponse = self.http.send(req).await?.json()?;
594
595        if !response.success {
596            return Err(ScopeError::Chain(format!(
597                "TronGrid API error: {}",
598                response.error.unwrap_or_else(|| "Unknown error".into())
599            )));
600        }
601
602        let transactions = response
603            .data
604            .into_iter()
605            .map(|tx| {
606                let (from, to, value) = tx
607                    .raw_data
608                    .and_then(|rd| rd.contract)
609                    .and_then(|contracts| contracts.into_iter().next())
610                    .and_then(|c| c.parameter)
611                    .and_then(|p| p.value)
612                    .map(|v| {
613                        (
614                            v.owner_address.unwrap_or_default(),
615                            v.to_address,
616                            v.amount.unwrap_or(0).to_string(),
617                        )
618                    })
619                    .unwrap_or_else(|| (String::new(), None, "0".to_string()));
620
621                let status = tx
622                    .ret
623                    .and_then(|r| r.into_iter().next())
624                    .and_then(|r| r.contract_ret)
625                    .map(|s| s == "SUCCESS");
626
627                Transaction {
628                    hash: tx.tx_id,
629                    block_number: tx.block_number,
630                    timestamp: tx.block_timestamp.map(|t| t / 1000),
631                    from,
632                    to,
633                    value,
634                    gas_limit: 0,
635                    gas_used: None,
636                    gas_price: "0".to_string(),
637                    nonce: 0,
638                    input: String::new(),
639                    status,
640                }
641            })
642            .collect();
643
644        Ok(transactions)
645    }
646
647    /// Fetches the current block number.
648    pub async fn get_block_number(&self) -> Result<u64> {
649        let url = format!("{}/wallet/getnowblock", self.api_url);
650
651        #[derive(Deserialize)]
652        struct BlockResponse {
653            block_header: Option<BlockHeader>,
654        }
655
656        #[derive(Deserialize)]
657        struct BlockHeader {
658            raw_data: Option<BlockRawData>,
659        }
660
661        #[derive(Deserialize)]
662        struct BlockRawData {
663            number: Option<u64>,
664        }
665
666        let resp = self.http.send(Request::post_json(&url, "")).await?;
667        let response: BlockResponse = resp.json()?;
668
669        response
670            .block_header
671            .and_then(|h| h.raw_data)
672            .and_then(|d| d.number)
673            .ok_or_else(|| ScopeError::Chain("Invalid block response".into()))
674    }
675}
676
677impl Default for TronClient {
678    fn default() -> Self {
679        Self {
680            http: crate::http::transport(),
681            api_url: DEFAULT_TRON_API.to_string(),
682            api_key: None,
683        }
684    }
685}
686
687/// Validates a Tron address format (T-address, base58check encoded).
688///
689/// Tron addresses:
690/// - Start with 'T'
691/// - Are 34 characters long
692/// - Use base58check encoding (includes checksum)
693///
694/// # Arguments
695///
696/// * `address` - The address to validate
697///
698/// TRC-20 token balance result.
699#[derive(Debug, Clone)]
700pub struct Trc20TokenBalance {
701    /// Token contract address (base58).
702    pub contract_address: String,
703    /// Raw balance string.
704    pub raw_balance: String,
705}
706
707/// Minimal DexScreener search response for price lookups.
708#[derive(Debug, Deserialize)]
709struct DexSearchResponse {
710    #[serde(default)]
711    pairs: Option<Vec<DexSearchPair>>,
712}
713
714/// A pair from DexScreener search results.
715#[derive(Debug, Deserialize)]
716#[serde(rename_all = "camelCase")]
717struct DexSearchPair {
718    #[serde(default)]
719    base_token_symbol: Option<String>,
720    #[serde(default)]
721    price_usd: Option<String>,
722}
723
724/// # Returns
725///
726/// Returns `Ok(())` if valid, or an error describing the validation failure.
727pub fn validate_tron_address(address: &str) -> Result<()> {
728    if address.is_empty() {
729        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
730    }
731
732    // Tron addresses start with 'T'
733    if !address.starts_with('T') {
734        return Err(ScopeError::InvalidAddress(format!(
735            "Tron address must start with 'T': {}",
736            address
737        )));
738    }
739
740    // Tron addresses are 34 characters
741    if address.len() != 34 {
742        return Err(ScopeError::InvalidAddress(format!(
743            "Tron address must be 34 characters, got {}: {}",
744            address.len(),
745            address
746        )));
747    }
748
749    // Validate base58 encoding
750    match bs58::decode(address).into_vec() {
751        Ok(bytes) => {
752            // Should decode to 25 bytes (1 prefix + 20 address + 4 checksum)
753            if bytes.len() != 25 {
754                return Err(ScopeError::InvalidAddress(format!(
755                    "Tron address must decode to 25 bytes, got {}: {}",
756                    bytes.len(),
757                    address
758                )));
759            }
760
761            // First byte should be 0x41 (Tron mainnet prefix)
762            if bytes[0] != 0x41 {
763                return Err(ScopeError::InvalidAddress(format!(
764                    "Invalid Tron address prefix: {}",
765                    address
766                )));
767            }
768
769            // Verify checksum: last 4 bytes must equal first 4 bytes of double SHA256 of first 21 bytes
770            let payload = &bytes[0..21];
771            let hash1 = Sha256::digest(payload);
772            let hash2 = Sha256::digest(hash1);
773            let expected_checksum = &hash2[0..4];
774            let actual_checksum = &bytes[21..25];
775
776            if expected_checksum != actual_checksum {
777                return Err(ScopeError::InvalidAddress(format!(
778                    "Invalid Tron address checksum: {}",
779                    address
780                )));
781            }
782        }
783        Err(e) => {
784            return Err(ScopeError::InvalidAddress(format!(
785                "Invalid base58 encoding: {}: {}",
786                e, address
787            )));
788        }
789    }
790
791    Ok(())
792}
793
794/// Validates a Tron transaction hash format.
795///
796/// Tron transaction hashes are 64-character hex strings.
797///
798/// # Arguments
799///
800/// * `hash` - The hash to validate
801///
802/// # Returns
803///
804/// Returns `Ok(())` if valid, or an error describing the validation failure.
805pub fn validate_tron_tx_hash(hash: &str) -> Result<()> {
806    if hash.is_empty() {
807        return Err(ScopeError::InvalidHash("Hash cannot be empty".into()));
808    }
809
810    // Tron tx hashes are 64 hex characters (without 0x prefix)
811    if hash.len() != 64 {
812        return Err(ScopeError::InvalidHash(format!(
813            "Tron transaction hash must be 64 characters, got {}: {}",
814            hash.len(),
815            hash
816        )));
817    }
818
819    // Validate hex encoding
820    if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
821        return Err(ScopeError::InvalidHash(format!(
822            "Tron hash contains invalid hex characters: {}",
823            hash
824        )));
825    }
826
827    Ok(())
828}
829
830// ============================================================================
831// ChainClient Trait Implementation
832// ============================================================================
833
834#[async_trait]
835impl ChainClient for TronClient {
836    fn chain_name(&self) -> &str {
837        "tron"
838    }
839
840    fn native_token_symbol(&self) -> &str {
841        "TRX"
842    }
843
844    async fn get_balance(&self, address: &str) -> Result<Balance> {
845        self.get_balance(address).await
846    }
847
848    async fn enrich_balance_usd(&self, balance: &mut Balance) {
849        self.enrich_balance_usd(balance).await
850    }
851
852    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
853        self.get_transaction(hash).await
854    }
855
856    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
857        self.get_transactions(address, limit).await
858    }
859
860    async fn get_block_number(&self) -> Result<u64> {
861        self.get_block_number().await
862    }
863
864    async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
865        let trc20_balances = self.get_trc20_balances(address).await?;
866        let mut result = Vec::with_capacity(trc20_balances.len());
867
868        for tb in trc20_balances {
869            let token = match self.get_token_info(&tb.contract_address).await {
870                Ok(info) => info,
871                Err(e) => {
872                    tracing::debug!(
873                        contract = %tb.contract_address,
874                        error = %e,
875                        "Could not fetch TRC-20 token info, using placeholder"
876                    );
877                    Token {
878                        contract_address: tb.contract_address.clone(),
879                        symbol: "TRC20".to_string(),
880                        name: "TRC-20 Token".to_string(),
881                        decimals: 6, // Common for USDT, USDC
882                    }
883                }
884            };
885
886            let raw: f64 = tb.raw_balance.parse().unwrap_or(0.0);
887            let divisor = 10_f64.powi(token.decimals as i32);
888            let formatted = format!("{:.6}", raw / divisor);
889
890            result.push(crate::chains::TokenBalance {
891                token,
892                balance: tb.raw_balance,
893                formatted_balance: formatted,
894                usd_value: None,
895            });
896        }
897
898        Ok(result)
899    }
900
901    async fn get_token_info(&self, address: &str) -> Result<Token> {
902        self.get_token_info(address).await
903    }
904
905    async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
906        self.get_token_holders(address, limit).await
907    }
908
909    async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
910        self.get_token_holder_count(address).await
911    }
912}
913
914// ============================================================================
915// Unit Tests
916// ============================================================================
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    // Valid Tron address (Binance cold wallet)
923    const VALID_ADDRESS: &str = "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf";
924
925    // Valid Tron transaction hash
926    const VALID_TX_HASH: &str = "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
927
928    #[test]
929    fn test_validate_tron_address_valid() {
930        assert!(validate_tron_address(VALID_ADDRESS).is_ok());
931    }
932
933    #[test]
934    fn test_validate_tron_address_empty() {
935        let result = validate_tron_address("");
936        assert!(result.is_err());
937        assert!(result.unwrap_err().to_string().contains("empty"));
938    }
939
940    #[test]
941    fn test_validate_tron_address_wrong_prefix() {
942        let result = validate_tron_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
943        assert!(result.is_err());
944        assert!(result.unwrap_err().to_string().contains("start with 'T'"));
945    }
946
947    #[test]
948    fn test_validate_tron_address_too_short() {
949        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZ");
950        assert!(result.is_err());
951        assert!(result.unwrap_err().to_string().contains("34 characters"));
952    }
953
954    #[test]
955    fn test_validate_tron_address_too_long() {
956        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCfAAAA");
957        assert!(result.is_err());
958        assert!(result.unwrap_err().to_string().contains("34 characters"));
959    }
960
961    #[test]
962    fn test_validate_tron_address_invalid_base58() {
963        // Contains '0' which is not valid base58
964        let result = validate_tron_address("T0qSquXBgUCLYvYC4XZgrprLK589dkhSCf");
965        assert!(result.is_err());
966        assert!(result.unwrap_err().to_string().contains("base58"));
967    }
968
969    #[test]
970    fn test_validate_tron_tx_hash_valid() {
971        assert!(validate_tron_tx_hash(VALID_TX_HASH).is_ok());
972    }
973
974    #[test]
975    fn test_validate_tron_tx_hash_empty() {
976        let result = validate_tron_tx_hash("");
977        assert!(result.is_err());
978        assert!(result.unwrap_err().to_string().contains("empty"));
979    }
980
981    #[test]
982    fn test_validate_tron_tx_hash_too_short() {
983        let result = validate_tron_tx_hash("b3c12d62ad7e7b8b83b09a68");
984        assert!(result.is_err());
985        assert!(result.unwrap_err().to_string().contains("64 characters"));
986    }
987
988    #[test]
989    fn test_validate_tron_tx_hash_invalid_hex() {
990        let hash = "g3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
991        let result = validate_tron_tx_hash(hash);
992        assert!(result.is_err());
993        assert!(result.unwrap_err().to_string().contains("invalid hex"));
994    }
995
996    #[test]
997    fn test_tron_client_default() {
998        let client = TronClient::default();
999        assert_eq!(client.chain_name(), "tron");
1000        assert_eq!(client.native_token_symbol(), "TRX");
1001        assert!(client.api_url.contains("trongrid"));
1002    }
1003
1004    #[test]
1005    fn test_tron_client_with_api_url() {
1006        let client = TronClient::with_api_url("https://custom.tron.api");
1007        assert_eq!(client.api_url, "https://custom.tron.api");
1008    }
1009
1010    #[test]
1011    fn test_tron_client_new() {
1012        let config = ChainsConfig::default();
1013        let client = TronClient::new(&config);
1014        assert!(client.is_ok());
1015    }
1016
1017    #[test]
1018    fn test_tron_client_new_with_custom_api() {
1019        let config = ChainsConfig {
1020            tron_api: Some("https://my-tron-api.com".to_string()),
1021            ..Default::default()
1022        };
1023        let client = TronClient::new(&config).unwrap();
1024        assert_eq!(client.api_url, "https://my-tron-api.com");
1025    }
1026
1027    #[test]
1028    fn test_tron_client_new_with_api_key() {
1029        use std::collections::HashMap;
1030
1031        let mut api_keys = HashMap::new();
1032        api_keys.insert("tronscan".to_string(), "test-key".to_string());
1033
1034        let config = ChainsConfig {
1035            api_keys,
1036            ..Default::default()
1037        };
1038
1039        let client = TronClient::new(&config).unwrap();
1040        assert_eq!(client.api_key, Some("test-key".to_string()));
1041    }
1042
1043    #[test]
1044    fn test_account_response_deserialization() {
1045        let json = r#"{
1046            "data": [{
1047                "balance": 1000000,
1048                "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1049                "create_time": 1600000000000,
1050                "trc20": []
1051            }],
1052            "success": true
1053        }"#;
1054
1055        let response: AccountResponse = serde_json::from_str(json).unwrap();
1056        assert!(response.success);
1057        assert_eq!(response.data.len(), 1);
1058        assert_eq!(response.data[0].balance, Some(1_000_000));
1059    }
1060
1061    #[test]
1062    fn test_transaction_response_deserialization() {
1063        let json = r#"{
1064            "data": [{
1065                "txID": "abc123",
1066                "block_number": 12345,
1067                "block_timestamp": 1600000000000,
1068                "ret": [{"contractRet": "SUCCESS"}]
1069            }],
1070            "success": true
1071        }"#;
1072
1073        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
1074        assert!(response.success);
1075        assert_eq!(response.data.len(), 1);
1076        assert_eq!(response.data[0].tx_id, "abc123");
1077    }
1078
1079    // ========================================================================
1080    // HTTP mocking tests
1081    // ========================================================================
1082
1083    #[tokio::test]
1084    async fn test_get_balance() {
1085        let mut server = mockito::Server::new_async().await;
1086        let _mock = server
1087            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1088            .with_status(200)
1089            .with_header("content-type", "application/json")
1090            .with_body(r#"{
1091                "data": [{"balance": 5000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}],
1092                "success": true
1093            }"#)
1094            .create_async()
1095            .await;
1096
1097        let client = TronClient::with_api_url(&server.url());
1098        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1099        assert_eq!(balance.raw, "5000000");
1100        assert_eq!(balance.symbol, "TRX");
1101        assert!(balance.formatted.contains("5.000000"));
1102    }
1103
1104    #[tokio::test]
1105    async fn test_get_balance_new_account() {
1106        let mut server = mockito::Server::new_async().await;
1107        let _mock = server
1108            .mock(
1109                "GET",
1110                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1111            )
1112            .with_status(200)
1113            .with_header("content-type", "application/json")
1114            .with_body(r#"{"data": [], "success": true}"#)
1115            .create_async()
1116            .await;
1117
1118        let client = TronClient::with_api_url(&server.url());
1119        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1120        assert_eq!(balance.raw, "0");
1121        assert!(balance.formatted.contains("0.000000"));
1122    }
1123
1124    #[tokio::test]
1125    async fn test_get_balance_api_error() {
1126        let mut server = mockito::Server::new_async().await;
1127        let _mock = server
1128            .mock(
1129                "GET",
1130                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1131            )
1132            .with_status(200)
1133            .with_header("content-type", "application/json")
1134            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1135            .create_async()
1136            .await;
1137
1138        let client = TronClient::with_api_url(&server.url());
1139        let result = client.get_balance(VALID_ADDRESS).await;
1140        assert!(result.is_err());
1141        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1142    }
1143
1144    #[tokio::test]
1145    async fn test_get_balance_invalid_address() {
1146        let client = TronClient::default();
1147        let result = client.get_balance("invalid").await;
1148        assert!(result.is_err());
1149    }
1150
1151    #[tokio::test]
1152    async fn test_get_transaction() {
1153        let mut server = mockito::Server::new_async().await;
1154        let _mock = server
1155            .mock(
1156                "GET",
1157                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1158            )
1159            .with_status(200)
1160            .with_header("content-type", "application/json")
1161            .with_body(
1162                r#"{
1163                "data": [{
1164                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1165                    "block_number": 50000000,
1166                    "block_timestamp": 1700000000000,
1167                    "raw_data": {
1168                        "contract": [{
1169                            "parameter": {
1170                                "value": {
1171                                    "amount": 1000000,
1172                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1173                                    "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
1174                                }
1175                            },
1176                            "type": "TransferContract"
1177                        }]
1178                    },
1179                    "ret": [{"contractRet": "SUCCESS"}]
1180                }],
1181                "success": true
1182            }"#,
1183            )
1184            .create_async()
1185            .await;
1186
1187        let client = TronClient::with_api_url(&server.url());
1188        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1189        assert_eq!(tx.hash, VALID_TX_HASH);
1190        assert_eq!(tx.from, "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
1191        assert_eq!(
1192            tx.to,
1193            Some("TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9".to_string())
1194        );
1195        assert_eq!(tx.value, "1000000");
1196        assert_eq!(tx.block_number, Some(50000000));
1197        assert_eq!(tx.timestamp, Some(1700000000)); // ms → s
1198        assert!(tx.status.unwrap());
1199    }
1200
1201    #[tokio::test]
1202    async fn test_get_transaction_failed() {
1203        let mut server = mockito::Server::new_async().await;
1204        let _mock = server
1205            .mock(
1206                "GET",
1207                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1208            )
1209            .with_status(200)
1210            .with_header("content-type", "application/json")
1211            .with_body(
1212                r#"{
1213                "data": [{
1214                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1215                    "block_number": 50000000,
1216                    "block_timestamp": 1700000000000,
1217                    "ret": [{"contractRet": "REVERT"}]
1218                }],
1219                "success": true
1220            }"#,
1221            )
1222            .create_async()
1223            .await;
1224
1225        let client = TronClient::with_api_url(&server.url());
1226        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1227        assert!(!tx.status.unwrap()); // REVERT → failure
1228    }
1229
1230    #[tokio::test]
1231    async fn test_get_transaction_not_found() {
1232        let mut server = mockito::Server::new_async().await;
1233        let _mock = server
1234            .mock(
1235                "GET",
1236                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1237            )
1238            .with_status(200)
1239            .with_header("content-type", "application/json")
1240            .with_body(r#"{"data": [], "success": true}"#)
1241            .create_async()
1242            .await;
1243
1244        let client = TronClient::with_api_url(&server.url());
1245        let result = client.get_transaction(VALID_TX_HASH).await;
1246        assert!(result.is_err());
1247    }
1248
1249    #[tokio::test]
1250    async fn test_get_transactions() {
1251        let mut server = mockito::Server::new_async().await;
1252        let _mock = server
1253            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()))
1254            .with_status(200)
1255            .with_header("content-type", "application/json")
1256            .with_body(r#"{
1257                "data": [
1258                    {
1259                        "txID": "aaa111",
1260                        "block_number": 50000000,
1261                        "block_timestamp": 1700000000000,
1262                        "raw_data": {"contract": [{"parameter": {"value": {"amount": 500000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"}}, "type": "TransferContract"}]},
1263                        "ret": [{"contractRet": "SUCCESS"}]
1264                    },
1265                    {
1266                        "txID": "bbb222",
1267                        "block_number": 50000001,
1268                        "block_timestamp": 1700000060000,
1269                        "ret": [{"contractRet": "SUCCESS"}]
1270                    }
1271                ],
1272                "success": true
1273            }"#)
1274            .create_async()
1275            .await;
1276
1277        let client = TronClient::with_api_url(&server.url());
1278        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1279        assert_eq!(txs.len(), 2);
1280        assert_eq!(txs[0].hash, "aaa111");
1281        assert_eq!(txs[0].value, "500000");
1282        assert!(txs[0].status.unwrap());
1283        // Second tx has no contract data → defaults
1284        assert_eq!(txs[1].value, "0");
1285    }
1286
1287    #[tokio::test]
1288    async fn test_get_transactions_error() {
1289        let mut server = mockito::Server::new_async().await;
1290        let _mock = server
1291            .mock(
1292                "GET",
1293                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1294            )
1295            .with_status(200)
1296            .with_header("content-type", "application/json")
1297            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1298            .create_async()
1299            .await;
1300
1301        let client = TronClient::with_api_url(&server.url());
1302        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1303        assert!(result.is_err());
1304    }
1305
1306    #[tokio::test]
1307    async fn test_get_trc20_balances() {
1308        let mut server = mockito::Server::new_async().await;
1309        let _mock = server
1310            .mock(
1311                "GET",
1312                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1313            )
1314            .with_status(200)
1315            .with_header("content-type", "application/json")
1316            .with_body(
1317                r#"{
1318                "data": [{
1319                    "balance": 1000000,
1320                    "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1321                    "trc20": [
1322                        {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"},
1323                        {"TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8": "0"}
1324                    ]
1325                }],
1326                "success": true
1327            }"#,
1328            )
1329            .create_async()
1330            .await;
1331
1332        let client = TronClient::with_api_url(&server.url());
1333        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1334        // Zero balance filtered out
1335        assert_eq!(balances.len(), 1);
1336        assert_eq!(
1337            balances[0].contract_address,
1338            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
1339        );
1340        assert_eq!(balances[0].raw_balance, "5000000");
1341    }
1342
1343    #[tokio::test]
1344    async fn test_get_trc20_balances_empty_account() {
1345        let mut server = mockito::Server::new_async().await;
1346        let _mock = server
1347            .mock(
1348                "GET",
1349                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1350            )
1351            .with_status(200)
1352            .with_header("content-type", "application/json")
1353            .with_body(r#"{"data": [], "success": true}"#)
1354            .create_async()
1355            .await;
1356
1357        let client = TronClient::with_api_url(&server.url());
1358        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1359        assert!(balances.is_empty());
1360    }
1361
1362    #[tokio::test]
1363    async fn test_get_block_number() {
1364        let mut server = mockito::Server::new_async().await;
1365        let _mock = server
1366            .mock("POST", "/wallet/getnowblock")
1367            .with_status(200)
1368            .with_header("content-type", "application/json")
1369            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1370            .create_async()
1371            .await;
1372
1373        let client = TronClient::with_api_url(&server.url());
1374        let block = client.get_block_number().await.unwrap();
1375        assert_eq!(block, 60000000);
1376    }
1377
1378    #[tokio::test]
1379    async fn test_get_block_number_invalid_response() {
1380        let mut server = mockito::Server::new_async().await;
1381        let _mock = server
1382            .mock("POST", "/wallet/getnowblock")
1383            .with_status(200)
1384            .with_header("content-type", "application/json")
1385            .with_body(r#"{}"#)
1386            .create_async()
1387            .await;
1388
1389        let client = TronClient::with_api_url(&server.url());
1390        let result = client.get_block_number().await;
1391        assert!(result.is_err());
1392    }
1393
1394    #[test]
1395    fn test_validate_tron_address_wrong_decoded_length() {
1396        // Valid base58 but wrong number of decoded bytes
1397        let result = validate_tron_address("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT1");
1398        assert!(result.is_err());
1399    }
1400
1401    #[test]
1402    fn test_validate_tron_tx_hash_wrong_length() {
1403        let result = validate_tron_tx_hash("abc123");
1404        assert!(result.is_err());
1405        assert!(result.unwrap_err().to_string().contains("64 characters"));
1406    }
1407
1408    #[tokio::test]
1409    async fn test_get_transaction_success() {
1410        let mut server = mockito::Server::new_async().await;
1411        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1412        let _mock = server
1413            .mock(
1414                "GET",
1415                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1416            )
1417            .with_status(200)
1418            .with_header("content-type", "application/json")
1419            .with_body(
1420                r#"{"data":[{
1421                "txID":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1422                "blockNumber":60000000,
1423                "block_timestamp":1700000000000,
1424                "raw_data":{"contract":[{"parameter":{"value":{
1425                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1426                    "to_address":"TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn",
1427                    "amount":1000000
1428                }}}]},
1429                "ret":[{"contractRet":"SUCCESS"}]
1430            }],"success":true}"#,
1431            )
1432            .create_async()
1433            .await;
1434
1435        let client = TronClient::with_api_url(&server.url());
1436        let tx = client.get_transaction(valid_hash).await.unwrap();
1437        assert_eq!(tx.hash, valid_hash);
1438        assert_eq!(tx.status, Some(true));
1439    }
1440
1441    #[tokio::test]
1442    async fn test_get_transaction_api_error() {
1443        let mut server = mockito::Server::new_async().await;
1444        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1445        let _mock = server
1446            .mock(
1447                "GET",
1448                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1449            )
1450            .with_status(200)
1451            .with_header("content-type", "application/json")
1452            .with_body(r#"{"data":[],"success":false,"error":"Transaction not found"}"#)
1453            .create_async()
1454            .await;
1455
1456        let client = TronClient::with_api_url(&server.url());
1457        let result = client.get_transaction(valid_hash).await;
1458        assert!(result.is_err());
1459    }
1460
1461    #[tokio::test]
1462    async fn test_get_transactions_success() {
1463        let mut server = mockito::Server::new_async().await;
1464        let _mock = server
1465            .mock(
1466                "GET",
1467                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1468            )
1469            .with_status(200)
1470            .with_header("content-type", "application/json")
1471            .with_body(
1472                r#"{"data":[{
1473                "txID":"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1474                "blockNumber":60000000,
1475                "block_timestamp":1700000000000,
1476                "raw_data":{"contract":[{"parameter":{"value":{
1477                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1478                    "amount":500000
1479                }}}]},
1480                "ret":[{"contractRet":"SUCCESS"}]
1481            }],"success":true}"#,
1482            )
1483            .create_async()
1484            .await;
1485
1486        let client = TronClient::with_api_url(&server.url());
1487        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1488        assert_eq!(txs.len(), 1);
1489    }
1490
1491    #[tokio::test]
1492    async fn test_tron_chain_client_trait_accessors() {
1493        let client = TronClient::with_api_url("http://localhost");
1494        let chain_client: &dyn ChainClient = &client;
1495        assert_eq!(chain_client.chain_name(), "tron");
1496        assert_eq!(chain_client.native_token_symbol(), "TRX");
1497    }
1498
1499    #[tokio::test]
1500    async fn test_chain_client_trait_get_balance() {
1501        let mut server = mockito::Server::new_async().await;
1502        let _mock = server
1503            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1504            .with_status(200)
1505            .with_header("content-type", "application/json")
1506            .with_body(r#"{"data": [{"balance": 1000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#)
1507            .create_async()
1508            .await;
1509
1510        let client = TronClient::with_api_url(&server.url());
1511        let chain_client: &dyn ChainClient = &client;
1512        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1513        assert_eq!(balance.symbol, "TRX");
1514    }
1515
1516    #[tokio::test]
1517    async fn test_chain_client_trait_get_block_number() {
1518        let mut server = mockito::Server::new_async().await;
1519        let _mock = server
1520            .mock("POST", "/wallet/getnowblock")
1521            .with_status(200)
1522            .with_header("content-type", "application/json")
1523            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1524            .create_async()
1525            .await;
1526
1527        let client = TronClient::with_api_url(&server.url());
1528        let chain_client: &dyn ChainClient = &client;
1529        let block = chain_client.get_block_number().await.unwrap();
1530        assert_eq!(block, 60000000);
1531    }
1532
1533    #[tokio::test]
1534    async fn test_chain_client_trait_get_token_balances() {
1535        let mut server = mockito::Server::new_async().await;
1536        let _mock = server
1537            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1538            .with_status(200)
1539            .with_header("content-type", "application/json")
1540            .with_body(r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"}]}], "success": true}"#)
1541            .create_async()
1542            .await;
1543
1544        let client = TronClient::with_api_url(&server.url());
1545        let chain_client: &dyn ChainClient = &client;
1546        let balances = chain_client
1547            .get_token_balances(VALID_ADDRESS)
1548            .await
1549            .unwrap();
1550        assert_eq!(balances.len(), 1);
1551        // Token info enriched via Tronscan (USDT) or fallback to placeholder
1552        assert!(
1553            balances[0].token.symbol == "USDT" || balances[0].token.symbol == "TRC20",
1554            "symbol should be USDT (Tronscan) or TRC20 (fallback)"
1555        );
1556        // Tronscan returns various name formats (e.g. "TetherToken", "Tether USD");
1557        // fallback is "TRC-20 Token" or "Unknown Token"
1558        assert!(!balances[0].token.name.is_empty(), "name must be set");
1559    }
1560
1561    #[tokio::test]
1562    async fn test_chain_client_trait_get_transaction_tron() {
1563        let mut server = mockito::Server::new_async().await;
1564        let _mock = server
1565            .mock(
1566                "GET",
1567                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1568            )
1569            .with_status(200)
1570            .with_header("content-type", "application/json")
1571            .with_body(
1572                r#"{"data": [{
1573                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1574                "block_number": 50000000,
1575                "block_timestamp": 1700000000000,
1576                "raw_data": {
1577                    "contract": [{
1578                        "parameter": {
1579                            "value": {
1580                                "amount": 1000000,
1581                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1582                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1583                            }
1584                        },
1585                        "type": "TransferContract"
1586                    }]
1587                },
1588                "ret": [{"contractRet": "SUCCESS"}]
1589            }], "success": true}"#,
1590            )
1591            .create_async()
1592            .await;
1593
1594        let client = TronClient::with_api_url(&server.url());
1595        let chain_client: &dyn ChainClient = &client;
1596        let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
1597        assert_eq!(tx.hash, VALID_TX_HASH);
1598        assert!(tx.status.unwrap());
1599    }
1600
1601    #[tokio::test]
1602    async fn test_chain_client_trait_get_transactions_tron() {
1603        let mut server = mockito::Server::new_async().await;
1604        let _mock = server
1605            .mock(
1606                "GET",
1607                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1608            )
1609            .with_status(200)
1610            .with_header("content-type", "application/json")
1611            .with_body(
1612                r#"{"data": [{
1613                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1614                "block_number": 50000000,
1615                "block_timestamp": 1700000000000,
1616                "raw_data": {
1617                    "contract": [{
1618                        "parameter": {
1619                            "value": {
1620                                "amount": 2000000,
1621                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1622                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1623                            }
1624                        }
1625                    }]
1626                },
1627                "ret": [{"contractRet": "REVERT"}]
1628            }], "success": true}"#,
1629            )
1630            .create_async()
1631            .await;
1632
1633        let client = TronClient::with_api_url(&server.url());
1634        let chain_client: &dyn ChainClient = &client;
1635        let txs = chain_client
1636            .get_transactions(VALID_ADDRESS, 10)
1637            .await
1638            .unwrap();
1639        assert_eq!(txs.len(), 1);
1640        assert!(!txs[0].status.unwrap()); // REVERT means failure
1641    }
1642
1643    #[tokio::test]
1644    async fn test_get_balance_with_api_key() {
1645        let mut server = mockito::Server::new_async().await;
1646        let _mock = server
1647            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1648            .with_status(200)
1649            .with_header("content-type", "application/json")
1650            .with_body(
1651                r#"{"data": [{"balance": 10000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#,
1652            )
1653            .create_async()
1654            .await;
1655
1656        let config = ChainsConfig {
1657            tron_api: Some(server.url()),
1658            api_keys: {
1659                let mut m = std::collections::HashMap::new();
1660                m.insert("tronscan".to_string(), "test-api-key".to_string());
1661                m
1662            },
1663            ..Default::default()
1664        };
1665        let client = TronClient::new(&config).unwrap();
1666        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1667        assert_eq!(balance.symbol, "TRX");
1668        assert!(balance.formatted.contains("TRX"));
1669    }
1670
1671    #[tokio::test]
1672    async fn test_get_trc20_balances_error_response() {
1673        let mut server = mockito::Server::new_async().await;
1674        let _mock = server
1675            .mock(
1676                "GET",
1677                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1678            )
1679            .with_status(200)
1680            .with_header("content-type", "application/json")
1681            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1682            .create_async()
1683            .await;
1684
1685        let client = TronClient::with_api_url(&server.url());
1686        let result = client.get_trc20_balances(VALID_ADDRESS).await;
1687        assert!(result.is_err());
1688        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1689    }
1690
1691    #[tokio::test]
1692    async fn test_get_trc20_balances_no_data() {
1693        let mut server = mockito::Server::new_async().await;
1694        let _mock = server
1695            .mock(
1696                "GET",
1697                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1698            )
1699            .with_status(200)
1700            .with_header("content-type", "application/json")
1701            .with_body(r#"{"data": [], "success": true}"#)
1702            .create_async()
1703            .await;
1704
1705        let client = TronClient::with_api_url(&server.url());
1706        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1707        assert!(balances.is_empty());
1708    }
1709
1710    #[tokio::test]
1711    async fn test_get_trc20_balances_with_api_key() {
1712        let mut server = mockito::Server::new_async().await;
1713        let _mock = server
1714            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1715            .with_status(200)
1716            .with_header("content-type", "application/json")
1717            .with_body(
1718                r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}]}], "success": true}"#,
1719            )
1720            .create_async()
1721            .await;
1722
1723        let config = ChainsConfig {
1724            tron_api: Some(server.url()),
1725            api_keys: {
1726                let mut m = std::collections::HashMap::new();
1727                m.insert("tronscan".to_string(), "my-api-key".to_string());
1728                m
1729            },
1730            ..Default::default()
1731        };
1732        let client = TronClient::new(&config).unwrap();
1733        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1734        assert_eq!(balances.len(), 1);
1735    }
1736
1737    #[test]
1738    fn test_validate_tron_address_bad_checksum() {
1739        // Construct a valid-looking address with bad checksum by modifying last char
1740        // TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf -> change last char
1741        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCe");
1742        assert!(result.is_err());
1743        // Could be checksum error or base58 decode error
1744        let err_str = result.unwrap_err().to_string();
1745        assert!(
1746            err_str.contains("checksum")
1747                || err_str.contains("base58")
1748                || err_str.contains("prefix")
1749        );
1750    }
1751
1752    #[tokio::test]
1753    async fn test_get_transaction_tron_success() {
1754        let mut server = mockito::Server::new_async().await;
1755        let _mock = server
1756            .mock(
1757                "GET",
1758                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1759            )
1760            .with_status(200)
1761            .with_header("content-type", "application/json")
1762            .with_body(
1763                r#"{"data": [{
1764                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1765                "block_number": 50000000,
1766                "block_timestamp": 1700000000000,
1767                "raw_data": {
1768                    "contract": [{
1769                        "parameter": {
1770                            "value": {
1771                                "amount": 5000000,
1772                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1773                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1774                            }
1775                        }
1776                    }]
1777                },
1778                "ret": [{"contractRet": "SUCCESS"}]
1779            }], "success": true}"#,
1780            )
1781            .create_async()
1782            .await;
1783
1784        let client = TronClient::with_api_url(&server.url());
1785        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1786        assert_eq!(tx.hash, VALID_TX_HASH);
1787        assert!(tx.status.unwrap());
1788        assert_eq!(tx.value, "5000000");
1789        assert_eq!(tx.timestamp, Some(1700000000)); // Converted from ms to s
1790    }
1791
1792    #[tokio::test]
1793    async fn test_get_transaction_tron_error() {
1794        let mut server = mockito::Server::new_async().await;
1795        let _mock = server
1796            .mock(
1797                "GET",
1798                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1799            )
1800            .with_status(200)
1801            .with_header("content-type", "application/json")
1802            .with_body(r#"{"data": [], "success": false, "error": "Transaction not found"}"#)
1803            .create_async()
1804            .await;
1805
1806        let client = TronClient::with_api_url(&server.url());
1807        let result = client.get_transaction(VALID_TX_HASH).await;
1808        assert!(result.is_err());
1809    }
1810
1811    #[tokio::test]
1812    async fn test_get_transactions_tron_success() {
1813        let mut server = mockito::Server::new_async().await;
1814        let _mock = server
1815            .mock(
1816                "GET",
1817                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1818            )
1819            .with_status(200)
1820            .with_header("content-type", "application/json")
1821            .with_body(
1822                r#"{"data": [
1823                {
1824                    "txID": "aaa12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1825                    "block_number": 50000001,
1826                    "block_timestamp": 1700000003000,
1827                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 1000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"}}}]},
1828                    "ret": [{"contractRet": "SUCCESS"}]
1829                },
1830                {
1831                    "txID": "bbb12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1832                    "block_number": 50000002,
1833                    "block_timestamp": 1700000006000,
1834                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 2000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"}}}]},
1835                    "ret": [{"contractRet": "SUCCESS"}]
1836                }
1837            ], "success": true}"#,
1838            )
1839            .create_async()
1840            .await;
1841
1842        let client = TronClient::with_api_url(&server.url());
1843        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1844        assert_eq!(txs.len(), 2);
1845    }
1846
1847    #[tokio::test]
1848    async fn test_get_transactions_tron_error() {
1849        let mut server = mockito::Server::new_async().await;
1850        let _mock = server
1851            .mock(
1852                "GET",
1853                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1854            )
1855            .with_status(200)
1856            .with_header("content-type", "application/json")
1857            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1858            .create_async()
1859            .await;
1860
1861        let client = TronClient::with_api_url(&server.url());
1862        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1863        assert!(result.is_err());
1864    }
1865
1866    #[tokio::test]
1867    async fn test_get_balance_error_response() {
1868        let mut server = mockito::Server::new_async().await;
1869        let _mock = server
1870            .mock(
1871                "GET",
1872                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1873            )
1874            .with_status(200)
1875            .with_header("content-type", "application/json")
1876            .with_body(r#"{"data": [], "success": false, "error": "Account not found"}"#)
1877            .create_async()
1878            .await;
1879
1880        let client = TronClient::with_api_url(&server.url());
1881        let result = client.get_balance(VALID_ADDRESS).await;
1882        assert!(result.is_err());
1883        assert!(
1884            result
1885                .unwrap_err()
1886                .to_string()
1887                .contains("Account not found")
1888        );
1889    }
1890
1891    #[tokio::test]
1892    async fn test_get_token_info_success() {
1893        // get_token_info uses TRONSCAN_API directly (not mockable via api_url),
1894        // so we test the response parsing path that mirrors get_token_info logic
1895        let info: serde_json::Value = serde_json::from_str(
1896            r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 6}]}"#,
1897        )
1898        .unwrap();
1899        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1900        let token_data = tokens.first().unwrap();
1901        let symbol = token_data
1902            .get("symbol")
1903            .and_then(|v| v.as_str())
1904            .unwrap_or("UNKNOWN");
1905        assert_eq!(symbol, "USDT");
1906        let name = token_data
1907            .get("contract_name")
1908            .and_then(|v| v.as_str())
1909            .unwrap_or("Unknown Token");
1910        assert_eq!(name, "TetherToken");
1911        let decimals = token_data
1912            .get("decimals")
1913            .and_then(|v| v.as_u64())
1914            .unwrap_or(6) as u8;
1915        assert_eq!(decimals, 6);
1916    }
1917
1918    #[tokio::test]
1919    async fn test_get_token_info_no_tokens() {
1920        let info: serde_json::Value = serde_json::from_str(r#"{"trc20_tokens": []}"#).unwrap();
1921        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1922        assert!(tokens.is_empty());
1923    }
1924
1925    #[tokio::test]
1926    async fn test_get_token_info_missing_field() {
1927        let info: serde_json::Value =
1928            serde_json::from_str(r#"{"trc20_tokens": [{"symbol": "TEST"}]}"#).unwrap();
1929        let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1930        let token_data = tokens.first().unwrap();
1931        let name = token_data
1932            .get("contract_name")
1933            .or_else(|| token_data.get("name"))
1934            .and_then(|v| v.as_str())
1935            .unwrap_or("Unknown Token");
1936        assert_eq!(name, "Unknown Token");
1937        let decimals = token_data
1938            .get("decimals")
1939            .and_then(|v| v.as_u64())
1940            .unwrap_or(6) as u8;
1941        assert_eq!(decimals, 6);
1942    }
1943
1944    #[tokio::test]
1945    async fn test_token_holder_response_parsing() {
1946        let json: serde_json::Value = serde_json::from_str(
1947            r#"{"trc20_tokens": [
1948                {"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "5000000"},
1949                {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "3000000"}
1950            ]}"#,
1951        )
1952        .unwrap();
1953        let holders_data: &[serde_json::Value] = json
1954            .get("trc20_tokens")
1955            .and_then(|v| v.as_array())
1956            .map(|v| v.as_slice())
1957            .unwrap_or(&[]);
1958        assert_eq!(holders_data.len(), 2);
1959
1960        let total_balance: f64 = holders_data
1961            .iter()
1962            .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
1963            .filter_map(|s| s.parse::<f64>().ok())
1964            .sum();
1965        assert_eq!(total_balance, 8000000.0);
1966
1967        let decimals: u8 = 6;
1968        let holders: Vec<TokenHolder> = holders_data
1969            .iter()
1970            .enumerate()
1971            .filter_map(|(i, h)| {
1972                let holder_address = h.get("holder_address")?.as_str()?.to_string();
1973                let balance_raw = h.get("balance")?.as_str()?.to_string();
1974                let balance: f64 = balance_raw.parse().ok()?;
1975                let percentage = if total_balance > 0.0 {
1976                    (balance / total_balance) * 100.0
1977                } else {
1978                    0.0
1979                };
1980                let divisor = 10_f64.powi(decimals as i32);
1981                let formatted = format!("{:.6}", balance / divisor);
1982                Some(TokenHolder {
1983                    address: holder_address,
1984                    balance: balance_raw,
1985                    formatted_balance: formatted,
1986                    percentage,
1987                    rank: (i + 1) as u32,
1988                })
1989            })
1990            .collect();
1991        assert_eq!(holders.len(), 2);
1992        assert_eq!(holders[0].rank, 1);
1993        assert_eq!(holders[1].rank, 2);
1994        assert!(holders[0].percentage > 60.0);
1995        assert!(holders[0].formatted_balance.contains("5.000000"));
1996    }
1997
1998    #[tokio::test]
1999    async fn test_token_holder_count_parsing() {
2000        let json: serde_json::Value = serde_json::from_str(r#"{"rangeTotal": 12345}"#).unwrap();
2001        let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
2002        assert_eq!(count, 12345);
2003
2004        let json_no_field: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
2005        let count2 = json_no_field
2006            .get("rangeTotal")
2007            .and_then(|v| v.as_u64())
2008            .unwrap_or(0);
2009        assert_eq!(count2, 0);
2010    }
2011
2012    #[test]
2013    fn test_dex_search_response_deserialization() {
2014        let json = r#"{"pairs":[{"baseTokenSymbol":"TRX","priceUsd":"0.08"}]}"#;
2015        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2016        assert!(result.is_ok());
2017        let resp = result.unwrap();
2018        let pairs = resp.pairs.unwrap();
2019        assert_eq!(pairs.len(), 1);
2020        assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2021    }
2022
2023    #[test]
2024    fn test_dex_search_response_empty() {
2025        let json = r#"{"pairs":[]}"#;
2026        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2027        assert!(result.is_ok());
2028        assert!(result.unwrap().pairs.unwrap().is_empty());
2029    }
2030
2031    #[test]
2032    fn test_dex_search_response_no_pairs() {
2033        let json = r#"{}"#;
2034        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2035        assert!(result.is_ok());
2036        assert!(result.unwrap().pairs.is_none());
2037    }
2038
2039    // -------------------------------------------------------------------------
2040    // Invalid input validation tests
2041    // -------------------------------------------------------------------------
2042
2043    #[tokio::test]
2044    async fn test_get_transaction_invalid_hash() {
2045        let client = TronClient::default();
2046        let result = client.get_transaction("not-a-valid-hash").await;
2047        assert!(result.is_err());
2048        assert!(result.unwrap_err().to_string().contains("64 characters"));
2049    }
2050
2051    #[tokio::test]
2052    async fn test_get_transactions_invalid_address() {
2053        let client = TronClient::default();
2054        let result = client.get_transactions("invalid-address", 10).await;
2055        assert!(result.is_err());
2056    }
2057
2058    #[tokio::test]
2059    async fn test_get_token_info_invalid_address() {
2060        let client = TronClient::default();
2061        let result = client.get_token_info("bad-address").await;
2062        assert!(result.is_err());
2063    }
2064
2065    #[tokio::test]
2066    async fn test_get_token_holders_invalid_address() {
2067        let client = TronClient::default();
2068        let result = client.get_token_holders("bad-address", 10).await;
2069        assert!(result.is_err());
2070    }
2071
2072    #[tokio::test]
2073    async fn test_get_token_holder_count_invalid_address() {
2074        let client = TronClient::default();
2075        let result = client.get_token_holder_count("bad-address").await;
2076        assert!(result.is_err());
2077    }
2078
2079    #[tokio::test]
2080    async fn test_chain_client_get_token_info_invalid_address() {
2081        let client = TronClient::default();
2082        let chain_client: &dyn ChainClient = &client;
2083        let result = chain_client.get_token_info("x").await;
2084        assert!(result.is_err());
2085    }
2086
2087    #[tokio::test]
2088    async fn test_chain_client_get_token_holders_invalid_address() {
2089        let client = TronClient::default();
2090        let chain_client: &dyn ChainClient = &client;
2091        let result = chain_client.get_token_holders("x", 10).await;
2092        assert!(result.is_err());
2093    }
2094
2095    #[tokio::test]
2096    async fn test_chain_client_get_token_holder_count_invalid_address() {
2097        let client = TronClient::default();
2098        let chain_client: &dyn ChainClient = &client;
2099        let result = chain_client.get_token_holder_count("x").await;
2100        assert!(result.is_err());
2101    }
2102
2103    // -------------------------------------------------------------------------
2104    // API error edge cases
2105    // -------------------------------------------------------------------------
2106
2107    #[tokio::test]
2108    async fn test_get_balance_api_error_unknown_error() {
2109        let mut server = mockito::Server::new_async().await;
2110        let _mock = server
2111            .mock(
2112                "GET",
2113                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2114            )
2115            .with_status(200)
2116            .with_header("content-type", "application/json")
2117            .with_body(r#"{"data": [], "success": false}"#)
2118            .create_async()
2119            .await;
2120
2121        let client = TronClient::with_api_url(&server.url());
2122        let result = client.get_balance(VALID_ADDRESS).await;
2123        assert!(result.is_err());
2124        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2125    }
2126
2127    #[tokio::test]
2128    async fn test_get_transaction_status_none() {
2129        let mut server = mockito::Server::new_async().await;
2130        let _mock = server
2131            .mock(
2132                "GET",
2133                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2134            )
2135            .with_status(200)
2136            .with_header("content-type", "application/json")
2137            .with_body(
2138                r#"{
2139                "data": [{
2140                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2141                    "block_number": 50000000,
2142                    "block_timestamp": 1700000000000,
2143                    "ret": []
2144                }],
2145                "success": true
2146            }"#,
2147            )
2148            .create_async()
2149            .await;
2150
2151        let client = TronClient::with_api_url(&server.url());
2152        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2153        assert_eq!(tx.status, None);
2154    }
2155
2156    #[tokio::test]
2157    async fn test_get_transaction_no_ret_field() {
2158        let mut server = mockito::Server::new_async().await;
2159        let _mock = server
2160            .mock(
2161                "GET",
2162                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2163            )
2164            .with_status(200)
2165            .with_header("content-type", "application/json")
2166            .with_body(
2167                r#"{
2168                "data": [{
2169                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2170                    "block_number": 50000000,
2171                    "block_timestamp": 1700000000000
2172                }],
2173                "success": true
2174            }"#,
2175            )
2176            .create_async()
2177            .await;
2178
2179        let client = TronClient::with_api_url(&server.url());
2180        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2181        assert_eq!(tx.status, None);
2182    }
2183
2184    #[tokio::test]
2185    async fn test_get_transaction_to_address_none() {
2186        let mut server = mockito::Server::new_async().await;
2187        let _mock = server
2188            .mock(
2189                "GET",
2190                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2191            )
2192            .with_status(200)
2193            .with_header("content-type", "application/json")
2194            .with_body(
2195                r#"{
2196                "data": [{
2197                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2198                    "block_number": 50000000,
2199                    "block_timestamp": 1700000000000,
2200                    "raw_data": {
2201                        "contract": [{
2202                            "parameter": {
2203                                "value": {
2204                                    "amount": 1000000,
2205                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
2206                                }
2207                            }
2208                        }]
2209                    },
2210                    "ret": [{"contractRet": "SUCCESS"}]
2211                }],
2212                "success": true
2213            }"#,
2214            )
2215            .create_async()
2216            .await;
2217
2218        let client = TronClient::with_api_url(&server.url());
2219        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2220        assert_eq!(tx.to, None);
2221        assert_eq!(tx.value, "1000000");
2222    }
2223
2224    #[tokio::test]
2225    async fn test_get_transaction_amount_none() {
2226        let mut server = mockito::Server::new_async().await;
2227        let _mock = server
2228            .mock(
2229                "GET",
2230                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2231            )
2232            .with_status(200)
2233            .with_header("content-type", "application/json")
2234            .with_body(
2235                r#"{
2236                "data": [{
2237                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2238                    "block_number": 50000000,
2239                    "block_timestamp": 1700000000000,
2240                    "raw_data": {
2241                        "contract": [{
2242                            "parameter": {
2243                                "value": {
2244                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2245                                    "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
2246                                }
2247                            }
2248                        }]
2249                    },
2250                    "ret": [{"contractRet": "SUCCESS"}]
2251                }],
2252                "success": true
2253            }"#,
2254            )
2255            .create_async()
2256            .await;
2257
2258        let client = TronClient::with_api_url(&server.url());
2259        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2260        assert_eq!(tx.value, "0");
2261    }
2262
2263    #[tokio::test]
2264    async fn test_get_transaction_no_raw_data() {
2265        let mut server = mockito::Server::new_async().await;
2266        let _mock = server
2267            .mock(
2268                "GET",
2269                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2270            )
2271            .with_status(200)
2272            .with_header("content-type", "application/json")
2273            .with_body(
2274                r#"{
2275                "data": [{
2276                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2277                    "block_number": 50000000,
2278                    "block_timestamp": 1700000000000,
2279                    "ret": [{"contractRet": "SUCCESS"}]
2280                }],
2281                "success": true
2282            }"#,
2283            )
2284            .create_async()
2285            .await;
2286
2287        let client = TronClient::with_api_url(&server.url());
2288        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2289        assert_eq!(tx.from, "");
2290        assert_eq!(tx.to, None);
2291        assert_eq!(tx.value, "0");
2292    }
2293
2294    #[tokio::test]
2295    async fn test_get_block_number_block_header_none() {
2296        let mut server = mockito::Server::new_async().await;
2297        let _mock = server
2298            .mock("POST", "/wallet/getnowblock")
2299            .with_status(200)
2300            .with_header("content-type", "application/json")
2301            .with_body(r#"{"other": "data"}"#)
2302            .create_async()
2303            .await;
2304
2305        let client = TronClient::with_api_url(&server.url());
2306        let result = client.get_block_number().await;
2307        assert!(result.is_err());
2308        assert!(result.unwrap_err().to_string().contains("Invalid block"));
2309    }
2310
2311    #[tokio::test]
2312    async fn test_get_block_number_raw_data_none() {
2313        let mut server = mockito::Server::new_async().await;
2314        let _mock = server
2315            .mock("POST", "/wallet/getnowblock")
2316            .with_status(200)
2317            .with_header("content-type", "application/json")
2318            .with_body(r#"{"block_header":{}}"#)
2319            .create_async()
2320            .await;
2321
2322        let client = TronClient::with_api_url(&server.url());
2323        let result = client.get_block_number().await;
2324        assert!(result.is_err());
2325    }
2326
2327    #[tokio::test]
2328    async fn test_get_block_number_number_none() {
2329        let mut server = mockito::Server::new_async().await;
2330        let _mock = server
2331            .mock("POST", "/wallet/getnowblock")
2332            .with_status(200)
2333            .with_header("content-type", "application/json")
2334            .with_body(r#"{"block_header":{"raw_data":{}}}"#)
2335            .create_async()
2336            .await;
2337
2338        let client = TronClient::with_api_url(&server.url());
2339        let result = client.get_block_number().await;
2340        assert!(result.is_err());
2341    }
2342
2343    #[tokio::test]
2344    async fn test_get_trc20_balances_api_error_unknown() {
2345        let mut server = mockito::Server::new_async().await;
2346        let _mock = server
2347            .mock(
2348                "GET",
2349                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2350            )
2351            .with_status(200)
2352            .with_header("content-type", "application/json")
2353            .with_body(r#"{"data": [], "success": false}"#)
2354            .create_async()
2355            .await;
2356
2357        let client = TronClient::with_api_url(&server.url());
2358        let result = client.get_trc20_balances(VALID_ADDRESS).await;
2359        assert!(result.is_err());
2360        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2361    }
2362
2363    #[tokio::test]
2364    async fn test_get_transactions_api_error_unknown() {
2365        let mut server = mockito::Server::new_async().await;
2366        let _mock = server
2367            .mock(
2368                "GET",
2369                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2370            )
2371            .with_status(200)
2372            .with_header("content-type", "application/json")
2373            .with_body(r#"{"data": [], "success": false}"#)
2374            .create_async()
2375            .await;
2376
2377        let client = TronClient::with_api_url(&server.url());
2378        let result = client.get_transactions(VALID_ADDRESS, 10).await;
2379        assert!(result.is_err());
2380        assert!(result.unwrap_err().to_string().contains("Unknown error"));
2381    }
2382
2383    #[tokio::test]
2384    async fn test_get_transaction_api_error_unknown() {
2385        let mut server = mockito::Server::new_async().await;
2386        let _mock = server
2387            .mock(
2388                "GET",
2389                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2390            )
2391            .with_status(200)
2392            .with_header("content-type", "application/json")
2393            .with_body(r#"{"data": [], "success": false}"#)
2394            .create_async()
2395            .await;
2396
2397        let client = TronClient::with_api_url(&server.url());
2398        let result = client.get_transaction(VALID_TX_HASH).await;
2399        assert!(result.is_err());
2400    }
2401
2402    // -------------------------------------------------------------------------
2403    // Struct construction and deserialization
2404    // -------------------------------------------------------------------------
2405
2406    #[test]
2407    fn test_trc20_token_balance_struct() {
2408        let balance = Trc20TokenBalance {
2409            contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2410            raw_balance: "5000000".to_string(),
2411        };
2412        assert_eq!(
2413            balance.contract_address,
2414            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
2415        );
2416        assert_eq!(balance.raw_balance, "5000000");
2417        let debug_str = format!("{:?}", balance);
2418        assert!(debug_str.contains("Trc20TokenBalance"));
2419    }
2420
2421    #[test]
2422    fn test_account_response_with_error_field() {
2423        let json = r#"{
2424            "data": [],
2425            "success": false,
2426            "error": "Custom error message"
2427        }"#;
2428        let response: AccountResponse = serde_json::from_str(json).unwrap();
2429        assert!(!response.success);
2430        assert_eq!(response.error, Some("Custom error message".to_string()));
2431    }
2432
2433    #[test]
2434    fn test_account_response_trc20_balances() {
2435        let json = r#"{
2436            "data": [{
2437                "balance": 1000000,
2438                "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2439                "trc20": [
2440                    {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}
2441                ]
2442            }],
2443            "success": true
2444        }"#;
2445        let response: AccountResponse = serde_json::from_str(json).unwrap();
2446        assert_eq!(response.data.len(), 1);
2447        assert_eq!(response.data[0].trc20.len(), 1);
2448        let trc20 = &response.data[0].trc20[0];
2449        assert_eq!(
2450            trc20.balances.get("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
2451            Some(&"10000000".to_string())
2452        );
2453    }
2454
2455    #[test]
2456    fn test_transaction_list_response_with_error() {
2457        let json = r#"{
2458            "data": [],
2459            "success": false,
2460            "error": "Transaction not found"
2461        }"#;
2462        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2463        assert!(!response.success);
2464        assert_eq!(response.error, Some("Transaction not found".to_string()));
2465    }
2466
2467    #[test]
2468    fn test_full_transaction_deserialization() {
2469        let json = r#"{
2470            "data": [{
2471                "txID": "abc123def456",
2472                "block_number": 12345,
2473                "block_timestamp": 1600000000000,
2474                "raw_data": {
2475                    "contract": [{
2476                        "parameter": {
2477                            "value": {
2478                                "amount": 999999,
2479                                "owner_address": "TFrom123",
2480                                "to_address": "TTo456"
2481                            }
2482                        },
2483                        "type": "TransferContract"
2484                    }]
2485                },
2486                "ret": [{"contractRet": "SUCCESS"}]
2487            }],
2488            "success": true
2489        }"#;
2490        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2491        let tx = &response.data[0];
2492        assert_eq!(tx.tx_id, "abc123def456");
2493        assert_eq!(tx.block_number, Some(12345));
2494        assert_eq!(tx.block_timestamp, Some(1600000000000));
2495        let contract_value = tx
2496            .raw_data
2497            .as_ref()
2498            .and_then(|r| r.contract.as_ref())
2499            .and_then(|c| c.first())
2500            .and_then(|c| c.parameter.as_ref())
2501            .and_then(|p| p.value.as_ref())
2502            .unwrap();
2503        assert_eq!(contract_value.amount, Some(999999));
2504        assert_eq!(contract_value.owner_address.as_deref(), Some("TFrom123"));
2505        assert_eq!(contract_value.to_address.as_deref(), Some("TTo456"));
2506        assert_eq!(
2507            tx.ret
2508                .as_ref()
2509                .and_then(|r| r.first())
2510                .and_then(|r| r.contract_ret.as_deref()),
2511            Some("SUCCESS")
2512        );
2513    }
2514
2515    #[test]
2516    fn test_tron_client_default_trait() {
2517        let client = TronClient::default();
2518        assert_eq!(client.chain_name(), "tron");
2519        assert_eq!(client.native_token_symbol(), "TRX");
2520        assert_eq!(client.api_url, DEFAULT_TRON_API);
2521    }
2522
2523    #[test]
2524    fn test_dex_search_pair_wtrx() {
2525        let json = r#"{"pairs":[{"baseTokenSymbol":"WTRX","priceUsd":"0.08"}]}"#;
2526        let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2527        assert!(result.is_ok());
2528        let resp = result.unwrap();
2529        let pairs = resp.pairs.unwrap();
2530        assert_eq!(pairs[0].base_token_symbol, Some("WTRX".to_string()));
2531        assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2532    }
2533
2534    #[tokio::test]
2535    async fn test_enrich_balance_usd_no_panic() {
2536        let mut balance = Balance {
2537            raw: "1000000".to_string(),
2538            formatted: "1.000000 TRX".to_string(),
2539            decimals: TRX_DECIMALS,
2540            symbol: "TRX".to_string(),
2541            usd_value: None,
2542        };
2543        let client = TronClient::default();
2544        client.enrich_balance_usd(&mut balance).await;
2545        // Should not panic; usd_value may or may not be set depending on DexScreener response
2546    }
2547
2548    #[test]
2549    fn test_trc20_token_balance_debug_format() {
2550        let b = Trc20TokenBalance {
2551            contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2552            raw_balance: "1000000".to_string(),
2553        };
2554        let s = format!("{:?}", b);
2555        assert!(s.contains("Trc20TokenBalance"));
2556        assert!(s.contains("1000000"));
2557    }
2558
2559    #[tokio::test]
2560    async fn test_get_transactions_with_minimal_contract_data() {
2561        let mut server = mockito::Server::new_async().await;
2562        let _mock = server
2563            .mock(
2564                "GET",
2565                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2566            )
2567            .with_status(200)
2568            .with_header("content-type", "application/json")
2569            .with_body(
2570                r#"{
2571                "data": [{
2572                    "txID": "c4d23e73be8f8c9c94c10b79c0c0a0c24b2c9a9c0a0c0a0c0a0c0a0c0a0c0a0c0a0c",
2573                    "block_number": 50000000,
2574                    "block_timestamp": 1700000000000,
2575                    "raw_data": {"contract": [{}]},
2576                    "ret": []
2577                }],
2578                "success": true
2579            }"#,
2580            )
2581            .create_async()
2582            .await;
2583
2584        let client = TronClient::with_api_url(&server.url());
2585        let txs = client.get_transactions(VALID_ADDRESS, 5).await.unwrap();
2586        assert_eq!(txs.len(), 1);
2587        assert_eq!(txs[0].value, "0");
2588        assert_eq!(txs[0].status, None);
2589    }
2590}