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