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 #[deprecated(
171 since = "0.8.0",
172 note = "pass the transport: use `TronClient::new_with_http`"
173 )]
174 pub fn new(config: &ChainsConfig) -> Result<Self> {
175 Self::new_with_http(config, crate::http::shared::fallback())
176 }
177
178 pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
180 let api_url = config
181 .tron_api
182 .as_deref()
183 .unwrap_or(DEFAULT_TRON_API)
184 .to_string();
185
186 Ok(Self {
187 http,
188 api_url,
189 api_key: config.api_keys.get("tronscan").cloned(),
190 })
191 }
192
193 pub fn with_api_url(api_url: &str) -> Self {
199 Self {
200 http: crate::http::shared::fallback(),
201 api_url: api_url.to_string(),
202 api_key: None,
203 }
204 }
205
206 pub fn chain_name(&self) -> &str {
208 "tron"
209 }
210
211 pub fn native_token_symbol(&self) -> &str {
213 "TRX"
214 }
215
216 pub async fn get_balance(&self, address: &str) -> Result<Balance> {
231 validate_tron_address(address)?;
233
234 let url = format!("{}/v1/accounts/{}", self.api_url, address);
235
236 tracing::debug!(url = %url, address = %address, "Fetching Tron balance");
237
238 let mut req = Request::get(&url);
239 if let Some(ref key) = self.api_key {
240 req = req.with_header("TRON-PRO-API-KEY", key);
241 }
242
243 let response: AccountResponse = self.http.send(req).await?.json()?;
244
245 if !response.success {
246 return Err(ScopeError::Chain(format!(
247 "TronGrid API error: {}",
248 response.error.unwrap_or_else(|| "Unknown error".into())
249 )));
250 }
251
252 let sun = response.data.first().and_then(|d| d.balance).unwrap_or(0);
254
255 let trx = sun as f64 / 10_f64.powi(TRX_DECIMALS as i32);
256
257 Ok(Balance {
258 raw: sun.to_string(),
259 formatted: format!("{:.6} TRX", trx),
260 decimals: TRX_DECIMALS,
261 symbol: "TRX".to_string(),
262 usd_value: None, })
264 }
265
266 pub async fn get_trc20_balances(&self, address: &str) -> Result<Vec<Trc20TokenBalance>> {
271 validate_tron_address(address)?;
272
273 let url = format!("{}/v1/accounts/{}", self.api_url, address);
274
275 tracing::debug!(url = %url, "Fetching TRC-20 token balances");
276
277 let mut req = Request::get(&url);
278 if let Some(ref key) = self.api_key {
279 req = req.with_header("TRON-PRO-API-KEY", key);
280 }
281
282 let response: AccountResponse = self.http.send(req).await?.json()?;
283
284 if !response.success {
285 return Err(ScopeError::Chain(format!(
286 "TronGrid API error: {}",
287 response.error.unwrap_or_else(|| "Unknown error".into())
288 )));
289 }
290
291 let account = match response.data.first() {
292 Some(data) => data,
293 None => return Ok(vec![]),
294 };
295
296 let mut balances = Vec::new();
297 for trc20 in &account.trc20 {
298 for (contract_address, raw_balance) in &trc20.balances {
299 if raw_balance == "0" {
301 continue;
302 }
303 balances.push(Trc20TokenBalance {
304 contract_address: contract_address.clone(),
305 raw_balance: raw_balance.clone(),
306 });
307 }
308 }
309
310 Ok(balances)
311 }
312
313 pub async fn get_token_info(&self, contract_address: &str) -> Result<Token> {
317 validate_tron_address(contract_address)?;
318
319 let url = format!(
320 "{}/api/token_trc20?contract={}&showAll=1",
321 TRONSCAN_API, contract_address
322 );
323
324 tracing::debug!(url = %url, "Fetching TRC-20 token info via Tronscan");
325
326 let mut req = Request::get(&url);
327 if let Some(ref key) = self.api_key {
328 req = req.with_header("TRON-PRO-API-KEY", key);
329 }
330
331 let resp = self.http.send(req).await?;
332 let json: serde_json::Value = serde_json::from_str(&resp.body)
333 .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
334
335 let tokens = json
336 .get("trc20_tokens")
337 .and_then(|v| v.as_array())
338 .ok_or_else(|| {
339 ScopeError::NotFound(format!(
340 "No token info found for TRC-20 contract {}",
341 contract_address
342 ))
343 })?;
344
345 let token_data = tokens.first().ok_or_else(|| {
346 ScopeError::NotFound(format!(
347 "No token info found for TRC-20 contract {}",
348 contract_address
349 ))
350 })?;
351
352 let symbol = token_data
353 .get("symbol")
354 .and_then(|v| v.as_str())
355 .unwrap_or("UNKNOWN")
356 .to_string();
357 let name = token_data
358 .get("contract_name")
359 .or_else(|| token_data.get("name"))
360 .and_then(|v| v.as_str())
361 .unwrap_or("Unknown Token")
362 .to_string();
363 let decimals = token_data
364 .get("decimals")
365 .and_then(|v| v.as_u64())
366 .unwrap_or(6) as u8;
367
368 Ok(Token {
369 contract_address: contract_address.to_string(),
370 symbol,
371 name,
372 decimals,
373 })
374 }
375
376 pub async fn get_token_holders(
380 &self,
381 contract_address: &str,
382 limit: u32,
383 ) -> Result<Vec<TokenHolder>> {
384 validate_tron_address(contract_address)?;
385
386 let effective_limit = limit.min(100);
387 let url = format!(
388 "{}/api/token_trc20/holders?contract_address={}&start=0&limit={}",
389 TRONSCAN_API, contract_address, effective_limit
390 );
391
392 tracing::debug!(url = %url, "Fetching TRC-20 token holders via Tronscan");
393
394 let mut req = Request::get(&url);
395 if let Some(ref key) = self.api_key {
396 req = req.with_header("TRON-PRO-API-KEY", key);
397 }
398
399 let resp = self.http.send(req).await?;
400 let json: serde_json::Value = serde_json::from_str(&resp.body)
401 .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan holders: {}", e)))?;
402
403 let holders_data: &[serde_json::Value] = json
404 .get("trc20_tokens")
405 .and_then(|v| v.as_array())
406 .map(|v| v.as_slice())
407 .unwrap_or(&[]);
408
409 let token_info = self.get_token_info(contract_address).await;
411 let decimals = token_info.as_ref().map(|t| t.decimals).unwrap_or(6);
412
413 let total_balance: f64 = holders_data
415 .iter()
416 .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
417 .filter_map(|s| s.parse::<f64>().ok())
418 .sum();
419
420 let token_holders: Vec<TokenHolder> = holders_data
421 .iter()
422 .enumerate()
423 .filter_map(|(i, h)| {
424 let holder_address = h.get("holder_address")?.as_str()?.to_string();
425 let balance_raw = h.get("balance")?.as_str()?.to_string();
426 let balance: f64 = balance_raw.parse().ok()?;
427 let percentage = if total_balance > 0.0 {
428 (balance / total_balance) * 100.0
429 } else {
430 0.0
431 };
432 let divisor = 10_f64.powi(decimals as i32);
433 let formatted = format!("{:.6}", balance / divisor);
434
435 Some(TokenHolder {
436 address: holder_address,
437 balance: balance_raw,
438 formatted_balance: formatted,
439 percentage,
440 rank: (i + 1) as u32,
441 })
442 })
443 .collect();
444
445 Ok(token_holders)
446 }
447
448 pub async fn get_token_holder_count(&self, contract_address: &str) -> Result<u64> {
450 validate_tron_address(contract_address)?;
451
452 let url = format!(
453 "{}/api/token_trc20/holders?contract_address={}&start=0&limit=1",
454 TRONSCAN_API, contract_address
455 );
456
457 let mut req = Request::get(&url);
458 if let Some(ref key) = self.api_key {
459 req = req.with_header("TRON-PRO-API-KEY", key);
460 }
461
462 let resp = self.http.send(req).await?;
463 let json: serde_json::Value = resp
464 .json()
465 .map_err(|e| ScopeError::Api(format!("Failed to parse Tronscan response: {}", e)))?;
466
467 let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
468
469 Ok(count)
470 }
471
472 pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
477 let url = DEXSCREENER_TRX_SEARCH;
479 if let Ok(resp) = self.http.send(Request::get(url)).await
480 && let Ok(search_result) = serde_json::from_str::<DexSearchResponse>(&resp.body)
481 && let Some(pairs) = search_result.pairs
482 {
483 for pair in &pairs {
484 if (pair.base_token_symbol.as_deref() == Some("TRX")
485 || pair.base_token_symbol.as_deref() == Some("WTRX"))
486 && let Some(price) = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok())
487 {
488 let sun: f64 = balance.raw.parse().unwrap_or(0.0);
489 let trx = sun / 10_f64.powi(TRX_DECIMALS as i32);
490 balance.usd_value = Some(trx * price);
491 return;
492 }
493 }
494 }
495 }
496
497 pub async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
507 validate_tron_tx_hash(hash)?;
509
510 let url = format!("{}/v1/transactions/{}", self.api_url, hash);
511
512 tracing::debug!(url = %url, hash = %hash, "Fetching Tron transaction");
513
514 let mut req = Request::get(&url);
515 if let Some(ref key) = self.api_key {
516 req = req.with_header("TRON-PRO-API-KEY", key);
517 }
518
519 let response: TransactionListResponse = self.http.send(req).await?.json()?;
520
521 if !response.success {
522 return Err(ScopeError::Chain(format!(
523 "TronGrid API error: {}",
524 response.error.unwrap_or_else(|| "Unknown error".into())
525 )));
526 }
527
528 let tx = response
529 .data
530 .into_iter()
531 .next()
532 .ok_or_else(|| ScopeError::Chain("Transaction not found".into()))?;
533
534 let (from, to, value) = tx
536 .raw_data
537 .and_then(|rd| rd.contract)
538 .and_then(|contracts| contracts.into_iter().next())
539 .and_then(|c| c.parameter)
540 .and_then(|p| p.value)
541 .map(|v| {
542 (
543 v.owner_address.unwrap_or_default(),
544 v.to_address,
545 v.amount.unwrap_or(0).to_string(),
546 )
547 })
548 .unwrap_or_else(|| (String::new(), None, "0".to_string()));
549
550 let status = tx
551 .ret
552 .and_then(|r| r.into_iter().next())
553 .and_then(|r| r.contract_ret)
554 .map(|s| s == "SUCCESS");
555
556 Ok(Transaction {
557 hash: tx.tx_id,
558 block_number: tx.block_number,
559 timestamp: tx.block_timestamp.map(|t| t / 1000), from,
561 to,
562 value,
563 gas_limit: 0, gas_used: None,
565 gas_price: "0".to_string(),
566 nonce: 0,
567 input: String::new(),
568 status,
569 })
570 }
571
572 pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
583 validate_tron_address(address)?;
584
585 let url = format!(
586 "{}/v1/accounts/{}/transactions?limit={}",
587 self.api_url, address, limit
588 );
589
590 tracing::debug!(url = %url, address = %address, "Fetching Tron transactions");
591
592 let mut req = Request::get(&url);
593 if let Some(ref key) = self.api_key {
594 req = req.with_header("TRON-PRO-API-KEY", key);
595 }
596
597 let response: TransactionListResponse = self.http.send(req).await?.json()?;
598
599 if !response.success {
600 return Err(ScopeError::Chain(format!(
601 "TronGrid API error: {}",
602 response.error.unwrap_or_else(|| "Unknown error".into())
603 )));
604 }
605
606 let transactions = response
607 .data
608 .into_iter()
609 .map(|tx| {
610 let (from, to, value) = tx
611 .raw_data
612 .and_then(|rd| rd.contract)
613 .and_then(|contracts| contracts.into_iter().next())
614 .and_then(|c| c.parameter)
615 .and_then(|p| p.value)
616 .map(|v| {
617 (
618 v.owner_address.unwrap_or_default(),
619 v.to_address,
620 v.amount.unwrap_or(0).to_string(),
621 )
622 })
623 .unwrap_or_else(|| (String::new(), None, "0".to_string()));
624
625 let status = tx
626 .ret
627 .and_then(|r| r.into_iter().next())
628 .and_then(|r| r.contract_ret)
629 .map(|s| s == "SUCCESS");
630
631 Transaction {
632 hash: tx.tx_id,
633 block_number: tx.block_number,
634 timestamp: tx.block_timestamp.map(|t| t / 1000),
635 from,
636 to,
637 value,
638 gas_limit: 0,
639 gas_used: None,
640 gas_price: "0".to_string(),
641 nonce: 0,
642 input: String::new(),
643 status,
644 }
645 })
646 .collect();
647
648 Ok(transactions)
649 }
650
651 pub async fn get_block_number(&self) -> Result<u64> {
653 let url = format!("{}/wallet/getnowblock", self.api_url);
654
655 #[derive(Deserialize)]
656 struct BlockResponse {
657 block_header: Option<BlockHeader>,
658 }
659
660 #[derive(Deserialize)]
661 struct BlockHeader {
662 raw_data: Option<BlockRawData>,
663 }
664
665 #[derive(Deserialize)]
666 struct BlockRawData {
667 number: Option<u64>,
668 }
669
670 let resp = self.http.send(Request::post_json(&url, "")).await?;
671 let response: BlockResponse = resp.json()?;
672
673 response
674 .block_header
675 .and_then(|h| h.raw_data)
676 .and_then(|d| d.number)
677 .ok_or_else(|| ScopeError::Chain("Invalid block response".into()))
678 }
679}
680
681impl Default for TronClient {
682 fn default() -> Self {
683 Self {
684 http: crate::http::shared::fallback(),
685 api_url: DEFAULT_TRON_API.to_string(),
686 api_key: None,
687 }
688 }
689}
690
691#[derive(Debug, Clone)]
704pub struct Trc20TokenBalance {
705 pub contract_address: String,
707 pub raw_balance: String,
709}
710
711#[derive(Debug, Deserialize)]
713struct DexSearchResponse {
714 #[serde(default)]
715 pairs: Option<Vec<DexSearchPair>>,
716}
717
718#[derive(Debug, Deserialize)]
720#[serde(rename_all = "camelCase")]
721struct DexSearchPair {
722 #[serde(default)]
723 base_token_symbol: Option<String>,
724 #[serde(default)]
725 price_usd: Option<String>,
726}
727
728pub fn validate_tron_address(address: &str) -> Result<()> {
732 if address.is_empty() {
733 return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
734 }
735
736 if !address.starts_with('T') {
738 return Err(ScopeError::InvalidAddress(format!(
739 "Tron address must start with 'T': {}",
740 address
741 )));
742 }
743
744 if address.len() != 34 {
746 return Err(ScopeError::InvalidAddress(format!(
747 "Tron address must be 34 characters, got {}: {}",
748 address.len(),
749 address
750 )));
751 }
752
753 match bs58::decode(address).into_vec() {
755 Ok(bytes) => {
756 if bytes.len() != 25 {
758 return Err(ScopeError::InvalidAddress(format!(
759 "Tron address must decode to 25 bytes, got {}: {}",
760 bytes.len(),
761 address
762 )));
763 }
764
765 if bytes[0] != 0x41 {
767 return Err(ScopeError::InvalidAddress(format!(
768 "Invalid Tron address prefix: {}",
769 address
770 )));
771 }
772
773 let payload = &bytes[0..21];
775 let hash1 = Sha256::digest(payload);
776 let hash2 = Sha256::digest(hash1);
777 let expected_checksum = &hash2[0..4];
778 let actual_checksum = &bytes[21..25];
779
780 if expected_checksum != actual_checksum {
781 return Err(ScopeError::InvalidAddress(format!(
782 "Invalid Tron address checksum: {}",
783 address
784 )));
785 }
786 }
787 Err(e) => {
788 return Err(ScopeError::InvalidAddress(format!(
789 "Invalid base58 encoding: {}: {}",
790 e, address
791 )));
792 }
793 }
794
795 Ok(())
796}
797
798pub fn validate_tron_tx_hash(hash: &str) -> Result<()> {
810 if hash.is_empty() {
811 return Err(ScopeError::InvalidHash("Hash cannot be empty".into()));
812 }
813
814 if hash.len() != 64 {
816 return Err(ScopeError::InvalidHash(format!(
817 "Tron transaction hash must be 64 characters, got {}: {}",
818 hash.len(),
819 hash
820 )));
821 }
822
823 if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
825 return Err(ScopeError::InvalidHash(format!(
826 "Tron hash contains invalid hex characters: {}",
827 hash
828 )));
829 }
830
831 Ok(())
832}
833
834#[async_trait]
839impl ChainClient for TronClient {
840 fn chain_name(&self) -> &str {
841 "tron"
842 }
843
844 fn native_token_symbol(&self) -> &str {
845 "TRX"
846 }
847
848 async fn get_balance(&self, address: &str) -> Result<Balance> {
849 self.get_balance(address).await
850 }
851
852 async fn enrich_balance_usd(&self, balance: &mut Balance) {
853 self.enrich_balance_usd(balance).await
854 }
855
856 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
857 self.get_transaction(hash).await
858 }
859
860 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
861 self.get_transactions(address, limit).await
862 }
863
864 async fn get_block_number(&self) -> Result<u64> {
865 self.get_block_number().await
866 }
867
868 async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
869 let trc20_balances = self.get_trc20_balances(address).await?;
870 let mut result = Vec::with_capacity(trc20_balances.len());
871
872 for tb in trc20_balances {
873 let token = match self.get_token_info(&tb.contract_address).await {
874 Ok(info) => info,
875 Err(e) => {
876 tracing::debug!(
877 contract = %tb.contract_address,
878 error = %e,
879 "Could not fetch TRC-20 token info, using placeholder"
880 );
881 Token {
882 contract_address: tb.contract_address.clone(),
883 symbol: "TRC20".to_string(),
884 name: "TRC-20 Token".to_string(),
885 decimals: 6, }
887 }
888 };
889
890 let raw: f64 = tb.raw_balance.parse().unwrap_or(0.0);
891 let divisor = 10_f64.powi(token.decimals as i32);
892 let formatted = format!("{:.6}", raw / divisor);
893
894 result.push(crate::chains::TokenBalance {
895 token,
896 balance: tb.raw_balance,
897 formatted_balance: formatted,
898 usd_value: None,
899 });
900 }
901
902 Ok(result)
903 }
904
905 async fn get_token_info(&self, address: &str) -> Result<Token> {
906 self.get_token_info(address).await
907 }
908
909 async fn get_token_holders(&self, address: &str, limit: u32) -> Result<Vec<TokenHolder>> {
910 self.get_token_holders(address, limit).await
911 }
912
913 async fn get_token_holder_count(&self, address: &str) -> Result<u64> {
914 self.get_token_holder_count(address).await
915 }
916}
917
918#[cfg(test)]
923mod tests {
924 use super::*;
925
926 const VALID_ADDRESS: &str = "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf";
928
929 const VALID_TX_HASH: &str = "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
931
932 #[test]
933 fn test_validate_tron_address_valid() {
934 assert!(validate_tron_address(VALID_ADDRESS).is_ok());
935 }
936
937 #[test]
938 fn test_validate_tron_address_empty() {
939 let result = validate_tron_address("");
940 assert!(result.is_err());
941 assert!(result.unwrap_err().to_string().contains("empty"));
942 }
943
944 #[test]
945 fn test_validate_tron_address_wrong_prefix() {
946 let result = validate_tron_address("ADqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
947 assert!(result.is_err());
948 assert!(result.unwrap_err().to_string().contains("start with 'T'"));
949 }
950
951 #[test]
952 fn test_validate_tron_address_too_short() {
953 let result = validate_tron_address("TDqSquXBgUCLYvYC4XZ");
954 assert!(result.is_err());
955 assert!(result.unwrap_err().to_string().contains("34 characters"));
956 }
957
958 #[test]
959 fn test_validate_tron_address_too_long() {
960 let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCfAAAA");
961 assert!(result.is_err());
962 assert!(result.unwrap_err().to_string().contains("34 characters"));
963 }
964
965 #[test]
966 fn test_validate_tron_address_invalid_base58() {
967 let result = validate_tron_address("T0qSquXBgUCLYvYC4XZgrprLK589dkhSCf");
969 assert!(result.is_err());
970 assert!(result.unwrap_err().to_string().contains("base58"));
971 }
972
973 #[test]
974 fn test_validate_tron_tx_hash_valid() {
975 assert!(validate_tron_tx_hash(VALID_TX_HASH).is_ok());
976 }
977
978 #[test]
979 fn test_validate_tron_tx_hash_empty() {
980 let result = validate_tron_tx_hash("");
981 assert!(result.is_err());
982 assert!(result.unwrap_err().to_string().contains("empty"));
983 }
984
985 #[test]
986 fn test_validate_tron_tx_hash_too_short() {
987 let result = validate_tron_tx_hash("b3c12d62ad7e7b8b83b09a68");
988 assert!(result.is_err());
989 assert!(result.unwrap_err().to_string().contains("64 characters"));
990 }
991
992 #[test]
993 fn test_validate_tron_tx_hash_invalid_hex() {
994 let hash = "g3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b";
995 let result = validate_tron_tx_hash(hash);
996 assert!(result.is_err());
997 assert!(result.unwrap_err().to_string().contains("invalid hex"));
998 }
999
1000 #[test]
1001 fn test_tron_client_default() {
1002 let client = TronClient::default();
1003 assert_eq!(client.chain_name(), "tron");
1004 assert_eq!(client.native_token_symbol(), "TRX");
1005 assert!(client.api_url.contains("trongrid"));
1006 }
1007
1008 #[test]
1009 fn test_tron_client_with_api_url() {
1010 let client = TronClient::with_api_url("https://custom.tron.api");
1011 assert_eq!(client.api_url, "https://custom.tron.api");
1012 }
1013
1014 #[test]
1015 fn test_tron_client_new() {
1016 let config = ChainsConfig::default();
1017 let client = TronClient::new_with_http(&config, crate::http::test_transport());
1018 assert!(client.is_ok());
1019 }
1020
1021 #[test]
1022 fn test_tron_client_new_with_custom_api() {
1023 let config = ChainsConfig {
1024 tron_api: Some("https://my-tron-api.com".to_string()),
1025 ..Default::default()
1026 };
1027 let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1028 assert_eq!(client.api_url, "https://my-tron-api.com");
1029 }
1030
1031 #[test]
1032 fn test_tron_client_new_with_api_key() {
1033 use std::collections::HashMap;
1034
1035 let mut api_keys = HashMap::new();
1036 api_keys.insert("tronscan".to_string(), "test-key".to_string());
1037
1038 let config = ChainsConfig {
1039 api_keys,
1040 ..Default::default()
1041 };
1042
1043 let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1044 assert_eq!(client.api_key, Some("test-key".to_string()));
1045 }
1046
1047 #[test]
1048 fn test_account_response_deserialization() {
1049 let json = r#"{
1050 "data": [{
1051 "balance": 1000000,
1052 "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1053 "create_time": 1600000000000,
1054 "trc20": []
1055 }],
1056 "success": true
1057 }"#;
1058
1059 let response: AccountResponse = serde_json::from_str(json).unwrap();
1060 assert!(response.success);
1061 assert_eq!(response.data.len(), 1);
1062 assert_eq!(response.data[0].balance, Some(1_000_000));
1063 }
1064
1065 #[test]
1066 fn test_transaction_response_deserialization() {
1067 let json = r#"{
1068 "data": [{
1069 "txID": "abc123",
1070 "block_number": 12345,
1071 "block_timestamp": 1600000000000,
1072 "ret": [{"contractRet": "SUCCESS"}]
1073 }],
1074 "success": true
1075 }"#;
1076
1077 let response: TransactionListResponse = serde_json::from_str(json).unwrap();
1078 assert!(response.success);
1079 assert_eq!(response.data.len(), 1);
1080 assert_eq!(response.data[0].tx_id, "abc123");
1081 }
1082
1083 #[tokio::test]
1088 async fn test_get_balance() {
1089 let mut server = mockito::Server::new_async().await;
1090 let _mock = server
1091 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1092 .with_status(200)
1093 .with_header("content-type", "application/json")
1094 .with_body(r#"{
1095 "data": [{"balance": 5000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}],
1096 "success": true
1097 }"#)
1098 .create_async()
1099 .await;
1100
1101 let client = TronClient::with_api_url(&server.url());
1102 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1103 assert_eq!(balance.raw, "5000000");
1104 assert_eq!(balance.symbol, "TRX");
1105 assert!(balance.formatted.contains("5.000000"));
1106 }
1107
1108 #[tokio::test]
1109 async fn test_get_balance_new_account() {
1110 let mut server = mockito::Server::new_async().await;
1111 let _mock = server
1112 .mock(
1113 "GET",
1114 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1115 )
1116 .with_status(200)
1117 .with_header("content-type", "application/json")
1118 .with_body(r#"{"data": [], "success": true}"#)
1119 .create_async()
1120 .await;
1121
1122 let client = TronClient::with_api_url(&server.url());
1123 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1124 assert_eq!(balance.raw, "0");
1125 assert!(balance.formatted.contains("0.000000"));
1126 }
1127
1128 #[tokio::test]
1129 async fn test_get_balance_api_error() {
1130 let mut server = mockito::Server::new_async().await;
1131 let _mock = server
1132 .mock(
1133 "GET",
1134 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1135 )
1136 .with_status(200)
1137 .with_header("content-type", "application/json")
1138 .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1139 .create_async()
1140 .await;
1141
1142 let client = TronClient::with_api_url(&server.url());
1143 let result = client.get_balance(VALID_ADDRESS).await;
1144 assert!(result.is_err());
1145 assert!(result.unwrap_err().to_string().contains("Rate limit"));
1146 }
1147
1148 #[tokio::test]
1149 async fn test_get_balance_invalid_address() {
1150 let client = TronClient::default();
1151 let result = client.get_balance("invalid").await;
1152 assert!(result.is_err());
1153 }
1154
1155 #[tokio::test]
1156 async fn test_get_transaction() {
1157 let mut server = mockito::Server::new_async().await;
1158 let _mock = server
1159 .mock(
1160 "GET",
1161 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1162 )
1163 .with_status(200)
1164 .with_header("content-type", "application/json")
1165 .with_body(
1166 r#"{
1167 "data": [{
1168 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1169 "block_number": 50000000,
1170 "block_timestamp": 1700000000000,
1171 "raw_data": {
1172 "contract": [{
1173 "parameter": {
1174 "value": {
1175 "amount": 1000000,
1176 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1177 "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
1178 }
1179 },
1180 "type": "TransferContract"
1181 }]
1182 },
1183 "ret": [{"contractRet": "SUCCESS"}]
1184 }],
1185 "success": true
1186 }"#,
1187 )
1188 .create_async()
1189 .await;
1190
1191 let client = TronClient::with_api_url(&server.url());
1192 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1193 assert_eq!(tx.hash, VALID_TX_HASH);
1194 assert_eq!(tx.from, "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
1195 assert_eq!(
1196 tx.to,
1197 Some("TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9".to_string())
1198 );
1199 assert_eq!(tx.value, "1000000");
1200 assert_eq!(tx.block_number, Some(50000000));
1201 assert_eq!(tx.timestamp, Some(1700000000)); assert!(tx.status.unwrap());
1203 }
1204
1205 #[tokio::test]
1206 async fn test_get_transaction_failed() {
1207 let mut server = mockito::Server::new_async().await;
1208 let _mock = server
1209 .mock(
1210 "GET",
1211 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1212 )
1213 .with_status(200)
1214 .with_header("content-type", "application/json")
1215 .with_body(
1216 r#"{
1217 "data": [{
1218 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1219 "block_number": 50000000,
1220 "block_timestamp": 1700000000000,
1221 "ret": [{"contractRet": "REVERT"}]
1222 }],
1223 "success": true
1224 }"#,
1225 )
1226 .create_async()
1227 .await;
1228
1229 let client = TronClient::with_api_url(&server.url());
1230 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1231 assert!(!tx.status.unwrap()); }
1233
1234 #[tokio::test]
1235 async fn test_get_transaction_not_found() {
1236 let mut server = mockito::Server::new_async().await;
1237 let _mock = server
1238 .mock(
1239 "GET",
1240 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1241 )
1242 .with_status(200)
1243 .with_header("content-type", "application/json")
1244 .with_body(r#"{"data": [], "success": true}"#)
1245 .create_async()
1246 .await;
1247
1248 let client = TronClient::with_api_url(&server.url());
1249 let result = client.get_transaction(VALID_TX_HASH).await;
1250 assert!(result.is_err());
1251 }
1252
1253 #[tokio::test]
1254 async fn test_get_transactions() {
1255 let mut server = mockito::Server::new_async().await;
1256 let _mock = server
1257 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()))
1258 .with_status(200)
1259 .with_header("content-type", "application/json")
1260 .with_body(r#"{
1261 "data": [
1262 {
1263 "txID": "aaa111",
1264 "block_number": 50000000,
1265 "block_timestamp": 1700000000000,
1266 "raw_data": {"contract": [{"parameter": {"value": {"amount": 500000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"}}, "type": "TransferContract"}]},
1267 "ret": [{"contractRet": "SUCCESS"}]
1268 },
1269 {
1270 "txID": "bbb222",
1271 "block_number": 50000001,
1272 "block_timestamp": 1700000060000,
1273 "ret": [{"contractRet": "SUCCESS"}]
1274 }
1275 ],
1276 "success": true
1277 }"#)
1278 .create_async()
1279 .await;
1280
1281 let client = TronClient::with_api_url(&server.url());
1282 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1283 assert_eq!(txs.len(), 2);
1284 assert_eq!(txs[0].hash, "aaa111");
1285 assert_eq!(txs[0].value, "500000");
1286 assert!(txs[0].status.unwrap());
1287 assert_eq!(txs[1].value, "0");
1289 }
1290
1291 #[tokio::test]
1292 async fn test_get_transactions_error() {
1293 let mut server = mockito::Server::new_async().await;
1294 let _mock = server
1295 .mock(
1296 "GET",
1297 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1298 )
1299 .with_status(200)
1300 .with_header("content-type", "application/json")
1301 .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1302 .create_async()
1303 .await;
1304
1305 let client = TronClient::with_api_url(&server.url());
1306 let result = client.get_transactions(VALID_ADDRESS, 10).await;
1307 assert!(result.is_err());
1308 }
1309
1310 #[tokio::test]
1311 async fn test_get_trc20_balances() {
1312 let mut server = mockito::Server::new_async().await;
1313 let _mock = server
1314 .mock(
1315 "GET",
1316 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1317 )
1318 .with_status(200)
1319 .with_header("content-type", "application/json")
1320 .with_body(
1321 r#"{
1322 "data": [{
1323 "balance": 1000000,
1324 "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1325 "trc20": [
1326 {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"},
1327 {"TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8": "0"}
1328 ]
1329 }],
1330 "success": true
1331 }"#,
1332 )
1333 .create_async()
1334 .await;
1335
1336 let client = TronClient::with_api_url(&server.url());
1337 let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1338 assert_eq!(balances.len(), 1);
1340 assert_eq!(
1341 balances[0].contract_address,
1342 "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
1343 );
1344 assert_eq!(balances[0].raw_balance, "5000000");
1345 }
1346
1347 #[tokio::test]
1348 async fn test_get_trc20_balances_empty_account() {
1349 let mut server = mockito::Server::new_async().await;
1350 let _mock = server
1351 .mock(
1352 "GET",
1353 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1354 )
1355 .with_status(200)
1356 .with_header("content-type", "application/json")
1357 .with_body(r#"{"data": [], "success": true}"#)
1358 .create_async()
1359 .await;
1360
1361 let client = TronClient::with_api_url(&server.url());
1362 let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1363 assert!(balances.is_empty());
1364 }
1365
1366 #[tokio::test]
1367 async fn test_get_block_number() {
1368 let mut server = mockito::Server::new_async().await;
1369 let _mock = server
1370 .mock("POST", "/wallet/getnowblock")
1371 .with_status(200)
1372 .with_header("content-type", "application/json")
1373 .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1374 .create_async()
1375 .await;
1376
1377 let client = TronClient::with_api_url(&server.url());
1378 let block = client.get_block_number().await.unwrap();
1379 assert_eq!(block, 60000000);
1380 }
1381
1382 #[tokio::test]
1383 async fn test_get_block_number_invalid_response() {
1384 let mut server = mockito::Server::new_async().await;
1385 let _mock = server
1386 .mock("POST", "/wallet/getnowblock")
1387 .with_status(200)
1388 .with_header("content-type", "application/json")
1389 .with_body(r#"{}"#)
1390 .create_async()
1391 .await;
1392
1393 let client = TronClient::with_api_url(&server.url());
1394 let result = client.get_block_number().await;
1395 assert!(result.is_err());
1396 }
1397
1398 #[test]
1399 fn test_validate_tron_address_wrong_decoded_length() {
1400 let result = validate_tron_address("TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT1");
1402 assert!(result.is_err());
1403 }
1404
1405 #[test]
1406 fn test_validate_tron_tx_hash_wrong_length() {
1407 let result = validate_tron_tx_hash("abc123");
1408 assert!(result.is_err());
1409 assert!(result.unwrap_err().to_string().contains("64 characters"));
1410 }
1411
1412 #[tokio::test]
1413 async fn test_get_transaction_success() {
1414 let mut server = mockito::Server::new_async().await;
1415 let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1416 let _mock = server
1417 .mock(
1418 "GET",
1419 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1420 )
1421 .with_status(200)
1422 .with_header("content-type", "application/json")
1423 .with_body(
1424 r#"{"data":[{
1425 "txID":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1426 "blockNumber":60000000,
1427 "block_timestamp":1700000000000,
1428 "raw_data":{"contract":[{"parameter":{"value":{
1429 "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1430 "to_address":"TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn",
1431 "amount":1000000
1432 }}}]},
1433 "ret":[{"contractRet":"SUCCESS"}]
1434 }],"success":true}"#,
1435 )
1436 .create_async()
1437 .await;
1438
1439 let client = TronClient::with_api_url(&server.url());
1440 let tx = client.get_transaction(valid_hash).await.unwrap();
1441 assert_eq!(tx.hash, valid_hash);
1442 assert_eq!(tx.status, Some(true));
1443 }
1444
1445 #[tokio::test]
1446 async fn test_get_transaction_api_error() {
1447 let mut server = mockito::Server::new_async().await;
1448 let valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1449 let _mock = server
1450 .mock(
1451 "GET",
1452 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1453 )
1454 .with_status(200)
1455 .with_header("content-type", "application/json")
1456 .with_body(r#"{"data":[],"success":false,"error":"Transaction not found"}"#)
1457 .create_async()
1458 .await;
1459
1460 let client = TronClient::with_api_url(&server.url());
1461 let result = client.get_transaction(valid_hash).await;
1462 assert!(result.is_err());
1463 }
1464
1465 #[tokio::test]
1466 async fn test_get_transactions_success() {
1467 let mut server = mockito::Server::new_async().await;
1468 let _mock = server
1469 .mock(
1470 "GET",
1471 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1472 )
1473 .with_status(200)
1474 .with_header("content-type", "application/json")
1475 .with_body(
1476 r#"{"data":[{
1477 "txID":"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
1478 "blockNumber":60000000,
1479 "block_timestamp":1700000000000,
1480 "raw_data":{"contract":[{"parameter":{"value":{
1481 "owner_address":"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1482 "amount":500000
1483 }}}]},
1484 "ret":[{"contractRet":"SUCCESS"}]
1485 }],"success":true}"#,
1486 )
1487 .create_async()
1488 .await;
1489
1490 let client = TronClient::with_api_url(&server.url());
1491 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1492 assert_eq!(txs.len(), 1);
1493 }
1494
1495 #[tokio::test]
1496 async fn test_tron_chain_client_trait_accessors() {
1497 let client = TronClient::with_api_url("http://localhost");
1498 let chain_client: &dyn ChainClient = &client;
1499 assert_eq!(chain_client.chain_name(), "tron");
1500 assert_eq!(chain_client.native_token_symbol(), "TRX");
1501 }
1502
1503 #[tokio::test]
1504 async fn test_chain_client_trait_get_balance() {
1505 let mut server = mockito::Server::new_async().await;
1506 let _mock = server
1507 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1508 .with_status(200)
1509 .with_header("content-type", "application/json")
1510 .with_body(r#"{"data": [{"balance": 1000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#)
1511 .create_async()
1512 .await;
1513
1514 let client = TronClient::with_api_url(&server.url());
1515 let chain_client: &dyn ChainClient = &client;
1516 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1517 assert_eq!(balance.symbol, "TRX");
1518 }
1519
1520 #[tokio::test]
1521 async fn test_chain_client_trait_get_block_number() {
1522 let mut server = mockito::Server::new_async().await;
1523 let _mock = server
1524 .mock("POST", "/wallet/getnowblock")
1525 .with_status(200)
1526 .with_header("content-type", "application/json")
1527 .with_body(r#"{"block_header":{"raw_data":{"number":60000000}}}"#)
1528 .create_async()
1529 .await;
1530
1531 let client = TronClient::with_api_url(&server.url());
1532 let chain_client: &dyn ChainClient = &client;
1533 let block = chain_client.get_block_number().await.unwrap();
1534 assert_eq!(block, 60000000);
1535 }
1536
1537 #[tokio::test]
1538 async fn test_chain_client_trait_get_token_balances() {
1539 let mut server = mockito::Server::new_async().await;
1540 let _mock = server
1541 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1542 .with_status(200)
1543 .with_header("content-type", "application/json")
1544 .with_body(r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "5000000"}]}], "success": true}"#)
1545 .create_async()
1546 .await;
1547
1548 let client = TronClient::with_api_url(&server.url());
1549 let chain_client: &dyn ChainClient = &client;
1550 let balances = chain_client
1551 .get_token_balances(VALID_ADDRESS)
1552 .await
1553 .unwrap();
1554 assert_eq!(balances.len(), 1);
1555 assert!(
1557 balances[0].token.symbol == "USDT" || balances[0].token.symbol == "TRC20",
1558 "symbol should be USDT (Tronscan) or TRC20 (fallback)"
1559 );
1560 assert!(!balances[0].token.name.is_empty(), "name must be set");
1563 }
1564
1565 #[tokio::test]
1566 async fn test_chain_client_trait_get_transaction_tron() {
1567 let mut server = mockito::Server::new_async().await;
1568 let _mock = server
1569 .mock(
1570 "GET",
1571 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1572 )
1573 .with_status(200)
1574 .with_header("content-type", "application/json")
1575 .with_body(
1576 r#"{"data": [{
1577 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1578 "block_number": 50000000,
1579 "block_timestamp": 1700000000000,
1580 "raw_data": {
1581 "contract": [{
1582 "parameter": {
1583 "value": {
1584 "amount": 1000000,
1585 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1586 "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1587 }
1588 },
1589 "type": "TransferContract"
1590 }]
1591 },
1592 "ret": [{"contractRet": "SUCCESS"}]
1593 }], "success": true}"#,
1594 )
1595 .create_async()
1596 .await;
1597
1598 let client = TronClient::with_api_url(&server.url());
1599 let chain_client: &dyn ChainClient = &client;
1600 let tx = chain_client.get_transaction(VALID_TX_HASH).await.unwrap();
1601 assert_eq!(tx.hash, VALID_TX_HASH);
1602 assert!(tx.status.unwrap());
1603 }
1604
1605 #[tokio::test]
1606 async fn test_chain_client_trait_get_transactions_tron() {
1607 let mut server = mockito::Server::new_async().await;
1608 let _mock = server
1609 .mock(
1610 "GET",
1611 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1612 )
1613 .with_status(200)
1614 .with_header("content-type", "application/json")
1615 .with_body(
1616 r#"{"data": [{
1617 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1618 "block_number": 50000000,
1619 "block_timestamp": 1700000000000,
1620 "raw_data": {
1621 "contract": [{
1622 "parameter": {
1623 "value": {
1624 "amount": 2000000,
1625 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1626 "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1627 }
1628 }
1629 }]
1630 },
1631 "ret": [{"contractRet": "REVERT"}]
1632 }], "success": true}"#,
1633 )
1634 .create_async()
1635 .await;
1636
1637 let client = TronClient::with_api_url(&server.url());
1638 let chain_client: &dyn ChainClient = &client;
1639 let txs = chain_client
1640 .get_transactions(VALID_ADDRESS, 10)
1641 .await
1642 .unwrap();
1643 assert_eq!(txs.len(), 1);
1644 assert!(!txs[0].status.unwrap()); }
1646
1647 #[tokio::test]
1648 async fn test_get_balance_with_api_key() {
1649 let mut server = mockito::Server::new_async().await;
1650 let _mock = server
1651 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1652 .with_status(200)
1653 .with_header("content-type", "application/json")
1654 .with_body(
1655 r#"{"data": [{"balance": 10000000, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": []}], "success": true}"#,
1656 )
1657 .create_async()
1658 .await;
1659
1660 let config = ChainsConfig {
1661 tron_api: Some(server.url()),
1662 api_keys: {
1663 let mut m = std::collections::HashMap::new();
1664 m.insert("tronscan".to_string(), "test-api-key".to_string());
1665 m
1666 },
1667 ..Default::default()
1668 };
1669 let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1670 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1671 assert_eq!(balance.symbol, "TRX");
1672 assert!(balance.formatted.contains("TRX"));
1673 }
1674
1675 #[tokio::test]
1676 async fn test_get_trc20_balances_error_response() {
1677 let mut server = mockito::Server::new_async().await;
1678 let _mock = server
1679 .mock(
1680 "GET",
1681 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1682 )
1683 .with_status(200)
1684 .with_header("content-type", "application/json")
1685 .with_body(r#"{"data": [], "success": false, "error": "Rate limit exceeded"}"#)
1686 .create_async()
1687 .await;
1688
1689 let client = TronClient::with_api_url(&server.url());
1690 let result = client.get_trc20_balances(VALID_ADDRESS).await;
1691 assert!(result.is_err());
1692 assert!(result.unwrap_err().to_string().contains("Rate limit"));
1693 }
1694
1695 #[tokio::test]
1696 async fn test_get_trc20_balances_no_data() {
1697 let mut server = mockito::Server::new_async().await;
1698 let _mock = server
1699 .mock(
1700 "GET",
1701 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1702 )
1703 .with_status(200)
1704 .with_header("content-type", "application/json")
1705 .with_body(r#"{"data": [], "success": true}"#)
1706 .create_async()
1707 .await;
1708
1709 let client = TronClient::with_api_url(&server.url());
1710 let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1711 assert!(balances.is_empty());
1712 }
1713
1714 #[tokio::test]
1715 async fn test_get_trc20_balances_with_api_key() {
1716 let mut server = mockito::Server::new_async().await;
1717 let _mock = server
1718 .mock("GET", mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()))
1719 .with_status(200)
1720 .with_header("content-type", "application/json")
1721 .with_body(
1722 r#"{"data": [{"balance": 0, "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "trc20": [{"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}]}], "success": true}"#,
1723 )
1724 .create_async()
1725 .await;
1726
1727 let config = ChainsConfig {
1728 tron_api: Some(server.url()),
1729 api_keys: {
1730 let mut m = std::collections::HashMap::new();
1731 m.insert("tronscan".to_string(), "my-api-key".to_string());
1732 m
1733 },
1734 ..Default::default()
1735 };
1736 let client = TronClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1737 let balances = client.get_trc20_balances(VALID_ADDRESS).await.unwrap();
1738 assert_eq!(balances.len(), 1);
1739 }
1740
1741 #[test]
1742 fn test_validate_tron_address_bad_checksum() {
1743 let result = validate_tron_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCe");
1746 assert!(result.is_err());
1747 let err_str = result.unwrap_err().to_string();
1749 assert!(
1750 err_str.contains("checksum")
1751 || err_str.contains("base58")
1752 || err_str.contains("prefix")
1753 );
1754 }
1755
1756 #[tokio::test]
1757 async fn test_get_transaction_tron_success() {
1758 let mut server = mockito::Server::new_async().await;
1759 let _mock = server
1760 .mock(
1761 "GET",
1762 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1763 )
1764 .with_status(200)
1765 .with_header("content-type", "application/json")
1766 .with_body(
1767 r#"{"data": [{
1768 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1769 "block_number": 50000000,
1770 "block_timestamp": 1700000000000,
1771 "raw_data": {
1772 "contract": [{
1773 "parameter": {
1774 "value": {
1775 "amount": 5000000,
1776 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
1777 "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"
1778 }
1779 }
1780 }]
1781 },
1782 "ret": [{"contractRet": "SUCCESS"}]
1783 }], "success": true}"#,
1784 )
1785 .create_async()
1786 .await;
1787
1788 let client = TronClient::with_api_url(&server.url());
1789 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
1790 assert_eq!(tx.hash, VALID_TX_HASH);
1791 assert!(tx.status.unwrap());
1792 assert_eq!(tx.value, "5000000");
1793 assert_eq!(tx.timestamp, Some(1700000000)); }
1795
1796 #[tokio::test]
1797 async fn test_get_transaction_tron_error() {
1798 let mut server = mockito::Server::new_async().await;
1799 let _mock = server
1800 .mock(
1801 "GET",
1802 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
1803 )
1804 .with_status(200)
1805 .with_header("content-type", "application/json")
1806 .with_body(r#"{"data": [], "success": false, "error": "Transaction not found"}"#)
1807 .create_async()
1808 .await;
1809
1810 let client = TronClient::with_api_url(&server.url());
1811 let result = client.get_transaction(VALID_TX_HASH).await;
1812 assert!(result.is_err());
1813 }
1814
1815 #[tokio::test]
1816 async fn test_get_transactions_tron_success() {
1817 let mut server = mockito::Server::new_async().await;
1818 let _mock = server
1819 .mock(
1820 "GET",
1821 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1822 )
1823 .with_status(200)
1824 .with_header("content-type", "application/json")
1825 .with_body(
1826 r#"{"data": [
1827 {
1828 "txID": "aaa12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1829 "block_number": 50000001,
1830 "block_timestamp": 1700000003000,
1831 "raw_data": {"contract": [{"parameter": {"value": {"amount": 1000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"}}}]},
1832 "ret": [{"contractRet": "SUCCESS"}]
1833 },
1834 {
1835 "txID": "bbb12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
1836 "block_number": 50000002,
1837 "block_timestamp": 1700000006000,
1838 "raw_data": {"contract": [{"parameter": {"value": {"amount": 2000000, "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "to_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCg"}}}]},
1839 "ret": [{"contractRet": "SUCCESS"}]
1840 }
1841 ], "success": true}"#,
1842 )
1843 .create_async()
1844 .await;
1845
1846 let client = TronClient::with_api_url(&server.url());
1847 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1848 assert_eq!(txs.len(), 2);
1849 }
1850
1851 #[tokio::test]
1852 async fn test_get_transactions_tron_error() {
1853 let mut server = mockito::Server::new_async().await;
1854 let _mock = server
1855 .mock(
1856 "GET",
1857 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
1858 )
1859 .with_status(200)
1860 .with_header("content-type", "application/json")
1861 .with_body(r#"{"data": [], "success": false, "error": "Invalid address"}"#)
1862 .create_async()
1863 .await;
1864
1865 let client = TronClient::with_api_url(&server.url());
1866 let result = client.get_transactions(VALID_ADDRESS, 10).await;
1867 assert!(result.is_err());
1868 }
1869
1870 #[tokio::test]
1871 async fn test_get_balance_error_response() {
1872 let mut server = mockito::Server::new_async().await;
1873 let _mock = server
1874 .mock(
1875 "GET",
1876 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
1877 )
1878 .with_status(200)
1879 .with_header("content-type", "application/json")
1880 .with_body(r#"{"data": [], "success": false, "error": "Account not found"}"#)
1881 .create_async()
1882 .await;
1883
1884 let client = TronClient::with_api_url(&server.url());
1885 let result = client.get_balance(VALID_ADDRESS).await;
1886 assert!(result.is_err());
1887 assert!(
1888 result
1889 .unwrap_err()
1890 .to_string()
1891 .contains("Account not found")
1892 );
1893 }
1894
1895 #[tokio::test]
1896 async fn test_get_token_info_success() {
1897 let info: serde_json::Value = serde_json::from_str(
1900 r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 6}]}"#,
1901 )
1902 .unwrap();
1903 let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1904 let token_data = tokens.first().unwrap();
1905 let symbol = token_data
1906 .get("symbol")
1907 .and_then(|v| v.as_str())
1908 .unwrap_or("UNKNOWN");
1909 assert_eq!(symbol, "USDT");
1910 let name = token_data
1911 .get("contract_name")
1912 .and_then(|v| v.as_str())
1913 .unwrap_or("Unknown Token");
1914 assert_eq!(name, "TetherToken");
1915 let decimals = token_data
1916 .get("decimals")
1917 .and_then(|v| v.as_u64())
1918 .unwrap_or(6) as u8;
1919 assert_eq!(decimals, 6);
1920 }
1921
1922 #[tokio::test]
1923 async fn test_get_token_info_no_tokens() {
1924 let info: serde_json::Value = serde_json::from_str(r#"{"trc20_tokens": []}"#).unwrap();
1925 let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1926 assert!(tokens.is_empty());
1927 }
1928
1929 #[tokio::test]
1930 async fn test_get_token_info_missing_field() {
1931 let info: serde_json::Value =
1932 serde_json::from_str(r#"{"trc20_tokens": [{"symbol": "TEST"}]}"#).unwrap();
1933 let tokens = info.get("trc20_tokens").and_then(|v| v.as_array()).unwrap();
1934 let token_data = tokens.first().unwrap();
1935 let name = token_data
1936 .get("contract_name")
1937 .or_else(|| token_data.get("name"))
1938 .and_then(|v| v.as_str())
1939 .unwrap_or("Unknown Token");
1940 assert_eq!(name, "Unknown Token");
1941 let decimals = token_data
1942 .get("decimals")
1943 .and_then(|v| v.as_u64())
1944 .unwrap_or(6) as u8;
1945 assert_eq!(decimals, 6);
1946 }
1947
1948 #[tokio::test]
1949 async fn test_token_holder_response_parsing() {
1950 let json: serde_json::Value = serde_json::from_str(
1951 r#"{"trc20_tokens": [
1952 {"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "5000000"},
1953 {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "3000000"}
1954 ]}"#,
1955 )
1956 .unwrap();
1957 let holders_data: &[serde_json::Value] = json
1958 .get("trc20_tokens")
1959 .and_then(|v| v.as_array())
1960 .map(|v| v.as_slice())
1961 .unwrap_or(&[]);
1962 assert_eq!(holders_data.len(), 2);
1963
1964 let total_balance: f64 = holders_data
1965 .iter()
1966 .filter_map(|h| h.get("balance").and_then(|v| v.as_str()))
1967 .filter_map(|s| s.parse::<f64>().ok())
1968 .sum();
1969 assert_eq!(total_balance, 8000000.0);
1970
1971 let decimals: u8 = 6;
1972 let holders: Vec<TokenHolder> = holders_data
1973 .iter()
1974 .enumerate()
1975 .filter_map(|(i, h)| {
1976 let holder_address = h.get("holder_address")?.as_str()?.to_string();
1977 let balance_raw = h.get("balance")?.as_str()?.to_string();
1978 let balance: f64 = balance_raw.parse().ok()?;
1979 let percentage = if total_balance > 0.0 {
1980 (balance / total_balance) * 100.0
1981 } else {
1982 0.0
1983 };
1984 let divisor = 10_f64.powi(decimals as i32);
1985 let formatted = format!("{:.6}", balance / divisor);
1986 Some(TokenHolder {
1987 address: holder_address,
1988 balance: balance_raw,
1989 formatted_balance: formatted,
1990 percentage,
1991 rank: (i + 1) as u32,
1992 })
1993 })
1994 .collect();
1995 assert_eq!(holders.len(), 2);
1996 assert_eq!(holders[0].rank, 1);
1997 assert_eq!(holders[1].rank, 2);
1998 assert!(holders[0].percentage > 60.0);
1999 assert!(holders[0].formatted_balance.contains("5.000000"));
2000 }
2001
2002 #[tokio::test]
2003 async fn test_token_holder_count_parsing() {
2004 let json: serde_json::Value = serde_json::from_str(r#"{"rangeTotal": 12345}"#).unwrap();
2005 let count = json.get("rangeTotal").and_then(|v| v.as_u64()).unwrap_or(0);
2006 assert_eq!(count, 12345);
2007
2008 let json_no_field: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
2009 let count2 = json_no_field
2010 .get("rangeTotal")
2011 .and_then(|v| v.as_u64())
2012 .unwrap_or(0);
2013 assert_eq!(count2, 0);
2014 }
2015
2016 #[test]
2017 fn test_dex_search_response_deserialization() {
2018 let json = r#"{"pairs":[{"baseTokenSymbol":"TRX","priceUsd":"0.08"}]}"#;
2019 let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2020 assert!(result.is_ok());
2021 let resp = result.unwrap();
2022 let pairs = resp.pairs.unwrap();
2023 assert_eq!(pairs.len(), 1);
2024 assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2025 }
2026
2027 #[test]
2028 fn test_dex_search_response_empty() {
2029 let json = r#"{"pairs":[]}"#;
2030 let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2031 assert!(result.is_ok());
2032 assert!(result.unwrap().pairs.unwrap().is_empty());
2033 }
2034
2035 #[test]
2036 fn test_dex_search_response_no_pairs() {
2037 let json = r#"{}"#;
2038 let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2039 assert!(result.is_ok());
2040 assert!(result.unwrap().pairs.is_none());
2041 }
2042
2043 #[tokio::test]
2048 async fn test_get_transaction_invalid_hash() {
2049 let client = TronClient::default();
2050 let result = client.get_transaction("not-a-valid-hash").await;
2051 assert!(result.is_err());
2052 assert!(result.unwrap_err().to_string().contains("64 characters"));
2053 }
2054
2055 #[tokio::test]
2056 async fn test_get_transactions_invalid_address() {
2057 let client = TronClient::default();
2058 let result = client.get_transactions("invalid-address", 10).await;
2059 assert!(result.is_err());
2060 }
2061
2062 #[tokio::test]
2063 async fn test_get_token_info_invalid_address() {
2064 let client = TronClient::default();
2065 let result = client.get_token_info("bad-address").await;
2066 assert!(result.is_err());
2067 }
2068
2069 #[tokio::test]
2070 async fn test_get_token_holders_invalid_address() {
2071 let client = TronClient::default();
2072 let result = client.get_token_holders("bad-address", 10).await;
2073 assert!(result.is_err());
2074 }
2075
2076 #[tokio::test]
2077 async fn test_get_token_holder_count_invalid_address() {
2078 let client = TronClient::default();
2079 let result = client.get_token_holder_count("bad-address").await;
2080 assert!(result.is_err());
2081 }
2082
2083 #[tokio::test]
2084 async fn test_chain_client_get_token_info_invalid_address() {
2085 let client = TronClient::default();
2086 let chain_client: &dyn ChainClient = &client;
2087 let result = chain_client.get_token_info("x").await;
2088 assert!(result.is_err());
2089 }
2090
2091 #[tokio::test]
2092 async fn test_chain_client_get_token_holders_invalid_address() {
2093 let client = TronClient::default();
2094 let chain_client: &dyn ChainClient = &client;
2095 let result = chain_client.get_token_holders("x", 10).await;
2096 assert!(result.is_err());
2097 }
2098
2099 #[tokio::test]
2100 async fn test_chain_client_get_token_holder_count_invalid_address() {
2101 let client = TronClient::default();
2102 let chain_client: &dyn ChainClient = &client;
2103 let result = chain_client.get_token_holder_count("x").await;
2104 assert!(result.is_err());
2105 }
2106
2107 #[tokio::test]
2112 async fn test_get_balance_api_error_unknown_error() {
2113 let mut server = mockito::Server::new_async().await;
2114 let _mock = server
2115 .mock(
2116 "GET",
2117 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2118 )
2119 .with_status(200)
2120 .with_header("content-type", "application/json")
2121 .with_body(r#"{"data": [], "success": false}"#)
2122 .create_async()
2123 .await;
2124
2125 let client = TronClient::with_api_url(&server.url());
2126 let result = client.get_balance(VALID_ADDRESS).await;
2127 assert!(result.is_err());
2128 assert!(result.unwrap_err().to_string().contains("Unknown error"));
2129 }
2130
2131 #[tokio::test]
2132 async fn test_get_transaction_status_none() {
2133 let mut server = mockito::Server::new_async().await;
2134 let _mock = server
2135 .mock(
2136 "GET",
2137 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2138 )
2139 .with_status(200)
2140 .with_header("content-type", "application/json")
2141 .with_body(
2142 r#"{
2143 "data": [{
2144 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2145 "block_number": 50000000,
2146 "block_timestamp": 1700000000000,
2147 "ret": []
2148 }],
2149 "success": true
2150 }"#,
2151 )
2152 .create_async()
2153 .await;
2154
2155 let client = TronClient::with_api_url(&server.url());
2156 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2157 assert_eq!(tx.status, None);
2158 }
2159
2160 #[tokio::test]
2161 async fn test_get_transaction_no_ret_field() {
2162 let mut server = mockito::Server::new_async().await;
2163 let _mock = server
2164 .mock(
2165 "GET",
2166 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2167 )
2168 .with_status(200)
2169 .with_header("content-type", "application/json")
2170 .with_body(
2171 r#"{
2172 "data": [{
2173 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2174 "block_number": 50000000,
2175 "block_timestamp": 1700000000000
2176 }],
2177 "success": true
2178 }"#,
2179 )
2180 .create_async()
2181 .await;
2182
2183 let client = TronClient::with_api_url(&server.url());
2184 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2185 assert_eq!(tx.status, None);
2186 }
2187
2188 #[tokio::test]
2189 async fn test_get_transaction_to_address_none() {
2190 let mut server = mockito::Server::new_async().await;
2191 let _mock = server
2192 .mock(
2193 "GET",
2194 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2195 )
2196 .with_status(200)
2197 .with_header("content-type", "application/json")
2198 .with_body(
2199 r#"{
2200 "data": [{
2201 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2202 "block_number": 50000000,
2203 "block_timestamp": 1700000000000,
2204 "raw_data": {
2205 "contract": [{
2206 "parameter": {
2207 "value": {
2208 "amount": 1000000,
2209 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
2210 }
2211 }
2212 }]
2213 },
2214 "ret": [{"contractRet": "SUCCESS"}]
2215 }],
2216 "success": true
2217 }"#,
2218 )
2219 .create_async()
2220 .await;
2221
2222 let client = TronClient::with_api_url(&server.url());
2223 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2224 assert_eq!(tx.to, None);
2225 assert_eq!(tx.value, "1000000");
2226 }
2227
2228 #[tokio::test]
2229 async fn test_get_transaction_amount_none() {
2230 let mut server = mockito::Server::new_async().await;
2231 let _mock = server
2232 .mock(
2233 "GET",
2234 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2235 )
2236 .with_status(200)
2237 .with_header("content-type", "application/json")
2238 .with_body(
2239 r#"{
2240 "data": [{
2241 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2242 "block_number": 50000000,
2243 "block_timestamp": 1700000000000,
2244 "raw_data": {
2245 "contract": [{
2246 "parameter": {
2247 "value": {
2248 "owner_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2249 "to_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9"
2250 }
2251 }
2252 }]
2253 },
2254 "ret": [{"contractRet": "SUCCESS"}]
2255 }],
2256 "success": true
2257 }"#,
2258 )
2259 .create_async()
2260 .await;
2261
2262 let client = TronClient::with_api_url(&server.url());
2263 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2264 assert_eq!(tx.value, "0");
2265 }
2266
2267 #[tokio::test]
2268 async fn test_get_transaction_no_raw_data() {
2269 let mut server = mockito::Server::new_async().await;
2270 let _mock = server
2271 .mock(
2272 "GET",
2273 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2274 )
2275 .with_status(200)
2276 .with_header("content-type", "application/json")
2277 .with_body(
2278 r#"{
2279 "data": [{
2280 "txID": "b3c12d62ad7e7b8b83b09a68b9b8f9b23a1b8f8b8f9b8f9b8f9b8f9b8f9b8f9b",
2281 "block_number": 50000000,
2282 "block_timestamp": 1700000000000,
2283 "ret": [{"contractRet": "SUCCESS"}]
2284 }],
2285 "success": true
2286 }"#,
2287 )
2288 .create_async()
2289 .await;
2290
2291 let client = TronClient::with_api_url(&server.url());
2292 let tx = client.get_transaction(VALID_TX_HASH).await.unwrap();
2293 assert_eq!(tx.from, "");
2294 assert_eq!(tx.to, None);
2295 assert_eq!(tx.value, "0");
2296 }
2297
2298 #[tokio::test]
2299 async fn test_get_block_number_block_header_none() {
2300 let mut server = mockito::Server::new_async().await;
2301 let _mock = server
2302 .mock("POST", "/wallet/getnowblock")
2303 .with_status(200)
2304 .with_header("content-type", "application/json")
2305 .with_body(r#"{"other": "data"}"#)
2306 .create_async()
2307 .await;
2308
2309 let client = TronClient::with_api_url(&server.url());
2310 let result = client.get_block_number().await;
2311 assert!(result.is_err());
2312 assert!(result.unwrap_err().to_string().contains("Invalid block"));
2313 }
2314
2315 #[tokio::test]
2316 async fn test_get_block_number_raw_data_none() {
2317 let mut server = mockito::Server::new_async().await;
2318 let _mock = server
2319 .mock("POST", "/wallet/getnowblock")
2320 .with_status(200)
2321 .with_header("content-type", "application/json")
2322 .with_body(r#"{"block_header":{}}"#)
2323 .create_async()
2324 .await;
2325
2326 let client = TronClient::with_api_url(&server.url());
2327 let result = client.get_block_number().await;
2328 assert!(result.is_err());
2329 }
2330
2331 #[tokio::test]
2332 async fn test_get_block_number_number_none() {
2333 let mut server = mockito::Server::new_async().await;
2334 let _mock = server
2335 .mock("POST", "/wallet/getnowblock")
2336 .with_status(200)
2337 .with_header("content-type", "application/json")
2338 .with_body(r#"{"block_header":{"raw_data":{}}}"#)
2339 .create_async()
2340 .await;
2341
2342 let client = TronClient::with_api_url(&server.url());
2343 let result = client.get_block_number().await;
2344 assert!(result.is_err());
2345 }
2346
2347 #[tokio::test]
2348 async fn test_get_trc20_balances_api_error_unknown() {
2349 let mut server = mockito::Server::new_async().await;
2350 let _mock = server
2351 .mock(
2352 "GET",
2353 mockito::Matcher::Regex(r"/v1/accounts/.*".to_string()),
2354 )
2355 .with_status(200)
2356 .with_header("content-type", "application/json")
2357 .with_body(r#"{"data": [], "success": false}"#)
2358 .create_async()
2359 .await;
2360
2361 let client = TronClient::with_api_url(&server.url());
2362 let result = client.get_trc20_balances(VALID_ADDRESS).await;
2363 assert!(result.is_err());
2364 assert!(result.unwrap_err().to_string().contains("Unknown error"));
2365 }
2366
2367 #[tokio::test]
2368 async fn test_get_transactions_api_error_unknown() {
2369 let mut server = mockito::Server::new_async().await;
2370 let _mock = server
2371 .mock(
2372 "GET",
2373 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2374 )
2375 .with_status(200)
2376 .with_header("content-type", "application/json")
2377 .with_body(r#"{"data": [], "success": false}"#)
2378 .create_async()
2379 .await;
2380
2381 let client = TronClient::with_api_url(&server.url());
2382 let result = client.get_transactions(VALID_ADDRESS, 10).await;
2383 assert!(result.is_err());
2384 assert!(result.unwrap_err().to_string().contains("Unknown error"));
2385 }
2386
2387 #[tokio::test]
2388 async fn test_get_transaction_api_error_unknown() {
2389 let mut server = mockito::Server::new_async().await;
2390 let _mock = server
2391 .mock(
2392 "GET",
2393 mockito::Matcher::Regex(r"/v1/transactions/.*".to_string()),
2394 )
2395 .with_status(200)
2396 .with_header("content-type", "application/json")
2397 .with_body(r#"{"data": [], "success": false}"#)
2398 .create_async()
2399 .await;
2400
2401 let client = TronClient::with_api_url(&server.url());
2402 let result = client.get_transaction(VALID_TX_HASH).await;
2403 assert!(result.is_err());
2404 }
2405
2406 #[test]
2411 fn test_trc20_token_balance_struct() {
2412 let balance = Trc20TokenBalance {
2413 contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2414 raw_balance: "5000000".to_string(),
2415 };
2416 assert_eq!(
2417 balance.contract_address,
2418 "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
2419 );
2420 assert_eq!(balance.raw_balance, "5000000");
2421 let debug_str = format!("{:?}", balance);
2422 assert!(debug_str.contains("Trc20TokenBalance"));
2423 }
2424
2425 #[test]
2426 fn test_account_response_with_error_field() {
2427 let json = r#"{
2428 "data": [],
2429 "success": false,
2430 "error": "Custom error message"
2431 }"#;
2432 let response: AccountResponse = serde_json::from_str(json).unwrap();
2433 assert!(!response.success);
2434 assert_eq!(response.error, Some("Custom error message".to_string()));
2435 }
2436
2437 #[test]
2438 fn test_account_response_trc20_balances() {
2439 let json = r#"{
2440 "data": [{
2441 "balance": 1000000,
2442 "address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf",
2443 "trc20": [
2444 {"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t": "10000000"}
2445 ]
2446 }],
2447 "success": true
2448 }"#;
2449 let response: AccountResponse = serde_json::from_str(json).unwrap();
2450 assert_eq!(response.data.len(), 1);
2451 assert_eq!(response.data[0].trc20.len(), 1);
2452 let trc20 = &response.data[0].trc20[0];
2453 assert_eq!(
2454 trc20.balances.get("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"),
2455 Some(&"10000000".to_string())
2456 );
2457 }
2458
2459 #[test]
2460 fn test_transaction_list_response_with_error() {
2461 let json = r#"{
2462 "data": [],
2463 "success": false,
2464 "error": "Transaction not found"
2465 }"#;
2466 let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2467 assert!(!response.success);
2468 assert_eq!(response.error, Some("Transaction not found".to_string()));
2469 }
2470
2471 #[test]
2472 fn test_full_transaction_deserialization() {
2473 let json = r#"{
2474 "data": [{
2475 "txID": "abc123def456",
2476 "block_number": 12345,
2477 "block_timestamp": 1600000000000,
2478 "raw_data": {
2479 "contract": [{
2480 "parameter": {
2481 "value": {
2482 "amount": 999999,
2483 "owner_address": "TFrom123",
2484 "to_address": "TTo456"
2485 }
2486 },
2487 "type": "TransferContract"
2488 }]
2489 },
2490 "ret": [{"contractRet": "SUCCESS"}]
2491 }],
2492 "success": true
2493 }"#;
2494 let response: TransactionListResponse = serde_json::from_str(json).unwrap();
2495 let tx = &response.data[0];
2496 assert_eq!(tx.tx_id, "abc123def456");
2497 assert_eq!(tx.block_number, Some(12345));
2498 assert_eq!(tx.block_timestamp, Some(1600000000000));
2499 let contract_value = tx
2500 .raw_data
2501 .as_ref()
2502 .and_then(|r| r.contract.as_ref())
2503 .and_then(|c| c.first())
2504 .and_then(|c| c.parameter.as_ref())
2505 .and_then(|p| p.value.as_ref())
2506 .unwrap();
2507 assert_eq!(contract_value.amount, Some(999999));
2508 assert_eq!(contract_value.owner_address.as_deref(), Some("TFrom123"));
2509 assert_eq!(contract_value.to_address.as_deref(), Some("TTo456"));
2510 assert_eq!(
2511 tx.ret
2512 .as_ref()
2513 .and_then(|r| r.first())
2514 .and_then(|r| r.contract_ret.as_deref()),
2515 Some("SUCCESS")
2516 );
2517 }
2518
2519 #[test]
2520 fn test_tron_client_default_trait() {
2521 let client = TronClient::default();
2522 assert_eq!(client.chain_name(), "tron");
2523 assert_eq!(client.native_token_symbol(), "TRX");
2524 assert_eq!(client.api_url, DEFAULT_TRON_API);
2525 }
2526
2527 #[test]
2528 fn test_dex_search_pair_wtrx() {
2529 let json = r#"{"pairs":[{"baseTokenSymbol":"WTRX","priceUsd":"0.08"}]}"#;
2530 let result: std::result::Result<DexSearchResponse, _> = serde_json::from_str(json);
2531 assert!(result.is_ok());
2532 let resp = result.unwrap();
2533 let pairs = resp.pairs.unwrap();
2534 assert_eq!(pairs[0].base_token_symbol, Some("WTRX".to_string()));
2535 assert_eq!(pairs[0].price_usd, Some("0.08".to_string()));
2536 }
2537
2538 #[tokio::test]
2539 async fn test_enrich_balance_usd_no_panic() {
2540 let mut balance = Balance {
2541 raw: "1000000".to_string(),
2542 formatted: "1.000000 TRX".to_string(),
2543 decimals: TRX_DECIMALS,
2544 symbol: "TRX".to_string(),
2545 usd_value: None,
2546 };
2547 let client = TronClient::default();
2548 client.enrich_balance_usd(&mut balance).await;
2549 }
2551
2552 #[test]
2553 fn test_trc20_token_balance_debug_format() {
2554 let b = Trc20TokenBalance {
2555 contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(),
2556 raw_balance: "1000000".to_string(),
2557 };
2558 let s = format!("{:?}", b);
2559 assert!(s.contains("Trc20TokenBalance"));
2560 assert!(s.contains("1000000"));
2561 }
2562
2563 #[tokio::test]
2564 async fn test_get_transactions_with_minimal_contract_data() {
2565 let mut server = mockito::Server::new_async().await;
2566 let _mock = server
2567 .mock(
2568 "GET",
2569 mockito::Matcher::Regex(r"/v1/accounts/.*/transactions.*".to_string()),
2570 )
2571 .with_status(200)
2572 .with_header("content-type", "application/json")
2573 .with_body(
2574 r#"{
2575 "data": [{
2576 "txID": "c4d23e73be8f8c9c94c10b79c0c0a0c24b2c9a9c0a0c0a0c0a0c0a0c0a0c0a0c0a0c",
2577 "block_number": 50000000,
2578 "block_timestamp": 1700000000000,
2579 "raw_data": {"contract": [{}]},
2580 "ret": []
2581 }],
2582 "success": true
2583 }"#,
2584 )
2585 .create_async()
2586 .await;
2587
2588 let client = TronClient::with_api_url(&server.url());
2589 let txs = client.get_transactions(VALID_ADDRESS, 5).await.unwrap();
2590 assert_eq!(txs.len(), 1);
2591 assert_eq!(txs[0].value, "0");
2592 assert_eq!(txs[0].status, None);
2593 }
2594 const USDT_CONTRACT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
2599
2600 struct ToMockServer {
2603 base: String,
2604 inner: Arc<dyn HttpClient>,
2605 }
2606
2607 #[async_trait]
2608 impl HttpClient for ToMockServer {
2609 async fn send(&self, mut req: Request) -> Result<crate::http::Response> {
2610 for host in [TRONSCAN_API, "https://api.dexscreener.com"] {
2611 if let Some(rest) = req.url.strip_prefix(host) {
2612 req.url = format!("{}{}", self.base, rest);
2613 }
2614 }
2615 self.inner.send(req).await
2616 }
2617 }
2618
2619 fn mocked_client(server: &mockito::Server, api_key: Option<&str>) -> TronClient {
2621 let mut config = ChainsConfig {
2622 tron_api: Some(server.url()),
2623 ..Default::default()
2624 };
2625 if let Some(key) = api_key {
2626 config
2627 .api_keys
2628 .insert("tronscan".to_string(), key.to_string());
2629 }
2630 let http = Arc::new(ToMockServer {
2631 base: server.url(),
2632 inner: crate::http::test_transport(),
2633 });
2634 TronClient::new_with_http(&config, http).unwrap()
2635 }
2636
2637 fn trx_balance(raw: &str) -> Balance {
2638 Balance {
2639 raw: raw.to_string(),
2640 formatted: String::new(),
2641 decimals: TRX_DECIMALS,
2642 symbol: "TRX".to_string(),
2643 usd_value: None,
2644 }
2645 }
2646
2647 async fn mock_token_info(
2648 server: &mut mockito::Server,
2649 status: usize,
2650 body: &str,
2651 ) -> mockito::Mock {
2652 server
2653 .mock("GET", "/api/token_trc20")
2654 .match_query(mockito::Matcher::Any)
2655 .with_status(status)
2656 .with_body(body)
2657 .create_async()
2658 .await
2659 }
2660
2661 async fn mock_holders(server: &mut mockito::Server, body: &str) -> mockito::Mock {
2662 server
2663 .mock("GET", "/api/token_trc20/holders")
2664 .match_query(mockito::Matcher::Any)
2665 .with_status(200)
2666 .with_body(body)
2667 .create_async()
2668 .await
2669 }
2670
2671 async fn mock_dex_search(server: &mut mockito::Server, body: &str) -> mockito::Mock {
2672 server
2673 .mock("GET", "/latest/dex/search")
2674 .match_query(mockito::Matcher::UrlEncoded("q".into(), "TRX USDT".into()))
2675 .with_status(200)
2676 .with_body(body)
2677 .create_async()
2678 .await
2679 }
2680
2681 #[tokio::test]
2682 async fn test_get_token_info_reads_tronscan_and_sends_the_api_key() {
2683 let mut server = mockito::Server::new_async().await;
2684 let mock = server
2685 .mock("GET", "/api/token_trc20")
2686 .match_query(mockito::Matcher::AllOf(vec![
2687 mockito::Matcher::UrlEncoded("contract".into(), USDT_CONTRACT.into()),
2688 mockito::Matcher::UrlEncoded("showAll".into(), "1".into()),
2689 ]))
2690 .match_header("TRON-PRO-API-KEY", "k1")
2691 .with_status(200)
2692 .with_body(
2693 r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "name": "Tether USD", "decimals": 6}]}"#,
2694 )
2695 .create_async()
2696 .await;
2697
2698 let client = mocked_client(&server, Some("k1"));
2699 let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2700 mock.assert_async().await;
2701 assert_eq!(token.contract_address, USDT_CONTRACT);
2702 assert_eq!(token.symbol, "USDT");
2703 assert_eq!(token.name, "TetherToken");
2705 assert_eq!(token.decimals, 6);
2706 }
2707
2708 #[tokio::test]
2709 async fn test_get_token_info_falls_back_to_name_then_defaults() {
2710 let mut server = mockito::Server::new_async().await;
2711 let client = mocked_client(&server, None);
2712
2713 let _m = mock_token_info(
2714 &mut server,
2715 200,
2716 r#"{"trc20_tokens": [{"name": "Plain Name", "decimals": 18}]}"#,
2717 )
2718 .await;
2719 let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2720 assert_eq!(token.symbol, "UNKNOWN");
2721 assert_eq!(token.name, "Plain Name");
2722 assert_eq!(token.decimals, 18);
2723
2724 server.reset();
2725 let _m = mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{}]}"#).await;
2726 let token = client.get_token_info(USDT_CONTRACT).await.unwrap();
2727 assert_eq!(token.symbol, "UNKNOWN");
2728 assert_eq!(token.name, "Unknown Token");
2729 assert_eq!(token.decimals, 6);
2730 }
2731
2732 #[tokio::test]
2733 async fn test_get_token_info_not_found_and_bad_json() {
2734 let mut server = mockito::Server::new_async().await;
2735 let client = mocked_client(&server, None);
2736 for body in [r#"{"trc20_tokens": []}"#, r#"{"other": 1}"#] {
2737 server.reset();
2738 let _m = mock_token_info(&mut server, 200, body).await;
2739 let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
2740 assert!(matches!(err, ScopeError::NotFound(_)), "{body}: {err}");
2741 assert!(err.to_string().contains(USDT_CONTRACT));
2742 }
2743
2744 server.reset();
2745 let _m = mock_token_info(&mut server, 200, "<html>not json</html>").await;
2746 let err = client.get_token_info(USDT_CONTRACT).await.unwrap_err();
2747 assert!(matches!(err, ScopeError::Api(_)), "{err}");
2748 assert!(err.to_string().contains("Tronscan"));
2749 }
2750
2751 #[tokio::test]
2752 async fn test_get_token_holders_ranks_and_shares_by_fetched_total() {
2753 let mut server = mockito::Server::new_async().await;
2754 let holders = server
2755 .mock("GET", "/api/token_trc20/holders")
2756 .match_query(mockito::Matcher::AllOf(vec![
2757 mockito::Matcher::UrlEncoded("contract_address".into(), USDT_CONTRACT.into()),
2758 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
2760 ]))
2761 .match_header("TRON-PRO-API-KEY", "k2")
2762 .with_status(200)
2763 .with_body(
2764 r#"{"trc20_tokens": [
2765 {"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "3000000000000000000"},
2766 {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "not-a-number"},
2767 {"balance": "500"},
2768 {"holder_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9", "balance": "1000000000000000000"}
2769 ]}"#,
2770 )
2771 .create_async()
2772 .await;
2773 let _info = mock_token_info(
2774 &mut server,
2775 200,
2776 r#"{"trc20_tokens": [{"symbol": "BIG", "decimals": 18}]}"#,
2777 )
2778 .await;
2779
2780 let client = mocked_client(&server, Some("k2"));
2781 let result = client.get_token_holders(USDT_CONTRACT, 500).await.unwrap();
2782 holders.assert_async().await;
2783
2784 assert_eq!(result.len(), 2);
2787 assert_eq!(result[0].address, VALID_ADDRESS);
2788 assert_eq!(result[0].rank, 1);
2789 assert_eq!(result[0].formatted_balance, "3.000000");
2790 assert_eq!(result[1].rank, 4);
2791 assert_eq!(result[1].formatted_balance, "1.000000");
2792 let total = 4e18 + 500.0;
2794 assert!((result[0].percentage - 3e18 / total * 100.0).abs() < 1e-9);
2795 assert!((result[1].percentage - 1e18 / total * 100.0).abs() < 1e-9);
2796 }
2797
2798 #[tokio::test]
2799 async fn test_get_token_holders_without_token_info_uses_six_decimals() {
2800 let mut server = mockito::Server::new_async().await;
2801 let _holders = mock_holders(
2802 &mut server,
2803 r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "2500000"}]}"#,
2804 )
2805 .await;
2806 let _info = mock_token_info(&mut server, 200, r#"{"trc20_tokens": []}"#).await;
2807
2808 let client = mocked_client(&server, None);
2809 let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
2810 assert_eq!(result.len(), 1);
2811 assert_eq!(result[0].formatted_balance, "2.500000");
2812 assert!((result[0].percentage - 100.0).abs() < 1e-9);
2813 }
2814
2815 #[tokio::test]
2816 async fn test_get_token_holders_zero_total_gives_zero_share() {
2817 let mut server = mockito::Server::new_async().await;
2818 let _holders = mock_holders(
2819 &mut server,
2820 r#"{"trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "0"}]}"#,
2821 )
2822 .await;
2823 let _info =
2824 mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
2825
2826 let client = mocked_client(&server, None);
2827 let result = client.get_token_holders(USDT_CONTRACT, 10).await.unwrap();
2828 assert_eq!(result.len(), 1);
2829 assert_eq!(result[0].percentage, 0.0);
2830 }
2831
2832 #[tokio::test]
2833 async fn test_get_token_holders_empty_and_bad_json() {
2834 let mut server = mockito::Server::new_async().await;
2835 let client = mocked_client(&server, None);
2836 let _holders = mock_holders(&mut server, r#"{"total": 0}"#).await;
2837 let _info =
2838 mock_token_info(&mut server, 200, r#"{"trc20_tokens": [{"decimals": 6}]}"#).await;
2839 assert!(
2840 client
2841 .get_token_holders(USDT_CONTRACT, 10)
2842 .await
2843 .unwrap()
2844 .is_empty()
2845 );
2846
2847 server.reset();
2848 let _holders = mock_holders(&mut server, "{").await;
2849 let err = client
2850 .get_token_holders(USDT_CONTRACT, 10)
2851 .await
2852 .unwrap_err();
2853 assert!(err.to_string().contains("Tronscan holders"), "{err}");
2854 }
2855
2856 #[tokio::test]
2857 async fn test_get_token_holder_count() {
2858 let mut server = mockito::Server::new_async().await;
2859 let client = mocked_client(&server, Some("k3"));
2860 let mock = server
2861 .mock("GET", "/api/token_trc20/holders")
2862 .match_query(mockito::Matcher::UrlEncoded("limit".into(), "1".into()))
2863 .match_header("TRON-PRO-API-KEY", "k3")
2864 .with_status(200)
2865 .with_body(r#"{"rangeTotal": 12345, "trc20_tokens": []}"#)
2866 .create_async()
2867 .await;
2868 assert_eq!(
2869 client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
2870 12345
2871 );
2872 mock.assert_async().await;
2873
2874 server.reset();
2875 let _m = mock_holders(&mut server, "{}").await;
2876 assert_eq!(
2877 client.get_token_holder_count(USDT_CONTRACT).await.unwrap(),
2878 0
2879 );
2880
2881 server.reset();
2882 let _m = mock_holders(&mut server, "nope").await;
2883 let err = client
2884 .get_token_holder_count(USDT_CONTRACT)
2885 .await
2886 .unwrap_err();
2887 assert!(matches!(err, ScopeError::Api(_)), "{err}");
2888 }
2889
2890 #[tokio::test]
2891 async fn test_enrich_balance_usd_uses_first_priced_trx_pair() {
2892 let mut server = mockito::Server::new_async().await;
2894 let _m = mock_dex_search(
2895 &mut server,
2896 r#"{"pairs": [
2897 {"baseTokenSymbol": "USDT", "priceUsd": "1.00"},
2898 {"baseTokenSymbol": "TRX", "priceUsd": "n/a"},
2899 {"baseTokenSymbol": "WTRX", "priceUsd": "0.25"},
2900 {"baseTokenSymbol": "TRX", "priceUsd": "9.99"}
2901 ]}"#,
2902 )
2903 .await;
2904
2905 let client = mocked_client(&server, None);
2906 let mut balance = trx_balance("4000000"); client.enrich_balance_usd(&mut balance).await;
2908 assert_eq!(balance.usd_value, Some(1.0));
2909
2910 let chain_client: &dyn ChainClient = &client;
2912 let mut balance = trx_balance("2000000");
2913 chain_client.enrich_balance_usd(&mut balance).await;
2914 assert_eq!(balance.usd_value, Some(0.5));
2915 }
2916
2917 #[tokio::test]
2918 async fn test_enrich_balance_usd_leaves_value_unset_without_a_price() {
2919 let mut server = mockito::Server::new_async().await;
2920 let client = mocked_client(&server, None);
2921 for body in [
2922 r#"{"pairs": null}"#,
2923 r#"{"pairs": [{"baseTokenSymbol": "BTC", "priceUsd": "60000"}]}"#,
2924 r#"{"pairs": [{"baseTokenSymbol": "TRX"}]}"#,
2925 "garbage",
2926 ] {
2927 server.reset();
2928 let _m = mock_dex_search(&mut server, body).await;
2929 let mut balance = trx_balance("1000000");
2930 client.enrich_balance_usd(&mut balance).await;
2931 assert_eq!(balance.usd_value, None, "{body}");
2932 }
2933 }
2934
2935 #[tokio::test]
2936 async fn test_chain_client_token_balances_use_tronscan_info_or_placeholder() {
2937 let account_body = format!(
2938 r#"{{"data": [{{"balance": 0, "address": "{VALID_ADDRESS}", "trc20": [{{"{USDT_CONTRACT}": "1234567"}}]}}], "success": true}}"#
2939 );
2940 let account_path = format!("/v1/accounts/{VALID_ADDRESS}");
2941 let mut server = mockito::Server::new_async().await;
2942 let _account = server
2943 .mock("GET", account_path.as_str())
2944 .with_status(200)
2945 .with_body(&account_body)
2946 .create_async()
2947 .await;
2948 let _info = mock_token_info(
2949 &mut server,
2950 200,
2951 r#"{"trc20_tokens": [{"symbol": "USDT", "contract_name": "TetherToken", "decimals": 3}]}"#,
2952 )
2953 .await;
2954
2955 let client = mocked_client(&server, None);
2956 let chain_client: &dyn ChainClient = &client;
2957 let balances = chain_client
2958 .get_token_balances(VALID_ADDRESS)
2959 .await
2960 .unwrap();
2961 assert_eq!(balances.len(), 1);
2962 assert_eq!(balances[0].token.symbol, "USDT");
2963 assert_eq!(balances[0].token.decimals, 3);
2964 assert_eq!(balances[0].balance, "1234567");
2965 assert_eq!(balances[0].formatted_balance, "1234.567000");
2966 assert_eq!(balances[0].usd_value, None);
2967
2968 server.reset();
2970 let _account = server
2971 .mock("GET", account_path.as_str())
2972 .with_status(200)
2973 .with_body(&account_body)
2974 .create_async()
2975 .await;
2976 let _info = mock_token_info(&mut server, 500, "down").await;
2977 let balances = chain_client
2978 .get_token_balances(VALID_ADDRESS)
2979 .await
2980 .unwrap();
2981 assert_eq!(balances[0].token.symbol, "TRC20");
2982 assert_eq!(balances[0].token.name, "TRC-20 Token");
2983 assert_eq!(balances[0].token.contract_address, USDT_CONTRACT);
2984 assert_eq!(balances[0].formatted_balance, "1.234567");
2985 }
2986
2987 #[tokio::test]
2988 async fn test_chain_client_token_queries_delegate_to_tronscan() {
2989 let mut server = mockito::Server::new_async().await;
2990 let _holders = mock_holders(
2991 &mut server,
2992 r#"{"rangeTotal": 7, "trc20_tokens": [{"holder_address": "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "balance": "1"}]}"#,
2993 )
2994 .await;
2995 let _info = mock_token_info(
2996 &mut server,
2997 200,
2998 r#"{"trc20_tokens": [{"symbol": "USDT", "decimals": 6}]}"#,
2999 )
3000 .await;
3001
3002 let client = mocked_client(&server, None);
3003 let chain_client: &dyn ChainClient = &client;
3004 assert_eq!(
3005 chain_client
3006 .get_token_info(USDT_CONTRACT)
3007 .await
3008 .unwrap()
3009 .symbol,
3010 "USDT"
3011 );
3012 assert_eq!(
3013 chain_client
3014 .get_token_holders(USDT_CONTRACT, 5)
3015 .await
3016 .unwrap()
3017 .len(),
3018 1
3019 );
3020 assert_eq!(
3021 chain_client
3022 .get_token_holder_count(USDT_CONTRACT)
3023 .await
3024 .unwrap(),
3025 7
3026 );
3027 }
3028
3029 #[tokio::test]
3030 async fn test_get_transaction_and_transactions_send_the_api_key() {
3031 let mut server = mockito::Server::new_async().await;
3032 let tx = server
3033 .mock("GET", format!("/v1/transactions/{VALID_TX_HASH}").as_str())
3034 .match_header("TRON-PRO-API-KEY", "k4")
3035 .with_status(200)
3036 .with_body(format!(
3037 r#"{{"data": [{{"txID": "{VALID_TX_HASH}"}}], "success": true}}"#
3038 ))
3039 .create_async()
3040 .await;
3041 let txs = server
3042 .mock(
3043 "GET",
3044 format!("/v1/accounts/{VALID_ADDRESS}/transactions").as_str(),
3045 )
3046 .match_query(mockito::Matcher::UrlEncoded("limit".into(), "3".into()))
3047 .match_header("TRON-PRO-API-KEY", "k4")
3048 .with_status(200)
3049 .with_body(r#"{"data": [], "success": true}"#)
3050 .create_async()
3051 .await;
3052
3053 let client = mocked_client(&server, Some("k4"));
3054 let got = client.get_transaction(VALID_TX_HASH).await.unwrap();
3055 assert_eq!(got.hash, VALID_TX_HASH);
3056 assert_eq!(got.from, "");
3057 assert_eq!(got.value, "0");
3058 assert!(
3059 client
3060 .get_transactions(VALID_ADDRESS, 3)
3061 .await
3062 .unwrap()
3063 .is_empty()
3064 );
3065 tx.assert_async().await;
3066 txs.assert_async().await;
3067 }
3068
3069 #[test]
3070 fn test_validate_tron_address_rejects_non_mainnet_prefix() {
3071 let mut bytes = vec![0x42u8];
3074 bytes.extend(0u8..20);
3075 let checksum = Sha256::digest(Sha256::digest(&bytes));
3076 bytes.extend_from_slice(&checksum[..4]);
3077 let address = bs58::encode(&bytes).into_string();
3078 assert_eq!(address, "TZJqCCFeCFdJJaGGgNhTbhcLdyjmqUrgFq");
3079
3080 let err = validate_tron_address(&address).unwrap_err();
3081 assert!(err.to_string().contains("prefix"), "{err}");
3082 }
3083
3084 #[test]
3085 #[allow(deprecated)]
3086 fn test_deprecated_new_reads_the_config() {
3087 let config = ChainsConfig {
3088 tron_api: Some("http://127.0.0.1:9".to_string()),
3089 ..Default::default()
3090 };
3091 let client = TronClient::new(&config).unwrap();
3092 assert_eq!(client.api_url, "http://127.0.0.1:9");
3093 assert!(client.api_key.is_none());
3094 }
3095}