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