openzeppelin_relayer/domain/transaction/stellar/
token.rs

1//! Utility functions for Stellar transaction domain logic.
2use 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, ContractDataEntry,
11    ContractId, Hash, LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry,
12    TrustLineEntryExt, TrustLineEntryV1,
13};
14use std::str::FromStr;
15use tracing::{debug, trace, warn};
16
17// Constants for Stellar address and asset validation
18const 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
23// ============================================================================
24// Helper Functions for Common Operations
25// ============================================================================
26
27/// Parse an asset identifier in CODE:ISSUER format.
28///
29/// # Arguments
30///
31/// * `asset_id` - Asset identifier in "CODE:ISSUER" format
32///
33/// # Returns
34///
35/// Tuple of (code, issuer) or error if format is invalid
36fn 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
42/// Validate and parse a classic asset issuer address.
43///
44/// Validates that the issuer is:
45/// - Non-empty
46/// - Exactly 56 characters
47/// - Starts with 'G'
48/// - Is a valid Stellar public key (not a contract address)
49///
50/// # Arguments
51///
52/// * `issuer` - Issuer address string
53/// * `asset_id` - Full asset identifier (for error messages)
54///
55/// # Returns
56///
57/// AccountId XDR type or error if validation fails
58fn 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    // Validate issuer is a valid Stellar public key (not a contract address)
83    parse_account_id(issuer)
84}
85
86// ============================================================================
87// Public API Functions
88// ============================================================================
89
90/// Fetch available token balance for a given account and asset identifier.
91///
92/// Supports:
93/// - Native XLM: Returns account balance directly
94/// - Traditional assets (Credit4/Credit12): Queries trustline balance via LedgerKey::Trustline
95///   and excludes funds locked in pending offers (selling_liabilities)
96/// - Contract tokens: Queries contract data balance via LedgerKey::ContractData
97///
98/// # Arguments
99///
100/// * `provider` - Stellar provider for querying ledger entries
101/// * `account_id` - Account address to check balance for
102/// * `asset_id` - Asset identifier:
103///   - "native" or "" for XLM
104///   - "CODE:ISSUER" for traditional assets (e.g., "USDC:GA5Z...")
105///   - Contract address (starts with "C", 56 chars) for Soroban contract tokens
106///
107/// # Returns
108///
109/// Available balance in stroops (or token's smallest unit) as u64, excluding funds locked
110/// in pending offers/orders, or error if balance cannot be fetched
111pub 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    // Handle native XLM - accept both "native" and "XLM" for UX
120    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    // Check if it's a contract address using proper StrKey validation
129    if ContractId::from_str(asset_id).is_ok() {
130        return get_contract_token_balance(provider, account_id, asset_id).await;
131    }
132
133    // Otherwise, treat as traditional asset (CODE:ISSUER format)
134    get_asset_trustline_balance(provider, account_id, asset_id).await
135}
136
137/// Fetch available balance for a traditional Stellar asset (Credit4/Credit12) via trustline
138///
139/// Returns the available balance excluding funds locked in pending offers/orders.
140/// For TrustLineEntry V1 (with liabilities), subtracts selling_liabilities from balance.
141/// For TrustLineEntry V0 (no liabilities), returns the total balance.
142async 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    // Validate asset code length before constructing buffer
153    // Stellar asset codes must be between 1 and 12 characters (inclusive)
154    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            // V1 has liabilities - calculate available balance by subtracting selling_liabilities
216            // selling_liabilities represents funds locked in sell offers for this asset
217            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            // V0 has no liabilities - return total balance
235            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
249/// Fetch balance for a Soroban contract token via ContractData
250async fn get_contract_token_balance<P>(
251    provider: &P,
252    account_id: &str,
253    contract_address: &str,
254) -> Result<u64, StellarTransactionUtilsError>
255where
256    P: StellarProviderTrait + Send + Sync,
257{
258    // Parse contract address and account ID
259    let contract_hash = parse_contract_address(contract_address)?;
260    let account_xdr_id = parse_account_id(account_id)?;
261    let account_sc_address = ScAddress::Account(account_xdr_id);
262
263    // Create balance key (Soroban token standard uses "Balance" as the key)
264    let balance_key = create_contract_data_key("Balance", Some(account_sc_address))?;
265
266    // Query contract data with durability fallback
267    let error_context = format!("contract {contract_address} balance for account {account_id}");
268    let ledger_entries =
269        query_contract_data_with_fallback(provider, contract_hash, balance_key, &error_context)
270            .await?;
271
272    // Extract balance from contract data entry
273    let entries = match ledger_entries.entries {
274        Some(entries) if !entries.is_empty() => entries,
275        _ => {
276            // No balance entry means balance is 0
277            warn!(
278                "No balance entry found for contract {} on account {}, assuming zero balance",
279                contract_address, account_id
280            );
281            return Ok(0);
282        }
283    };
284
285    let entry_result = &entries[0];
286    let entry = parse_ledger_entry_from_xdr(&entry_result.xdr, &error_context)?;
287
288    match entry {
289        LedgerEntryData::ContractData(ContractDataEntry { val, .. }) => match val {
290            ScVal::I128(parts) => {
291                if parts.hi != 0 {
292                    return Err(StellarTransactionUtilsError::BalanceTooLarge(
293                        parts.hi, parts.lo,
294                    ));
295                }
296                // Check if parts.lo represents a negative value when interpreted as i64
297                // Similar to the I64 branch, we check for negative before casting to u64
298                let lo_as_i64 = parts.lo as i64;
299                if lo_as_i64 < 0 {
300                    return Err(StellarTransactionUtilsError::NegativeBalanceI128(parts.lo));
301                }
302                Ok(lo_as_i64 as u64)
303            }
304            ScVal::U64(n) => Ok(n),
305            ScVal::I64(n) => {
306                if n < 0 {
307                    return Err(StellarTransactionUtilsError::NegativeBalanceI64(n));
308                }
309                Ok(n as u64)
310            }
311            other => Err(StellarTransactionUtilsError::UnexpectedBalanceType(
312                format!("{other:?}"),
313            )),
314        },
315        _ => Err(StellarTransactionUtilsError::UnexpectedContractDataEntryType),
316    }
317}
318
319/// Fetch token metadata for a given asset identifier.
320///
321/// Determines the token kind and fetches appropriate metadata:
322/// - Native XLM: decimals = 7, canonical_asset_id = "native"
323///   - Accepts "native", "XLM", or empty string "" (empty string is treated as native XLM)
324/// - Classic assets (CODE:ISSUER): decimals = 7 (default), canonical_asset_id = asset
325///   - Code must be 1-12 characters, issuer must be a valid Stellar address (G...)
326/// - Contract tokens: queries contract for decimals, canonical_asset_id = contract_id
327///   - Must be a valid StrKey-encoded contract address (C...)
328///
329/// # Arguments
330///
331/// * `provider` - Stellar provider for querying ledger entries
332/// * `asset_id` - Asset identifier:
333///   - "native", "XLM", or "" (empty string) for XLM
334///   - "CODE:ISSUER" for traditional assets (e.g., "USDC:GA5Z...")
335///   - Contract address (StrKey format starting with "C") for Soroban contract tokens
336///
337/// # Returns
338///
339/// Token metadata including kind, decimals, and canonical asset ID, or error if metadata cannot be fetched
340///
341/// # Errors
342///
343/// Returns `RelayerError::Internal` if:
344/// - Asset identifier format is invalid
345/// - Asset code is empty or exceeds 12 characters
346/// - Issuer address is invalid (not 56 chars, doesn't start with 'G', or invalid format)
347/// - Contract address is invalid StrKey format
348pub async fn get_token_metadata<P>(
349    provider: &P,
350    asset_id: &str,
351) -> Result<StellarTokenMetadata, StellarTransactionUtilsError>
352where
353    P: StellarProviderTrait + Send + Sync,
354{
355    // Handle native XLM (empty string is intentionally treated as native XLM)
356    if asset_id == "native" || asset_id == "XLM" || asset_id.is_empty() {
357        return Ok(StellarTokenMetadata {
358            kind: StellarTokenKind::Native,
359            decimals: DEFAULT_STELLAR_DECIMALS,
360            canonical_asset_id: "native".to_string(),
361        });
362    }
363
364    // Check if it's a contract address using proper StrKey validation
365    if ContractId::from_str(asset_id).is_ok() {
366        // Valid contract address - fetch decimals from contract, default to 7 if not found
367        let decimals = get_contract_token_decimals(provider, asset_id)
368            .await
369            .unwrap_or_else(|| {
370                warn!(
371                    contract_address = %asset_id,
372                    "Could not fetch decimals from contract, using default"
373                );
374                DEFAULT_STELLAR_DECIMALS
375            });
376
377        return Ok(StellarTokenMetadata {
378            kind: StellarTokenKind::Contract {
379                contract_id: asset_id.to_string(),
380            },
381            decimals,
382            canonical_asset_id: asset_id.to_uppercase().to_string(),
383        });
384    }
385
386    // Otherwise, treat as traditional asset (CODE:ISSUER format)
387    // Parse to validate format
388    let (code, issuer) = parse_asset_identifier(asset_id)?;
389
390    // Validate asset code
391    if code.is_empty() {
392        return Err(StellarTransactionUtilsError::EmptyAssetCode(
393            asset_id.to_string(),
394        ));
395    }
396
397    if code.len() > MAX_ASSET_CODE_LENGTH {
398        return Err(StellarTransactionUtilsError::AssetCodeTooLong(
399            MAX_ASSET_CODE_LENGTH,
400            code.to_string(),
401        ));
402    }
403
404    // Validate and parse issuer address
405    validate_and_parse_issuer(issuer, asset_id)?;
406
407    // Classic assets typically use 7 decimals (Stellar standard)
408    // In the future, this could be queried from the asset's trustline or asset info
409    Ok(StellarTokenMetadata {
410        kind: StellarTokenKind::Classic {
411            code: code.to_string(),
412            issuer: issuer.to_string(),
413        },
414        decimals: DEFAULT_STELLAR_DECIMALS,
415        canonical_asset_id: asset_id.to_string(),
416    })
417}
418
419/// Attempts to fetch decimals for a contract token by invoking the contract's decimals() function.
420///
421/// This implementation uses multiple strategies:
422/// 1. First tries to invoke the contract's `decimals()` function (SEP-41 standard)
423/// 2. Falls back to querying contract data storage if invocation fails
424/// 3. Returns None if all methods fail
425///
426/// # Arguments
427///
428/// * `provider` - Stellar provider for querying ledger entries and invoking contracts
429/// * `contract_address` - Contract address in StrKey format (must be valid ContractId)
430///
431/// # Returns
432///
433/// Some(u32) if decimals are found, None if decimals cannot be determined.
434/// Logs warnings for debugging when decimals cannot be fetched.
435///
436/// # Note
437///
438/// This function assumes the contract follows SEP-41 token interface with a `decimals()`
439/// function. Non-standard tokens may not have this function.
440pub async fn get_contract_token_decimals<P>(provider: &P, contract_address: &str) -> Option<u32>
441where
442    P: StellarProviderTrait + Send + Sync,
443{
444    debug!(
445        contract_address = %contract_address,
446        "Fetching decimals for contract token"
447    );
448
449    // Parse contract address - if invalid, log and return None
450    let contract_hash = match parse_contract_address(contract_address) {
451        Ok(hash) => hash,
452        Err(e) => {
453            warn!(
454                contract_address = %contract_address,
455                error = %e,
456                "Failed to parse contract address"
457            );
458            return None;
459        }
460    };
461
462    // Strategy 1: Try invoking the decimals() function (preferred method for mainnet)
463    if let Some(decimals) = invoke_decimals_function(provider, contract_address).await {
464        debug!(
465            contract_address = %contract_address,
466            decimals = %decimals,
467            "Successfully fetched decimals via contract invocation"
468        );
469        return Some(decimals);
470    }
471
472    // Strategy 2: Fall back to querying contract data storage
473    debug!(
474        contract_address = %contract_address,
475        "Contract invocation failed, trying storage query"
476    );
477
478    query_decimals_from_storage(provider, contract_address, contract_hash).await
479}
480
481/// Invoke the decimals() function on a contract token.
482///
483/// This is the standard way to fetch decimals for SEP-41 compliant tokens.
484///
485/// # Arguments
486///
487/// * `provider` - Stellar provider for contract invocation
488/// * `contract_address` - Contract address string (for logging and invocation)
489///
490/// # Returns
491///
492/// Some(u32) if invocation succeeds, None otherwise
493async fn invoke_decimals_function<P>(provider: &P, contract_address: &str) -> Option<u32>
494where
495    P: StellarProviderTrait + Send + Sync,
496{
497    // Create function name symbol
498    let function_name = match ScSymbol::try_from("decimals") {
499        Ok(sym) => sym,
500        Err(e) => {
501            warn!(contract_address = %contract_address, error = ?e, "Failed to create decimals symbol");
502            return None;
503        }
504    };
505
506    // No arguments for decimals()
507    let args: Vec<ScVal> = vec![];
508
509    // Call contract function (read-only via simulation)
510    match provider
511        .call_contract(contract_address, &function_name, args)
512        .await
513    {
514        Ok(result) => extract_u32_from_scval(&result, "decimals() result"),
515        Err(e) => {
516            debug!(contract_address = %contract_address, error = %e, "Failed to invoke decimals() function");
517            None
518        }
519    }
520}
521
522/// Query decimals from contract data storage.
523///
524/// This is a fallback method for tokens that store decimals in contract data
525/// instead of providing a decimals() function.
526///
527/// # Arguments
528///
529/// * `provider` - Stellar provider for querying ledger entries
530/// * `contract_address` - Contract address string (for logging)
531/// * `contract_hash` - Parsed contract hash
532///
533/// # Returns
534///
535/// Some(u32) if decimals are found in storage, None otherwise
536async fn query_decimals_from_storage<P>(
537    provider: &P,
538    contract_address: &str,
539    contract_hash: Hash,
540) -> Option<u32>
541where
542    P: StellarProviderTrait + Send + Sync,
543{
544    // Create decimals key (SEP-41 token standard uses "Decimals" as the key)
545    let decimals_key = match create_contract_data_key("Decimals", None) {
546        Ok(key) => key,
547        Err(e) => {
548            warn!(
549                contract_address = %contract_address,
550                error = %e,
551                "Failed to create Decimals key"
552            );
553            return None;
554        }
555    };
556
557    // Query contract data with durability fallback
558    let error_context = format!("contract {contract_address} decimals");
559    let ledger_entries = match query_contract_data_with_fallback(
560        provider,
561        contract_hash,
562        decimals_key,
563        &error_context,
564    )
565    .await
566    {
567        Ok(entries) => entries,
568        Err(e) => {
569            debug!(
570                contract_address = %contract_address,
571                error = %e,
572                "Failed to query contract data for decimals"
573            );
574            return None;
575        }
576    };
577
578    // Extract ScVal from contract data entry
579    let val = match extract_scval_from_contract_data(&ledger_entries, &error_context) {
580        Ok(v) => v,
581        Err(_) => {
582            trace!(
583                contract_address = %contract_address,
584                "No decimals entry found in contract data"
585            );
586            return None;
587        }
588    };
589
590    // Extract decimals value from ScVal
591    extract_u32_from_scval(&val, "decimals storage value")
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::domain::transaction::stellar::test_helpers::{create_account_id, TEST_PK};
598    use crate::services::provider::MockStellarProviderTrait;
599    use futures::future::ready;
600    use mockall::predicate::*;
601    use soroban_rs::xdr::{AccountEntry, AccountEntryExt, SequenceNumber, Thresholds};
602    use std::str::FromStr;
603
604    // Helper function to create a test provider
605    fn create_mock_provider() -> MockStellarProviderTrait {
606        MockStellarProviderTrait::new()
607    }
608
609    // Helper function to create a mock AccountEntry
610    fn create_mock_account_entry(balance: i64) -> AccountEntry {
611        AccountEntry {
612            account_id: create_account_id(TEST_PK),
613            balance,
614            seq_num: SequenceNumber(1),
615            num_sub_entries: 0,
616            inflation_dest: None,
617            flags: 0,
618            home_domain: Default::default(),
619            thresholds: Thresholds([1, 0, 0, 0]),
620            signers: Default::default(),
621            ext: AccountEntryExt::V0,
622        }
623    }
624
625    #[test]
626    fn test_parse_asset_identifier_valid() {
627        let result =
628            parse_asset_identifier("USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
629        assert!(result.is_ok());
630        let (code, issuer) = result.unwrap();
631        assert_eq!(code, "USDC");
632        assert_eq!(
633            issuer,
634            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
635        );
636    }
637
638    #[test]
639    fn test_parse_asset_identifier_invalid() {
640        // Missing colon
641        let result =
642            parse_asset_identifier("USDCGBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
643        assert!(result.is_err());
644        match result.unwrap_err() {
645            StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
646            e => panic!("Expected InvalidAssetFormat, got: {:?}", e),
647        }
648
649        // Empty string
650        let result = parse_asset_identifier("");
651        assert!(result.is_err());
652    }
653
654    #[test]
655    fn test_parse_asset_identifier_multiple_colons() {
656        // Multiple colons - only first is used
657        let result = parse_asset_identifier(
658            "USD:C:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
659        );
660        assert!(result.is_ok());
661        let (code, issuer) = result.unwrap();
662        assert_eq!(code, "USD");
663        assert_eq!(
664            issuer,
665            "C:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
666        );
667    }
668
669    #[test]
670    fn test_validate_and_parse_issuer_valid() {
671        let issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
672        let result = validate_and_parse_issuer(issuer, "USDC:GBBD47...");
673        assert!(result.is_ok());
674    }
675
676    #[test]
677    fn test_validate_and_parse_issuer_empty() {
678        let result = validate_and_parse_issuer("", "USDC:");
679        assert!(result.is_err());
680        match result.unwrap_err() {
681            StellarTransactionUtilsError::EmptyIssuerAddress(_) => {}
682            e => panic!("Expected EmptyIssuerAddress, got: {:?}", e),
683        }
684    }
685
686    #[test]
687    fn test_validate_and_parse_issuer_wrong_length() {
688        let result = validate_and_parse_issuer("SHORTADDR", "USDC:SHORTADDR");
689        assert!(result.is_err());
690        match result.unwrap_err() {
691            StellarTransactionUtilsError::InvalidIssuerLength(expected, _) => {
692                assert_eq!(expected, STELLAR_ADDRESS_LENGTH);
693            }
694            e => panic!("Expected InvalidIssuerLength, got: {:?}", e),
695        }
696    }
697
698    #[test]
699    fn test_validate_and_parse_issuer_wrong_prefix() {
700        // Contract address (starts with 'C') is not valid as issuer
701        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
702        let result = validate_and_parse_issuer(contract_addr, "USDC:C...");
703        assert!(result.is_err());
704        match result.unwrap_err() {
705            StellarTransactionUtilsError::InvalidIssuerPrefix(expected, _) => {
706                assert_eq!(expected, STELLAR_ACCOUNT_PREFIX);
707            }
708            e => panic!("Expected InvalidIssuerPrefix, got: {:?}", e),
709        }
710    }
711
712    #[test]
713    fn test_validate_and_parse_issuer_invalid_checksum() {
714        // Valid length and prefix but invalid checksum
715        let bad_issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA6"; // Changed last char
716        let result = validate_and_parse_issuer(bad_issuer, "USDC:G...");
717        assert!(result.is_err());
718    }
719
720    #[tokio::test]
721    async fn test_get_token_balance_native_xlm() {
722        let mut provider = create_mock_provider();
723
724        let test_balance = 100_0000000i64; // 100 XLM
725        let account_entry = create_mock_account_entry(test_balance);
726
727        provider
728            .expect_get_account()
729            .with(eq(TEST_PK))
730            .times(1)
731            .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
732
733        let result = get_token_balance(&provider, TEST_PK, "native").await;
734
735        assert!(result.is_ok());
736        assert_eq!(result.unwrap(), test_balance as u64);
737    }
738
739    #[tokio::test]
740    async fn test_get_token_balance_xlm_identifier() {
741        let mut provider = create_mock_provider();
742
743        let test_balance = 50_0000000i64; // 50 XLM
744        let account_entry = create_mock_account_entry(test_balance);
745
746        provider
747            .expect_get_account()
748            .with(eq(TEST_PK))
749            .times(1)
750            .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
751
752        // Test with "XLM" identifier
753        let result = get_token_balance(&provider, TEST_PK, "XLM").await;
754
755        assert!(result.is_ok());
756        assert_eq!(result.unwrap(), test_balance as u64);
757    }
758
759    #[test]
760    fn test_asset_code_length_validation() {
761        // Valid codes (1-12 characters)
762        assert!(parse_asset_identifier(
763            "A:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
764        )
765        .is_ok());
766        assert!(parse_asset_identifier(
767            "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
768        )
769        .is_ok());
770        assert!(parse_asset_identifier(
771            "MAXLENCODE12:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
772        )
773        .is_ok());
774
775        // Empty code - parsed successfully, but should fail in validation
776        let (code, _) =
777            parse_asset_identifier(":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")
778                .unwrap();
779        assert_eq!(code, "");
780    }
781
782    #[tokio::test]
783    async fn test_get_token_metadata_native() {
784        let provider = create_mock_provider();
785
786        let result = get_token_metadata(&provider, "native").await;
787        assert!(result.is_ok());
788        let metadata = result.unwrap();
789
790        assert_eq!(metadata.kind, StellarTokenKind::Native);
791        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
792        assert_eq!(metadata.canonical_asset_id, "native");
793    }
794
795    #[tokio::test]
796    async fn test_get_token_metadata_xlm_identifier() {
797        let provider = create_mock_provider();
798
799        let result = get_token_metadata(&provider, "XLM").await;
800        assert!(result.is_ok());
801        let metadata = result.unwrap();
802
803        assert_eq!(metadata.kind, StellarTokenKind::Native);
804        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
805        assert_eq!(metadata.canonical_asset_id, "native");
806    }
807
808    #[tokio::test]
809    async fn test_get_token_metadata_empty_string() {
810        let provider = create_mock_provider();
811
812        // Empty string should be treated as native XLM
813        let result = get_token_metadata(&provider, "").await;
814        assert!(result.is_ok());
815        let metadata = result.unwrap();
816
817        assert_eq!(metadata.kind, StellarTokenKind::Native);
818        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
819        assert_eq!(metadata.canonical_asset_id, "native");
820    }
821
822    #[tokio::test]
823    async fn test_get_token_metadata_classic_asset() {
824        let provider = create_mock_provider();
825
826        let asset_id = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
827        let result = get_token_metadata(&provider, asset_id).await;
828        assert!(result.is_ok());
829        let metadata = result.unwrap();
830
831        match metadata.kind {
832            StellarTokenKind::Classic { code, issuer } => {
833                assert_eq!(code, "USDC");
834                assert_eq!(
835                    issuer,
836                    "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
837                );
838            }
839            _ => panic!("Expected Classic token kind"),
840        }
841        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
842        assert_eq!(metadata.canonical_asset_id, asset_id);
843    }
844
845    #[tokio::test]
846    async fn test_get_token_metadata_classic_asset_credit12() {
847        let provider = create_mock_provider();
848
849        let asset_id = "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
850        let result = get_token_metadata(&provider, asset_id).await;
851        assert!(result.is_ok());
852        let metadata = result.unwrap();
853
854        match metadata.kind {
855            StellarTokenKind::Classic { code, issuer } => {
856                assert_eq!(code, "LONGASSETCD");
857                assert_eq!(
858                    issuer,
859                    "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
860                );
861            }
862            _ => panic!("Expected Classic token kind"),
863        }
864        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
865    }
866
867    #[tokio::test]
868    async fn test_get_token_metadata_invalid_format() {
869        let provider = create_mock_provider();
870
871        let result = get_token_metadata(&provider, "INVALID_NO_COLON").await;
872        assert!(result.is_err());
873        match result.unwrap_err() {
874            StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
875            e => panic!("Expected InvalidAssetFormat, got: {:?}", e),
876        }
877    }
878
879    #[tokio::test]
880    async fn test_get_token_metadata_empty_code() {
881        let provider = create_mock_provider();
882
883        let result = get_token_metadata(
884            &provider,
885            ":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
886        )
887        .await;
888        assert!(result.is_err());
889        match result.unwrap_err() {
890            StellarTransactionUtilsError::EmptyAssetCode(_) => {}
891            e => panic!("Expected EmptyAssetCode, got: {:?}", e),
892        }
893    }
894
895    #[tokio::test]
896    async fn test_get_token_metadata_code_too_long() {
897        let provider = create_mock_provider();
898
899        let result = get_token_metadata(
900            &provider,
901            "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
902        )
903        .await;
904        assert!(result.is_err());
905        match result.unwrap_err() {
906            StellarTransactionUtilsError::AssetCodeTooLong(max, code) => {
907                assert_eq!(max, MAX_ASSET_CODE_LENGTH);
908                assert_eq!(code, "VERYLONGASSETCODE");
909            }
910            e => panic!("Expected AssetCodeTooLong, got: {:?}", e),
911        }
912    }
913
914    #[tokio::test]
915    async fn test_get_token_metadata_empty_issuer() {
916        let provider = create_mock_provider();
917
918        let result = get_token_metadata(&provider, "USDC:").await;
919        assert!(result.is_err());
920        match result.unwrap_err() {
921            StellarTransactionUtilsError::EmptyIssuerAddress(_) => {}
922            e => panic!("Expected EmptyIssuerAddress, got: {:?}", e),
923        }
924    }
925
926    #[tokio::test]
927    async fn test_get_token_metadata_invalid_issuer_length() {
928        let provider = create_mock_provider();
929
930        let result = get_token_metadata(&provider, "USDC:INVALID").await;
931        assert!(result.is_err());
932        match result.unwrap_err() {
933            StellarTransactionUtilsError::InvalidIssuerLength(expected, _) => {
934                assert_eq!(expected, STELLAR_ADDRESS_LENGTH);
935            }
936            e => panic!("Expected InvalidIssuerLength, got: {:?}", e),
937        }
938    }
939
940    #[tokio::test]
941    async fn test_get_token_metadata_invalid_issuer_prefix() {
942        let provider = create_mock_provider();
943
944        // Using contract address as issuer (starts with C, not G)
945        let result = get_token_metadata(
946            &provider,
947            "USDC:CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
948        )
949        .await;
950        assert!(result.is_err());
951        match result.unwrap_err() {
952            StellarTransactionUtilsError::InvalidIssuerPrefix(expected, _) => {
953                assert_eq!(expected, STELLAR_ACCOUNT_PREFIX);
954            }
955            e => panic!("Expected InvalidIssuerPrefix, got: {:?}", e),
956        }
957    }
958
959    #[tokio::test]
960    async fn test_get_token_metadata_contract_valid() {
961        let mut provider = create_mock_provider();
962
963        // Mock contract decimals query to return None (uses default)
964        provider.expect_call_contract().returning(|_, _, _| {
965            Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
966                "Contract call failed".to_string(),
967            ))))
968        });
969
970        provider.expect_get_ledger_entries().returning(|_| {
971            Box::pin(ready(Ok(
972                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
973                    entries: None,
974                    latest_ledger: 0,
975                },
976            )))
977        });
978
979        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
980        let result = get_token_metadata(&provider, contract_addr).await;
981        assert!(result.is_ok());
982        let metadata = result.unwrap();
983
984        match metadata.kind {
985            StellarTokenKind::Contract { contract_id } => {
986                assert_eq!(contract_id, contract_addr);
987            }
988            _ => panic!("Expected Contract token kind"),
989        }
990        assert_eq!(metadata.decimals, DEFAULT_STELLAR_DECIMALS);
991        assert_eq!(metadata.canonical_asset_id, contract_addr.to_uppercase());
992    }
993
994    #[tokio::test]
995    async fn test_get_token_balance_trustline_v0_success() {
996        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
997        use soroban_rs::xdr::{
998            LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, WriteXdr,
999        };
1000
1001        let mut provider = create_mock_provider();
1002
1003        // Mock trustline response with V0 extension (no liabilities)
1004        provider.expect_get_ledger_entries().returning(|_| {
1005            let trustline_entry = TrustLineEntry {
1006                account_id: create_account_id(TEST_PK),
1007                asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1008                    asset_code: AssetCode4(*b"USDC"),
1009                    issuer: create_account_id(
1010                        "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1011                    ),
1012                }),
1013                balance: 10_0000000, // 10 USDC
1014                limit: 1000_0000000,
1015                flags: 1,
1016                ext: TrustLineEntryExt::V0,
1017            };
1018
1019            let ledger_entry = LedgerEntry {
1020                last_modified_ledger_seq: 0,
1021                data: LedgerEntryData::Trustline(trustline_entry),
1022                ext: LedgerEntryExt::V0,
1023            };
1024
1025            let xdr_base64 = ledger_entry
1026                .data
1027                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1028                .unwrap();
1029
1030            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1031                entries: Some(vec![LedgerEntryResult {
1032                    key: String::new(),
1033                    xdr: xdr_base64,
1034                    last_modified_ledger: 0,
1035                    live_until_ledger_seq_ledger_seq: None,
1036                }]),
1037                latest_ledger: 0,
1038            })))
1039        });
1040
1041        let account = TEST_PK;
1042        let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1043
1044        let result = get_token_balance(&provider, account, asset).await;
1045        assert!(result.is_ok());
1046        assert_eq!(result.unwrap(), 10_0000000);
1047    }
1048
1049    #[tokio::test]
1050    async fn test_get_token_balance_trustline_v1_with_liabilities() {
1051        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1052        use soroban_rs::xdr::{
1053            LedgerEntry, LedgerEntryData, LedgerEntryExt, Liabilities, TrustLineAsset,
1054            TrustLineEntryV1, TrustLineEntryV1Ext, WriteXdr,
1055        };
1056
1057        let mut provider = create_mock_provider();
1058
1059        // Mock trustline response with V1 extension (with liabilities)
1060        provider.expect_get_ledger_entries().returning(|_| {
1061            let trustline_entry = TrustLineEntry {
1062                account_id: create_account_id(TEST_PK),
1063                asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1064                    asset_code: AssetCode4(*b"USDC"),
1065                    issuer: create_account_id(
1066                        "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1067                    ),
1068                }),
1069                balance: 10_0000000, // 10 USDC
1070                limit: 1000_0000000,
1071                flags: 1,
1072                ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1073                    liabilities: Liabilities {
1074                        buying: 1_0000000,  // 1 USDC buying liability
1075                        selling: 2_0000000, // 2 USDC selling liability
1076                    },
1077                    ext: TrustLineEntryV1Ext::V0,
1078                }),
1079            };
1080
1081            let ledger_entry = LedgerEntry {
1082                last_modified_ledger_seq: 0,
1083                data: LedgerEntryData::Trustline(trustline_entry),
1084                ext: LedgerEntryExt::V0,
1085            };
1086
1087            let xdr_base64 = ledger_entry
1088                .data
1089                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1090                .unwrap();
1091
1092            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1093                entries: Some(vec![LedgerEntryResult {
1094                    key: String::new(),
1095                    xdr: xdr_base64,
1096                    last_modified_ledger: 0,
1097                    live_until_ledger_seq_ledger_seq: None,
1098                }]),
1099                latest_ledger: 0,
1100            })))
1101        });
1102
1103        let account = TEST_PK;
1104        let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1105
1106        let result = get_token_balance(&provider, account, asset).await;
1107        assert!(result.is_ok());
1108        // Available balance = 10 - 2 (selling liabilities) = 8 USDC
1109        assert_eq!(result.unwrap(), 8_0000000);
1110    }
1111
1112    #[tokio::test]
1113    async fn test_get_token_balance_trustline_v1_selling_exceeds_balance() {
1114        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1115        use soroban_rs::xdr::{
1116            LedgerEntry, LedgerEntryData, LedgerEntryExt, Liabilities, TrustLineAsset,
1117            TrustLineEntryV1, TrustLineEntryV1Ext, WriteXdr,
1118        };
1119
1120        let mut provider = create_mock_provider();
1121
1122        // Mock trustline where selling liabilities exceed balance (edge case)
1123        provider.expect_get_ledger_entries().returning(|_| {
1124            let trustline_entry = TrustLineEntry {
1125                account_id: create_account_id(TEST_PK),
1126                asset: TrustLineAsset::CreditAlphanum4(AlphaNum4 {
1127                    asset_code: AssetCode4(*b"USDC"),
1128                    issuer: create_account_id(
1129                        "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1130                    ),
1131                }),
1132                balance: 5_0000000, // 5 USDC
1133                limit: 1000_0000000,
1134                flags: 1,
1135                ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1136                    liabilities: Liabilities {
1137                        buying: 0,
1138                        selling: 10_0000000, // 10 USDC selling (more than balance)
1139                    },
1140                    ext: TrustLineEntryV1Ext::V0,
1141                }),
1142            };
1143
1144            let ledger_entry = LedgerEntry {
1145                last_modified_ledger_seq: 0,
1146                data: LedgerEntryData::Trustline(trustline_entry),
1147                ext: LedgerEntryExt::V0,
1148            };
1149
1150            let xdr_base64 = ledger_entry
1151                .data
1152                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1153                .unwrap();
1154
1155            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1156                entries: Some(vec![LedgerEntryResult {
1157                    key: String::new(),
1158                    xdr: xdr_base64,
1159                    last_modified_ledger: 0,
1160                    live_until_ledger_seq_ledger_seq: None,
1161                }]),
1162                latest_ledger: 0,
1163            })))
1164        });
1165
1166        let account = TEST_PK;
1167        let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1168
1169        let result = get_token_balance(&provider, account, asset).await;
1170        assert!(result.is_ok());
1171        // saturating_sub should return 0 when selling exceeds balance
1172        assert_eq!(result.unwrap(), 0);
1173    }
1174
1175    #[tokio::test]
1176    async fn test_get_token_balance_trustline_not_found() {
1177        use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1178
1179        let mut provider = create_mock_provider();
1180
1181        // Mock empty response (no trustline)
1182        provider.expect_get_ledger_entries().returning(|_| {
1183            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1184                entries: None,
1185                latest_ledger: 0,
1186            })))
1187        });
1188
1189        let account = TEST_PK;
1190        let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1191
1192        let result = get_token_balance(&provider, account, asset).await;
1193        assert!(result.is_err());
1194        match result.unwrap_err() {
1195            StellarTransactionUtilsError::NoTrustlineFound(asset_id, account_id) => {
1196                assert_eq!(asset_id, asset);
1197                assert_eq!(account_id, account);
1198            }
1199            e => panic!("Expected NoTrustlineFound, got: {:?}", e),
1200        }
1201    }
1202
1203    #[tokio::test]
1204    async fn test_get_token_balance_trustline_empty_entries() {
1205        use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1206
1207        let mut provider = create_mock_provider();
1208
1209        // Mock response with empty entries vec
1210        provider.expect_get_ledger_entries().returning(|_| {
1211            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1212                entries: Some(vec![]),
1213                latest_ledger: 0,
1214            })))
1215        });
1216
1217        let account = TEST_PK;
1218        let asset = "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1219
1220        let result = get_token_balance(&provider, account, asset).await;
1221        assert!(result.is_err());
1222        match result.unwrap_err() {
1223            StellarTransactionUtilsError::NoTrustlineFound(asset_id, account_id) => {
1224                assert_eq!(asset_id, asset);
1225                assert_eq!(account_id, account);
1226            }
1227            e => panic!("Expected NoTrustlineFound, got: {:?}", e),
1228        }
1229    }
1230
1231    #[tokio::test]
1232    async fn test_get_token_balance_trustline_credit12() {
1233        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1234        use soroban_rs::xdr::{
1235            LedgerEntry, LedgerEntryData, LedgerEntryExt, TrustLineAsset, WriteXdr,
1236        };
1237
1238        let mut provider = create_mock_provider();
1239
1240        // Mock trustline for Credit12 asset
1241        provider.expect_get_ledger_entries().returning(|_| {
1242            let trustline_entry = TrustLineEntry {
1243                account_id: create_account_id(TEST_PK),
1244                asset: TrustLineAsset::CreditAlphanum12(AlphaNum12 {
1245                    asset_code: AssetCode12(*b"LONGASSETCD\0"),
1246                    issuer: create_account_id(
1247                        "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1248                    ),
1249                }),
1250                balance: 25_0000000, // 25 units
1251                limit: 1000_0000000,
1252                flags: 1,
1253                ext: TrustLineEntryExt::V0,
1254            };
1255
1256            let ledger_entry = LedgerEntry {
1257                last_modified_ledger_seq: 0,
1258                data: LedgerEntryData::Trustline(trustline_entry),
1259                ext: LedgerEntryExt::V0,
1260            };
1261
1262            let xdr_base64 = ledger_entry
1263                .data
1264                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1265                .unwrap();
1266
1267            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1268                entries: Some(vec![LedgerEntryResult {
1269                    key: String::new(),
1270                    xdr: xdr_base64,
1271                    last_modified_ledger: 0,
1272                    live_until_ledger_seq_ledger_seq: None,
1273                }]),
1274                latest_ledger: 0,
1275            })))
1276        });
1277
1278        let account = TEST_PK;
1279        let asset = "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1280
1281        let result = get_token_balance(&provider, account, asset).await;
1282        assert!(result.is_ok());
1283        assert_eq!(result.unwrap(), 25_0000000);
1284    }
1285
1286    #[tokio::test]
1287    async fn test_get_token_balance_trustline_invalid_asset_code_too_long() {
1288        let provider = create_mock_provider();
1289
1290        let account = TEST_PK;
1291        let asset = "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1292
1293        let result = get_token_balance(&provider, account, asset).await;
1294        assert!(result.is_err());
1295        match result.unwrap_err() {
1296            StellarTransactionUtilsError::AssetCodeTooLong(max, code) => {
1297                assert_eq!(max, MAX_ASSET_CODE_LENGTH);
1298                assert_eq!(code, "VERYLONGASSETCODE");
1299            }
1300            e => panic!("Expected AssetCodeTooLong, got: {:?}", e),
1301        }
1302    }
1303
1304    #[test]
1305    fn test_constants() {
1306        // Verify constants are set correctly
1307        assert_eq!(STELLAR_ADDRESS_LENGTH, 56);
1308        assert_eq!(MAX_ASSET_CODE_LENGTH, 12);
1309        assert_eq!(DEFAULT_STELLAR_DECIMALS, 7);
1310        assert_eq!(STELLAR_ACCOUNT_PREFIX, 'G');
1311    }
1312
1313    #[test]
1314    fn test_contract_id_validation() {
1315        // Valid contract address
1316        let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1317        assert!(ContractId::from_str(valid_contract).is_ok());
1318
1319        // Invalid contract address (not a contract, it's an account)
1320        let account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1321        assert!(ContractId::from_str(account).is_err());
1322
1323        // Invalid format
1324        assert!(ContractId::from_str("INVALID").is_err());
1325        assert!(ContractId::from_str("").is_err());
1326    }
1327
1328    #[test]
1329    fn test_asset_identifier_edge_cases() {
1330        // Whitespace handling
1331        let result =
1332            parse_asset_identifier("USD :GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1333        assert!(result.is_ok());
1334        let (code, _) = result.unwrap();
1335        assert_eq!(code, "USD "); // Whitespace preserved
1336
1337        // Unicode characters (if supported)
1338        let result =
1339            parse_asset_identifier("U$D:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1340        assert!(result.is_ok());
1341        let (code, _) = result.unwrap();
1342        assert_eq!(code, "U$D");
1343    }
1344
1345    #[tokio::test]
1346    async fn test_get_token_balance_contract_token_no_balance_entry() {
1347        let mut provider = create_mock_provider();
1348
1349        // Mock empty response (no balance entry)
1350        provider.expect_get_ledger_entries().returning(|_| {
1351            Box::pin(ready(Ok(
1352                soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse {
1353                    entries: None,
1354                    latest_ledger: 0,
1355                },
1356            )))
1357        });
1358
1359        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1360        let account = TEST_PK;
1361
1362        let result = get_token_balance(&provider, account, contract_addr).await;
1363
1364        // Should return 0 for non-existent balance
1365        assert!(result.is_ok());
1366        assert_eq!(result.unwrap(), 0);
1367    }
1368
1369    #[tokio::test]
1370    async fn test_get_token_balance_contract_token_i128_balance() {
1371        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1372        use soroban_rs::xdr::{
1373            ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry,
1374            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1375        };
1376
1377        let mut provider = create_mock_provider();
1378
1379        // Mock response with I128 balance
1380        provider.expect_get_ledger_entries().returning(|_| {
1381            let balance_val = ScVal::I128(Int128Parts { hi: 0, lo: 1000000 });
1382
1383            let contract_data = ContractDataEntry {
1384                ext: ExtensionPoint::V0,
1385                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1386                key: ScVal::Vec(None),
1387                durability: ContractDataDurability::Persistent,
1388                val: balance_val,
1389            };
1390
1391            let ledger_entry = LedgerEntry {
1392                last_modified_ledger_seq: 0,
1393                data: LedgerEntryData::ContractData(contract_data),
1394                ext: LedgerEntryExt::V0,
1395            };
1396
1397            let xdr_base64 = ledger_entry
1398                .data
1399                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1400                .unwrap();
1401
1402            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1403                entries: Some(vec![LedgerEntryResult {
1404                    key: String::new(),
1405                    xdr: xdr_base64,
1406                    last_modified_ledger: 0,
1407                    live_until_ledger_seq_ledger_seq: None,
1408                }]),
1409                latest_ledger: 0,
1410            })))
1411        });
1412
1413        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1414        let account = TEST_PK;
1415
1416        let result = get_token_balance(&provider, account, contract_addr).await;
1417        assert!(result.is_ok());
1418        assert_eq!(result.unwrap(), 1000000);
1419    }
1420
1421    #[tokio::test]
1422    async fn test_get_token_balance_contract_token_i128_balance_too_large() {
1423        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1424        use soroban_rs::xdr::{
1425            ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry,
1426            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1427        };
1428
1429        let mut provider = create_mock_provider();
1430
1431        // Mock response with I128 balance where hi != 0
1432        provider.expect_get_ledger_entries().returning(|_| {
1433            let balance_val = ScVal::I128(Int128Parts {
1434                hi: 1, // Non-zero hi means balance is too large
1435                lo: 1000000,
1436            });
1437
1438            let contract_data = ContractDataEntry {
1439                ext: ExtensionPoint::V0,
1440                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1441                key: ScVal::Vec(None),
1442                durability: ContractDataDurability::Persistent,
1443                val: balance_val,
1444            };
1445
1446            let ledger_entry = LedgerEntry {
1447                last_modified_ledger_seq: 0,
1448                data: LedgerEntryData::ContractData(contract_data),
1449                ext: LedgerEntryExt::V0,
1450            };
1451
1452            let xdr_base64 = ledger_entry
1453                .data
1454                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1455                .unwrap();
1456
1457            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1458                entries: Some(vec![LedgerEntryResult {
1459                    key: String::new(),
1460                    xdr: xdr_base64,
1461                    last_modified_ledger: 0,
1462                    live_until_ledger_seq_ledger_seq: None,
1463                }]),
1464                latest_ledger: 0,
1465            })))
1466        });
1467
1468        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1469        let account = TEST_PK;
1470
1471        let result = get_token_balance(&provider, account, contract_addr).await;
1472        assert!(result.is_err());
1473        match result.unwrap_err() {
1474            StellarTransactionUtilsError::BalanceTooLarge(hi, lo) => {
1475                assert_eq!(hi, 1);
1476                assert_eq!(lo, 1000000);
1477            }
1478            e => panic!("Expected BalanceTooLarge, got: {:?}", e),
1479        }
1480    }
1481
1482    #[tokio::test]
1483    async fn test_get_token_balance_contract_token_i128_negative() {
1484        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1485        use soroban_rs::xdr::{
1486            ContractDataDurability, ContractDataEntry, ExtensionPoint, Int128Parts, LedgerEntry,
1487            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1488        };
1489
1490        let mut provider = create_mock_provider();
1491
1492        // Mock response with negative I128 balance
1493        provider.expect_get_ledger_entries().returning(|_| {
1494            let balance_val = ScVal::I128(Int128Parts {
1495                hi: 0,
1496                lo: u64::MAX, // When cast to i64, this is negative
1497            });
1498
1499            let contract_data = ContractDataEntry {
1500                ext: ExtensionPoint::V0,
1501                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1502                key: ScVal::Vec(None),
1503                durability: ContractDataDurability::Persistent,
1504                val: balance_val,
1505            };
1506
1507            let ledger_entry = LedgerEntry {
1508                last_modified_ledger_seq: 0,
1509                data: LedgerEntryData::ContractData(contract_data),
1510                ext: LedgerEntryExt::V0,
1511            };
1512
1513            let xdr_base64 = ledger_entry
1514                .data
1515                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1516                .unwrap();
1517
1518            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1519                entries: Some(vec![LedgerEntryResult {
1520                    key: String::new(),
1521                    xdr: xdr_base64,
1522                    last_modified_ledger: 0,
1523                    live_until_ledger_seq_ledger_seq: None,
1524                }]),
1525                latest_ledger: 0,
1526            })))
1527        });
1528
1529        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1530        let account = TEST_PK;
1531
1532        let result = get_token_balance(&provider, account, contract_addr).await;
1533        assert!(result.is_err());
1534        match result.unwrap_err() {
1535            StellarTransactionUtilsError::NegativeBalanceI128(_) => {}
1536            e => panic!("Expected NegativeBalanceI128, got: {:?}", e),
1537        }
1538    }
1539
1540    #[tokio::test]
1541    async fn test_get_token_balance_contract_token_u64_balance() {
1542        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1543        use soroban_rs::xdr::{
1544            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1545            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1546        };
1547
1548        let mut provider = create_mock_provider();
1549
1550        // Mock response with U64 balance
1551        provider.expect_get_ledger_entries().returning(|_| {
1552            let balance_val = ScVal::U64(5000000);
1553
1554            let contract_data = ContractDataEntry {
1555                ext: ExtensionPoint::V0,
1556                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1557                key: ScVal::Vec(None),
1558                durability: ContractDataDurability::Persistent,
1559                val: balance_val,
1560            };
1561
1562            let ledger_entry = LedgerEntry {
1563                last_modified_ledger_seq: 0,
1564                data: LedgerEntryData::ContractData(contract_data),
1565                ext: LedgerEntryExt::V0,
1566            };
1567
1568            let xdr_base64 = ledger_entry
1569                .data
1570                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1571                .unwrap();
1572
1573            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1574                entries: Some(vec![LedgerEntryResult {
1575                    key: String::new(),
1576                    xdr: xdr_base64,
1577                    last_modified_ledger: 0,
1578                    live_until_ledger_seq_ledger_seq: None,
1579                }]),
1580                latest_ledger: 0,
1581            })))
1582        });
1583
1584        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1585        let account = TEST_PK;
1586
1587        let result = get_token_balance(&provider, account, contract_addr).await;
1588        assert!(result.is_ok());
1589        assert_eq!(result.unwrap(), 5000000);
1590    }
1591
1592    #[tokio::test]
1593    async fn test_get_token_balance_contract_token_i64_positive() {
1594        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1595        use soroban_rs::xdr::{
1596            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1597            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1598        };
1599
1600        let mut provider = create_mock_provider();
1601
1602        // Mock response with positive I64 balance
1603        provider.expect_get_ledger_entries().returning(|_| {
1604            let balance_val = ScVal::I64(3000000);
1605
1606            let contract_data = ContractDataEntry {
1607                ext: ExtensionPoint::V0,
1608                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1609                key: ScVal::Vec(None),
1610                durability: ContractDataDurability::Persistent,
1611                val: balance_val,
1612            };
1613
1614            let ledger_entry = LedgerEntry {
1615                last_modified_ledger_seq: 0,
1616                data: LedgerEntryData::ContractData(contract_data),
1617                ext: LedgerEntryExt::V0,
1618            };
1619
1620            let xdr_base64 = ledger_entry
1621                .data
1622                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1623                .unwrap();
1624
1625            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1626                entries: Some(vec![LedgerEntryResult {
1627                    key: String::new(),
1628                    xdr: xdr_base64,
1629                    last_modified_ledger: 0,
1630                    live_until_ledger_seq_ledger_seq: None,
1631                }]),
1632                latest_ledger: 0,
1633            })))
1634        });
1635
1636        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1637        let account = TEST_PK;
1638
1639        let result = get_token_balance(&provider, account, contract_addr).await;
1640        assert!(result.is_ok());
1641        assert_eq!(result.unwrap(), 3000000);
1642    }
1643
1644    #[tokio::test]
1645    async fn test_get_token_balance_contract_token_i64_negative() {
1646        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1647        use soroban_rs::xdr::{
1648            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1649            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1650        };
1651
1652        let mut provider = create_mock_provider();
1653
1654        // Mock response with negative I64 balance
1655        provider.expect_get_ledger_entries().returning(|_| {
1656            let balance_val = ScVal::I64(-1000);
1657
1658            let contract_data = ContractDataEntry {
1659                ext: ExtensionPoint::V0,
1660                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1661                key: ScVal::Vec(None),
1662                durability: ContractDataDurability::Persistent,
1663                val: balance_val,
1664            };
1665
1666            let ledger_entry = LedgerEntry {
1667                last_modified_ledger_seq: 0,
1668                data: LedgerEntryData::ContractData(contract_data),
1669                ext: LedgerEntryExt::V0,
1670            };
1671
1672            let xdr_base64 = ledger_entry
1673                .data
1674                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1675                .unwrap();
1676
1677            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1678                entries: Some(vec![LedgerEntryResult {
1679                    key: String::new(),
1680                    xdr: xdr_base64,
1681                    last_modified_ledger: 0,
1682                    live_until_ledger_seq_ledger_seq: None,
1683                }]),
1684                latest_ledger: 0,
1685            })))
1686        });
1687
1688        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1689        let account = TEST_PK;
1690
1691        let result = get_token_balance(&provider, account, contract_addr).await;
1692        assert!(result.is_err());
1693        match result.unwrap_err() {
1694            StellarTransactionUtilsError::NegativeBalanceI64(n) => {
1695                assert_eq!(n, -1000);
1696            }
1697            e => panic!("Expected NegativeBalanceI64, got: {:?}", e),
1698        }
1699    }
1700
1701    #[tokio::test]
1702    async fn test_get_token_balance_contract_token_unexpected_balance_type() {
1703        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1704        use soroban_rs::xdr::{
1705            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1706            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1707        };
1708
1709        let mut provider = create_mock_provider();
1710
1711        // Mock response with unexpected balance type (Bool)
1712        provider.expect_get_ledger_entries().returning(|_| {
1713            let balance_val = ScVal::Bool(true);
1714
1715            let contract_data = ContractDataEntry {
1716                ext: ExtensionPoint::V0,
1717                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1718                key: ScVal::Vec(None),
1719                durability: ContractDataDurability::Persistent,
1720                val: balance_val,
1721            };
1722
1723            let ledger_entry = LedgerEntry {
1724                last_modified_ledger_seq: 0,
1725                data: LedgerEntryData::ContractData(contract_data),
1726                ext: LedgerEntryExt::V0,
1727            };
1728
1729            let xdr_base64 = ledger_entry
1730                .data
1731                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1732                .unwrap();
1733
1734            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1735                entries: Some(vec![LedgerEntryResult {
1736                    key: String::new(),
1737                    xdr: xdr_base64,
1738                    last_modified_ledger: 0,
1739                    live_until_ledger_seq_ledger_seq: None,
1740                }]),
1741                latest_ledger: 0,
1742            })))
1743        });
1744
1745        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1746        let account = TEST_PK;
1747
1748        let result = get_token_balance(&provider, account, contract_addr).await;
1749        assert!(result.is_err());
1750        match result.unwrap_err() {
1751            StellarTransactionUtilsError::UnexpectedBalanceType(_) => {}
1752            e => panic!("Expected UnexpectedBalanceType, got: {:?}", e),
1753        }
1754    }
1755
1756    #[test]
1757    fn test_asset_code_boundary_cases() {
1758        // Exactly 4 characters (Credit4)
1759        let (code, _) =
1760            parse_asset_identifier("ABCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")
1761                .unwrap();
1762        assert_eq!(code, "ABCD");
1763        assert!(code.len() <= 4);
1764
1765        // Exactly 5 characters (Credit12)
1766        let (code, _) = parse_asset_identifier(
1767            "ABCDE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1768        )
1769        .unwrap();
1770        assert_eq!(code, "ABCDE");
1771        assert!(code.len() > 4 && code.len() <= 12);
1772
1773        // Exactly 12 characters (Credit12 max)
1774        let (code, _) = parse_asset_identifier(
1775            "ABCDEFGHIJKL:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1776        )
1777        .unwrap();
1778        assert_eq!(code, "ABCDEFGHIJKL");
1779        assert_eq!(code.len(), 12);
1780
1781        // 13 characters (too long) - parsing succeeds, but validation should fail
1782        let (code, _) = parse_asset_identifier(
1783            "ABCDEFGHIJKLM:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1784        )
1785        .unwrap();
1786        assert_eq!(code, "ABCDEFGHIJKLM");
1787        assert!(code.len() > MAX_ASSET_CODE_LENGTH);
1788    }
1789
1790    #[tokio::test]
1791    async fn test_get_token_metadata_contract_with_decimals() {
1792        let mut provider = create_mock_provider();
1793
1794        // Mock successful decimals query
1795        let decimals_value = ScVal::U32(6);
1796        provider
1797            .expect_call_contract()
1798            .returning(move |_, _, _| Box::pin(ready(Ok(decimals_value.clone()))));
1799
1800        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1801        let result = get_token_metadata(&provider, contract_addr).await;
1802        assert!(result.is_ok());
1803        let metadata = result.unwrap();
1804
1805        assert_eq!(metadata.decimals, 6);
1806        match metadata.kind {
1807            StellarTokenKind::Contract { contract_id } => {
1808                assert_eq!(contract_id, contract_addr);
1809            }
1810            _ => panic!("Expected Contract token kind"),
1811        }
1812    }
1813
1814    #[tokio::test]
1815    async fn test_get_contract_token_decimals_from_storage_fallback() {
1816        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1817        use soroban_rs::xdr::{
1818            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1819            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1820        };
1821
1822        let mut provider = create_mock_provider();
1823
1824        // Mock failed contract invocation
1825        provider.expect_call_contract().returning(|_, _, _| {
1826            Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1827                "Contract call failed".to_string(),
1828            ))))
1829        });
1830
1831        // Mock successful storage query with decimals = 8
1832        provider.expect_get_ledger_entries().returning(|_| {
1833            let decimals_val = ScVal::U32(8);
1834
1835            let contract_data = ContractDataEntry {
1836                ext: ExtensionPoint::V0,
1837                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1838                key: ScVal::Vec(None),
1839                durability: ContractDataDurability::Persistent,
1840                val: decimals_val,
1841            };
1842
1843            let ledger_entry = LedgerEntry {
1844                last_modified_ledger_seq: 0,
1845                data: LedgerEntryData::ContractData(contract_data),
1846                ext: LedgerEntryExt::V0,
1847            };
1848
1849            let xdr_base64 = ledger_entry
1850                .data
1851                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1852                .unwrap();
1853
1854            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1855                entries: Some(vec![LedgerEntryResult {
1856                    key: String::new(),
1857                    xdr: xdr_base64,
1858                    last_modified_ledger: 0,
1859                    live_until_ledger_seq_ledger_seq: None,
1860                }]),
1861                latest_ledger: 0,
1862            })))
1863        });
1864
1865        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1866        let result = get_contract_token_decimals(&provider, contract_addr).await;
1867
1868        assert!(result.is_some());
1869        assert_eq!(result.unwrap(), 8);
1870    }
1871
1872    #[tokio::test]
1873    async fn test_get_contract_token_decimals_both_methods_fail() {
1874        use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1875
1876        let mut provider = create_mock_provider();
1877
1878        // Mock failed contract invocation
1879        provider.expect_call_contract().returning(|_, _, _| {
1880            Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1881                "Contract call failed".to_string(),
1882            ))))
1883        });
1884
1885        // Mock failed storage query (no entries)
1886        provider.expect_get_ledger_entries().returning(|_| {
1887            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1888                entries: None,
1889                latest_ledger: 0,
1890            })))
1891        });
1892
1893        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1894        let result = get_contract_token_decimals(&provider, contract_addr).await;
1895
1896        // Should return None when both methods fail
1897        assert!(result.is_none());
1898    }
1899
1900    #[tokio::test]
1901    async fn test_get_contract_token_decimals_invalid_contract_address() {
1902        let provider = create_mock_provider();
1903
1904        let invalid_addr = "INVALID_CONTRACT_ADDRESS";
1905        let result = get_contract_token_decimals(&provider, invalid_addr).await;
1906
1907        // Should return None for invalid contract address
1908        assert!(result.is_none());
1909    }
1910
1911    #[tokio::test]
1912    async fn test_invoke_decimals_function_success() {
1913        let mut provider = create_mock_provider();
1914
1915        let decimals_value = ScVal::U32(9);
1916        provider
1917            .expect_call_contract()
1918            .returning(move |_, _, _| Box::pin(ready(Ok(decimals_value.clone()))));
1919
1920        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1921        let result = invoke_decimals_function(&provider, contract_addr).await;
1922
1923        assert!(result.is_some());
1924        assert_eq!(result.unwrap(), 9);
1925    }
1926
1927    #[tokio::test]
1928    async fn test_invoke_decimals_function_failure() {
1929        let mut provider = create_mock_provider();
1930
1931        provider.expect_call_contract().returning(|_, _, _| {
1932            Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1933                "Contract not found".to_string(),
1934            ))))
1935        });
1936
1937        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1938        let result = invoke_decimals_function(&provider, contract_addr).await;
1939
1940        assert!(result.is_none());
1941    }
1942
1943    #[tokio::test]
1944    async fn test_query_decimals_from_storage_success() {
1945        use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1946        use soroban_rs::xdr::{
1947            ContractDataDurability, ContractDataEntry, ExtensionPoint, LedgerEntry,
1948            LedgerEntryData, LedgerEntryExt, ScVal, WriteXdr,
1949        };
1950
1951        let mut provider = create_mock_provider();
1952
1953        provider.expect_get_ledger_entries().returning(|_| {
1954            let decimals_val = ScVal::U32(18);
1955
1956            let contract_data = ContractDataEntry {
1957                ext: ExtensionPoint::V0,
1958                contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1959                key: ScVal::Vec(None),
1960                durability: ContractDataDurability::Persistent,
1961                val: decimals_val,
1962            };
1963
1964            let ledger_entry = LedgerEntry {
1965                last_modified_ledger_seq: 0,
1966                data: LedgerEntryData::ContractData(contract_data),
1967                ext: LedgerEntryExt::V0,
1968            };
1969
1970            let xdr_base64 = ledger_entry
1971                .data
1972                .to_xdr_base64(soroban_rs::xdr::Limits::none())
1973                .unwrap();
1974
1975            Box::pin(ready(Ok(GetLedgerEntriesResponse {
1976                entries: Some(vec![LedgerEntryResult {
1977                    key: String::new(),
1978                    xdr: xdr_base64,
1979                    last_modified_ledger: 0,
1980                    live_until_ledger_seq_ledger_seq: None,
1981                }]),
1982                latest_ledger: 0,
1983            })))
1984        });
1985
1986        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1987        let contract_hash = parse_contract_address(contract_addr).unwrap();
1988        let result = query_decimals_from_storage(&provider, contract_addr, contract_hash).await;
1989
1990        assert!(result.is_some());
1991        assert_eq!(result.unwrap(), 18);
1992    }
1993
1994    #[tokio::test]
1995    async fn test_query_decimals_from_storage_no_entry() {
1996        use soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse;
1997
1998        let mut provider = create_mock_provider();
1999
2000        provider.expect_get_ledger_entries().returning(|_| {
2001            Box::pin(ready(Ok(GetLedgerEntriesResponse {
2002                entries: None,
2003                latest_ledger: 0,
2004            })))
2005        });
2006
2007        let contract_addr = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
2008        let contract_hash = parse_contract_address(contract_addr).unwrap();
2009        let result = query_decimals_from_storage(&provider, contract_addr, contract_hash).await;
2010
2011        assert!(result.is_none());
2012    }
2013
2014    #[test]
2015    fn test_stellar_address_validation() {
2016        // Valid Stellar account address (starts with G)
2017        let valid_account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
2018        assert_eq!(valid_account.len(), STELLAR_ADDRESS_LENGTH);
2019        assert!(valid_account.starts_with(STELLAR_ACCOUNT_PREFIX));
2020
2021        // Valid contract address (starts with C)
2022        let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
2023        assert_eq!(valid_contract.len(), STELLAR_ADDRESS_LENGTH);
2024        assert!(!valid_contract.starts_with(STELLAR_ACCOUNT_PREFIX));
2025
2026        // Invalid length
2027        let short = "GBBD47IF6LWK7P7";
2028        assert!(short.len() != STELLAR_ADDRESS_LENGTH);
2029
2030        // Invalid prefix (M is for muxed accounts)
2031        let muxed = "MAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDDEEEEEEEE";
2032        assert!(!muxed.starts_with(STELLAR_ACCOUNT_PREFIX));
2033    }
2034
2035    #[tokio::test]
2036    async fn test_get_token_balance_different_identifiers() {
2037        let mut provider = create_mock_provider();
2038
2039        let test_balance = 75_0000000i64; // 75 XLM
2040        let account_entry = create_mock_account_entry(test_balance);
2041
2042        // Mock get_account to be called twice
2043        provider
2044            .expect_get_account()
2045            .times(2)
2046            .returning(move |_| Box::pin(ready(Ok(account_entry.clone()))));
2047
2048        let account = TEST_PK;
2049
2050        // Test with "native"
2051        let result1 = get_token_balance(&provider, account, "native").await;
2052        assert!(result1.is_ok());
2053        assert_eq!(result1.unwrap(), test_balance as u64);
2054
2055        // Test with "XLM"
2056        let result2 = get_token_balance(&provider, account, "XLM").await;
2057        assert!(result2.is_ok());
2058        assert_eq!(result2.unwrap(), test_balance as u64);
2059    }
2060
2061    #[test]
2062    fn test_parse_asset_identifier_colon_in_issuer() {
2063        // Edge case: what if issuer somehow contains a colon?
2064        // split_once only splits on first colon
2065        let result = parse_asset_identifier(
2066            "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5:EXTRA",
2067        );
2068        assert!(result.is_ok());
2069        let (code, issuer) = result.unwrap();
2070        assert_eq!(code, "USDC");
2071        assert_eq!(
2072            issuer,
2073            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5:EXTRA"
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn test_get_token_metadata_case_sensitivity() {
2079        let provider = create_mock_provider();
2080
2081        // Asset codes are case-sensitive in Stellar
2082        let asset_id = "usdc:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
2083        let result = get_token_metadata(&provider, asset_id).await;
2084        assert!(result.is_ok());
2085        let metadata = result.unwrap();
2086
2087        match metadata.kind {
2088            StellarTokenKind::Classic { code, .. } => {
2089                assert_eq!(code, "usdc"); // Lowercase preserved
2090            }
2091            _ => panic!("Expected Classic token kind"),
2092        }
2093    }
2094
2095    #[test]
2096    fn test_max_asset_code_length() {
2097        // Test that MAX_ASSET_CODE_LENGTH is correctly set
2098        assert_eq!(MAX_ASSET_CODE_LENGTH, 12);
2099
2100        // Asset codes up to 4 chars should be Credit4
2101        for len in 1..=4 {
2102            let code = "A".repeat(len);
2103            assert!(code.len() <= 4);
2104        }
2105
2106        // Asset codes 5-12 chars should be Credit12
2107        for len in 5..=12 {
2108            let code = "A".repeat(len);
2109            assert!(code.len() > 4 && code.len() <= MAX_ASSET_CODE_LENGTH);
2110        }
2111
2112        // Asset codes > 12 should be invalid
2113        let too_long = "A".repeat(13);
2114        assert!(too_long.len() > MAX_ASSET_CODE_LENGTH);
2115    }
2116}