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 async_trait::async_trait;
35use reqwest::Client;
36use serde::Deserialize;
37use serde_json;
38use sha2::{Digest, Sha256};
39
40/// Default TronGrid API endpoint.
41const DEFAULT_TRON_API: &str = "https://api.trongrid.io";
42
43/// Tronscan API base for token info and holder lookups.
44const TRONSCAN_API: &str = "https://apilist.tronscanapi.com";
45
46/// DexScreener search URL for TRX/USDT price lookup.
47const DEXSCREENER_TRX_SEARCH: &str = "https://api.dexscreener.com/latest/dex/search?q=TRX%20USDT";
48
49/// Tron native token decimals (TRX uses 6 decimals, stored as "sun").
50const TRX_DECIMALS: u8 = 6;
51
52/// Tron blockchain client.
53///
54/// Uses TronGrid REST API for data retrieval.
55#[derive(Debug, Clone)]
56pub struct TronClient {
57    /// HTTP client for API requests.
58    client: Client,
59
60    /// TronGrid API base URL.
61    api_url: String,
62
63    /// TronGrid API key for higher rate limits.
64    api_key: Option<String>,
65}
66
67/// Account response from TronGrid API.
68#[derive(Debug, Deserialize)]
69struct AccountResponse {
70    data: Vec<AccountData>,
71    success: bool,
72    error: Option<String>,
73}
74
75/// Account data from TronGrid.
76#[derive(Debug, Deserialize)]
77#[allow(dead_code)] // Fields used for deserialization
78struct AccountData {
79    balance: Option<u64>,
80    address: String,
81    create_time: Option<u64>,
82    #[serde(default)]
83    trc20: Vec<Trc20Balance>,
84}
85
86/// TRC20 token balance.
87#[derive(Debug, Deserialize)]
88#[allow(dead_code)] // Reserved for future TRC20 token support
89struct Trc20Balance {
90    #[serde(flatten)]
91    balances: std::collections::HashMap<String, String>,
92}
93
94/// Transaction list response from TronGrid.
95#[derive(Debug, Deserialize)]
96struct TransactionListResponse {
97    data: Vec<TronTransaction>,
98    success: bool,
99    error: Option<String>,
100}
101
102/// Tron transaction from API.
103#[derive(Debug, Deserialize)]
104struct TronTransaction {
105    #[serde(rename = "txID")]
106    tx_id: String,
107    block_number: Option<u64>,
108    block_timestamp: Option<u64>,
109    raw_data: Option<RawData>,
110    ret: Option<Vec<TransactionResult>>,
111}
112
113/// Raw transaction data.
114#[derive(Debug, Deserialize)]
115struct RawData {
116    contract: Option<Vec<Contract>>,
117}
118
119/// Contract call in transaction.
120#[derive(Debug, Deserialize)]
121#[allow(dead_code)] // Fields used for deserialization
122struct Contract {
123    parameter: Option<ContractParameter>,
124    #[serde(rename = "type")]
125    contract_type: Option<String>,
126}
127
128/// Contract parameters.
129#[derive(Debug, Deserialize)]
130struct ContractParameter {
131    value: Option<ContractValue>,
132}
133
134/// Contract value containing transfer details.
135#[derive(Debug, Deserialize)]
136struct ContractValue {
137    amount: Option<u64>,
138    owner_address: Option<String>,
139    to_address: Option<String>,
140}
141
142/// Transaction result.
143#[derive(Debug, Deserialize)]
144struct TransactionResult {
145    #[serde(rename = "contractRet")]
146    contract_ret: Option<String>,
147}
148
149impl TronClient {
150    /// Creates a new Tron client with the given configuration.
151    ///
152    /// # Arguments
153    ///
154    /// * `config` - Chain configuration containing API endpoint and keys
155    ///
156    /// # Returns
157    ///
158    /// Returns a configured [`TronClient`] instance.
159    ///
160    /// # Examples
161    ///
162    /// ```rust,no_run
163    /// use scope::chains::TronClient;
164    /// use scope::config::ChainsConfig;
165    ///
166    /// let config = ChainsConfig::default();
167    /// let client = TronClient::new(&config).unwrap();
168    /// ```
169    pub fn new(config: &ChainsConfig) -> Result<Self> {
170        let client = Client::builder()
171            .timeout(std::time::Duration::from_secs(30))
172            .build()
173            .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
174
175        let api_url = config
176            .tron_api
177            .as_deref()
178            .unwrap_or(DEFAULT_TRON_API)
179            .to_string();
180
181        Ok(Self {
182            client,
183            api_url,
184            api_key: config.api_keys.get("tronscan").cloned(),
185        })
186    }
187
188    /// Creates a client with a custom API URL.
189    ///
190    /// # Arguments
191    ///
192    /// * `api_url` - The TronGrid API endpoint URL
193    pub fn with_api_url(api_url: &str) -> Self {
194        Self {
195            client: Client::new(),
196            api_url: api_url.to_string(),
197            api_key: None,
198        }
199    }
200
201    /// Returns the chain name.
202    pub fn chain_name(&self) -> &str {
203        "tron"
204    }
205
206    /// Returns the native token symbol.
207    pub fn native_token_symbol(&self) -> &str {
208        "TRX"
209    }
210
211    /// Fetches the TRX balance for an address.
212    ///
213    /// # Arguments
214    ///
215    /// * `address` - The Tron address (T-address format)
216    ///
217    /// # Returns
218    ///
219    /// Returns a [`Balance`] struct with the balance in multiple formats.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
224    /// Returns [`ScopeError::Request`] if the API request fails.
225    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
226        // Validate address
227        validate_tron_address(address)?;
228
229        let url = format!("{}/v1/accounts/{}", self.api_url, address);
230
231        tracing::debug!(url = %url, address = %address, "Fetching Tron balance");
232
233        let mut request = self.client.get(&url);
234        if let Some(ref key) = self.api_key {
235            request = request.header("TRON-PRO-API-KEY", key);
236        }
237
238        let response: AccountResponse = request.send().await?.json().await?;
239
240        if !response.success {
241            return Err(ScopeError::Chain(format!(
242                "TronGrid API error: {}",
243                response.error.unwrap_or_else(|| "Unknown error".into())
244            )));
245        }
246
247        // Account may not exist yet (no balance)
248        let sun = response.data.first().and_then(|d| d.balance).unwrap_or(0);
249
250        let trx = sun as f64 / 10_f64.powi(TRX_DECIMALS as i32);
251
252        Ok(Balance {
253            raw: sun.to_string(),
254            formatted: format!("{:.6} TRX", trx),
255            decimals: TRX_DECIMALS,
256            symbol: "TRX".to_string(),
257            usd_value: None, // Populated by caller via enrich_balance_usd
258        })
259    }
260
261    /// Fetches TRC-20 token balances for an address.
262    ///
263    /// Uses the TronGrid `/v1/accounts/{address}` endpoint which includes
264    /// TRC-20 balances in the account data.
265    pub async fn get_trc20_balances(&self, address: &str) -> Result<Vec<Trc20TokenBalance>> {
266        validate_tron_address(address)?;
267
268        let url = format!("{}/v1/accounts/{}", self.api_url, address);
269
270        tracing::debug!(url = %url, "Fetching TRC-20 token balances");
271
272        let mut request = self.client.get(&url);
273        if let Some(ref key) = self.api_key {
274            request = request.header("TRON-PRO-API-KEY", key);
275        }
276
277        let response: AccountResponse = request.send().await?.json().await?;
278
279        if !response.success {
280            return Err(ScopeError::Chain(format!(
281                "TronGrid API error: {}",
282                response.error.unwrap_or_else(|| "Unknown error".into())
283            )));
284        }
285
286        let account = match response.data.first() {
287            Some(data) => data,
288            None => return Ok(vec![]),
289        };
290
291        let mut balances = Vec::new();
292        for trc20 in &account.trc20 {
293            for (contract_address, raw_balance) in &trc20.balances {
294                // Skip zero balances
295                if raw_balance == "0" {
296                    continue;
297                }
298                balances.push(Trc20TokenBalance {
299                    contract_address: contract_address.clone(),
300                    raw_balance: raw_balance.clone(),
301                });
302            }
303        }
304
305        Ok(balances)
306    }
307
308    /// Fetches TRC-20 token info from Tronscan API.
309    ///
310    /// Returns symbol, name, decimals, and other metadata for a TRC-20 contract.
311    pub async fn get_token_info(&self, contract_address: &str) -> Result<Token> {
312        validate_tron_address(contract_address)?;
313
314        let url = format!(
315            "{}/api/token_trc20?contract={}&showAll=1",
316            TRONSCAN_API, contract_address
317        );
318
319        tracing::debug!(url = %url, "Fetching TRC-20 token info via Tronscan");
320
321        let mut request = self.client.get(&url);
322        if let Some(ref key) = self.api_key {
323            request = request.header("TRON-PRO-API-KEY", key);
324        }
325
326        let response = request.send().await?;
327        let text = response.text().await?;
328        let json: serde_json::Value = serde_json::from_str(&text)
329            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
330
331        let tokens = json
332            .get("trc20_tokens")
333            .and_then(|v| v.as_array())
334            .ok_or_else(|| {
335                ScopeError::NotFound(format!(
336                    "No token info found for TRC-20 contract {}",
337                    contract_address
338                ))
339            })?;
340
341        let token_data = tokens.first().ok_or_else(|| {
342            ScopeError::NotFound(format!(
343                "No token info found for TRC-20 contract {}",
344                contract_address
345            ))
346        })?;
347
348        let symbol = token_data
349            .get("symbol")
350            .and_then(|v| v.as_str())
351            .unwrap_or("UNKNOWN")
352            .to_string();
353        let name = token_data
354            .get("contract_name")
355            .or_else(|| token_data.get("name"))
356            .and_then(|v| v.as_str())
357            .unwrap_or("Unknown Token")
358            .to_string();
359        let decimals = token_data
360            .get("decimals")
361            .and_then(|v| v.as_u64())
362            .unwrap_or(6) as u8;
363
364        Ok(Token {
365            contract_address: contract_address.to_string(),
366            symbol,
367            name,
368            decimals,
369        })
370    }
371
372    /// Fetches top TRC-20 token holders from Tronscan API.
373    ///
374    /// Returns holders sorted by balance (largest first).
375    pub async fn get_token_holders(
376        &self,
377        contract_address: &str,
378        limit: u32,
379    ) -> Result<Vec<TokenHolder>> {
380        validate_tron_address(contract_address)?;
381
382        let effective_limit = limit.min(100);
383        let url = format!(
384            "{}/api/token_trc20/holders?contract_address={}&start=0&limit={}",
385            TRONSCAN_API, contract_address, effective_limit
386        );
387
388        tracing::debug!(url = %url, "Fetching TRC-20 token holders via Tronscan");
389
390        let mut request = self.client.get(&url);
391        if let Some(ref key) = self.api_key {
392            request = request.header("TRON-PRO-API-KEY", key);
393        }
394
395        let response = request.send().await?;
396        let text = response.text().await?;
397        let json: serde_json::Value = serde_json::from_str(&text)
398            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan holders: {}", e)))?;
399
400        let holders_data: &[serde_json::Value] = json
401            .get("trc20_tokens")
402            .and_then(|v| v.as_array())
403            .map(|v| v.as_slice())
404            .unwrap_or(&[]);
405
406        // Get decimals for formatted balance
407        let token_info = self.get_token_info(contract_address).await;
408        let decimals = token_info.as_ref().map(|t| t.decimals).unwrap_or(6);
409
410        // Percentage is relative to sum of fetched holder balances (same as EVM chains)
411        let total_balance: f64 = holders_data
412            .iter()
413            .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
414            .filter_map(|s| s.parse::<f64>().ok())
415            .sum();
416
417        let token_holders: Vec<TokenHolder> = holders_data
418            .iter()
419            .enumerate()
420            .filter_map(|(i, h)| {
421                let holder_address = h.get("holder_address")?.as_str()?.to_string();
422                let balance_raw = h.get("balance")?.as_str()?.to_string();
423                let balance: f64 = balance_raw.parse().ok()?;
424                let percentage = if total_balance > 0.0 {
425                    (balance / total_balance) * 100.0
426                } else {
427                    0.0
428                };
429                let divisor = 10_f64.powi(decimals as i32);
430                let formatted = format!("{:.6}", balance / divisor);
431
432                Some(TokenHolder {
433                    address: holder_address,
434                    balance: balance_raw,
435                    formatted_balance: formatted,
436                    percentage,
437                    rank: (i + 1) as u32,
438                })
439            })
440            .collect();
441
442        Ok(token_holders)
443    }
444
445    /// Fetches total holder count for a TRC-20 token.
446    pub async fn get_token_holder_count(&self, contract_address: &str) -> Result<u64> {
447        validate_tron_address(contract_address)?;
448
449        let url = format!(
450            "{}/api/token_trc20/holders?contract_address={}&start=0&limit=1",
451            TRONSCAN_API, contract_address
452        );
453
454        let mut request = self.client.get(&url);
455        if let Some(ref key) = self.api_key {
456            request = request.header("TRON-PRO-API-KEY", key);
457        }
458
459        let response = request.send().await?;
460        let json: serde_json::Value = response
461            .json()
462            .await
463            .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
464
465        let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
466
467        Ok(count)
468    }
469
470    /// Enriches a balance with a USD value using DexScreener price lookup.
471    ///
472    /// Note: Tron native token price lookup via DexScreener is not yet supported.
473    /// This is a placeholder that uses CoinGecko-style simple price API as fallback.
474    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
475        // Try to get TRX price from DexScreener search API
476        let url = DEXSCREENER_TRX_SEARCH;
477        if let Ok(response) = self.client.get(url).send().await
478            && let Ok(text) = response.text().await
479            && let Ok(search_result) = serde_json::from_str::<DexSearchResponse>(&text)
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 request = self.client.get(&url);
514        if let Some(ref key) = self.api_key {
515            request = request.header("TRON-PRO-API-KEY", key);
516        }
517
518        let response: TransactionListResponse = request.send().await?.json().await?;
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 request = self.client.get(&url);
592        if let Some(ref key) = self.api_key {
593            request = request.header("TRON-PRO-API-KEY", key);
594        }
595
596        let response: TransactionListResponse = request.send().await?.json().await?;
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 response: BlockResponse = self.client.post(&url).send().await?.json().await?;
670
671        response
672            .block_header
673            .and_then(|h| h.raw_data)
674            .and_then(|d| d.number)
675            .ok_or_else(|| ScopeError::Chain("Invalid block response".into()))
676    }
677}
678
679impl Default for TronClient {
680    fn default() -> Self {
681        Self {
682            client: Client::new(),
683            api_url: DEFAULT_TRON_API.to_string(),
684            api_key: None,
685        }
686    }
687}
688
689/// Validates a Tron address format (T-address, base58check encoded).
690///
691/// Tron addresses:
692/// - Start with 'T'
693/// - Are 34 characters long
694/// - Use base58check encoding (includes checksum)
695///
696/// # Arguments
697///
698/// * `address` - The address to validate
699///
700/// TRC-20 token balance result.
701#[derive(Debug, Clone)]
702pub struct Trc20TokenBalance {
703    /// Token contract address (base58).
704    pub contract_address: String,
705    /// Raw balance string.
706    pub raw_balance: String,
707}
708
709/// Minimal DexScreener search response for price lookups.
710#[derive(Debug, Deserialize)]
711struct DexSearchResponse {
712    #[serde(default)]
713    pairs: Option<Vec<DexSearchPair>>,
714}
715
716/// A pair from DexScreener search results.
717#[derive(Debug, Deserialize)]
718#[serde(rename_all = "camelCase")]
719struct DexSearchPair {
720    #[serde(default)]
721    base_token_symbol: Option<String>,
722    #[serde(default)]
723    price_usd: Option<String>,
724}
725
726/// # Returns
727///
728/// Returns `Ok(())` if valid, or an error describing the validation failure.
729pub fn validate_tron_address(address: &str) -> Result<()> {
730    if address.is_empty() {
731        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
732    }
733
734    // Tron addresses start with 'T'
735    if !address.starts_with('T') {
736        return Err(ScopeError::InvalidAddress(format!(
737            "Tron address must start with 'T': {}",
738            address
739        )));
740    }
741
742    // Tron addresses are 34 characters
743    if address.len() != 34 {
744        return Err(ScopeError::InvalidAddress(format!(
745            "Tron address must be 34 characters, got {}: {}",
746            address.len(),
747            address
748        )));
749    }
750
751    // Validate base58 encoding
752    match bs58::decode(address).into_vec() {
753        Ok(bytes) => {
754            // Should decode to 25 bytes (1 prefix + 20 address + 4 checksum)
755            if bytes.len() != 25 {
756                return Err(ScopeError::InvalidAddress(format!(
757                    "Tron address must decode to 25 bytes, got {}: {}",
758                    bytes.len(),
759                    address
760                )));
761            }
762
763            // First byte should be 0x41 (Tron mainnet prefix)
764            if bytes[0] != 0x41 {
765                return Err(ScopeError::InvalidAddress(format!(
766                    "Invalid Tron address prefix: {}",
767                    address
768                )));
769            }
770
771            // Verify checksum: last 4 bytes must equal first 4 bytes of double SHA256 of first 21 bytes
772            let payload = &bytes[0..21];
773            let hash1 = Sha256::digest(payload);
774            let hash2 = Sha256::digest(hash1);
775            let expected_checksum = &hash2[0..4];
776            let actual_checksum = &bytes[21..25];
777
778            if expected_checksum != actual_checksum {
779                return Err(ScopeError::InvalidAddress(format!(
780                    "Invalid Tron address checksum: {}",
781                    address
782                )));
783            }
784        }
785        Err(e) => {
786            return Err(ScopeError::InvalidAddress(format!(
787                "Invalid base58 encoding: {}: {}",
788                e, address
789            )));
790        }
791    }
792
793    Ok(())
794}
795
796/// Validates a Tron transaction hash format.
797///
798/// Tron transaction hashes are 64-character hex strings.
799///
800/// # Arguments
801///
802/// * `hash` - The hash to validate
803///
804/// # Returns
805///
806/// Returns `Ok(())` if valid, or an error describing the validation failure.
807pub fn validate_tron_tx_hash(hash: &str) -> Result<()> {
808    if hash.is_empty() {
809        return Err(ScopeError::InvalidHash("Hash cannot be empty".into()));
810    }
811
812    // Tron tx hashes are 64 hex characters (without 0x prefix)
813    if hash.len() != 64 {
814        return Err(ScopeError::InvalidHash(format!(
815            "Tron transaction hash must be 64 characters, got {}: {}",
816            hash.len(),
817            hash
818        )));
819    }
820
821    // Validate hex encoding
822    if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
823        return Err(ScopeError::InvalidHash(format!(
824            "Tron hash contains invalid hex characters: {}",
825            hash
826        )));
827    }
828
829    Ok(())
830}
831
832// ============================================================================
833// ChainClient Trait Implementation
834// ============================================================================
835
836#[async_trait]
837impl ChainClient for TronClient {
838    fn chain_name(&self) -> &str {
839        "tron"
840    }
841
842    fn native_token_symbol(&self) -> &str {
843        "TRX"
844    }
845
846    async fn get_balance(&self, address: &str) -> Result<Balance> {
847        self.get_balance(address).await
848    }
849
850    async fn enrich_balance_usd(&self, balance: &mut Balance) {
851        self.enrich_balance_usd(balance).await
852    }
853
854    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
855        self.get_transaction(hash).await
856    }
857
858    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
859        self.get_transactions(address, limit).await
860    }
861
862    async fn get_block_number(&self) -> Result<u64> {
863        self.get_block_number().await
864    }
865
866    async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
867        let trc20_balances = self.get_trc20_balances(address).await?;
868        let mut result = Vec::with_capacity(trc20_balances.len());
869
870        for tb in trc20_balances {
871            let token = match self.get_token_info(&tb.contract_address).await {
872                Ok(info) => info,
873                Err(e) => {
874                    tracing::debug!(
875                        contract = %tb.contract_address,
876                        error = %e,
877                        "Could not fetch TRC-20 token info, using placeholder"
878                    );
879                    Token {
880                        contract_address: tb.contract_address.clone(),
881                        symbol: "TRC20".to_string(),
882                        name: "TRC-20 Token".to_string(),
883                        decimals: 6, // Common for USDT, USDC
884                    }
885                }
886            };
887
888            let raw: f64 = tb.raw_balance.parse().unwrap_or(0.0);
889            let divisor = 10_f64.powi(token.decimals as i32);
890            let formatted = format!("{:.6}", raw / divisor);
891
892            result.push(crate::chains::TokenBalance {
893                token,
894                balance: tb.raw_balance,
895                formatted_balance: formatted,
896                usd_value: None,
897            });
898        }
899
900        Ok(result)
901    }
902
903    async fn get_token_info(&self, address: &str) -> Result<Token> {
904        self.get_token_info(address).await
905    }
906
907    async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
908        self.get_token_holders(address, limit).await
909    }
910
911    async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
912        self.get_token_holder_count(address).await
913    }
914}
915
916// ============================================================================
917// Unit Tests
918// ============================================================================
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    // Valid Tron address (Binance cold wallet)
925    const VALID_ADDRESS: &str = "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf";
926
927    // Valid Tron transaction hash
928    const VALID_TX_HASH: &str = "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
929
930    #[test]
931    fn test_validate_tron_address_valid() {
932        assert!(validate_tron_address(VALID_ADDRESS).is_ok());
933    }
934
935    #[test]
936    fn test_validate_tron_address_empty() {
937        let result = validate_tron_address("");
938        assert!(result.is_err());
939        assert!(result.unwrap_err().to_string().contains("empty"));
940    }
941
942    #[test]
943    fn test_validate_tron_address_wrong_prefix() {
944        let result = validate_tron_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
945        assert!(result.is_err());
946        assert!(result.unwrap_err().to_string().contains("start with 'T'"));
947    }
948
949    #[test]
950    fn test_validate_tron_address_too_short() {
951        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZ");
952        assert!(result.is_err());
953        assert!(result.unwrap_err().to_string().contains("34 characters"));
954    }
955
956    #[test]
957    fn test_validate_tron_address_too_long() {
958        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCfAAAA");
959        assert!(result.is_err());
960        assert!(result.unwrap_err().to_string().contains("34 characters"));
961    }
962
963    #[test]
964    fn test_validate_tron_address_invalid_base58() {
965        // Contains '0' which is not valid base58
966        let result = validate_tron_address("T0qSquXBgUCLYvYC4XZgrprLK589dkhSCf");
967        assert!(result.is_err());
968        assert!(result.unwrap_err().to_string().contains("base58"));
969    }
970
971    #[test]
972    fn test_validate_tron_tx_hash_valid() {
973        assert!(validate_tron_tx_hash(VALID_TX_HASH).is_ok());
974    }
975
976    #[test]
977    fn test_validate_tron_tx_hash_empty() {
978        let result = validate_tron_tx_hash("");
979        assert!(result.is_err());
980        assert!(result.unwrap_err().to_string().contains("empty"));
981    }
982
983    #[test]
984    fn test_validate_tron_tx_hash_too_short() {
985        let result = validate_tron_tx_hash("b3c12d62ad7e7b8b83b09a68");
986        assert!(result.is_err());
987        assert!(result.unwrap_err().to_string().contains("64 characters"));
988    }
989
990    #[test]
991    fn test_validate_tron_tx_hash_invalid_hex() {
992        let hash = "g3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
993        let result = validate_tron_tx_hash(hash);
994        assert!(result.is_err());
995        assert!(result.unwrap_err().to_string().contains("invalid hex"));
996    }
997
998    #[test]
999    fn test_tron_client_default() {
1000        let client = TronClient::default();
1001        assert_eq!(client.chain_name(), "tron");
1002        assert_eq!(client.native_token_symbol(), "TRX");
1003        assert!(client.api_url.contains("trongrid"));
1004    }
1005
1006    #[test]
1007    fn test_tron_client_with_api_url() {
1008        let client = TronClient::with_api_url("https://custom.tron.api");
1009        assert_eq!(client.api_url, "https://custom.tron.api");
1010    }
1011
1012    #[test]
1013    fn test_tron_client_new() {
1014        let config = ChainsConfig::default();
1015        let client = TronClient::new(&config);
1016        assert!(client.is_ok());
1017    }
1018
1019    #[test]
1020    fn test_tron_client_new_with_custom_api() {
1021        let config = ChainsConfig {
1022            tron_api: Some("https://my-tron-api.com".to_string()),
1023            ..Default::default()
1024        };
1025        let client = TronClient::new(&config).unwrap();
1026        assert_eq!(client.api_url, "https://my-tron-api.com");
1027    }
1028
1029    #[test]
1030    fn test_tron_client_new_with_api_key() {
1031        use std::collections::HashMap;
1032
1033        let mut api_keys = HashMap::new();
1034        api_keys.insert("tronscan".to_string(), "test-key".to_string());
1035
1036        let config = ChainsConfig {
1037            api_keys,
1038            ..Default::default()
1039        };
1040
1041        let client = TronClient::new(&config).unwrap();
1042        assert_eq!(client.api_key, Some("test-key".to_string()));
1043    }
1044
1045    #[test]
1046    fn test_account_response_deserialization() {
1047        let json = r#"{
1048            "data": [{
1049                "balance": 1000000,
1050                "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1051                "create_time": 1600000000000,
1052                "trc20": []
1053            }],
1054            "success": true
1055        }"#;
1056
1057        let response: AccountResponse = serde_json::from_str(json).unwrap();
1058        assert!(response.success);
1059        assert_eq!(response.data.len(), 1);
1060        assert_eq!(response.data[0].balance, Some(1_000_000));
1061    }
1062
1063    #[test]
1064    fn test_transaction_response_deserialization() {
1065        let json = r#"{
1066            "data": [{
1067                "txID": "abc123",
1068                "block_number": 12345,
1069                "block_timestamp": 1600000000000,
1070                "ret": [{"contractRet": "SUCCESS"}]
1071            }],
1072            "success": true
1073        }"#;
1074
1075        let response: TransactionListResponse = serde_json::from_str(json).unwrap();
1076        assert!(response.success);
1077        assert_eq!(response.data.len(), 1);
1078        assert_eq!(response.data[0].tx_id, "abc123");
1079    }
1080
1081    // ========================================================================
1082    // HTTP mocking tests
1083    // ========================================================================
1084
1085    #[tokio::test]
1086    async fn test_get_balance() {
1087        let mut server = mockito::Server::new_async().await;
1088        let _mock = server
1089            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1090            .with_status(200)
1091            .with_header("content-type", "application/json")
1092            .with_body(r#"{
1093                "data": [{"balance": 5000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}],
1094                "success": true
1095            }"#)
1096            .create_async()
1097            .await;
1098
1099        let client = TronClient::with_api_url(&server.url());
1100        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1101        assert_eq!(balance.raw, "5000000");
1102        assert_eq!(balance.symbol, "TRX");
1103        assert!(balance.formatted.contains("5.000000"));
1104    }
1105
1106    #[tokio::test]
1107    async fn test_get_balance_new_account() {
1108        let mut server = mockito::Server::new_async().await;
1109        let _mock = server
1110            .mock(
1111                "GET",
1112                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1113            )
1114            .with_status(200)
1115            .with_header("content-type", "application/json")
1116            .with_body(r#"{"data": [], "success": true}"#)
1117            .create_async()
1118            .await;
1119
1120        let client = TronClient::with_api_url(&server.url());
1121        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1122        assert_eq!(balance.raw, "0");
1123        assert!(balance.formatted.contains("0.000000"));
1124    }
1125
1126    #[tokio::test]
1127    async fn test_get_balance_api_error() {
1128        let mut server = mockito::Server::new_async().await;
1129        let _mock = server
1130            .mock(
1131                "GET",
1132                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1133            )
1134            .with_status(200)
1135            .with_header("content-type", "application/json")
1136            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1137            .create_async()
1138            .await;
1139
1140        let client = TronClient::with_api_url(&server.url());
1141        let result = client.get_balance(VALID_ADDRESS).await;
1142        assert!(result.is_err());
1143        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1144    }
1145
1146    #[tokio::test]
1147    async fn test_get_balance_invalid_address() {
1148        let client = TronClient::default();
1149        let result = client.get_balance("invalid").await;
1150        assert!(result.is_err());
1151    }
1152
1153    #[tokio::test]
1154    async fn test_get_transaction() {
1155        let mut server = mockito::Server::new_async().await;
1156        let _mock = server
1157            .mock(
1158                "GET",
1159                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1160            )
1161            .with_status(200)
1162            .with_header("content-type", "application/json")
1163            .with_body(
1164                r#"{
1165                "data": [{
1166                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1167                    "block_number": 50000000,
1168                    "block_timestamp": 1700000000000,
1169                    "raw_data": {
1170                        "contract": [{
1171                            "parameter": {
1172                                "value": {
1173                                    "amount": 1000000,
1174                                    "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1175                                    "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
1176                                }
1177                            },
1178                            "type": "TransferContract"
1179                        }]
1180                    },
1181                    "ret": [{"contractRet": "SUCCESS"}]
1182                }],
1183                "success": true
1184            }"#,
1185            )
1186            .create_async()
1187            .await;
1188
1189        let client = TronClient::with_api_url(&server.url());
1190        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1191        assert_eq!(tx.hash, VALID_TX_HASH);
1192        assert_eq!(tx.from, "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
1193        assert_eq!(
1194            tx.to,
1195            Some("TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9".to_string())
1196        );
1197        assert_eq!(tx.value, "1000000");
1198        assert_eq!(tx.block_number, Some(50000000));
1199        assert_eq!(tx.timestamp, Some(1700000000)); // ms → s
1200        assert!(tx.status.unwrap());
1201    }
1202
1203    #[tokio::test]
1204    async fn test_get_transaction_failed() {
1205        let mut server = mockito::Server::new_async().await;
1206        let _mock = server
1207            .mock(
1208                "GET",
1209                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1210            )
1211            .with_status(200)
1212            .with_header("content-type", "application/json")
1213            .with_body(
1214                r#"{
1215                "data": [{
1216                    "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1217                    "block_number": 50000000,
1218                    "block_timestamp": 1700000000000,
1219                    "ret": [{"contractRet": "REVERT"}]
1220                }],
1221                "success": true
1222            }"#,
1223            )
1224            .create_async()
1225            .await;
1226
1227        let client = TronClient::with_api_url(&server.url());
1228        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1229        assert!(!tx.status.unwrap()); // REVERT → failure
1230    }
1231
1232    #[tokio::test]
1233    async fn test_get_transaction_not_found() {
1234        let mut server = mockito::Server::new_async().await;
1235        let _mock = server
1236            .mock(
1237                "GET",
1238                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1239            )
1240            .with_status(200)
1241            .with_header("content-type", "application/json")
1242            .with_body(r#"{"data": [], "success": true}"#)
1243            .create_async()
1244            .await;
1245
1246        let client = TronClient::with_api_url(&server.url());
1247        let result = client.get_transaction(VALID_TX_HASH).await;
1248        assert!(result.is_err());
1249    }
1250
1251    #[tokio::test]
1252    async fn test_get_transactions() {
1253        let mut server = mockito::Server::new_async().await;
1254        let _mock = server
1255            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()))
1256            .with_status(200)
1257            .with_header("content-type", "application/json")
1258            .with_body(r#"{
1259                "data": [
1260                    {
1261                        "txID": "aaa111",
1262                        "block_number": 50000000,
1263                        "block_timestamp": 1700000000000,
1264                        "raw_data": {"contract": [{"parameter": {"value": {"amount": 500000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"}}, "type": "TransferContract"}]},
1265                        "ret": [{"contractRet": "SUCCESS"}]
1266                    },
1267                    {
1268                        "txID": "bbb222",
1269                        "block_number": 50000001,
1270                        "block_timestamp": 1700000060000,
1271                        "ret": [{"contractRet": "SUCCESS"}]
1272                    }
1273                ],
1274                "success": true
1275            }"#)
1276            .create_async()
1277            .await;
1278
1279        let client = TronClient::with_api_url(&server.url());
1280        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1281        assert_eq!(txs.len(), 2);
1282        assert_eq!(txs[0].hash, "aaa111");
1283        assert_eq!(txs[0].value, "500000");
1284        assert!(txs[0].status.unwrap());
1285        // Second tx has no contract data → defaults
1286        assert_eq!(txs[1].value, "0");
1287    }
1288
1289    #[tokio::test]
1290    async fn test_get_transactions_error() {
1291        let mut server = mockito::Server::new_async().await;
1292        let _mock = server
1293            .mock(
1294                "GET",
1295                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1296            )
1297            .with_status(200)
1298            .with_header("content-type", "application/json")
1299            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1300            .create_async()
1301            .await;
1302
1303        let client = TronClient::with_api_url(&server.url());
1304        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1305        assert!(result.is_err());
1306    }
1307
1308    #[tokio::test]
1309    async fn test_get_trc20_balances() {
1310        let mut server = mockito::Server::new_async().await;
1311        let _mock = server
1312            .mock(
1313                "GET",
1314                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1315            )
1316            .with_status(200)
1317            .with_header("content-type", "application/json")
1318            .with_body(
1319                r#"{
1320                "data": [{
1321                    "balance": 1000000,
1322                    "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1323                    "trc20": [
1324                        {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"},
1325                        {"TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8": "0"}
1326                    ]
1327                }],
1328                "success": true
1329            }"#,
1330            )
1331            .create_async()
1332            .await;
1333
1334        let client = TronClient::with_api_url(&server.url());
1335        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1336        // Zero balance filtered out
1337        assert_eq!(balances.len(), 1);
1338        assert_eq!(
1339            balances[0].contract_address,
1340            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
1341        );
1342        assert_eq!(balances[0].raw_balance, "5000000");
1343    }
1344
1345    #[tokio::test]
1346    async fn test_get_trc20_balances_empty_account() {
1347        let mut server = mockito::Server::new_async().await;
1348        let _mock = server
1349            .mock(
1350                "GET",
1351                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1352            )
1353            .with_status(200)
1354            .with_header("content-type", "application/json")
1355            .with_body(r#"{"data": [], "success": true}"#)
1356            .create_async()
1357            .await;
1358
1359        let client = TronClient::with_api_url(&server.url());
1360        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1361        assert!(balances.is_empty());
1362    }
1363
1364    #[tokio::test]
1365    async fn test_get_block_number() {
1366        let mut server = mockito::Server::new_async().await;
1367        let _mock = server
1368            .mock("POST", "/wallet/getnowblock")
1369            .with_status(200)
1370            .with_header("content-type", "application/json")
1371            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1372            .create_async()
1373            .await;
1374
1375        let client = TronClient::with_api_url(&server.url());
1376        let block = client.get_block_number().await.unwrap();
1377        assert_eq!(block, 60000000);
1378    }
1379
1380    #[tokio::test]
1381    async fn test_get_block_number_invalid_response() {
1382        let mut server = mockito::Server::new_async().await;
1383        let _mock = server
1384            .mock("POST", "/wallet/getnowblock")
1385            .with_status(200)
1386            .with_header("content-type", "application/json")
1387            .with_body(r#"{}"#)
1388            .create_async()
1389            .await;
1390
1391        let client = TronClient::with_api_url(&server.url());
1392        let result = client.get_block_number().await;
1393        assert!(result.is_err());
1394    }
1395
1396    #[test]
1397    fn test_validate_tron_address_wrong_decoded_length() {
1398        // Valid base58 but wrong number of decoded bytes
1399        let result = validate_tron_address("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT1");
1400        assert!(result.is_err());
1401    }
1402
1403    #[test]
1404    fn test_validate_tron_tx_hash_wrong_length() {
1405        let result = validate_tron_tx_hash("abc123");
1406        assert!(result.is_err());
1407        assert!(result.unwrap_err().to_string().contains("64 characters"));
1408    }
1409
1410    #[tokio::test]
1411    async fn test_get_transaction_success() {
1412        let mut server = mockito::Server::new_async().await;
1413        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1414        let _mock = server
1415            .mock(
1416                "GET",
1417                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1418            )
1419            .with_status(200)
1420            .with_header("content-type", "application/json")
1421            .with_body(
1422                r#"{"data":[{
1423                "txID":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1424                "blockNumber":60000000,
1425                "block_timestamp":1700000000000,
1426                "raw_data":{"contract":[{"parameter":{"value":{
1427                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1428                    "to_address":"TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn",
1429                    "amount":1000000
1430                }}}]},
1431                "ret":[{"contractRet":"SUCCESS"}]
1432            }],"success":true}"#,
1433            )
1434            .create_async()
1435            .await;
1436
1437        let client = TronClient::with_api_url(&server.url());
1438        let tx = client.get_transaction(valid_hash).await.unwrap();
1439        assert_eq!(tx.hash, valid_hash);
1440        assert_eq!(tx.status, Some(true));
1441    }
1442
1443    #[tokio::test]
1444    async fn test_get_transaction_api_error() {
1445        let mut server = mockito::Server::new_async().await;
1446        let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1447        let _mock = server
1448            .mock(
1449                "GET",
1450                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1451            )
1452            .with_status(200)
1453            .with_header("content-type", "application/json")
1454            .with_body(r#"{"data":[],"success":false,"error":"Transaction not found"}"#)
1455            .create_async()
1456            .await;
1457
1458        let client = TronClient::with_api_url(&server.url());
1459        let result = client.get_transaction(valid_hash).await;
1460        assert!(result.is_err());
1461    }
1462
1463    #[tokio::test]
1464    async fn test_get_transactions_success() {
1465        let mut server = mockito::Server::new_async().await;
1466        let _mock = server
1467            .mock(
1468                "GET",
1469                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1470            )
1471            .with_status(200)
1472            .with_header("content-type", "application/json")
1473            .with_body(
1474                r#"{"data":[{
1475                "txID":"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1476                "blockNumber":60000000,
1477                "block_timestamp":1700000000000,
1478                "raw_data":{"contract":[{"parameter":{"value":{
1479                    "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1480                    "amount":500000
1481                }}}]},
1482                "ret":[{"contractRet":"SUCCESS"}]
1483            }],"success":true}"#,
1484            )
1485            .create_async()
1486            .await;
1487
1488        let client = TronClient::with_api_url(&server.url());
1489        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1490        assert_eq!(txs.len(), 1);
1491    }
1492
1493    #[tokio::test]
1494    async fn test_tron_chain_client_trait_accessors() {
1495        let client = TronClient::with_api_url("http://localhost");
1496        let chain_client: &dyn ChainClient = &client;
1497        assert_eq!(chain_client.chain_name(), "tron");
1498        assert_eq!(chain_client.native_token_symbol(), "TRX");
1499    }
1500
1501    #[tokio::test]
1502    async fn test_chain_client_trait_get_balance() {
1503        let mut server = mockito::Server::new_async().await;
1504        let _mock = server
1505            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1506            .with_status(200)
1507            .with_header("content-type", "application/json")
1508            .with_body(r#"{"data": [{"balance": 1000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#)
1509            .create_async()
1510            .await;
1511
1512        let client = TronClient::with_api_url(&server.url());
1513        let chain_client: &dyn ChainClient = &client;
1514        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1515        assert_eq!(balance.symbol, "TRX");
1516    }
1517
1518    #[tokio::test]
1519    async fn test_chain_client_trait_get_block_number() {
1520        let mut server = mockito::Server::new_async().await;
1521        let _mock = server
1522            .mock("POST", "/wallet/getnowblock")
1523            .with_status(200)
1524            .with_header("content-type", "application/json")
1525            .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1526            .create_async()
1527            .await;
1528
1529        let client = TronClient::with_api_url(&server.url());
1530        let chain_client: &dyn ChainClient = &client;
1531        let block = chain_client.get_block_number().await.unwrap();
1532        assert_eq!(block, 60000000);
1533    }
1534
1535    #[tokio::test]
1536    async fn test_chain_client_trait_get_token_balances() {
1537        let mut server = mockito::Server::new_async().await;
1538        let _mock = server
1539            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1540            .with_status(200)
1541            .with_header("content-type", "application/json")
1542            .with_body(r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"}]}], "success": true}"#)
1543            .create_async()
1544            .await;
1545
1546        let client = TronClient::with_api_url(&server.url());
1547        let chain_client: &dyn ChainClient = &client;
1548        let balances = chain_client
1549            .get_token_balances(VALID_ADDRESS)
1550            .await
1551            .unwrap();
1552        assert_eq!(balances.len(), 1);
1553        // Token info enriched via Tronscan (USDT) or fallback to placeholder
1554        assert!(
1555            balances[0].token.symbol == "USDT" || balances[0].token.symbol == "TRC20",
1556            "symbol should be USDT (Tronscan) or TRC20 (fallback)"
1557        );
1558        // Tronscan returns various name formats (e.g. "TetherToken", "Tether USD");
1559        // fallback is "TRC-20 Token" or "Unknown Token"
1560        assert!(!balances[0].token.name.is_empty(), "name must be set");
1561    }
1562
1563    #[tokio::test]
1564    async fn test_chain_client_trait_get_transaction_tron() {
1565        let mut server = mockito::Server::new_async().await;
1566        let _mock = server
1567            .mock(
1568                "GET",
1569                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1570            )
1571            .with_status(200)
1572            .with_header("content-type", "application/json")
1573            .with_body(
1574                r#"{"data": [{
1575                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1576                "block_number": 50000000,
1577                "block_timestamp": 1700000000000,
1578                "raw_data": {
1579                    "contract": [{
1580                        "parameter": {
1581                            "value": {
1582                                "amount": 1000000,
1583                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1584                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1585                            }
1586                        },
1587                        "type": "TransferContract"
1588                    }]
1589                },
1590                "ret": [{"contractRet": "SUCCESS"}]
1591            }], "success": true}"#,
1592            )
1593            .create_async()
1594            .await;
1595
1596        let client = TronClient::with_api_url(&server.url());
1597        let chain_client: &dyn ChainClient = &client;
1598        let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
1599        assert_eq!(tx.hash, VALID_TX_HASH);
1600        assert!(tx.status.unwrap());
1601    }
1602
1603    #[tokio::test]
1604    async fn test_chain_client_trait_get_transactions_tron() {
1605        let mut server = mockito::Server::new_async().await;
1606        let _mock = server
1607            .mock(
1608                "GET",
1609                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1610            )
1611            .with_status(200)
1612            .with_header("content-type", "application/json")
1613            .with_body(
1614                r#"{"data": [{
1615                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1616                "block_number": 50000000,
1617                "block_timestamp": 1700000000000,
1618                "raw_data": {
1619                    "contract": [{
1620                        "parameter": {
1621                            "value": {
1622                                "amount": 2000000,
1623                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1624                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1625                            }
1626                        }
1627                    }]
1628                },
1629                "ret": [{"contractRet": "REVERT"}]
1630            }], "success": true}"#,
1631            )
1632            .create_async()
1633            .await;
1634
1635        let client = TronClient::with_api_url(&server.url());
1636        let chain_client: &dyn ChainClient = &client;
1637        let txs = chain_client
1638            .get_transactions(VALID_ADDRESS, 10)
1639            .await
1640            .unwrap();
1641        assert_eq!(txs.len(), 1);
1642        assert!(!txs[0].status.unwrap()); // REVERT means failure
1643    }
1644
1645    #[tokio::test]
1646    async fn test_get_balance_with_api_key() {
1647        let mut server = mockito::Server::new_async().await;
1648        let _mock = server
1649            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1650            .with_status(200)
1651            .with_header("content-type", "application/json")
1652            .with_body(
1653                r#"{"data": [{"balance": 10000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#,
1654            )
1655            .create_async()
1656            .await;
1657
1658        let config = ChainsConfig {
1659            tron_api: Some(server.url()),
1660            api_keys: {
1661                let mut m = std::collections::HashMap::new();
1662                m.insert("tronscan".to_string(), "test-api-key".to_string());
1663                m
1664            },
1665            ..Default::default()
1666        };
1667        let client = TronClient::new(&config).unwrap();
1668        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1669        assert_eq!(balance.symbol, "TRX");
1670        assert!(balance.formatted.contains("TRX"));
1671    }
1672
1673    #[tokio::test]
1674    async fn test_get_trc20_balances_error_response() {
1675        let mut server = mockito::Server::new_async().await;
1676        let _mock = server
1677            .mock(
1678                "GET",
1679                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1680            )
1681            .with_status(200)
1682            .with_header("content-type", "application/json")
1683            .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1684            .create_async()
1685            .await;
1686
1687        let client = TronClient::with_api_url(&server.url());
1688        let result = client.get_trc20_balances(VALID_ADDRESS).await;
1689        assert!(result.is_err());
1690        assert!(result.unwrap_err().to_string().contains("Rate limit"));
1691    }
1692
1693    #[tokio::test]
1694    async fn test_get_trc20_balances_no_data() {
1695        let mut server = mockito::Server::new_async().await;
1696        let _mock = server
1697            .mock(
1698                "GET",
1699                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1700            )
1701            .with_status(200)
1702            .with_header("content-type", "application/json")
1703            .with_body(r#"{"data": [], "success": true}"#)
1704            .create_async()
1705            .await;
1706
1707        let client = TronClient::with_api_url(&server.url());
1708        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1709        assert!(balances.is_empty());
1710    }
1711
1712    #[tokio::test]
1713    async fn test_get_trc20_balances_with_api_key() {
1714        let mut server = mockito::Server::new_async().await;
1715        let _mock = server
1716            .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1717            .with_status(200)
1718            .with_header("content-type", "application/json")
1719            .with_body(
1720                r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}]}], "success": true}"#,
1721            )
1722            .create_async()
1723            .await;
1724
1725        let config = ChainsConfig {
1726            tron_api: Some(server.url()),
1727            api_keys: {
1728                let mut m = std::collections::HashMap::new();
1729                m.insert("tronscan".to_string(), "my-api-key".to_string());
1730                m
1731            },
1732            ..Default::default()
1733        };
1734        let client = TronClient::new(&config).unwrap();
1735        let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1736        assert_eq!(balances.len(), 1);
1737    }
1738
1739    #[test]
1740    fn test_validate_tron_address_bad_checksum() {
1741        // Construct a valid-looking address with bad checksum by modifying last char
1742        // TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf -> change last char
1743        let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCe");
1744        assert!(result.is_err());
1745        // Could be checksum error or base58 decode error
1746        let err_str = result.unwrap_err().to_string();
1747        assert!(
1748            err_str.contains("checksum")
1749                || err_str.contains("base58")
1750                || err_str.contains("prefix")
1751        );
1752    }
1753
1754    #[tokio::test]
1755    async fn test_get_transaction_tron_success() {
1756        let mut server = mockito::Server::new_async().await;
1757        let _mock = server
1758            .mock(
1759                "GET",
1760                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1761            )
1762            .with_status(200)
1763            .with_header("content-type", "application/json")
1764            .with_body(
1765                r#"{"data": [{
1766                "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1767                "block_number": 50000000,
1768                "block_timestamp": 1700000000000,
1769                "raw_data": {
1770                    "contract": [{
1771                        "parameter": {
1772                            "value": {
1773                                "amount": 5000000,
1774                                "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1775                                "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1776                            }
1777                        }
1778                    }]
1779                },
1780                "ret": [{"contractRet": "SUCCESS"}]
1781            }], "success": true}"#,
1782            )
1783            .create_async()
1784            .await;
1785
1786        let client = TronClient::with_api_url(&server.url());
1787        let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1788        assert_eq!(tx.hash, VALID_TX_HASH);
1789        assert!(tx.status.unwrap());
1790        assert_eq!(tx.value, "5000000");
1791        assert_eq!(tx.timestamp, Some(1700000000)); // Converted from ms to s
1792    }
1793
1794    #[tokio::test]
1795    async fn test_get_transaction_tron_error() {
1796        let mut server = mockito::Server::new_async().await;
1797        let _mock = server
1798            .mock(
1799                "GET",
1800                mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1801            )
1802            .with_status(200)
1803            .with_header("content-type", "application/json")
1804            .with_body(r#"{"data": [], "success": false, "error": "Transaction not found"}"#)
1805            .create_async()
1806            .await;
1807
1808        let client = TronClient::with_api_url(&server.url());
1809        let result = client.get_transaction(VALID_TX_HASH).await;
1810        assert!(result.is_err());
1811    }
1812
1813    #[tokio::test]
1814    async fn test_get_transactions_tron_success() {
1815        let mut server = mockito::Server::new_async().await;
1816        let _mock = server
1817            .mock(
1818                "GET",
1819                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1820            )
1821            .with_status(200)
1822            .with_header("content-type", "application/json")
1823            .with_body(
1824                r#"{"data": [
1825                {
1826                    "txID": "aaa12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1827                    "block_number": 50000001,
1828                    "block_timestamp": 1700000003000,
1829                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 1000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"}}}]},
1830                    "ret": [{"contractRet": "SUCCESS"}]
1831                },
1832                {
1833                    "txID": "bbb12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1834                    "block_number": 50000002,
1835                    "block_timestamp": 1700000006000,
1836                    "raw_data": {"contract": [{"parameter": {"value": {"amount": 2000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"}}}]},
1837                    "ret": [{"contractRet": "SUCCESS"}]
1838                }
1839            ], "success": true}"#,
1840            )
1841            .create_async()
1842            .await;
1843
1844        let client = TronClient::with_api_url(&server.url());
1845        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1846        assert_eq!(txs.len(), 2);
1847    }
1848
1849    #[tokio::test]
1850    async fn test_get_transactions_tron_error() {
1851        let mut server = mockito::Server::new_async().await;
1852        let _mock = server
1853            .mock(
1854                "GET",
1855                mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1856            )
1857            .with_status(200)
1858            .with_header("content-type", "application/json")
1859            .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1860            .create_async()
1861            .await;
1862
1863        let client = TronClient::with_api_url(&server.url());
1864        let result = client.get_transactions(VALID_ADDRESS, 10).await;
1865        assert!(result.is_err());
1866    }
1867
1868    #[tokio::test]
1869    async fn test_get_balance_error_response() {
1870        let mut server = mockito::Server::new_async().await;
1871        let _mock = server
1872            .mock(
1873                "GET",
1874                mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1875            )
1876            .with_status(200)
1877            .with_header("content-type", "application/json")
1878            .with_body(r#"{"data": [], "success": false, "error": "Account not found"}"#)
1879            .create_async()
1880            .await;
1881
1882        let client = TronClient::with_api_url(&server.url());
1883        let result = client.get_balance(VALID_ADDRESS).await;
1884        assert!(result.is_err());
1885        assert!(
1886            result
1887                .unwrap_err()
1888                .to_string()
1889                .contains("Account not found")
1890        );
1891    }
1892}