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