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, ContractId, Hash,
11    LedgerEntryData, LedgerKey, ScAddress, ScSymbol, ScVal, TrustLineEntry, TrustLineEntryExt,
12    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 by invoking the balance() function
250///
251/// This function works for all SEP-41 compliant tokens:
252/// - SAC (Stellar Asset Contract) tokens
253/// - Native Soroban tokens
254///
255/// Uses simulation to invoke the contract's balance(id: Address) -> i128 function.
256/// This approach is simpler and more reliable than direct storage queries because
257/// it lets the contract handle the balance lookup internally (SAC tokens delegate
258/// to classic trustlines, native tokens read from contract storage).
259async 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    // Build the account address as ScVal::Address
268    let account_xdr_id = parse_account_id(account_id)?;
269    let account_sc_address = ScAddress::Account(account_xdr_id);
270
271    // Create the "balance" function name symbol
272    let function_name = ScSymbol::try_from("balance".as_bytes().to_vec()).map_err(|e| {
273        StellarTransactionUtilsError::SymbolCreationFailed("balance".into(), format!("{e:?}"))
274    })?;
275
276    // Call balance(id: Address) -> i128 via simulation
277    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    // Parse i128 result to u64
292    match result {
293        ScVal::I128(parts) => {
294            // Check for overflow (hi should be 0 for values that fit in u64)
295            if parts.hi != 0 {
296                return Err(StellarTransactionUtilsError::BalanceTooLarge(
297                    parts.hi, parts.lo,
298                ));
299            }
300            // Check for negative balance
301            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
317/// Fetch token metadata for a given asset identifier.
318///
319/// Determines the token kind and fetches appropriate metadata:
320/// - Native XLM: decimals = 7, canonical_asset_id = "native"
321///   - Accepts "native", "XLM", or empty string "" (empty string is treated as native XLM)
322/// - Classic assets (CODE:ISSUER): decimals = 7 (default), canonical_asset_id = asset
323///   - Code must be 1-12 characters, issuer must be a valid Stellar address (G...)
324/// - Contract tokens: queries contract for decimals, canonical_asset_id = contract_id
325///   - Must be a valid StrKey-encoded contract address (C...)
326///
327/// # Arguments
328///
329/// * `provider` - Stellar provider for querying ledger entries
330/// * `asset_id` - Asset identifier:
331///   - "native", "XLM", or "" (empty string) for XLM
332///   - "CODE:ISSUER" for traditional assets (e.g., "USDC:GA5Z...")
333///   - Contract address (StrKey format starting with "C") for Soroban contract tokens
334///
335/// # Returns
336///
337/// Token metadata including kind, decimals, and canonical asset ID, or error if metadata cannot be fetched
338///
339/// # Errors
340///
341/// Returns `RelayerError::Internal` if:
342/// - Asset identifier format is invalid
343/// - Asset code is empty or exceeds 12 characters
344/// - Issuer address is invalid (not 56 chars, doesn't start with 'G', or invalid format)
345/// - Contract address is invalid StrKey format
346pub 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    // Handle native XLM (empty string is intentionally treated as native XLM)
354    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    // Check if it's a contract address using proper StrKey validation
363    if ContractId::from_str(asset_id).is_ok() {
364        // Valid contract address - fetch decimals from contract, default to 7 if not found
365        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    // Otherwise, treat as traditional asset (CODE:ISSUER format)
385    // Parse to validate format
386    let (code, issuer) = parse_asset_identifier(asset_id)?;
387
388    // Validate asset code
389    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 address
403    validate_and_parse_issuer(issuer, asset_id)?;
404
405    // Classic assets typically use 7 decimals (Stellar standard)
406    // In the future, this could be queried from the asset's trustline or asset info
407    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
417/// Attempts to fetch decimals for a contract token by invoking the contract's decimals() function.
418///
419/// This implementation uses multiple strategies:
420/// 1. First tries to invoke the contract's `decimals()` function (SEP-41 standard)
421/// 2. Falls back to querying contract data storage if invocation fails
422/// 3. Returns None if all methods fail
423///
424/// # Arguments
425///
426/// * `provider` - Stellar provider for querying ledger entries and invoking contracts
427/// * `contract_address` - Contract address in StrKey format (must be valid ContractId)
428///
429/// # Returns
430///
431/// Some(u32) if decimals are found, None if decimals cannot be determined.
432/// Logs warnings for debugging when decimals cannot be fetched.
433///
434/// # Note
435///
436/// This function assumes the contract follows SEP-41 token interface with a `decimals()`
437/// function. Non-standard tokens may not have this function.
438pub 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    // Parse contract address - if invalid, log and return None
448    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    // Strategy 1: Try invoking the decimals() function (preferred method for mainnet)
461    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    // Strategy 2: Fall back to querying contract data storage
471    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
479/// Invoke the decimals() function on a contract token.
480///
481/// This is the standard way to fetch decimals for SEP-41 compliant tokens.
482///
483/// # Arguments
484///
485/// * `provider` - Stellar provider for contract invocation
486/// * `contract_address` - Contract address string (for logging and invocation)
487///
488/// # Returns
489///
490/// Some(u32) if invocation succeeds, None otherwise
491async fn invoke_decimals_function<P>(provider: &P, contract_address: &str) -> Option<u32>
492where
493    P: StellarProviderTrait + Send + Sync,
494{
495    // Create function name symbol
496    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    // No arguments for decimals()
505    let args: Vec<ScVal> = vec![];
506
507    // Call contract function (read-only via simulation)
508    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
520/// Query decimals from contract data storage.
521///
522/// This is a fallback method for tokens that store decimals in contract data
523/// instead of providing a decimals() function.
524///
525/// # Arguments
526///
527/// * `provider` - Stellar provider for querying ledger entries
528/// * `contract_address` - Contract address string (for logging)
529/// * `contract_hash` - Parsed contract hash
530///
531/// # Returns
532///
533/// Some(u32) if decimals are found in storage, None otherwise
534async 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    // Create decimals key (SEP-41 token standard uses "Decimals" as the key)
543    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    // Query contract data with durability fallback
556    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    // Extract ScVal from contract data entry
577    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 decimals value from ScVal
589    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    // Helper function to create a test provider
603    fn create_mock_provider() -> MockStellarProviderTrait {
604        MockStellarProviderTrait::new()
605    }
606
607    // Helper function to create a mock AccountEntry
608    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        // Missing colon
639        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        // Empty string
648        let result = parse_asset_identifier("");
649        assert!(result.is_err());
650    }
651
652    #[test]
653    fn test_parse_asset_identifier_multiple_colons() {
654        // Multiple colons - only first is used
655        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        // Contract address (starts with 'C') is not valid as issuer
699        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        // Valid length and prefix but invalid checksum
713        let bad_issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA6"; // Changed last char
714        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; // 100 XLM
723        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; // 50 XLM
742        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        // Test with "XLM" identifier
751        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        // Valid codes (1-12 characters)
760        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        // Empty code - parsed successfully, but should fail in validation
774        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        // Empty string should be treated as native XLM
811        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        // Using contract address as issuer (starts with C, not G)
943        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        // Mock contract decimals query to return None (uses default)
962        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        // Mock trustline response with V0 extension (no liabilities)
1002        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, // 10 USDC
1012                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        // Mock trustline response with V1 extension (with liabilities)
1058        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, // 10 USDC
1068                limit: 1000_0000000,
1069                flags: 1,
1070                ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1071                    liabilities: Liabilities {
1072                        buying: 1_0000000,  // 1 USDC buying liability
1073                        selling: 2_0000000, // 2 USDC selling liability
1074                    },
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        // Available balance = 10 - 2 (selling liabilities) = 8 USDC
1107        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        // Mock trustline where selling liabilities exceed balance (edge case)
1121        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, // 5 USDC
1131                limit: 1000_0000000,
1132                flags: 1,
1133                ext: TrustLineEntryExt::V1(TrustLineEntryV1 {
1134                    liabilities: Liabilities {
1135                        buying: 0,
1136                        selling: 10_0000000, // 10 USDC selling (more than balance)
1137                    },
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        // saturating_sub should return 0 when selling exceeds balance
1170        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        // Mock empty response (no trustline)
1180        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        // Mock response with empty entries vec
1208        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        // Mock trustline for Credit12 asset
1239        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, // 25 units
1249                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        // Verify constants are set correctly
1305        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        // Valid contract address
1314        let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1315        assert!(ContractId::from_str(valid_contract).is_ok());
1316
1317        // Invalid contract address (not a contract, it's an account)
1318        let account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1319        assert!(ContractId::from_str(account).is_err());
1320
1321        // Invalid format
1322        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        // Whitespace handling
1329        let result =
1330            parse_asset_identifier("USD :GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1331        assert!(result.is_ok());
1332        let (code, _) = result.unwrap();
1333        assert_eq!(code, "USD "); // Whitespace preserved
1334
1335        // Unicode characters (if supported)
1336        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        // Mock call_contract to return 0 balance (contract returns 0 for non-existent balances)
1350        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        // Should return 0 for non-existent balance
1360        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        // Mock call_contract to return I128 balance
1371        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        // Mock call_contract to return I128 balance with hi != 0 (too large)
1390        provider.expect_call_contract().returning(|_, _, _| {
1391            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
1392                hi: 1, // Non-zero hi means balance is too large
1393                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        // Mock call_contract to return negative I128 balance
1418        provider.expect_call_contract().returning(|_, _, _| {
1419            Box::pin(ready(Ok(ScVal::I128(Int128Parts {
1420                hi: 0,
1421                lo: u64::MAX, // When cast to i64, this is negative
1422            }))))
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        // Mock call_contract to return U64 balance - this is unexpected for balance() which returns i128
1441        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        // U64 is not a valid return type for balance(), should return UnexpectedBalanceType
1450        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        // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128
1462        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        // I64 is not a valid return type for balance(), should return UnexpectedBalanceType
1471        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        // Mock call_contract to return I64 balance - this is unexpected for balance() which returns i128
1483        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        // I64 is not a valid return type for balance(), should return UnexpectedBalanceType
1492        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        // Mock call_contract to return unexpected balance type (Bool)
1504        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        // Exactly 4 characters (Credit4)
1522        let (code, _) =
1523            parse_asset_identifier("ABCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")
1524                .unwrap();
1525        assert_eq!(code, "ABCD");
1526        assert!(code.len() <= 4);
1527
1528        // Exactly 5 characters (Credit12)
1529        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        // Exactly 12 characters (Credit12 max)
1537        let (code, _) = parse_asset_identifier(
1538            "ABCDEFGHIJKL:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1539        )
1540        .unwrap();
1541        assert_eq!(code, "ABCDEFGHIJKL");
1542        assert_eq!(code.len(), 12);
1543
1544        // 13 characters (too long) - parsing succeeds, but validation should fail
1545        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        // Mock successful decimals query
1558        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        // Mock failed contract invocation
1588        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        // Mock successful storage query with decimals = 8
1595        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        // Mock failed contract invocation
1642        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        // Mock failed storage query (no entries)
1649        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        // Should return None when both methods fail
1660        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        // Should return None for invalid contract address
1671        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        // Valid Stellar account address (starts with G)
1780        let valid_account = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
1781        assert_eq!(valid_account.len(), STELLAR_ADDRESS_LENGTH);
1782        assert!(valid_account.starts_with(STELLAR_ACCOUNT_PREFIX));
1783
1784        // Valid contract address (starts with C)
1785        let valid_contract = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
1786        assert_eq!(valid_contract.len(), STELLAR_ADDRESS_LENGTH);
1787        assert!(!valid_contract.starts_with(STELLAR_ACCOUNT_PREFIX));
1788
1789        // Invalid length
1790        let short = "GBBD47IF6LWK7P7";
1791        assert!(short.len() != STELLAR_ADDRESS_LENGTH);
1792
1793        // Invalid prefix (M is for muxed accounts)
1794        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; // 75 XLM
1803        let account_entry = create_mock_account_entry(test_balance);
1804
1805        // Mock get_account to be called twice
1806        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        // Test with "native"
1814        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        // Test with "XLM"
1819        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        // Edge case: what if issuer somehow contains a colon?
1827        // split_once only splits on first colon
1828        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        // Asset codes are case-sensitive in Stellar
1845        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"); // Lowercase preserved
1853            }
1854            _ => panic!("Expected Classic token kind"),
1855        }
1856    }
1857
1858    #[test]
1859    fn test_max_asset_code_length() {
1860        // Test that MAX_ASSET_CODE_LENGTH is correctly set
1861        assert_eq!(MAX_ASSET_CODE_LENGTH, 12);
1862
1863        // Asset codes up to 4 chars should be Credit4
1864        for len in 1..=4 {
1865            let code = "A".repeat(len);
1866            assert!(code.len() <= 4);
1867        }
1868
1869        // Asset codes 5-12 chars should be Credit12
1870        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        // Asset codes > 12 should be invalid
1876        let too_long = "A".repeat(13);
1877        assert!(too_long.len() > MAX_ASSET_CODE_LENGTH);
1878    }
1879}