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