1use crate::domain::transaction::stellar::utils::{
3 create_contract_data_key, extract_scval_from_contract_data, extract_u32_from_scval,
4 parse_account_id, parse_contract_address, parse_ledger_entry_from_xdr,
5 query_contract_data_with_fallback, StellarTransactionUtilsError,
6};
7use crate::models::{StellarTokenKind, StellarTokenMetadata};
8use crate::services::provider::StellarProviderTrait;
9use soroban_rs::xdr::{
10 AccountId, AlphaNum12, AlphaNum4, Asset, AssetCode12, AssetCode4, ContractId, Hash,
11 LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, TrustLineEntryExt,
12 TrustLineEntryV1,
13};
14use std::str::FromStr;
15use tracing::{debug, trace, warn};
16
17const STELLAR_ADDRESS_LENGTH: usize = 56;
19const MAX_ASSET_CODE_LENGTH: usize = 12;
20const DEFAULT_STELLAR_DECIMALS: u32 = 7;
21const STELLAR_ACCOUNT_PREFIX: char = 'G';
22
23fn parse_asset_identifier(asset_id: &str) -> Result<(&str, &str), StellarTransactionUtilsError> {
37 asset_id
38 .split_once(':')
39 .ok_or_else(|| StellarTransactionUtilsError::InvalidAssetFormat(asset_id.to_string()))
40}
41
42fn validate_and_parse_issuer(
59 issuer: &str,
60 asset_id: &str,
61) -> Result<AccountId, StellarTransactionUtilsError> {
62 if issuer.is_empty() {
63 return Err(StellarTransactionUtilsError::EmptyIssuerAddress(
64 asset_id.to_string(),
65 ));
66 }
67
68 if issuer.len() != STELLAR_ADDRESS_LENGTH {
69 return Err(StellarTransactionUtilsError::InvalidIssuerLength(
70 STELLAR_ADDRESS_LENGTH,
71 issuer.to_string(),
72 ));
73 }
74
75 if !issuer.starts_with(STELLAR_ACCOUNT_PREFIX) {
76 return Err(StellarTransactionUtilsError::InvalidIssuerPrefix(
77 STELLAR_ACCOUNT_PREFIX,
78 issuer.to_string(),
79 ));
80 }
81
82 parse_account_id(issuer)
84}
85
86pub async fn get_token_balance<P>(
112 provider: &P,
113 account_id: &str,
114 asset_id: &str,
115) -> Result<u64, StellarTransactionUtilsError>
116where
117 P: StellarProviderTrait + Send + Sync,
118{
119 if asset_id == "native" || asset_id == "XLM" {
121 let account_entry = provider
122 .get_account(account_id)
123 .await
124 .map_err(|e| StellarTransactionUtilsError::AccountFetchFailed(e.to_string()))?;
125 return Ok(account_entry.balance as u64);
126 }
127
128 if ContractId::from_str(asset_id).is_ok() {
130 return get_contract_token_balance(provider, account_id, asset_id).await;
131 }
132
133 get_asset_trustline_balance(provider, account_id, asset_id).await
135}
136
137async fn get_asset_trustline_balance<P>(
143 provider: &P,
144 account_id: &str,
145 asset_id: &str,
146) -> Result<u64, StellarTransactionUtilsError>
147where
148 P: StellarProviderTrait + Send + Sync,
149{
150 let (code, issuer) = parse_asset_identifier(asset_id)?;
151
152 if code.is_empty() || code.len() > MAX_ASSET_CODE_LENGTH {
155 return Err(StellarTransactionUtilsError::AssetCodeTooLong(
156 MAX_ASSET_CODE_LENGTH,
157 code.to_string(),
158 ));
159 }
160
161 let issuer_id = parse_account_id(issuer)?;
162 let account_xdr = parse_account_id(account_id)?;
163
164 let asset = if code.len() <= 4 {
165 let mut buf = [0u8; 4];
166 buf[..code.len()].copy_from_slice(code.as_bytes());
167 Asset::CreditAlphanum4(AlphaNum4 {
168 asset_code: AssetCode4(buf),
169 issuer: issuer_id,
170 })
171 } else {
172 let mut buf = [0u8; 12];
173 buf[..code.len()].copy_from_slice(code.as_bytes());
174 Asset::CreditAlphanum12(AlphaNum12 {
175 asset_code: AssetCode12(buf),
176 issuer: issuer_id,
177 })
178 };
179
180 let ledger_key = LedgerKey::Trustline(soroban_rs::xdr::LedgerKeyTrustLine {
181 account_id: account_xdr,
182 asset: match asset {
183 Asset::CreditAlphanum4(a) => soroban_rs::xdr::TrustLineAsset::CreditAlphanum4(a),
184 Asset::CreditAlphanum12(a) => soroban_rs::xdr::TrustLineAsset::CreditAlphanum12(a),
185 Asset::Native => return Err(StellarTransactionUtilsError::NativeAssetInTrustlineQuery),
186 },
187 });
188
189 let resp = provider
190 .get_ledger_entries(&[ledger_key])
191 .await
192 .map_err(|e| {
193 StellarTransactionUtilsError::TrustlineQueryFailed(asset_id.into(), e.to_string())
194 })?;
195
196 let entries = resp.entries.ok_or_else(|| {
197 StellarTransactionUtilsError::NoTrustlineFound(asset_id.into(), account_id.into())
198 })?;
199
200 if entries.is_empty() {
201 return Err(StellarTransactionUtilsError::NoTrustlineFound(
202 asset_id.into(),
203 account_id.into(),
204 ));
205 }
206
207 let entry = parse_ledger_entry_from_xdr(&entries[0].xdr, asset_id)?;
208
209 match entry {
210 LedgerEntryData::Trustline(TrustLineEntry {
211 balance,
212 ext: TrustLineEntryExt::V1(TrustLineEntryV1 { liabilities, .. }),
213 ..
214 }) => {
215 let available_balance = balance.saturating_sub(liabilities.selling);
218 debug!(
219 account_id = %account_id,
220 asset_id = %asset_id,
221 total_balance = balance,
222 selling_liabilities = liabilities.selling,
223 buying_liabilities = liabilities.buying,
224 available_balance = available_balance,
225 "Trustline balance retrieved (V1 with liabilities)"
226 );
227 Ok(available_balance.max(0) as u64)
228 }
229 LedgerEntryData::Trustline(TrustLineEntry {
230 balance,
231 ext: TrustLineEntryExt::V0,
232 ..
233 }) => {
234 debug!(
236 account_id = %account_id,
237 asset_id = %asset_id,
238 balance_raw = balance,
239 balance_u64 = balance as u64,
240 "Trustline balance retrieved (V0, no liabilities)"
241 );
242 Ok(balance.max(0) as u64)
243 }
244
245 _ => Err(StellarTransactionUtilsError::UnexpectedTrustlineEntryType),
246 }
247}
248
249async fn get_contract_token_balance<P>(
260 provider: &P,
261 account_id: &str,
262 contract_address: &str,
263) -> Result<u64, StellarTransactionUtilsError>
264where
265 P: StellarProviderTrait + Send + Sync,
266{
267 let account_xdr_id = parse_account_id(account_id)?;
269 let account_sc_address = ScAddress::Account(account_xdr_id);
270
271 let function_name = ScSymbol::try_from("balance".as_bytes().to_vec()).map_err(|e| {
273 StellarTransactionUtilsError::SymbolCreationFailed("balance".into(), format!("{e:?}"))
274 })?;
275
276 debug!(
278 "Querying balance for account {} on contract {} via simulation",
279 account_id, contract_address
280 );
281
282 let result = provider
283 .call_contract(
284 contract_address,
285 &function_name,
286 vec![ScVal::Address(account_sc_address)],
287 )
288 .await
289 .map_err(|e| StellarTransactionUtilsError::SimulationFailed(e.to_string()))?;
290
291 match result {
293 ScVal::I128(parts) => {
294 if parts.hi != 0 {
296 return Err(StellarTransactionUtilsError::BalanceTooLarge(
297 parts.hi, parts.lo,
298 ));
299 }
300 let lo_as_i64 = parts.lo as i64;
302 if lo_as_i64 < 0 {
303 return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo));
304 }
305 debug!(
306 "Balance for account {} on contract {}: {}",
307 account_id, contract_address, lo_as_i64
308 );
309 Ok(lo_as_i64 as u64)
310 }
311 other => Err(StellarTransactionUtilsError::UnexpectedBalanceType(
312 format!("{other:?}"),
313 )),
314 }
315}
316
317pub async fn get_token_metadata<P>(
347 provider: &P,
348 asset_id: &str,
349) -> Result<StellarTokenMetadata, StellarTransactionUtilsError>
350where
351 P: StellarProviderTrait + Send + Sync,
352{
353 if asset_id == "native" || asset_id == "XLM" || asset_id.is_empty() {
355 return Ok(StellarTokenMetadata {
356 kind: StellarTokenKind::Native,
357 decimals: DEFAULT_STELLAR_DECIMALS,
358 canonical_asset_id: "native".to_string(),
359 });
360 }
361
362 if ContractId::from_str(asset_id).is_ok() {
364 let decimals = get_contract_token_decimals(provider, asset_id)
366 .await
367 .unwrap_or_else(|| {
368 warn!(
369 contract_address = %asset_id,
370 "Could not fetch decimals from contract, using default"
371 );
372 DEFAULT_STELLAR_DECIMALS
373 });
374
375 return Ok(StellarTokenMetadata {
376 kind: StellarTokenKind::Contract {
377 contract_id: asset_id.to_string(),
378 },
379 decimals,
380 canonical_asset_id: asset_id.to_uppercase().to_string(),
381 });
382 }
383
384 let (code, issuer) = parse_asset_identifier(asset_id)?;
387
388 if code.is_empty() {
390 return Err(StellarTransactionUtilsError::EmptyAssetCode(
391 asset_id.to_string(),
392 ));
393 }
394
395 if code.len() > MAX_ASSET_CODE_LENGTH {
396 return Err(StellarTransactionUtilsError::AssetCodeTooLong(
397 MAX_ASSET_CODE_LENGTH,
398 code.to_string(),
399 ));
400 }
401
402 validate_and_parse_issuer(issuer, asset_id)?;
404
405 Ok(StellarTokenMetadata {
408 kind: StellarTokenKind::Classic {
409 code: code.to_string(),
410 issuer: issuer.to_string(),
411 },
412 decimals: DEFAULT_STELLAR_DECIMALS,
413 canonical_asset_id: asset_id.to_string(),
414 })
415}
416
417pub async fn get_contract_token_decimals<P>(provider: &P, contract_address: &str) -> Option<u32>
439where
440 P: StellarProviderTrait + Send + Sync,
441{
442 debug!(
443 contract_address = %contract_address,
444 "Fetching decimals for contract token"
445 );
446
447 let contract_hash = match parse_contract_address(contract_address) {
449 Ok(hash) => hash,
450 Err(e) => {
451 warn!(
452 contract_address = %contract_address,
453 error = %e,
454 "Failed to parse contract address"
455 );
456 return None;
457 }
458 };
459
460 if let Some(decimals) = invoke_decimals_function(provider, contract_address).await {
462 debug!(
463 contract_address = %contract_address,
464 decimals = %decimals,
465 "Successfully fetched decimals via contract invocation"
466 );
467 return Some(decimals);
468 }
469
470 debug!(
472 contract_address = %contract_address,
473 "Contract invocation failed, trying storage query"
474 );
475
476 query_decimals_from_storage(provider, contract_address, contract_hash).await
477}
478
479async fn invoke_decimals_function<P>(provider: &P, contract_address: &str) -> Option<u32>
492where
493 P: StellarProviderTrait + Send + Sync,
494{
495 let function_name = match ScSymbol::try_from("decimals") {
497 Ok(sym) => sym,
498 Err(e) => {
499 warn!(contract_address = %contract_address, error = ?e, "Failed to create decimals symbol");
500 return None;
501 }
502 };
503
504 let args: Vec<ScVal> = vec![];
506
507 match provider
509 .call_contract(contract_address, &function_name, args)
510 .await
511 {
512 Ok(result) => extract_u32_from_scval(&result, "decimals() result"),
513 Err(e) => {
514 debug!(contract_address = %contract_address, error = %e, "Failed to invoke decimals() function");
515 None
516 }
517 }
518}
519
520async fn query_decimals_from_storage<P>(
535 provider: &P,
536 contract_address: &str,
537 contract_hash: Hash,
538) -> Option<u32>
539where
540 P: StellarProviderTrait + Send + Sync,
541{
542 let decimals_key = match create_contract_data_key("Decimals", None) {
544 Ok(key) => key,
545 Err(e) => {
546 warn!(
547 contract_address = %contract_address,
548 error = %e,
549 "Failed to create Decimals key"
550 );
551 return None;
552 }
553 };
554
555 let error_context = format!("contract {contract_address} decimals");
557 let ledger_entries = match query_contract_data_with_fallback(
558 provider,
559 contract_hash,
560 decimals_key,
561 &error_context,
562 )
563 .await
564 {
565 Ok(entries) => entries,
566 Err(e) => {
567 debug!(
568 contract_address = %contract_address,
569 error = %e,
570 "Failed to query contract data for decimals"
571 );
572 return None;
573 }
574 };
575
576 let val = match extract_scval_from_contract_data(&ledger_entries, &error_context) {
578 Ok(v) => v,
579 Err(_) => {
580 trace!(
581 contract_address = %contract_address,
582 "No decimals entry found in contract data"
583 );
584 return None;
585 }
586 };
587
588 extract_u32_from_scval(&val, "decimals storage value")
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::domain::transaction::stellar::test_helpers::{create_account_id, TEST_PK};
596 use crate::services::provider::MockStellarProviderTrait;
597 use futures::future::ready;
598 use mockall::predicate::*;
599 use soroban_rs::xdr::{AccountEntry, AccountEntryExt, SequenceNumber, Thresholds};
600 use std::str::FromStr;
601
602 fn create_mock_provider() -> MockStellarProviderTrait {
604 MockStellarProviderTrait::new()
605 }
606
607 fn create_mock_account_entry(balance: i64) -> AccountEntry {
609 AccountEntry {
610 account_id: create_account_id(TEST_PK),
611 balance,
612 seq_num: SequenceNumber(1),
613 num_sub_entries: 0,
614 inflation_dest: None,
615 flags: 0,
616 home_domain: Default::default(),
617 thresholds: Thresholds([1, 0, 0, 0]),
618 signers: Default::default(),
619 ext: AccountEntryExt::V0,
620 }
621 }
622
623 #[test]
624 fn test_parse_asset_identifier_valid() {
625 let result =
626 parse_asset_identifier("USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
627 assert!(result.is_ok());
628 let (code, issuer) = result.unwrap();
629 assert_eq!(code, "USDC");
630 assert_eq!(
631 issuer,
632 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
633 );
634 }
635
636 #[test]
637 fn test_parse_asset_identifier_invalid() {
638 let result =
640 parse_asset_identifier("USDCGBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
641 assert!(result.is_err());
642 match result.unwrap_err() {
643 StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
644 e => panic!("Expected InvalidAssetFormat, got: {e:?}"),
645 }
646
647 let result = parse_asset_identifier("");
649 assert!(result.is_err());
650 }
651
652 #[test]
653 fn test_parse_asset_identifier_multiple_colons() {
654 let result = parse_asset_identifier(
656 "USD:C:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
657 );
658 assert!(result.is_ok());
659 let (code, issuer) = result.unwrap();
660 assert_eq!(code, "USD");
661 assert_eq!(
662 issuer,
663 "C:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
664 );
665 }
666
667 #[test]
668 fn test_validate_and_parse_issuer_valid() {
669 let issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
670 let result = validate_and_parse_issuer(issuer, "USDC:GBBD47...");
671 assert!(result.is_ok());
672 }
673
674 #[test]
675 fn test_validate_and_parse_issuer_empty() {
676 let result = validate_and_parse_issuer("", "USDC:");
677 assert!(result.is_err());
678 match result.unwrap_err() {
679 StellarTransactionUtilsError::EmptyIssuerAddress(_) => {}
680 e => panic!("Expected EmptyIssuerAddress, got: {e:?}"),
681 }
682 }
683
684 #[test]
685 fn test_validate_and_parse_issuer_wrong_length() {
686 let result = validate_and_parse_issuer("SHORTADDR", "USDC:SHORTADDR");
687 assert!(result.is_err());
688 match result.unwrap_err() {
689 StellarTransactionUtilsError::InvalidIssuerLength(expected, _) => {
690 assert_eq!(expected, STELLAR_ADDRESS_LENGTH);
691 }
692 e => panic!("Expected InvalidIssuerLength, got: {e:?}"),
693 }
694 }
695
696 #[test]
697 fn test_validate_and_parse_issuer_wrong_prefix() {
698 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
700 let result = validate_and_parse_issuer(contract_addr, "USDC:C...");
701 assert!(result.is_err());
702 match result.unwrap_err() {
703 StellarTransactionUtilsError::InvalidIssuerPrefix(expected, _) => {
704 assert_eq!(expected, STELLAR_ACCOUNT_PREFIX);
705 }
706 e => panic!("Expected InvalidIssuerPrefix, got: {e:?}"),
707 }
708 }
709
710 #[test]
711 fn test_validate_and_parse_issuer_invalid_checksum() {
712 let bad_issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA6"; let result = validate_and_parse_issuer(bad_issuer, "USDC:G...");
715 assert!(result.is_err());
716 }
717
718 #[tokio::test]
719 async fn test_get_token_balance_native_xlm() {
720 let mut provider = create_mock_provider();
721
722 let test_balance = 100_0000000i64; let account_entry = create_mock_account_entry(test_balance);
724
725 provider
726 .expect_get_account()
727 .with(eq(TEST_PK))
728 .times(1)
729 .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
730
731 let result = get_token_balance(&provider, TEST_PK, "native").await;
732
733 assert!(result.is_ok());
734 assert_eq!(result.unwrap(), test_balance as u64);
735 }
736
737 #[tokio::test]
738 async fn test_get_token_balance_xlm_identifier() {
739 let mut provider = create_mock_provider();
740
741 let test_balance = 50_0000000i64; let account_entry = create_mock_account_entry(test_balance);
743
744 provider
745 .expect_get_account()
746 .with(eq(TEST_PK))
747 .times(1)
748 .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
749
750 let result = get_token_balance(&provider, TEST_PK, "XLM").await;
752
753 assert!(result.is_ok());
754 assert_eq!(result.unwrap(), test_balance as u64);
755 }
756
757 #[test]
758 fn test_asset_code_length_validation() {
759 assert!(parse_asset_identifier(
761 "A:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
762 )
763 .is_ok());
764 assert!(parse_asset_identifier(
765 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
766 )
767 .is_ok());
768 assert!(parse_asset_identifier(
769 "MAXLENCODE12:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
770 )
771 .is_ok());
772
773 let (code, _) =
775 parse_asset_identifier(":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")
776 .unwrap();
777 assert_eq!(code, "");
778 }
779
780 #[tokio::test]
781 async fn test_get_token_metadata_native() {
782 let provider = create_mock_provider();
783
784 let result = get_token_metadata(&provider, "native").await;
785 assert!(result.is_ok());
786 let metadata = result.unwrap();
787
788 assert_eq!(metadata.kind, StellarTokenKind::Native);
789 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
790 assert_eq!(metadata.canonical_asset_id, "native");
791 }
792
793 #[tokio::test]
794 async fn test_get_token_metadata_xlm_identifier() {
795 let provider = create_mock_provider();
796
797 let result = get_token_metadata(&provider, "XLM").await;
798 assert!(result.is_ok());
799 let metadata = result.unwrap();
800
801 assert_eq!(metadata.kind, StellarTokenKind::Native);
802 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
803 assert_eq!(metadata.canonical_asset_id, "native");
804 }
805
806 #[tokio::test]
807 async fn test_get_token_metadata_empty_string() {
808 let provider = create_mock_provider();
809
810 let result = get_token_metadata(&provider, "").await;
812 assert!(result.is_ok());
813 let metadata = result.unwrap();
814
815 assert_eq!(metadata.kind, StellarTokenKind::Native);
816 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
817 assert_eq!(metadata.canonical_asset_id, "native");
818 }
819
820 #[tokio::test]
821 async fn test_get_token_metadata_classic_asset() {
822 let provider = create_mock_provider();
823
824 let asset_id = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
825 let result = get_token_metadata(&provider, asset_id).await;
826 assert!(result.is_ok());
827 let metadata = result.unwrap();
828
829 match metadata.kind {
830 StellarTokenKind::Classic { code, issuer } => {
831 assert_eq!(code, "USDC");
832 assert_eq!(
833 issuer,
834 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
835 );
836 }
837 _ => panic!("Expected Classic token kind"),
838 }
839 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
840 assert_eq!(metadata.canonical_asset_id, asset_id);
841 }
842
843 #[tokio::test]
844 async fn test_get_token_metadata_classic_asset_credit12() {
845 let provider = create_mock_provider();
846
847 let asset_id = "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
848 let result = get_token_metadata(&provider, asset_id).await;
849 assert!(result.is_ok());
850 let metadata = result.unwrap();
851
852 match metadata.kind {
853 StellarTokenKind::Classic { code, issuer } => {
854 assert_eq!(code, "LONGASSETCD");
855 assert_eq!(
856 issuer,
857 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
858 );
859 }
860 _ => panic!("Expected Classic token kind"),
861 }
862 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
863 }
864
865 #[tokio::test]
866 async fn test_get_token_metadata_invalid_format() {
867 let provider = create_mock_provider();
868
869 let result = get_token_metadata(&provider, "INVALID_NO_COLON").await;
870 assert!(result.is_err());
871 match result.unwrap_err() {
872 StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
873 e => panic!("Expected InvalidAssetFormat, got: {e:?}"),
874 }
875 }
876
877 #[tokio::test]
878 async fn test_get_token_metadata_empty_code() {
879 let provider = create_mock_provider();
880
881 let result = get_token_metadata(
882 &provider,
883 ":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
884 )
885 .await;
886 assert!(result.is_err());
887 match result.unwrap_err() {
888 StellarTransactionUtilsError::EmptyAssetCode(_) => {}
889 e => panic!("Expected EmptyAssetCode, got: {e:?}"),
890 }
891 }
892
893 #[tokio::test]
894 async fn test_get_token_metadata_code_too_long() {
895 let provider = create_mock_provider();
896
897 let result = get_token_metadata(
898 &provider,
899 "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
900 )
901 .await;
902 assert!(result.is_err());
903 match result.unwrap_err() {
904 StellarTransactionUtilsError::AssetCodeTooLong(max, code) => {
905 assert_eq!(max, MAX_ASSET_CODE_LENGTH);
906 assert_eq!(code, "VERYLONGASSETCODE");
907 }
908 e => panic!("Expected AssetCodeTooLong, got: {e:?}"),
909 }
910 }
911
912 #[tokio::test]
913 async fn test_get_token_metadata_empty_issuer() {
914 let provider = create_mock_provider();
915
916 let result = get_token_metadata(&provider, "USDC:").await;
917 assert!(result.is_err());
918 match result.unwrap_err() {
919 StellarTransactionUtilsError::EmptyIssuerAddress(_) => {}
920 e => panic!("Expected EmptyIssuerAddress, got: {e:?}"),
921 }
922 }
923
924 #[tokio::test]
925 async fn test_get_token_metadata_invalid_issuer_length() {
926 let provider = create_mock_provider();
927
928 let result = get_token_metadata(&provider, "USDC:INVALID").await;
929 assert!(result.is_err());
930 match result.unwrap_err() {
931 StellarTransactionUtilsError::InvalidIssuerLength(expected, _) => {
932 assert_eq!(expected, STELLAR_ADDRESS_LENGTH);
933 }
934 e => panic!("Expected InvalidIssuerLength, got: {e:?}"),
935 }
936 }
937
938 #[tokio::test]
939 async fn test_get_token_metadata_invalid_issuer_prefix() {
940 let provider = create_mock_provider();
941
942 let result = get_token_metadata(
944 &provider,
945 "USDC:CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
946 )
947 .await;
948 assert!(result.is_err());
949 match result.unwrap_err() {
950 StellarTransactionUtilsError::InvalidIssuerPrefix(expected, _) => {
951 assert_eq!(expected, STELLAR_ACCOUNT_PREFIX);
952 }
953 e => panic!("Expected InvalidIssuerPrefix, got: {e:?}"),
954 }
955 }
956
957 #[tokio::test]
958 async fn test_get_token_metadata_contract_valid() {
959 let mut provider = create_mock_provider();
960
961 provider.expect_call_contract().returning(|_, _, _| {
963 Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
964 "Contract call failed".to_string(),
965 ))))
966 });
967
968 provider.expect_get_ledger_entries().returning(|_| {
969 Box::pin(ready(Ok(
970 soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
971 entries: None,
972 latest_ledger: 0,
973 },
974 )))
975 });
976
977 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
978 let result = get_token_metadata(&provider, contract_addr).await;
979 assert!(result.is_ok());
980 let metadata = result.unwrap();
981
982 match metadata.kind {
983 StellarTokenKind::Contract { contract_id } => {
984 assert_eq!(contract_id, contract_addr);
985 }
986 _ => panic!("Expected Contract token kind"),
987 }
988 assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
989 assert_eq!(metadata.canonical_asset_id, contract_addr.to_uppercase());
990 }
991
992 #[tokio::test]
993 async fn test_get_token_balance_trustline_v0_success() {
994 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
995 use soroban_rs::xdr::{
996 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, WriteXdr,
997 };
998
999 let mut provider = create_mock_provider();
1000
1001 provider.expect_get_ledger_entries().returning(|_| {
1003 let trustline_entry = TrustLineEntry {
1004 account_id: create_account_id(TEST_PK),
1005 asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1006 asset_code: AssetCode4(*b"USDC"),
1007 issuer: create_account_id(
1008 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1009 ),
1010 }),
1011 balance: 10_0000000, limit: 1000_0000000,
1013 flags: 1,
1014 ext: TrustLineEntryExt::V0,
1015 };
1016
1017 let ledger_entry = LedgerEntry {
1018 last_modified_ledger_seq: 0,
1019 data: LedgerEntryData::Trustline(trustline_entry),
1020 ext: LedgerEntryExt::V0,
1021 };
1022
1023 let xdr_base64 = ledger_entry
1024 .data
1025 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1026 .unwrap();
1027
1028 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1029 entries: Some(vec![LedgerEntryResult {
1030 key: String::new(),
1031 xdr: xdr_base64,
1032 last_modified_ledger: 0,
1033 live_until_ledger_seq_ledger_seq: None,
1034 }]),
1035 latest_ledger: 0,
1036 })))
1037 });
1038
1039 let account = TEST_PK;
1040 let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1041
1042 let result = get_token_balance(&provider, account, asset).await;
1043 assert!(result.is_ok());
1044 assert_eq!(result.unwrap(), 10_0000000);
1045 }
1046
1047 #[tokio::test]
1048 async fn test_get_token_balance_trustline_v1_with_liabilities() {
1049 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1050 use soroban_rs::xdr::{
1051 LedgerEntry, LedgerEntryData, LedgerEntryExt, Liabilities, TrustLineAsset,
1052 TrustLineEntryV1, TrustLineEntryV1Ext, WriteXdr,
1053 };
1054
1055 let mut provider = create_mock_provider();
1056
1057 provider.expect_get_ledger_entries().returning(|_| {
1059 let trustline_entry = TrustLineEntry {
1060 account_id: create_account_id(TEST_PK),
1061 asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1062 asset_code: AssetCode4(*b"USDC"),
1063 issuer: create_account_id(
1064 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1065 ),
1066 }),
1067 balance: 10_0000000, limit: 1000_0000000,
1069 flags: 1,
1070 ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1071 liabilities: Liabilities {
1072 buying: 1_0000000, selling: 2_0000000, },
1075 ext: TrustLineEntryV1Ext::V0,
1076 }),
1077 };
1078
1079 let ledger_entry = LedgerEntry {
1080 last_modified_ledger_seq: 0,
1081 data: LedgerEntryData::Trustline(trustline_entry),
1082 ext: LedgerEntryExt::V0,
1083 };
1084
1085 let xdr_base64 = ledger_entry
1086 .data
1087 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1088 .unwrap();
1089
1090 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1091 entries: Some(vec![LedgerEntryResult {
1092 key: String::new(),
1093 xdr: xdr_base64,
1094 last_modified_ledger: 0,
1095 live_until_ledger_seq_ledger_seq: None,
1096 }]),
1097 latest_ledger: 0,
1098 })))
1099 });
1100
1101 let account = TEST_PK;
1102 let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1103
1104 let result = get_token_balance(&provider, account, asset).await;
1105 assert!(result.is_ok());
1106 assert_eq!(result.unwrap(), 8_0000000);
1108 }
1109
1110 #[tokio::test]
1111 async fn test_get_token_balance_trustline_v1_selling_exceeds_balance() {
1112 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1113 use soroban_rs::xdr::{
1114 LedgerEntry, LedgerEntryData, LedgerEntryExt, Liabilities, TrustLineAsset,
1115 TrustLineEntryV1, TrustLineEntryV1Ext, WriteXdr,
1116 };
1117
1118 let mut provider = create_mock_provider();
1119
1120 provider.expect_get_ledger_entries().returning(|_| {
1122 let trustline_entry = TrustLineEntry {
1123 account_id: create_account_id(TEST_PK),
1124 asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1125 asset_code: AssetCode4(*b"USDC"),
1126 issuer: create_account_id(
1127 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1128 ),
1129 }),
1130 balance: 5_0000000, limit: 1000_0000000,
1132 flags: 1,
1133 ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1134 liabilities: Liabilities {
1135 buying: 0,
1136 selling: 10_0000000, },
1138 ext: TrustLineEntryV1Ext::V0,
1139 }),
1140 };
1141
1142 let ledger_entry = LedgerEntry {
1143 last_modified_ledger_seq: 0,
1144 data: LedgerEntryData::Trustline(trustline_entry),
1145 ext: LedgerEntryExt::V0,
1146 };
1147
1148 let xdr_base64 = ledger_entry
1149 .data
1150 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1151 .unwrap();
1152
1153 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1154 entries: Some(vec![LedgerEntryResult {
1155 key: String::new(),
1156 xdr: xdr_base64,
1157 last_modified_ledger: 0,
1158 live_until_ledger_seq_ledger_seq: None,
1159 }]),
1160 latest_ledger: 0,
1161 })))
1162 });
1163
1164 let account = TEST_PK;
1165 let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1166
1167 let result = get_token_balance(&provider, account, asset).await;
1168 assert!(result.is_ok());
1169 assert_eq!(result.unwrap(), 0);
1171 }
1172
1173 #[tokio::test]
1174 async fn test_get_token_balance_trustline_not_found() {
1175 use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1176
1177 let mut provider = create_mock_provider();
1178
1179 provider.expect_get_ledger_entries().returning(|_| {
1181 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1182 entries: None,
1183 latest_ledger: 0,
1184 })))
1185 });
1186
1187 let account = TEST_PK;
1188 let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1189
1190 let result = get_token_balance(&provider, account, asset).await;
1191 assert!(result.is_err());
1192 match result.unwrap_err() {
1193 StellarTransactionUtilsError::NoTrustlineFound(asset_id, account_id) => {
1194 assert_eq!(asset_id, asset);
1195 assert_eq!(account_id, account);
1196 }
1197 e => panic!("Expected NoTrustlineFound, got: {e:?}"),
1198 }
1199 }
1200
1201 #[tokio::test]
1202 async fn test_get_token_balance_trustline_empty_entries() {
1203 use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1204
1205 let mut provider = create_mock_provider();
1206
1207 provider.expect_get_ledger_entries().returning(|_| {
1209 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1210 entries: Some(vec![]),
1211 latest_ledger: 0,
1212 })))
1213 });
1214
1215 let account = TEST_PK;
1216 let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1217
1218 let result = get_token_balance(&provider, account, asset).await;
1219 assert!(result.is_err());
1220 match result.unwrap_err() {
1221 StellarTransactionUtilsError::NoTrustlineFound(asset_id, account_id) => {
1222 assert_eq!(asset_id, asset);
1223 assert_eq!(account_id, account);
1224 }
1225 e => panic!("Expected NoTrustlineFound, got: {e:?}"),
1226 }
1227 }
1228
1229 #[tokio::test]
1230 async fn test_get_token_balance_trustline_credit12() {
1231 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1232 use soroban_rs::xdr::{
1233 LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, WriteXdr,
1234 };
1235
1236 let mut provider = create_mock_provider();
1237
1238 provider.expect_get_ledger_entries().returning(|_| {
1240 let trustline_entry = TrustLineEntry {
1241 account_id: create_account_id(TEST_PK),
1242 asset: TrustLineAsset::CreditAlphanum12(AlphaNum12 {
1243 asset_code: AssetCode12(*b"LONGASSETCD\0"),
1244 issuer: create_account_id(
1245 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1246 ),
1247 }),
1248 balance: 25_0000000, limit: 1000_0000000,
1250 flags: 1,
1251 ext: TrustLineEntryExt::V0,
1252 };
1253
1254 let ledger_entry = LedgerEntry {
1255 last_modified_ledger_seq: 0,
1256 data: LedgerEntryData::Trustline(trustline_entry),
1257 ext: LedgerEntryExt::V0,
1258 };
1259
1260 let xdr_base64 = ledger_entry
1261 .data
1262 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1263 .unwrap();
1264
1265 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1266 entries: Some(vec![LedgerEntryResult {
1267 key: String::new(),
1268 xdr: xdr_base64,
1269 last_modified_ledger: 0,
1270 live_until_ledger_seq_ledger_seq: None,
1271 }]),
1272 latest_ledger: 0,
1273 })))
1274 });
1275
1276 let account = TEST_PK;
1277 let asset = "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1278
1279 let result = get_token_balance(&provider, account, asset).await;
1280 assert!(result.is_ok());
1281 assert_eq!(result.unwrap(), 25_0000000);
1282 }
1283
1284 #[tokio::test]
1285 async fn test_get_token_balance_trustline_invalid_asset_code_too_long() {
1286 let provider = create_mock_provider();
1287
1288 let account = TEST_PK;
1289 let asset = "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1290
1291 let result = get_token_balance(&provider, account, asset).await;
1292 assert!(result.is_err());
1293 match result.unwrap_err() {
1294 StellarTransactionUtilsError::AssetCodeTooLong(max, code) => {
1295 assert_eq!(max, MAX_ASSET_CODE_LENGTH);
1296 assert_eq!(code, "VERYLONGASSETCODE");
1297 }
1298 e => panic!("Expected AssetCodeTooLong, got: {e:?}"),
1299 }
1300 }
1301
1302 #[test]
1303 fn test_constants() {
1304 assert_eq!(STELLAR_ADDRESS_LENGTH, 56);
1306 assert_eq!(MAX_ASSET_CODE_LENGTH, 12);
1307 assert_eq!(DEFAULT_STELLAR_DECIMALS, 7);
1308 assert_eq!(STELLAR_ACCOUNT_PREFIX, 'G');
1309 }
1310
1311 #[test]
1312 fn test_contract_id_validation() {
1313 let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1315 assert!(ContractId::from_str(valid_contract).is_ok());
1316
1317 let account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1319 assert!(ContractId::from_str(account).is_err());
1320
1321 assert!(ContractId::from_str("INVALID").is_err());
1323 assert!(ContractId::from_str("").is_err());
1324 }
1325
1326 #[test]
1327 fn test_asset_identifier_edge_cases() {
1328 let result =
1330 parse_asset_identifier("USD :GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1331 assert!(result.is_ok());
1332 let (code, _) = result.unwrap();
1333 assert_eq!(code, "USD "); let result =
1337 parse_asset_identifier("U$D:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1338 assert!(result.is_ok());
1339 let (code, _) = result.unwrap();
1340 assert_eq!(code, "U$D");
1341 }
1342
1343 #[tokio::test]
1344 async fn test_get_token_balance_contract_token_no_balance_entry() {
1345 use soroban_rs::xdr::Int128Parts;
1346
1347 let mut provider = create_mock_provider();
1348
1349 provider
1351 .expect_call_contract()
1352 .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 0 })))));
1353
1354 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1355 let account = TEST_PK;
1356
1357 let result = get_token_balance(&provider, account, contract_addr).await;
1358
1359 assert!(result.is_ok());
1361 assert_eq!(result.unwrap(), 0);
1362 }
1363
1364 #[tokio::test]
1365 async fn test_get_token_balance_contract_token_i128_balance() {
1366 use soroban_rs::xdr::Int128Parts;
1367
1368 let mut provider = create_mock_provider();
1369
1370 provider.expect_call_contract().returning(|_, _, _| {
1372 Box::pin(ready(Ok(ScVal::I128(Int128Parts { hi: 0, lo: 1000000 }))))
1373 });
1374
1375 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1376 let account = TEST_PK;
1377
1378 let result = get_token_balance(&provider, account, contract_addr).await;
1379 assert!(result.is_ok());
1380 assert_eq!(result.unwrap(), 1000000);
1381 }
1382
1383 #[tokio::test]
1384 async fn test_get_token_balance_contract_token_i128_balance_too_large() {
1385 use soroban_rs::xdr::Int128Parts;
1386
1387 let mut provider = create_mock_provider();
1388
1389 provider.expect_call_contract().returning(|_, _, _| {
1391 Box::pin(ready(Ok(ScVal::I128(Int128Parts {
1392 hi: 1, lo: 1000000,
1394 }))))
1395 });
1396
1397 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1398 let account = TEST_PK;
1399
1400 let result = get_token_balance(&provider, account, contract_addr).await;
1401 assert!(result.is_err());
1402 match result.unwrap_err() {
1403 StellarTransactionUtilsError::BalanceTooLarge(hi, lo) => {
1404 assert_eq!(hi, 1);
1405 assert_eq!(lo, 1000000);
1406 }
1407 e => panic!("Expected BalanceTooLarge, got: {e:?}"),
1408 }
1409 }
1410
1411 #[tokio::test]
1412 async fn test_get_token_balance_contract_token_i128_negative() {
1413 use soroban_rs::xdr::Int128Parts;
1414
1415 let mut provider = create_mock_provider();
1416
1417 provider.expect_call_contract().returning(|_, _, _| {
1419 Box::pin(ready(Ok(ScVal::I128(Int128Parts {
1420 hi: 0,
1421 lo: u64::MAX, }))))
1423 });
1424
1425 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1426 let account = TEST_PK;
1427
1428 let result = get_token_balance(&provider, account, contract_addr).await;
1429 assert!(result.is_err());
1430 match result.unwrap_err() {
1431 StellarTransactionUtilsError::NegativeBalanceI128(_) => {}
1432 e => panic!("Expected NegativeBalanceI128, got: {e:?}"),
1433 }
1434 }
1435
1436 #[tokio::test]
1437 async fn test_get_token_balance_contract_token_u64_balance() {
1438 let mut provider = create_mock_provider();
1439
1440 provider
1442 .expect_call_contract()
1443 .returning(|_, _, _| Box::pin(ready(Ok(ScVal::U64(5000000)))));
1444
1445 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1446 let account = TEST_PK;
1447
1448 let result = get_token_balance(&provider, account, contract_addr).await;
1449 assert!(result.is_err());
1451 match result.unwrap_err() {
1452 StellarTransactionUtilsError::UnexpectedBalanceType(_) => {}
1453 e => panic!("Expected UnexpectedBalanceType, got: {:?}", e),
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn test_get_token_balance_contract_token_i64_positive() {
1459 let mut provider = create_mock_provider();
1460
1461 provider
1463 .expect_call_contract()
1464 .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(3000000)))));
1465
1466 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1467 let account = TEST_PK;
1468
1469 let result = get_token_balance(&provider, account, contract_addr).await;
1470 assert!(result.is_err());
1472 match result.unwrap_err() {
1473 StellarTransactionUtilsError::UnexpectedBalanceType(_) => {}
1474 e => panic!("Expected UnexpectedBalanceType, got: {:?}", e),
1475 }
1476 }
1477
1478 #[tokio::test]
1479 async fn test_get_token_balance_contract_token_i64_negative() {
1480 let mut provider = create_mock_provider();
1481
1482 provider
1484 .expect_call_contract()
1485 .returning(|_, _, _| Box::pin(ready(Ok(ScVal::I64(-1000)))));
1486
1487 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1488 let account = TEST_PK;
1489
1490 let result = get_token_balance(&provider, account, contract_addr).await;
1491 assert!(result.is_err());
1493 match result.unwrap_err() {
1494 StellarTransactionUtilsError::UnexpectedBalanceType(_) => {}
1495 e => panic!("Expected UnexpectedBalanceType, got: {:?}", e),
1496 }
1497 }
1498
1499 #[tokio::test]
1500 async fn test_get_token_balance_contract_token_unexpected_balance_type() {
1501 let mut provider = create_mock_provider();
1502
1503 provider
1505 .expect_call_contract()
1506 .returning(|_, _, _| Box::pin(ready(Ok(ScVal::Bool(true)))));
1507
1508 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1509 let account = TEST_PK;
1510
1511 let result = get_token_balance(&provider, account, contract_addr).await;
1512 assert!(result.is_err());
1513 match result.unwrap_err() {
1514 StellarTransactionUtilsError::UnexpectedBalanceType(_) => {}
1515 e => panic!("Expected UnexpectedBalanceType, got: {e:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn test_asset_code_boundary_cases() {
1521 let (code, _) =
1523 parse_asset_identifier("ABCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")
1524 .unwrap();
1525 assert_eq!(code, "ABCD");
1526 assert!(code.len() <= 4);
1527
1528 let (code, _) = parse_asset_identifier(
1530 "ABCDE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1531 )
1532 .unwrap();
1533 assert_eq!(code, "ABCDE");
1534 assert!(code.len() > 4 && code.len() <= 12);
1535
1536 let (code, _) = parse_asset_identifier(
1538 "ABCDEFGHIJKL:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1539 )
1540 .unwrap();
1541 assert_eq!(code, "ABCDEFGHIJKL");
1542 assert_eq!(code.len(), 12);
1543
1544 let (code, _) = parse_asset_identifier(
1546 "ABCDEFGHIJKLM:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1547 )
1548 .unwrap();
1549 assert_eq!(code, "ABCDEFGHIJKLM");
1550 assert!(code.len() > MAX_ASSET_CODE_LENGTH);
1551 }
1552
1553 #[tokio::test]
1554 async fn test_get_token_metadata_contract_with_decimals() {
1555 let mut provider = create_mock_provider();
1556
1557 let decimals_value = ScVal::U32(6);
1559 provider
1560 .expect_call_contract()
1561 .returning(move |_, _, _| Box::pin(ready(Ok(decimals_value.clone()))));
1562
1563 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1564 let result = get_token_metadata(&provider, contract_addr).await;
1565 assert!(result.is_ok());
1566 let metadata = result.unwrap();
1567
1568 assert_eq!(metadata.decimals, 6);
1569 match metadata.kind {
1570 StellarTokenKind::Contract { contract_id } => {
1571 assert_eq!(contract_id, contract_addr);
1572 }
1573 _ => panic!("Expected Contract token kind"),
1574 }
1575 }
1576
1577 #[tokio::test]
1578 async fn test_get_contract_token_decimals_from_storage_fallback() {
1579 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1580 use soroban_rs::xdr::{
1581 ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1582 LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1583 };
1584
1585 let mut provider = create_mock_provider();
1586
1587 provider.expect_call_contract().returning(|_, _, _| {
1589 Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1590 "Contract call failed".to_string(),
1591 ))))
1592 });
1593
1594 provider.expect_get_ledger_entries().returning(|_| {
1596 let decimals_val = ScVal::U32(8);
1597
1598 let contract_data = ContractDataEntry {
1599 ext: ExtensionPoint::V0,
1600 contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1601 key: ScVal::Vec(None),
1602 durability: ContractDataDurability::Persistent,
1603 val: decimals_val,
1604 };
1605
1606 let ledger_entry = LedgerEntry {
1607 last_modified_ledger_seq: 0,
1608 data: LedgerEntryData::ContractData(contract_data),
1609 ext: LedgerEntryExt::V0,
1610 };
1611
1612 let xdr_base64 = ledger_entry
1613 .data
1614 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1615 .unwrap();
1616
1617 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1618 entries: Some(vec![LedgerEntryResult {
1619 key: String::new(),
1620 xdr: xdr_base64,
1621 last_modified_ledger: 0,
1622 live_until_ledger_seq_ledger_seq: None,
1623 }]),
1624 latest_ledger: 0,
1625 })))
1626 });
1627
1628 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1629 let result = get_contract_token_decimals(&provider, contract_addr).await;
1630
1631 assert!(result.is_some());
1632 assert_eq!(result.unwrap(), 8);
1633 }
1634
1635 #[tokio::test]
1636 async fn test_get_contract_token_decimals_both_methods_fail() {
1637 use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1638
1639 let mut provider = create_mock_provider();
1640
1641 provider.expect_call_contract().returning(|_, _, _| {
1643 Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1644 "Contract call failed".to_string(),
1645 ))))
1646 });
1647
1648 provider.expect_get_ledger_entries().returning(|_| {
1650 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1651 entries: None,
1652 latest_ledger: 0,
1653 })))
1654 });
1655
1656 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1657 let result = get_contract_token_decimals(&provider, contract_addr).await;
1658
1659 assert!(result.is_none());
1661 }
1662
1663 #[tokio::test]
1664 async fn test_get_contract_token_decimals_invalid_contract_address() {
1665 let provider = create_mock_provider();
1666
1667 let invalid_addr = "INVALID_CONTRACT_ADDRESS";
1668 let result = get_contract_token_decimals(&provider, invalid_addr).await;
1669
1670 assert!(result.is_none());
1672 }
1673
1674 #[tokio::test]
1675 async fn test_invoke_decimals_function_success() {
1676 let mut provider = create_mock_provider();
1677
1678 let decimals_value = ScVal::U32(9);
1679 provider
1680 .expect_call_contract()
1681 .returning(move |_, _, _| Box::pin(ready(Ok(decimals_value.clone()))));
1682
1683 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1684 let result = invoke_decimals_function(&provider, contract_addr).await;
1685
1686 assert!(result.is_some());
1687 assert_eq!(result.unwrap(), 9);
1688 }
1689
1690 #[tokio::test]
1691 async fn test_invoke_decimals_function_failure() {
1692 let mut provider = create_mock_provider();
1693
1694 provider.expect_call_contract().returning(|_, _, _| {
1695 Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1696 "Contract not found".to_string(),
1697 ))))
1698 });
1699
1700 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1701 let result = invoke_decimals_function(&provider, contract_addr).await;
1702
1703 assert!(result.is_none());
1704 }
1705
1706 #[tokio::test]
1707 async fn test_query_decimals_from_storage_success() {
1708 use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1709 use soroban_rs::xdr::{
1710 ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1711 LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1712 };
1713
1714 let mut provider = create_mock_provider();
1715
1716 provider.expect_get_ledger_entries().returning(|_| {
1717 let decimals_val = ScVal::U32(18);
1718
1719 let contract_data = ContractDataEntry {
1720 ext: ExtensionPoint::V0,
1721 contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1722 key: ScVal::Vec(None),
1723 durability: ContractDataDurability::Persistent,
1724 val: decimals_val,
1725 };
1726
1727 let ledger_entry = LedgerEntry {
1728 last_modified_ledger_seq: 0,
1729 data: LedgerEntryData::ContractData(contract_data),
1730 ext: LedgerEntryExt::V0,
1731 };
1732
1733 let xdr_base64 = ledger_entry
1734 .data
1735 .to_xdr_base64(soroban_rs::xdr::Limits::none())
1736 .unwrap();
1737
1738 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1739 entries: Some(vec![LedgerEntryResult {
1740 key: String::new(),
1741 xdr: xdr_base64,
1742 last_modified_ledger: 0,
1743 live_until_ledger_seq_ledger_seq: None,
1744 }]),
1745 latest_ledger: 0,
1746 })))
1747 });
1748
1749 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1750 let contract_hash = parse_contract_address(contract_addr).unwrap();
1751 let result = query_decimals_from_storage(&provider, contract_addr, contract_hash).await;
1752
1753 assert!(result.is_some());
1754 assert_eq!(result.unwrap(), 18);
1755 }
1756
1757 #[tokio::test]
1758 async fn test_query_decimals_from_storage_no_entry() {
1759 use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1760
1761 let mut provider = create_mock_provider();
1762
1763 provider.expect_get_ledger_entries().returning(|_| {
1764 Box::pin(ready(Ok(GetLedgerEntriesResponse {
1765 entries: None,
1766 latest_ledger: 0,
1767 })))
1768 });
1769
1770 let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1771 let contract_hash = parse_contract_address(contract_addr).unwrap();
1772 let result = query_decimals_from_storage(&provider, contract_addr, contract_hash).await;
1773
1774 assert!(result.is_none());
1775 }
1776
1777 #[test]
1778 fn test_stellar_address_validation() {
1779 let valid_account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1781 assert_eq!(valid_account.len(), STELLAR_ADDRESS_LENGTH);
1782 assert!(valid_account.starts_with(STELLAR_ACCOUNT_PREFIX));
1783
1784 let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1786 assert_eq!(valid_contract.len(), STELLAR_ADDRESS_LENGTH);
1787 assert!(!valid_contract.starts_with(STELLAR_ACCOUNT_PREFIX));
1788
1789 let short = "GBBD47IF6LWK7P7";
1791 assert!(short.len() != STELLAR_ADDRESS_LENGTH);
1792
1793 let muxed = "MAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDDEEEEEEEE";
1795 assert!(!muxed.starts_with(STELLAR_ACCOUNT_PREFIX));
1796 }
1797
1798 #[tokio::test]
1799 async fn test_get_token_balance_different_identifiers() {
1800 let mut provider = create_mock_provider();
1801
1802 let test_balance = 75_0000000i64; let account_entry = create_mock_account_entry(test_balance);
1804
1805 provider
1807 .expect_get_account()
1808 .times(2)
1809 .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
1810
1811 let account = TEST_PK;
1812
1813 let result1 = get_token_balance(&provider, account, "native").await;
1815 assert!(result1.is_ok());
1816 assert_eq!(result1.unwrap(), test_balance as u64);
1817
1818 let result2 = get_token_balance(&provider, account, "XLM").await;
1820 assert!(result2.is_ok());
1821 assert_eq!(result2.unwrap(), test_balance as u64);
1822 }
1823
1824 #[test]
1825 fn test_parse_asset_identifier_colon_in_issuer() {
1826 let result = parse_asset_identifier(
1829 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5:EXTRA",
1830 );
1831 assert!(result.is_ok());
1832 let (code, issuer) = result.unwrap();
1833 assert_eq!(code, "USDC");
1834 assert_eq!(
1835 issuer,
1836 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5:EXTRA"
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn test_get_token_metadata_case_sensitivity() {
1842 let provider = create_mock_provider();
1843
1844 let asset_id = "usdc:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1846 let result = get_token_metadata(&provider, asset_id).await;
1847 assert!(result.is_ok());
1848 let metadata = result.unwrap();
1849
1850 match metadata.kind {
1851 StellarTokenKind::Classic { code, .. } => {
1852 assert_eq!(code, "usdc"); }
1854 _ => panic!("Expected Classic token kind"),
1855 }
1856 }
1857
1858 #[test]
1859 fn test_max_asset_code_length() {
1860 assert_eq!(MAX_ASSET_CODE_LENGTH, 12);
1862
1863 for len in 1..=4 {
1865 let code = "A".repeat(len);
1866 assert!(code.len() <= 4);
1867 }
1868
1869 for len in 5..=12 {
1871 let code = "A".repeat(len);
1872 assert!(code.len() > 4 && code.len() <= MAX_ASSET_CODE_LENGTH);
1873 }
1874
1875 let too_long = "A".repeat(13);
1877 assert!(too_long.len() > MAX_ASSET_CODE_LENGTH);
1878 }
1879}