openzeppelin_relayer/domain/transaction/stellar/
utils.rs

1//! Utility functions for Stellar transaction domain logic.
2use crate::constants::{
3    DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, STELLAR_DEFAULT_TRANSACTION_FEE, STELLAR_MAX_OPERATIONS,
4};
5use crate::domain::relayer::xdr_utils::{extract_operations, xdr_needs_simulation};
6use crate::models::{AssetSpec, OperationSpec, RelayerError, RelayerStellarPolicy};
7use crate::services::provider::StellarProviderTrait;
8use crate::services::stellar_dex::StellarDexServiceTrait;
9use base64::{engine::general_purpose, Engine};
10use chrono::{DateTime, Utc};
11use serde::Serialize;
12use soroban_rs::xdr::{
13    AccountId, AlphaNum12, AlphaNum4, Asset, ChangeTrustAsset, ContractDataEntry, ContractId, Hash,
14    LedgerEntryData, LedgerKey, LedgerKeyContractData, Limits, Operation, Preconditions,
15    PublicKey as XdrPublicKey, ReadXdr, ScAddress, ScSymbol, ScVal, TimeBounds, TimePoint,
16    TransactionEnvelope, Uint256, VecM,
17};
18use std::str::FromStr;
19use stellar_strkey::ed25519::PublicKey;
20use thiserror::Error;
21use tracing::{debug, warn};
22
23// ============================================================================
24// Error Types
25// ============================================================================
26
27/// Errors that can occur during Stellar transaction utility operations.
28///
29/// This error type is specific to Stellar transaction utilities and provides
30/// detailed error information. It can be converted to `RelayerError` using
31/// the `From` trait implementation.
32#[derive(Error, Debug, Serialize)]
33pub enum StellarTransactionUtilsError {
34    #[error("Sequence overflow: {0}")]
35    SequenceOverflow(String),
36
37    #[error("Failed to parse XDR: {0}")]
38    XdrParseFailed(String),
39
40    #[error("Failed to extract operations: {0}")]
41    OperationExtractionFailed(String),
42
43    #[error("Failed to check if simulation is needed: {0}")]
44    SimulationCheckFailed(String),
45
46    #[error("Failed to simulate transaction: {0}")]
47    SimulationFailed(String),
48
49    #[error("Transaction simulation returned no results")]
50    SimulationNoResults,
51
52    #[error("Failed to get DEX quote: {0}")]
53    DexQuoteFailed(String),
54
55    #[error("Invalid asset identifier format: {0}")]
56    InvalidAssetFormat(String),
57
58    #[error("Asset code too long (max {0} characters): {1}")]
59    AssetCodeTooLong(usize, String),
60
61    #[error("Too many operations (max {0})")]
62    TooManyOperations(usize),
63
64    #[error("Cannot add operations to fee-bump transactions")]
65    CannotModifyFeeBump,
66
67    #[error("Cannot set time bounds on fee-bump transactions")]
68    CannotSetTimeBoundsOnFeeBump,
69
70    #[error("Invalid transaction format: {0}")]
71    InvalidTransactionFormat(String),
72
73    #[error("Invalid account address '{0}': {1}")]
74    InvalidAccountAddress(String, String),
75
76    #[error("Invalid contract address '{0}': {1}")]
77    InvalidContractAddress(String, String),
78
79    #[error("Failed to create {0} symbol: {1:?}")]
80    SymbolCreationFailed(String, String),
81
82    #[error("Failed to create {0} key vector: {1:?}")]
83    KeyVectorCreationFailed(String, String),
84
85    #[error("Failed to query contract data (Persistent) for {0}: {1}")]
86    ContractDataQueryPersistentFailed(String, String),
87
88    #[error("Failed to query contract data (Temporary) for {0}: {1}")]
89    ContractDataQueryTemporaryFailed(String, String),
90
91    #[error("Failed to parse ledger entry XDR for {0}: {1}")]
92    LedgerEntryParseFailed(String, String),
93
94    #[error("No entries found for {0}")]
95    NoEntriesFound(String),
96
97    #[error("Empty entries for {0}")]
98    EmptyEntries(String),
99
100    #[error("Unexpected ledger entry type for {0} (expected ContractData)")]
101    UnexpectedLedgerEntryType(String),
102
103    // Token-specific errors
104    #[error("Asset code cannot be empty in asset identifier: {0}")]
105    EmptyAssetCode(String),
106
107    #[error("Issuer address cannot be empty in asset identifier: {0}")]
108    EmptyIssuerAddress(String),
109
110    #[error("Invalid issuer address length (expected {0} characters): {1}")]
111    InvalidIssuerLength(usize, String),
112
113    #[error("Invalid issuer address format (must start with '{0}'): {1}")]
114    InvalidIssuerPrefix(char, String),
115
116    #[error("Failed to fetch account for balance: {0}")]
117    AccountFetchFailed(String),
118
119    #[error("Failed to query trustline for asset {0}: {1}")]
120    TrustlineQueryFailed(String, String),
121
122    #[error("No trustline found for asset {0} on account {1}")]
123    NoTrustlineFound(String, String),
124
125    #[error("Unsupported trustline entry version")]
126    UnsupportedTrustlineVersion,
127
128    #[error("Unexpected ledger entry type for trustline query")]
129    UnexpectedTrustlineEntryType,
130
131    #[error("Balance too large (i128 hi={0}, lo={1}) to fit in u64")]
132    BalanceTooLarge(i64, u64),
133
134    #[error("Negative balance not allowed: i128 lo={0}")]
135    NegativeBalanceI128(u64),
136
137    #[error("Negative balance not allowed: i64={0}")]
138    NegativeBalanceI64(i64),
139
140    #[error("Unexpected balance value type in contract data: {0:?}. Expected I128, U64, or I64")]
141    UnexpectedBalanceType(String),
142
143    #[error("Unexpected ledger entry type for contract data query")]
144    UnexpectedContractDataEntryType,
145
146    #[error("Native asset should be handled before trustline query")]
147    NativeAssetInTrustlineQuery,
148
149    #[error("Failed to invoke contract function '{0}': {1}")]
150    ContractInvocationFailed(String, String),
151}
152
153impl From<StellarTransactionUtilsError> for RelayerError {
154    fn from(error: StellarTransactionUtilsError) -> Self {
155        match &error {
156            StellarTransactionUtilsError::SequenceOverflow(msg)
157            | StellarTransactionUtilsError::SimulationCheckFailed(msg)
158            | StellarTransactionUtilsError::SimulationFailed(msg)
159            | StellarTransactionUtilsError::XdrParseFailed(msg)
160            | StellarTransactionUtilsError::OperationExtractionFailed(msg)
161            | StellarTransactionUtilsError::DexQuoteFailed(msg) => {
162                RelayerError::Internal(msg.clone())
163            }
164            StellarTransactionUtilsError::SimulationNoResults => RelayerError::Internal(
165                "Transaction simulation failed: no results returned".to_string(),
166            ),
167            StellarTransactionUtilsError::InvalidAssetFormat(msg)
168            | StellarTransactionUtilsError::InvalidTransactionFormat(msg) => {
169                RelayerError::ValidationError(msg.clone())
170            }
171            StellarTransactionUtilsError::AssetCodeTooLong(max_len, code) => {
172                RelayerError::ValidationError(format!(
173                    "Asset code too long (max {max_len} characters): {code}"
174                ))
175            }
176            StellarTransactionUtilsError::TooManyOperations(max) => {
177                RelayerError::ValidationError(format!("Too many operations (max {max})"))
178            }
179            StellarTransactionUtilsError::CannotModifyFeeBump => RelayerError::ValidationError(
180                "Cannot add operations to fee-bump transactions".to_string(),
181            ),
182            StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump => {
183                RelayerError::ValidationError(
184                    "Cannot set time bounds on fee-bump transactions".to_string(),
185                )
186            }
187            StellarTransactionUtilsError::InvalidAccountAddress(_, msg)
188            | StellarTransactionUtilsError::InvalidContractAddress(_, msg)
189            | StellarTransactionUtilsError::SymbolCreationFailed(_, msg)
190            | StellarTransactionUtilsError::KeyVectorCreationFailed(_, msg)
191            | StellarTransactionUtilsError::ContractDataQueryPersistentFailed(_, msg)
192            | StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(_, msg)
193            | StellarTransactionUtilsError::LedgerEntryParseFailed(_, msg) => {
194                RelayerError::Internal(msg.clone())
195            }
196            StellarTransactionUtilsError::NoEntriesFound(_)
197            | StellarTransactionUtilsError::EmptyEntries(_)
198            | StellarTransactionUtilsError::UnexpectedLedgerEntryType(_)
199            | StellarTransactionUtilsError::EmptyAssetCode(_)
200            | StellarTransactionUtilsError::EmptyIssuerAddress(_)
201            | StellarTransactionUtilsError::NoTrustlineFound(_, _)
202            | StellarTransactionUtilsError::UnsupportedTrustlineVersion
203            | StellarTransactionUtilsError::UnexpectedTrustlineEntryType
204            | StellarTransactionUtilsError::BalanceTooLarge(_, _)
205            | StellarTransactionUtilsError::NegativeBalanceI128(_)
206            | StellarTransactionUtilsError::NegativeBalanceI64(_)
207            | StellarTransactionUtilsError::UnexpectedBalanceType(_)
208            | StellarTransactionUtilsError::UnexpectedContractDataEntryType
209            | StellarTransactionUtilsError::NativeAssetInTrustlineQuery => {
210                RelayerError::ValidationError(error.to_string())
211            }
212            StellarTransactionUtilsError::InvalidIssuerLength(expected, actual) => {
213                RelayerError::ValidationError(format!(
214                    "Invalid issuer address length (expected {expected} characters): {actual}"
215                ))
216            }
217            StellarTransactionUtilsError::InvalidIssuerPrefix(prefix, addr) => {
218                RelayerError::ValidationError(format!(
219                    "Invalid issuer address format (must start with '{prefix}'): {addr}"
220                ))
221            }
222            StellarTransactionUtilsError::AccountFetchFailed(msg)
223            | StellarTransactionUtilsError::TrustlineQueryFailed(_, msg)
224            | StellarTransactionUtilsError::ContractInvocationFailed(_, msg) => {
225                RelayerError::ProviderError(msg.clone())
226            }
227        }
228    }
229}
230
231/// Returns true if any operation needs simulation (contract invocation, creation, or wasm upload).
232pub fn needs_simulation(operations: &[OperationSpec]) -> bool {
233    operations.iter().any(|op| {
234        matches!(
235            op,
236            OperationSpec::InvokeContract { .. }
237                | OperationSpec::CreateContract { .. }
238                | OperationSpec::UploadWasm { .. }
239        )
240    })
241}
242
243pub fn next_sequence_u64(seq_num: i64) -> Result<u64, RelayerError> {
244    let next_i64 = seq_num
245        .checked_add(1)
246        .ok_or_else(|| RelayerError::ProviderError("sequence overflow".into()))?;
247    u64::try_from(next_i64)
248        .map_err(|_| RelayerError::ProviderError("sequence overflows u64".into()))
249}
250
251pub fn i64_from_u64(value: u64) -> Result<i64, RelayerError> {
252    i64::try_from(value).map_err(|_| RelayerError::ProviderError("u64→i64 overflow".into()))
253}
254
255/// Detects if an error is due to a bad sequence number.
256/// Returns true if the error message contains indicators of sequence number mismatch.
257pub fn is_bad_sequence_error(error_msg: &str) -> bool {
258    let error_lower = error_msg.to_lowercase();
259    error_lower.contains("txbadseq")
260}
261
262/// Fetches the current sequence number from the blockchain and calculates the next usable sequence.
263/// This is a shared helper that can be used by both stellar_relayer and stellar_transaction.
264///
265/// # Returns
266/// The next usable sequence number (on-chain sequence + 1)
267pub async fn fetch_next_sequence_from_chain<P>(
268    provider: &P,
269    relayer_address: &str,
270) -> Result<u64, String>
271where
272    P: StellarProviderTrait,
273{
274    debug!(
275        "Fetching sequence from chain for address: {}",
276        relayer_address
277    );
278
279    // Fetch account info from chain
280    let account = provider
281        .get_account(relayer_address)
282        .await
283        .map_err(|e| format!("Failed to fetch account from chain: {e}"))?;
284
285    let on_chain_seq = account.seq_num.0; // Extract the i64 value
286    let next_usable = next_sequence_u64(on_chain_seq)
287        .map_err(|e| format!("Failed to calculate next sequence: {e}"))?;
288
289    debug!(
290        "Fetched sequence from chain: on-chain={}, next usable={}",
291        on_chain_seq, next_usable
292    );
293    Ok(next_usable)
294}
295
296/// Convert a V0 transaction to V1 format for signing.
297/// This is needed because the signature payload for V0 transactions uses V1 format internally.
298pub fn convert_v0_to_v1_transaction(
299    v0_tx: &soroban_rs::xdr::TransactionV0,
300) -> soroban_rs::xdr::Transaction {
301    soroban_rs::xdr::Transaction {
302        source_account: soroban_rs::xdr::MuxedAccount::Ed25519(
303            v0_tx.source_account_ed25519.clone(),
304        ),
305        fee: v0_tx.fee,
306        seq_num: v0_tx.seq_num.clone(),
307        cond: match v0_tx.time_bounds.clone() {
308            Some(tb) => soroban_rs::xdr::Preconditions::Time(tb),
309            None => soroban_rs::xdr::Preconditions::None,
310        },
311        memo: v0_tx.memo.clone(),
312        operations: v0_tx.operations.clone(),
313        ext: soroban_rs::xdr::TransactionExt::V0,
314    }
315}
316
317/// Create a signature payload for the given envelope type
318pub fn create_signature_payload(
319    envelope: &soroban_rs::xdr::TransactionEnvelope,
320    network_id: &soroban_rs::xdr::Hash,
321) -> Result<soroban_rs::xdr::TransactionSignaturePayload, RelayerError> {
322    let tagged_transaction = match envelope {
323        soroban_rs::xdr::TransactionEnvelope::TxV0(e) => {
324            // For V0, convert to V1 transaction format for signing
325            let v1_tx = convert_v0_to_v1_transaction(&e.tx);
326            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(v1_tx)
327        }
328        soroban_rs::xdr::TransactionEnvelope::Tx(e) => {
329            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(e.tx.clone())
330        }
331        soroban_rs::xdr::TransactionEnvelope::TxFeeBump(e) => {
332            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::TxFeeBump(e.tx.clone())
333        }
334    };
335
336    Ok(soroban_rs::xdr::TransactionSignaturePayload {
337        network_id: network_id.clone(),
338        tagged_transaction,
339    })
340}
341
342/// Create signature payload for a transaction directly (for operations-based signing)
343pub fn create_transaction_signature_payload(
344    transaction: &soroban_rs::xdr::Transaction,
345    network_id: &soroban_rs::xdr::Hash,
346) -> soroban_rs::xdr::TransactionSignaturePayload {
347    soroban_rs::xdr::TransactionSignaturePayload {
348        network_id: network_id.clone(),
349        tagged_transaction: soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(
350            transaction.clone(),
351        ),
352    }
353}
354
355// ============================================================================
356// Account and Contract Address Utilities
357// ============================================================================
358
359/// Parse a Stellar account address string into an AccountId XDR type.
360///
361/// # Arguments
362///
363/// * `account_id` - Stellar account address (must be valid PublicKey)
364///
365/// # Returns
366///
367/// AccountId XDR type or error if address is invalid
368pub fn parse_account_id(account_id: &str) -> Result<AccountId, StellarTransactionUtilsError> {
369    let account_pk = PublicKey::from_str(account_id).map_err(|e| {
370        StellarTransactionUtilsError::InvalidAccountAddress(account_id.to_string(), e.to_string())
371    })?;
372    let account_uint256 = Uint256(account_pk.0);
373    let account_xdr_pk = XdrPublicKey::PublicKeyTypeEd25519(account_uint256);
374    Ok(AccountId(account_xdr_pk))
375}
376
377/// Parse a contract address string into a ContractId and extract the hash.
378///
379/// # Arguments
380///
381/// * `contract_address` - Contract address in StrKey format
382///
383/// # Returns
384///
385/// Contract hash (Hash) or error if address is invalid
386pub fn parse_contract_address(
387    contract_address: &str,
388) -> Result<Hash, StellarTransactionUtilsError> {
389    let contract_id = ContractId::from_str(contract_address).map_err(|e| {
390        StellarTransactionUtilsError::InvalidContractAddress(
391            contract_address.to_string(),
392            e.to_string(),
393        )
394    })?;
395    Ok(contract_id.0)
396}
397
398// ============================================================================
399// Contract Data Utilities
400// ============================================================================
401
402/// Create an ScVal key for contract data queries.
403///
404/// Creates a ScVal::Vec containing a symbol and optional address.
405/// Used for SEP-41 token interface keys like "Balance" and "Decimals".
406///
407/// # Arguments
408///
409/// * `symbol` - Symbol name (e.g., "Balance", "Decimals")
410/// * `address` - Optional ScAddress to include in the key
411///
412/// # Returns
413///
414/// ScVal::Vec key or error if creation fails
415pub fn create_contract_data_key(
416    symbol: &str,
417    address: Option<ScAddress>,
418) -> Result<ScVal, StellarTransactionUtilsError> {
419    if address.is_none() {
420        let sym = ScSymbol::try_from(symbol).map_err(|e| {
421            StellarTransactionUtilsError::SymbolCreationFailed(symbol.to_string(), format!("{e:?}"))
422        })?;
423        return Ok(ScVal::Symbol(sym));
424    }
425
426    let mut key_items: Vec<ScVal> =
427        vec![ScVal::Symbol(ScSymbol::try_from(symbol).map_err(|e| {
428            StellarTransactionUtilsError::SymbolCreationFailed(symbol.to_string(), format!("{e:?}"))
429        })?)];
430
431    if let Some(addr) = address {
432        key_items.push(ScVal::Address(addr));
433    }
434
435    let key_vec: VecM<ScVal, { u32::MAX }> = VecM::try_from(key_items).map_err(|e| {
436        StellarTransactionUtilsError::KeyVectorCreationFailed(symbol.to_string(), format!("{e:?}"))
437    })?;
438
439    Ok(ScVal::Vec(Some(soroban_rs::xdr::ScVec(key_vec))))
440}
441
442/// Query contract data with Persistent/Temporary durability fallback.
443///
444/// Queries contract data storage, trying Persistent durability first,
445/// then falling back to Temporary if not found. This handles both
446/// production tokens (Persistent) and test tokens (Temporary).
447///
448/// # Arguments
449///
450/// * `provider` - Stellar provider for querying ledger entries
451/// * `contract_hash` - Contract hash (Hash)
452/// * `key` - ScVal key to query
453/// * `error_context` - Context string for error messages
454///
455/// # Returns
456///
457/// GetLedgerEntriesResponse or error if query fails
458pub async fn query_contract_data_with_fallback<P>(
459    provider: &P,
460    contract_hash: Hash,
461    key: ScVal,
462    error_context: &str,
463) -> Result<soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse, StellarTransactionUtilsError>
464where
465    P: StellarProviderTrait + Send + Sync,
466{
467    let contract_address_sc =
468        soroban_rs::xdr::ScAddress::Contract(soroban_rs::xdr::ContractId(contract_hash));
469
470    let mut ledger_key = LedgerKey::ContractData(LedgerKeyContractData {
471        contract: contract_address_sc.clone(),
472        key: key.clone(),
473        durability: soroban_rs::xdr::ContractDataDurability::Persistent,
474    });
475
476    // Query ledger entry with Persistent durability
477    let mut ledger_entries = provider
478        .get_ledger_entries(&[ledger_key.clone()])
479        .await
480        .map_err(|e| {
481            StellarTransactionUtilsError::ContractDataQueryPersistentFailed(
482                error_context.to_string(),
483                e.to_string(),
484            )
485        })?;
486
487    // If not found, try Temporary durability
488    if ledger_entries
489        .entries
490        .as_ref()
491        .map(|e| e.is_empty())
492        .unwrap_or(true)
493    {
494        ledger_key = LedgerKey::ContractData(LedgerKeyContractData {
495            contract: contract_address_sc,
496            key,
497            durability: soroban_rs::xdr::ContractDataDurability::Temporary,
498        });
499        ledger_entries = provider
500            .get_ledger_entries(&[ledger_key])
501            .await
502            .map_err(|e| {
503                StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(
504                    error_context.to_string(),
505                    e.to_string(),
506                )
507            })?;
508    }
509
510    Ok(ledger_entries)
511}
512
513/// Parse a ledger entry from base64 XDR string.
514///
515/// Handles both LedgerEntry and LedgerEntryChange formats. If the XDR is a
516/// LedgerEntryChange, extracts the LedgerEntry from it.
517///
518/// # Arguments
519///
520/// * `xdr_string` - Base64-encoded XDR string
521/// * `context` - Context string for error messages
522///
523/// # Returns
524///
525/// Parsed LedgerEntry or error if parsing fails
526pub fn parse_ledger_entry_from_xdr(
527    xdr_string: &str,
528    context: &str,
529) -> Result<LedgerEntryData, StellarTransactionUtilsError> {
530    let trimmed_xdr = xdr_string.trim();
531
532    // Ensure valid base64
533    if general_purpose::STANDARD.decode(trimmed_xdr).is_err() {
534        return Err(StellarTransactionUtilsError::LedgerEntryParseFailed(
535            context.to_string(),
536            "Invalid base64".to_string(),
537        ));
538    }
539
540    // Parse as LedgerEntryData (what Soroban RPC actually returns)
541    match LedgerEntryData::from_xdr_base64(trimmed_xdr, Limits::none()) {
542        Ok(data) => Ok(data),
543        Err(e) => Err(StellarTransactionUtilsError::LedgerEntryParseFailed(
544            context.to_string(),
545            format!("Failed to parse LedgerEntryData: {e}"),
546        )),
547    }
548}
549
550/// Extract ScVal from contract data entry.
551///
552/// Parses the first entry from GetLedgerEntriesResponse and extracts
553/// the ScVal from ContractDataEntry.
554///
555/// # Arguments
556///
557/// * `ledger_entries` - Response from get_ledger_entries
558/// * `context` - Context string for error messages and logging
559///
560/// # Returns
561///
562/// ScVal from contract data or error if extraction fails
563pub fn extract_scval_from_contract_data(
564    ledger_entries: &soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse,
565    context: &str,
566) -> Result<ScVal, StellarTransactionUtilsError> {
567    let entries = ledger_entries
568        .entries
569        .as_ref()
570        .ok_or_else(|| StellarTransactionUtilsError::NoEntriesFound(context.into()))?;
571
572    if entries.is_empty() {
573        return Err(StellarTransactionUtilsError::EmptyEntries(context.into()));
574    }
575
576    let entry_xdr = &entries[0].xdr;
577    let entry = parse_ledger_entry_from_xdr(entry_xdr, context)?;
578
579    match entry {
580        LedgerEntryData::ContractData(ContractDataEntry { val, .. }) => Ok(val.clone()),
581
582        _ => Err(StellarTransactionUtilsError::UnexpectedLedgerEntryType(
583            context.into(),
584        )),
585    }
586}
587
588/// Extract a u32 value from an ScVal.
589///
590/// Handles multiple ScVal types that can represent numeric values.
591///
592/// # Arguments
593///
594/// * `val` - ScVal to extract from
595/// * `context` - Context string (for logging)
596///
597/// # Returns
598///
599/// Some(u32) if extraction succeeds, None otherwise
600pub fn extract_u32_from_scval(val: &ScVal, context: &str) -> Option<u32> {
601    let result = match val {
602        ScVal::U32(n) => Ok(*n),
603        ScVal::I32(n) => (*n).try_into().map_err(|_| "Negative I32"),
604        ScVal::U64(n) => (*n).try_into().map_err(|_| "U64 overflow"),
605        ScVal::I64(n) => (*n).try_into().map_err(|_| "I64 overflow/negative"),
606        ScVal::U128(n) => {
607            if n.hi == 0 {
608                n.lo.try_into().map_err(|_| "U128 lo overflow")
609            } else {
610                Err("U128 hi set")
611            }
612        }
613        ScVal::I128(n) => {
614            if n.hi == 0 {
615                n.lo.try_into().map_err(|_| "I128 lo overflow")
616            } else {
617                Err("I128 hi set/negative")
618            }
619        }
620        _ => Err("Unsupported ScVal type"),
621    };
622
623    match result {
624        Ok(v) => Some(v),
625        Err(msg) => {
626            warn!(context = %context, val = ?val, "Failed to extract u32: {}", msg);
627            None
628        }
629    }
630}
631
632// ============================================================================
633// Gas Abstraction Utility Functions
634// ============================================================================
635
636/// Convert raw token amount to UI amount based on decimals
637///
638/// Uses pure integer arithmetic to avoid floating-point precision errors.
639/// This is safer for financial calculations where precision is critical.
640pub fn amount_to_ui_amount(amount: u64, decimals: u8) -> String {
641    if decimals == 0 {
642        return amount.to_string();
643    }
644
645    let amount_str = amount.to_string();
646    let len = amount_str.len();
647    let decimals_usize = decimals as usize;
648
649    let combined = if len > decimals_usize {
650        let split_idx = len - decimals_usize;
651        let whole = &amount_str[..split_idx];
652        let frac = &amount_str[split_idx..];
653        format!("{whole}.{frac}")
654    } else {
655        // Need to pad with leading zeros
656        let zeros = "0".repeat(decimals_usize - len);
657        format!("0.{zeros}{amount_str}")
658    };
659
660    // Trim trailing zeros
661    let mut trimmed = combined.trim_end_matches('0').to_string();
662    if trimmed.ends_with('.') {
663        trimmed.pop();
664    }
665
666    // If we stripped everything (e.g. amount 0), return "0"
667    if trimmed.is_empty() {
668        "0".to_string()
669    } else {
670        trimmed
671    }
672}
673
674/// Count operations in a transaction envelope from XDR base64 string
675///
676/// Parses the XDR string, extracts operations, and returns the count.
677pub fn count_operations_from_xdr(xdr: &str) -> Result<usize, StellarTransactionUtilsError> {
678    let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| {
679        StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
680    })?;
681
682    let operations = extract_operations(&envelope).map_err(|e| {
683        StellarTransactionUtilsError::OperationExtractionFailed(format!(
684            "Failed to extract operations: {e}"
685        ))
686    })?;
687
688    Ok(operations.len())
689}
690
691/// Parse transaction and count operations
692///
693/// Supports both XDR (base64 string) and operations array formats
694pub fn parse_transaction_and_count_operations(
695    transaction_json: &serde_json::Value,
696) -> Result<usize, StellarTransactionUtilsError> {
697    // Try to parse as XDR string first
698    if let Some(xdr_str) = transaction_json.as_str() {
699        let envelope =
700            TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
701                StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
702            })?;
703
704        let operations = extract_operations(&envelope).map_err(|e| {
705            StellarTransactionUtilsError::OperationExtractionFailed(format!(
706                "Failed to extract operations: {e}"
707            ))
708        })?;
709
710        return Ok(operations.len());
711    }
712
713    // Try to parse as operations array
714    if let Some(ops_array) = transaction_json.as_array() {
715        return Ok(ops_array.len());
716    }
717
718    // Try to parse as object with operations field
719    if let Some(obj) = transaction_json.as_object() {
720        if let Some(ops) = obj.get("operations") {
721            if let Some(ops_array) = ops.as_array() {
722                return Ok(ops_array.len());
723            }
724        }
725        if let Some(xdr_str) = obj.get("transaction_xdr").and_then(|v| v.as_str()) {
726            let envelope =
727                TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
728                    StellarTransactionUtilsError::XdrParseFailed(format!(
729                        "Failed to parse XDR: {e}"
730                    ))
731                })?;
732
733            let operations = extract_operations(&envelope).map_err(|e| {
734                StellarTransactionUtilsError::OperationExtractionFailed(format!(
735                    "Failed to extract operations: {e}"
736                ))
737            })?;
738
739            return Ok(operations.len());
740        }
741    }
742
743    Err(StellarTransactionUtilsError::InvalidTransactionFormat(
744        "Transaction must be either XDR string or operations array".to_string(),
745    ))
746}
747
748/// Fee quote structure containing fee estimates in both tokens and stroops
749#[derive(Debug)]
750pub struct FeeQuote {
751    pub fee_in_token: u64,
752    pub fee_in_token_ui: String,
753    pub fee_in_stroops: u64,
754    pub conversion_rate: f64,
755}
756
757/// Estimate the base transaction fee in XLM (stroops)
758///
759/// For Stellar, the base fee is typically 100 stroops per operation.
760pub fn estimate_base_fee(num_operations: usize) -> u64 {
761    (num_operations.max(1) as u64) * STELLAR_DEFAULT_TRANSACTION_FEE as u64
762}
763
764/// Estimate transaction fee in XLM (stroops) based on envelope content
765///
766/// This function intelligently estimates fees by:
767/// 1. Checking if the transaction needs simulation (contains Soroban operations)
768/// 2. If simulation is needed, performs simulation and uses `min_resource_fee` from the response
769/// 3. If simulation is not needed, counts operations and uses `estimate_base_fee`
770///
771/// # Arguments
772/// * `envelope` - The transaction envelope to estimate fees for
773/// * `provider` - Stellar provider for simulation (required if simulation is needed)
774/// * `operations_override` - Optional override for operations count (useful when operations will be added, e.g., +1 for fee payment)
775///
776/// # Returns
777/// Estimated fee in stroops (XLM)
778pub async fn estimate_fee<P>(
779    envelope: &TransactionEnvelope,
780    provider: &P,
781    operations_override: Option<usize>,
782) -> Result<u64, StellarTransactionUtilsError>
783where
784    P: StellarProviderTrait + Send + Sync,
785{
786    // Check if simulation is needed
787    let needs_sim = xdr_needs_simulation(envelope).map_err(|e| {
788        StellarTransactionUtilsError::SimulationCheckFailed(format!(
789            "Failed to check if simulation is needed: {e}"
790        ))
791    })?;
792
793    if needs_sim {
794        debug!("Transaction contains Soroban operations, simulating to get accurate fee");
795
796        // For simulation, we simulate the envelope as-is
797        let simulation_result = provider
798            .simulate_transaction_envelope(envelope)
799            .await
800            .map_err(|e| {
801                StellarTransactionUtilsError::SimulationFailed(format!(
802                    "Failed to simulate transaction: {e}"
803                ))
804            })?;
805
806        // Check simulation success
807        if simulation_result.results.is_empty() {
808            return Err(StellarTransactionUtilsError::SimulationNoResults);
809        }
810
811        // Use min_resource_fee from simulation (this includes all fees for Soroban operations)
812        // If operations_override is provided, we add the base fee for additional operations
813        let resource_fee = simulation_result.min_resource_fee as u64;
814        let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as u64;
815        let required_fee = inclusion_fee + resource_fee;
816
817        debug!("Simulation returned fee: {} stroops", required_fee);
818        Ok(required_fee)
819    } else {
820        // No simulation needed, count operations and estimate base fee
821        let num_operations = if let Some(override_count) = operations_override {
822            override_count
823        } else {
824            let operations = extract_operations(envelope).map_err(|e| {
825                StellarTransactionUtilsError::OperationExtractionFailed(format!(
826                    "Failed to extract operations: {e}"
827                ))
828            })?;
829            operations.len()
830        };
831
832        let fee = estimate_base_fee(num_operations);
833        debug!(
834            "No simulation needed, estimated fee from {} operations: {} stroops",
835            num_operations, fee
836        );
837        Ok(fee)
838    }
839}
840
841/// Convert XLM fee to token amount using DEX service
842///
843/// This function converts an XLM fee (in stroops) to the equivalent amount in the requested token
844/// using the DEX service. For native XLM, no conversion is needed.
845/// Optionally applies a fee margin percentage to the XLM fee before conversion.
846///
847/// # Arguments
848/// * `dex_service` - DEX service for token conversion quotes
849/// * `policy` - Stellar relayer policy for slippage and token decimals
850/// * `xlm_fee` - Fee amount in XLM stroops (already estimated)
851/// * `fee_token` - Token identifier (e.g., "native" or "USDC:GA5Z...")
852///
853/// # Returns
854/// A tuple containing:
855/// * `FeeQuote` - Fee quote with amounts in both token and XLM
856/// * `u64` - Buffered XLM fee (with margin applied if specified)
857pub async fn convert_xlm_fee_to_token<D>(
858    dex_service: &D,
859    policy: &RelayerStellarPolicy,
860    xlm_fee: u64,
861    fee_token: &str,
862) -> Result<FeeQuote, StellarTransactionUtilsError>
863where
864    D: StellarDexServiceTrait + Send + Sync,
865{
866    // Handle native XLM - no conversion needed
867    if fee_token == "native" || fee_token.is_empty() {
868        debug!("Converting XLM fee to native XLM: {}", xlm_fee);
869        let buffered_fee = if let Some(margin) = policy.fee_margin_percentage {
870            (xlm_fee as f64 * (1.0 + margin as f64 / 100.0)) as u64
871        } else {
872            xlm_fee
873        };
874
875        return Ok(FeeQuote {
876            fee_in_token: buffered_fee,
877            fee_in_token_ui: amount_to_ui_amount(buffered_fee, 7),
878            fee_in_stroops: buffered_fee,
879            conversion_rate: 1.0,
880        });
881    }
882
883    debug!("Converting XLM fee to token: {}", fee_token);
884
885    // Apply fee margin if specified in policy
886    let buffered_xlm_fee = if let Some(margin) = policy.fee_margin_percentage {
887        (xlm_fee as f64 * (1.0 + margin as f64 / 100.0)) as u64
888    } else {
889        xlm_fee
890    };
891
892    // Get slippage from policy or use default
893    let slippage = policy
894        .get_allowed_token_entry(fee_token)
895        .and_then(|token| {
896            token
897                .swap_config
898                .as_ref()
899                .and_then(|config| config.slippage_percentage)
900        })
901        .or(policy.slippage_percentage)
902        .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE);
903
904    // Get quote from DEX service
905    // Get token decimals from policy or default to 7
906    let token_decimals = policy.get_allowed_token_decimals(fee_token);
907    let quote = dex_service
908        .get_xlm_to_token_quote(fee_token, buffered_xlm_fee, slippage, token_decimals)
909        .await
910        .map_err(|e| {
911            StellarTransactionUtilsError::DexQuoteFailed(format!("Failed to get quote: {e}"))
912        })?;
913
914    debug!(
915        "Quote from DEX: input={} stroops XLM, output={} stroops token, input_asset={}, output_asset={}",
916        quote.in_amount, quote.out_amount, quote.input_asset, quote.output_asset
917    );
918
919    // Calculate conversion rate
920    let conversion_rate = if buffered_xlm_fee > 0 {
921        quote.out_amount as f64 / buffered_xlm_fee as f64
922    } else {
923        0.0
924    };
925
926    let fee_quote = FeeQuote {
927        fee_in_token: quote.out_amount,
928        fee_in_token_ui: amount_to_ui_amount(quote.out_amount, token_decimals.unwrap_or(7)),
929        fee_in_stroops: buffered_xlm_fee,
930        conversion_rate,
931    };
932
933    debug!(
934        "Final fee quote: fee_in_token={} stroops ({} {}), fee_in_stroops={} stroops XLM, conversion_rate={}",
935        fee_quote.fee_in_token, fee_quote.fee_in_token_ui, fee_token, fee_quote.fee_in_stroops, fee_quote.conversion_rate
936    );
937
938    Ok(fee_quote)
939}
940
941/// Parse transaction envelope from JSON value
942pub fn parse_transaction_envelope(
943    transaction_json: &serde_json::Value,
944) -> Result<TransactionEnvelope, StellarTransactionUtilsError> {
945    // Try to parse as XDR string first
946    if let Some(xdr_str) = transaction_json.as_str() {
947        return TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
948            StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
949        });
950    }
951
952    // Try to parse as object with transaction_xdr field
953    if let Some(obj) = transaction_json.as_object() {
954        if let Some(xdr_str) = obj.get("transaction_xdr").and_then(|v| v.as_str()) {
955            return TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
956                StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
957            });
958        }
959    }
960
961    Err(StellarTransactionUtilsError::InvalidTransactionFormat(
962        "Transaction must be XDR string or object with transaction_xdr field".to_string(),
963    ))
964}
965
966/// Create fee payment operation
967pub fn create_fee_payment_operation(
968    destination: &str,
969    asset_id: &str,
970    amount: i64,
971) -> Result<OperationSpec, StellarTransactionUtilsError> {
972    // Parse asset identifier
973    let asset = if asset_id == "native" || asset_id.is_empty() {
974        AssetSpec::Native
975    } else {
976        // Parse "CODE:ISSUER" format
977        if let Some(colon_pos) = asset_id.find(':') {
978            let code = asset_id[..colon_pos].to_string();
979            let issuer = asset_id[colon_pos + 1..].to_string();
980
981            // Determine if it's Credit4 or Credit12 based on code length
982            if code.len() <= 4 {
983                AssetSpec::Credit4 { code, issuer }
984            } else if code.len() <= 12 {
985                AssetSpec::Credit12 { code, issuer }
986            } else {
987                return Err(StellarTransactionUtilsError::AssetCodeTooLong(
988                    12, // Stellar max asset code length
989                    code,
990                ));
991            }
992        } else {
993            return Err(StellarTransactionUtilsError::InvalidAssetFormat(format!(
994                "Invalid asset identifier format. Expected 'native' or 'CODE:ISSUER', got: {asset_id}"
995            )));
996        }
997    };
998
999    Ok(OperationSpec::Payment {
1000        destination: destination.to_string(),
1001        amount,
1002        asset,
1003    })
1004}
1005
1006/// Add operation to transaction envelope
1007pub fn add_operation_to_envelope(
1008    envelope: &mut TransactionEnvelope,
1009    operation: Operation,
1010) -> Result<(), StellarTransactionUtilsError> {
1011    match envelope {
1012        TransactionEnvelope::TxV0(ref mut e) => {
1013            // Extract existing operations
1014            let mut ops: Vec<Operation> = e.tx.operations.iter().cloned().collect();
1015            ops.push(operation);
1016
1017            // Convert back to VecM
1018            let operations: VecM<Operation, 100> = ops.try_into().map_err(|_| {
1019                StellarTransactionUtilsError::TooManyOperations(STELLAR_MAX_OPERATIONS)
1020            })?;
1021
1022            e.tx.operations = operations;
1023
1024            // Update fee to account for new operation
1025            e.tx.fee = (e.tx.operations.len() as u32) * STELLAR_DEFAULT_TRANSACTION_FEE;
1026            // 100 stroops per operation
1027        }
1028        TransactionEnvelope::Tx(ref mut e) => {
1029            // Extract existing operations
1030            let mut ops: Vec<Operation> = e.tx.operations.iter().cloned().collect();
1031            ops.push(operation);
1032
1033            // Convert back to VecM
1034            let operations: VecM<Operation, 100> = ops.try_into().map_err(|_| {
1035                StellarTransactionUtilsError::TooManyOperations(STELLAR_MAX_OPERATIONS)
1036            })?;
1037
1038            e.tx.operations = operations;
1039
1040            // Update fee to account for new operation
1041            e.tx.fee = (e.tx.operations.len() as u32) * STELLAR_DEFAULT_TRANSACTION_FEE;
1042            // 100 stroops per operation
1043        }
1044        TransactionEnvelope::TxFeeBump(_) => {
1045            return Err(StellarTransactionUtilsError::CannotModifyFeeBump);
1046        }
1047    }
1048    Ok(())
1049}
1050
1051/// Extract time bounds from a transaction envelope
1052///
1053/// Handles both regular transactions (TxV0, Tx) and fee-bump transactions
1054/// (extracts from inner transaction).
1055///
1056/// # Arguments
1057/// * `envelope` - The transaction envelope to extract time bounds from
1058///
1059/// # Returns
1060/// Some(TimeBounds) if present, None otherwise
1061pub fn extract_time_bounds(envelope: &TransactionEnvelope) -> Option<&TimeBounds> {
1062    match envelope {
1063        TransactionEnvelope::TxV0(e) => e.tx.time_bounds.as_ref(),
1064        TransactionEnvelope::Tx(e) => match &e.tx.cond {
1065            Preconditions::Time(tb) => Some(tb),
1066            _ => None,
1067        },
1068        TransactionEnvelope::TxFeeBump(fb) => {
1069            // Extract from inner transaction
1070            match &fb.tx.inner_tx {
1071                soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_tx) => {
1072                    match &inner_tx.tx.cond {
1073                        Preconditions::Time(tb) => Some(tb),
1074                        _ => None,
1075                    }
1076                }
1077            }
1078        }
1079    }
1080}
1081
1082/// Set time bounds on transaction envelope
1083pub fn set_time_bounds(
1084    envelope: &mut TransactionEnvelope,
1085    valid_until: DateTime<Utc>,
1086) -> Result<(), StellarTransactionUtilsError> {
1087    let max_time = valid_until.timestamp() as u64;
1088    let time_bounds = TimeBounds {
1089        min_time: TimePoint(0),
1090        max_time: TimePoint(max_time),
1091    };
1092
1093    match envelope {
1094        TransactionEnvelope::TxV0(ref mut e) => {
1095            e.tx.time_bounds = Some(time_bounds);
1096        }
1097        TransactionEnvelope::Tx(ref mut e) => {
1098            e.tx.cond = Preconditions::Time(time_bounds);
1099        }
1100        TransactionEnvelope::TxFeeBump(_) => {
1101            return Err(StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump);
1102        }
1103    }
1104    Ok(())
1105}
1106
1107/// Extract asset identifier from CreditAlphanum4
1108fn credit_alphanum4_to_asset_id(
1109    alpha4: &AlphaNum4,
1110) -> Result<String, StellarTransactionUtilsError> {
1111    // Extract code (trim null bytes)
1112    let code_bytes = alpha4.asset_code.0;
1113    let code_len = code_bytes.iter().position(|&b| b == 0).unwrap_or(4);
1114    let code = String::from_utf8(code_bytes[..code_len].to_vec()).map_err(|e| {
1115        StellarTransactionUtilsError::InvalidAssetFormat(format!("Invalid asset code: {e}"))
1116    })?;
1117
1118    // Extract issuer
1119    let issuer = match &alpha4.issuer.0 {
1120        XdrPublicKey::PublicKeyTypeEd25519(uint256) => {
1121            let bytes: [u8; 32] = uint256.0;
1122            let pk = PublicKey(bytes);
1123            pk.to_string()
1124        }
1125    };
1126
1127    Ok(format!("{code}:{issuer}"))
1128}
1129
1130/// Extract asset identifier from CreditAlphanum12
1131fn credit_alphanum12_to_asset_id(
1132    alpha12: &AlphaNum12,
1133) -> Result<String, StellarTransactionUtilsError> {
1134    // Extract code (trim null bytes)
1135    let code_bytes = alpha12.asset_code.0;
1136    let code_len = code_bytes.iter().position(|&b| b == 0).unwrap_or(12);
1137    let code = String::from_utf8(code_bytes[..code_len].to_vec()).map_err(|e| {
1138        StellarTransactionUtilsError::InvalidAssetFormat(format!("Invalid asset code: {e}"))
1139    })?;
1140
1141    // Extract issuer
1142    let issuer = match &alpha12.issuer.0 {
1143        XdrPublicKey::PublicKeyTypeEd25519(uint256) => {
1144            let bytes: [u8; 32] = uint256.0;
1145            let pk = PublicKey(bytes);
1146            pk.to_string()
1147        }
1148    };
1149
1150    Ok(format!("{code}:{issuer}"))
1151}
1152
1153/// Convert ChangeTrustAsset XDR to asset identifier string
1154///
1155/// Returns `Some(asset_id)` for CreditAlphanum4 and CreditAlphanum12 assets,
1156/// or `None` for Native or PoolShare (which don't have asset identifiers).
1157///
1158/// # Arguments
1159///
1160/// * `change_trust_asset` - The ChangeTrustAsset to convert
1161///
1162/// # Returns
1163///
1164/// Asset identifier string in "CODE:ISSUER" format, or None for Native/PoolShare
1165pub fn change_trust_asset_to_asset_id(
1166    change_trust_asset: &ChangeTrustAsset,
1167) -> Result<Option<String>, StellarTransactionUtilsError> {
1168    match change_trust_asset {
1169        ChangeTrustAsset::Native | ChangeTrustAsset::PoolShare(_) => Ok(None),
1170        ChangeTrustAsset::CreditAlphanum4(alpha4) => {
1171            // Convert to Asset and use the unified function
1172            let asset = Asset::CreditAlphanum4(alpha4.clone());
1173            asset_to_asset_id(&asset).map(Some)
1174        }
1175        ChangeTrustAsset::CreditAlphanum12(alpha12) => {
1176            // Convert to Asset and use the unified function
1177            let asset = Asset::CreditAlphanum12(alpha12.clone());
1178            asset_to_asset_id(&asset).map(Some)
1179        }
1180    }
1181}
1182
1183/// Convert Asset XDR to asset identifier string
1184///
1185/// # Arguments
1186///
1187/// * `asset` - The Asset to convert
1188///
1189/// # Returns
1190///
1191/// Asset identifier string ("native" for Native, or "CODE:ISSUER" for credit assets)
1192pub fn asset_to_asset_id(asset: &Asset) -> Result<String, StellarTransactionUtilsError> {
1193    match asset {
1194        Asset::Native => Ok("native".to_string()),
1195        Asset::CreditAlphanum4(alpha4) => credit_alphanum4_to_asset_id(alpha4),
1196        Asset::CreditAlphanum12(alpha12) => credit_alphanum12_to_asset_id(alpha12),
1197    }
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
1204    use crate::models::AssetSpec;
1205    use crate::models::{AuthSpec, ContractSource, WasmSource};
1206
1207    fn payment_op(destination: &str) -> OperationSpec {
1208        OperationSpec::Payment {
1209            destination: destination.to_string(),
1210            amount: 100,
1211            asset: AssetSpec::Native,
1212        }
1213    }
1214
1215    #[test]
1216    fn returns_false_for_only_payment_ops() {
1217        let ops = vec![payment_op(TEST_PK)];
1218        assert!(!needs_simulation(&ops));
1219    }
1220
1221    #[test]
1222    fn returns_true_for_invoke_contract_ops() {
1223        let ops = vec![OperationSpec::InvokeContract {
1224            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1225                .to_string(),
1226            function_name: "transfer".to_string(),
1227            args: vec![],
1228            auth: None,
1229        }];
1230        assert!(needs_simulation(&ops));
1231    }
1232
1233    #[test]
1234    fn returns_true_for_upload_wasm_ops() {
1235        let ops = vec![OperationSpec::UploadWasm {
1236            wasm: WasmSource::Hex {
1237                hex: "deadbeef".to_string(),
1238            },
1239            auth: None,
1240        }];
1241        assert!(needs_simulation(&ops));
1242    }
1243
1244    #[test]
1245    fn returns_true_for_create_contract_ops() {
1246        let ops = vec![OperationSpec::CreateContract {
1247            source: ContractSource::Address {
1248                address: TEST_PK.to_string(),
1249            },
1250            wasm_hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
1251                .to_string(),
1252            salt: None,
1253            constructor_args: None,
1254            auth: None,
1255        }];
1256        assert!(needs_simulation(&ops));
1257    }
1258
1259    #[test]
1260    fn returns_true_for_single_invoke_host_function() {
1261        let ops = vec![OperationSpec::InvokeContract {
1262            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1263                .to_string(),
1264            function_name: "transfer".to_string(),
1265            args: vec![],
1266            auth: Some(AuthSpec::SourceAccount),
1267        }];
1268        assert!(needs_simulation(&ops));
1269    }
1270
1271    #[test]
1272    fn returns_false_for_multiple_payment_ops() {
1273        let ops = vec![payment_op(TEST_PK), payment_op(TEST_PK)];
1274        assert!(!needs_simulation(&ops));
1275    }
1276
1277    mod next_sequence_u64_tests {
1278        use super::*;
1279
1280        #[test]
1281        fn test_increment() {
1282            assert_eq!(next_sequence_u64(0).unwrap(), 1);
1283
1284            assert_eq!(next_sequence_u64(12345).unwrap(), 12346);
1285        }
1286
1287        #[test]
1288        fn test_error_path_overflow_i64_max() {
1289            let result = next_sequence_u64(i64::MAX);
1290            assert!(result.is_err());
1291            match result.unwrap_err() {
1292                RelayerError::ProviderError(msg) => assert_eq!(msg, "sequence overflow"),
1293                _ => panic!("Unexpected error type"),
1294            }
1295        }
1296    }
1297
1298    mod i64_from_u64_tests {
1299        use super::*;
1300
1301        #[test]
1302        fn test_happy_path_conversion() {
1303            assert_eq!(i64_from_u64(0).unwrap(), 0);
1304            assert_eq!(i64_from_u64(12345).unwrap(), 12345);
1305            assert_eq!(i64_from_u64(i64::MAX as u64).unwrap(), i64::MAX);
1306        }
1307
1308        #[test]
1309        fn test_error_path_overflow_u64_max() {
1310            let result = i64_from_u64(u64::MAX);
1311            assert!(result.is_err());
1312            match result.unwrap_err() {
1313                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1314                _ => panic!("Unexpected error type"),
1315            }
1316        }
1317
1318        #[test]
1319        fn test_edge_case_just_above_i64_max() {
1320            // Smallest u64 value that will overflow i64
1321            let value = (i64::MAX as u64) + 1;
1322            let result = i64_from_u64(value);
1323            assert!(result.is_err());
1324            match result.unwrap_err() {
1325                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1326                _ => panic!("Unexpected error type"),
1327            }
1328        }
1329    }
1330
1331    mod is_bad_sequence_error_tests {
1332        use super::*;
1333
1334        #[test]
1335        fn test_detects_txbadseq() {
1336            assert!(is_bad_sequence_error(
1337                "Failed to send transaction: transaction submission failed: TxBadSeq"
1338            ));
1339            assert!(is_bad_sequence_error("Error: TxBadSeq"));
1340            assert!(is_bad_sequence_error("txbadseq"));
1341            assert!(is_bad_sequence_error("TXBADSEQ"));
1342        }
1343
1344        #[test]
1345        fn test_returns_false_for_other_errors() {
1346            assert!(!is_bad_sequence_error("network timeout"));
1347            assert!(!is_bad_sequence_error("insufficient balance"));
1348            assert!(!is_bad_sequence_error("tx_insufficient_fee"));
1349            assert!(!is_bad_sequence_error("bad_auth"));
1350            assert!(!is_bad_sequence_error(""));
1351        }
1352    }
1353
1354    mod status_check_utils_tests {
1355        use crate::models::{
1356            NetworkTransactionData, StellarTransactionData, TransactionError, TransactionInput,
1357            TransactionRepoModel,
1358        };
1359        use crate::utils::mocks::mockutils::create_mock_transaction;
1360        use chrono::{Duration, Utc};
1361
1362        /// Helper to create a test transaction with a specific created_at timestamp
1363        fn create_test_tx_with_age(seconds_ago: i64) -> TransactionRepoModel {
1364            let created_at = (Utc::now() - Duration::seconds(seconds_ago)).to_rfc3339();
1365            let mut tx = create_mock_transaction();
1366            tx.id = format!("test-tx-{}", seconds_ago);
1367            tx.created_at = created_at;
1368            tx.network_data = NetworkTransactionData::Stellar(StellarTransactionData {
1369                source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
1370                    .to_string(),
1371                fee: None,
1372                sequence_number: None,
1373                memo: None,
1374                valid_until: None,
1375                network_passphrase: "Test SDF Network ; September 2015".to_string(),
1376                signatures: vec![],
1377                hash: Some("test-hash-12345".to_string()),
1378                simulation_transaction_data: None,
1379                transaction_input: TransactionInput::Operations(vec![]),
1380                signed_envelope_xdr: None,
1381            });
1382            tx
1383        }
1384
1385        mod get_age_since_created_tests {
1386            use crate::domain::transaction::util::get_age_since_created;
1387
1388            use super::*;
1389
1390            #[test]
1391            fn test_returns_correct_age_for_recent_transaction() {
1392                let tx = create_test_tx_with_age(30); // 30 seconds ago
1393                let age = get_age_since_created(&tx).unwrap();
1394
1395                // Allow for small timing differences (within 1 second)
1396                assert!(age.num_seconds() >= 29 && age.num_seconds() <= 31);
1397            }
1398
1399            #[test]
1400            fn test_returns_correct_age_for_old_transaction() {
1401                let tx = create_test_tx_with_age(3600); // 1 hour ago
1402                let age = get_age_since_created(&tx).unwrap();
1403
1404                // Allow for small timing differences
1405                assert!(age.num_seconds() >= 3599 && age.num_seconds() <= 3601);
1406            }
1407
1408            #[test]
1409            fn test_returns_zero_age_for_just_created_transaction() {
1410                let tx = create_test_tx_with_age(0); // Just now
1411                let age = get_age_since_created(&tx).unwrap();
1412
1413                // Should be very close to 0
1414                assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
1415            }
1416
1417            #[test]
1418            fn test_handles_negative_age_gracefully() {
1419                // Create transaction with future timestamp (clock skew scenario)
1420                let created_at = (Utc::now() + Duration::seconds(10)).to_rfc3339();
1421                let mut tx = create_mock_transaction();
1422                tx.created_at = created_at;
1423
1424                let age = get_age_since_created(&tx).unwrap();
1425
1426                // Age should be negative
1427                assert!(age.num_seconds() < 0);
1428            }
1429
1430            #[test]
1431            fn test_returns_error_for_invalid_created_at() {
1432                let mut tx = create_mock_transaction();
1433                tx.created_at = "invalid-timestamp".to_string();
1434
1435                let result = get_age_since_created(&tx);
1436                assert!(result.is_err());
1437
1438                match result.unwrap_err() {
1439                    TransactionError::UnexpectedError(msg) => {
1440                        assert!(msg.contains("Invalid created_at timestamp"));
1441                    }
1442                    _ => panic!("Expected UnexpectedError"),
1443                }
1444            }
1445
1446            #[test]
1447            fn test_returns_error_for_empty_created_at() {
1448                let mut tx = create_mock_transaction();
1449                tx.created_at = "".to_string();
1450
1451                let result = get_age_since_created(&tx);
1452                assert!(result.is_err());
1453            }
1454
1455            #[test]
1456            fn test_handles_various_rfc3339_formats() {
1457                let mut tx = create_mock_transaction();
1458
1459                // Test with UTC timezone
1460                tx.created_at = "2025-01-01T12:00:00Z".to_string();
1461                assert!(get_age_since_created(&tx).is_ok());
1462
1463                // Test with offset timezone
1464                tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
1465                assert!(get_age_since_created(&tx).is_ok());
1466
1467                // Test with milliseconds
1468                tx.created_at = "2025-01-01T12:00:00.123Z".to_string();
1469                assert!(get_age_since_created(&tx).is_ok());
1470            }
1471        }
1472    }
1473
1474    #[test]
1475    fn test_create_signature_payload_functions() {
1476        use soroban_rs::xdr::{
1477            Hash, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope,
1478            Uint256,
1479        };
1480
1481        // Test create_transaction_signature_payload
1482        let transaction = soroban_rs::xdr::Transaction {
1483            source_account: soroban_rs::xdr::MuxedAccount::Ed25519(Uint256([1u8; 32])),
1484            fee: 100,
1485            seq_num: SequenceNumber(123),
1486            cond: soroban_rs::xdr::Preconditions::None,
1487            memo: soroban_rs::xdr::Memo::None,
1488            operations: vec![].try_into().unwrap(),
1489            ext: soroban_rs::xdr::TransactionExt::V0,
1490        };
1491        let network_id = Hash([2u8; 32]);
1492
1493        let payload = create_transaction_signature_payload(&transaction, &network_id);
1494        assert_eq!(payload.network_id, network_id);
1495
1496        // Test create_signature_payload with V0 envelope
1497        let v0_tx = TransactionV0 {
1498            source_account_ed25519: Uint256([1u8; 32]),
1499            fee: 100,
1500            seq_num: SequenceNumber(123),
1501            time_bounds: None,
1502            memo: soroban_rs::xdr::Memo::None,
1503            operations: vec![].try_into().unwrap(),
1504            ext: soroban_rs::xdr::TransactionV0Ext::V0,
1505        };
1506        let v0_envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
1507            tx: v0_tx,
1508            signatures: vec![].try_into().unwrap(),
1509        });
1510
1511        let v0_payload = create_signature_payload(&v0_envelope, &network_id).unwrap();
1512        assert_eq!(v0_payload.network_id, network_id);
1513    }
1514
1515    mod convert_v0_to_v1_transaction_tests {
1516        use super::*;
1517        use soroban_rs::xdr::{SequenceNumber, TransactionV0, Uint256};
1518
1519        #[test]
1520        fn test_convert_v0_to_v1_transaction() {
1521            // Create a simple V0 transaction
1522            let v0_tx = TransactionV0 {
1523                source_account_ed25519: Uint256([1u8; 32]),
1524                fee: 100,
1525                seq_num: SequenceNumber(123),
1526                time_bounds: None,
1527                memo: soroban_rs::xdr::Memo::None,
1528                operations: vec![].try_into().unwrap(),
1529                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1530            };
1531
1532            // Convert to V1
1533            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1534
1535            // Check that conversion worked correctly
1536            assert_eq!(v1_tx.fee, v0_tx.fee);
1537            assert_eq!(v1_tx.seq_num, v0_tx.seq_num);
1538            assert_eq!(v1_tx.memo, v0_tx.memo);
1539            assert_eq!(v1_tx.operations, v0_tx.operations);
1540            assert!(matches!(v1_tx.ext, soroban_rs::xdr::TransactionExt::V0));
1541            assert!(matches!(v1_tx.cond, soroban_rs::xdr::Preconditions::None));
1542
1543            // Check source account conversion
1544            match v1_tx.source_account {
1545                soroban_rs::xdr::MuxedAccount::Ed25519(addr) => {
1546                    assert_eq!(addr, v0_tx.source_account_ed25519);
1547                }
1548                _ => panic!("Expected Ed25519 muxed account"),
1549            }
1550        }
1551
1552        #[test]
1553        fn test_convert_v0_to_v1_transaction_with_time_bounds() {
1554            // Create a V0 transaction with time bounds
1555            let time_bounds = soroban_rs::xdr::TimeBounds {
1556                min_time: soroban_rs::xdr::TimePoint(100),
1557                max_time: soroban_rs::xdr::TimePoint(200),
1558            };
1559
1560            let v0_tx = TransactionV0 {
1561                source_account_ed25519: Uint256([2u8; 32]),
1562                fee: 200,
1563                seq_num: SequenceNumber(456),
1564                time_bounds: Some(time_bounds.clone()),
1565                memo: soroban_rs::xdr::Memo::Text("test".try_into().unwrap()),
1566                operations: vec![].try_into().unwrap(),
1567                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1568            };
1569
1570            // Convert to V1
1571            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1572
1573            // Check that time bounds were correctly converted to preconditions
1574            match v1_tx.cond {
1575                soroban_rs::xdr::Preconditions::Time(tb) => {
1576                    assert_eq!(tb, time_bounds);
1577                }
1578                _ => panic!("Expected Time preconditions"),
1579            }
1580        }
1581    }
1582}
1583
1584#[cfg(test)]
1585mod parse_contract_address_tests {
1586    use super::*;
1587    use crate::domain::transaction::stellar::test_helpers::{
1588        TEST_CONTRACT, TEST_PK as TEST_ACCOUNT,
1589    };
1590
1591    #[test]
1592    fn test_parse_valid_contract_address() {
1593        let result = parse_contract_address(TEST_CONTRACT);
1594        assert!(result.is_ok());
1595
1596        let hash = result.unwrap();
1597        assert_eq!(hash.0.len(), 32);
1598    }
1599
1600    #[test]
1601    fn test_parse_invalid_contract_address() {
1602        let result = parse_contract_address("INVALID_CONTRACT");
1603        assert!(result.is_err());
1604
1605        match result.unwrap_err() {
1606            StellarTransactionUtilsError::InvalidContractAddress(addr, _) => {
1607                assert_eq!(addr, "INVALID_CONTRACT");
1608            }
1609            _ => panic!("Expected InvalidContractAddress error"),
1610        }
1611    }
1612
1613    #[test]
1614    fn test_parse_contract_address_wrong_prefix() {
1615        // Try with an account address instead of contract
1616        let result = parse_contract_address(TEST_ACCOUNT);
1617        assert!(result.is_err());
1618    }
1619
1620    #[test]
1621    fn test_parse_empty_contract_address() {
1622        let result = parse_contract_address("");
1623        assert!(result.is_err());
1624    }
1625}
1626
1627// ============================================================================
1628// Contract Data Key Tests
1629// ============================================================================
1630
1631#[cfg(test)]
1632mod create_contract_data_key_tests {
1633    use super::*;
1634    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
1635    use stellar_strkey::ed25519::PublicKey;
1636
1637    #[test]
1638    fn test_create_key_without_address() {
1639        let result = create_contract_data_key("Balance", None);
1640        assert!(result.is_ok());
1641
1642        match result.unwrap() {
1643            ScVal::Symbol(sym) => {
1644                assert_eq!(sym.to_string(), "Balance");
1645            }
1646            _ => panic!("Expected Symbol ScVal"),
1647        }
1648    }
1649
1650    #[test]
1651    fn test_create_key_with_address() {
1652        let pk = PublicKey::from_string(TEST_ACCOUNT).unwrap();
1653        let uint256 = Uint256(pk.0);
1654        let account_id = AccountId(soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(uint256));
1655        let sc_address = ScAddress::Account(account_id);
1656
1657        let result = create_contract_data_key("Balance", Some(sc_address.clone()));
1658        assert!(result.is_ok());
1659
1660        match result.unwrap() {
1661            ScVal::Vec(Some(vec)) => {
1662                assert_eq!(vec.0.len(), 2);
1663                match &vec.0[0] {
1664                    ScVal::Symbol(sym) => assert_eq!(sym.to_string(), "Balance"),
1665                    _ => panic!("Expected Symbol as first element"),
1666                }
1667                match &vec.0[1] {
1668                    ScVal::Address(addr) => assert_eq!(addr, &sc_address),
1669                    _ => panic!("Expected Address as second element"),
1670                }
1671            }
1672            _ => panic!("Expected Vec ScVal"),
1673        }
1674    }
1675
1676    #[test]
1677    fn test_create_key_invalid_symbol() {
1678        // Test with symbol that's too long or has invalid characters
1679        let very_long_symbol = "a".repeat(100);
1680        let result = create_contract_data_key(&very_long_symbol, None);
1681        assert!(result.is_err());
1682
1683        match result.unwrap_err() {
1684            StellarTransactionUtilsError::SymbolCreationFailed(_, _) => {}
1685            _ => panic!("Expected SymbolCreationFailed error"),
1686        }
1687    }
1688
1689    #[test]
1690    fn test_create_key_decimals() {
1691        let result = create_contract_data_key("Decimals", None);
1692        assert!(result.is_ok());
1693    }
1694}
1695
1696// ============================================================================
1697// Extract ScVal from Contract Data Tests
1698// ============================================================================
1699
1700#[cfg(test)]
1701mod extract_scval_from_contract_data_tests {
1702    use super::*;
1703    use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1704    use soroban_rs::xdr::{
1705        ContractDataDurability, ContractDataEntry, ExtensionPoint, Hash, LedgerEntry,
1706        LedgerEntryData, LedgerEntryExt, ScSymbol, ScVal, WriteXdr,
1707    };
1708
1709    #[test]
1710    fn test_extract_scval_success() {
1711        let contract_data = ContractDataEntry {
1712            ext: ExtensionPoint::V0,
1713            contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
1714            key: ScVal::Symbol(ScSymbol::try_from("test").unwrap()),
1715            durability: ContractDataDurability::Persistent,
1716            val: ScVal::U32(42),
1717        };
1718
1719        let ledger_entry = LedgerEntry {
1720            last_modified_ledger_seq: 100,
1721            data: LedgerEntryData::ContractData(contract_data),
1722            ext: LedgerEntryExt::V0,
1723        };
1724
1725        let xdr = ledger_entry
1726            .data
1727            .to_xdr_base64(soroban_rs::xdr::Limits::none())
1728            .unwrap();
1729
1730        let response = GetLedgerEntriesResponse {
1731            entries: Some(vec![LedgerEntryResult {
1732                key: "test_key".to_string(),
1733                xdr,
1734                last_modified_ledger: 100,
1735                live_until_ledger_seq_ledger_seq: None,
1736            }]),
1737            latest_ledger: 100,
1738        };
1739
1740        let result = extract_scval_from_contract_data(&response, "test");
1741        assert!(result.is_ok());
1742
1743        match result.unwrap() {
1744            ScVal::U32(val) => assert_eq!(val, 42),
1745            _ => panic!("Expected U32 ScVal"),
1746        }
1747    }
1748
1749    #[test]
1750    fn test_extract_scval_no_entries() {
1751        let response = GetLedgerEntriesResponse {
1752            entries: None,
1753            latest_ledger: 100,
1754        };
1755
1756        let result = extract_scval_from_contract_data(&response, "test");
1757        assert!(result.is_err());
1758
1759        match result.unwrap_err() {
1760            StellarTransactionUtilsError::NoEntriesFound(_) => {}
1761            _ => panic!("Expected NoEntriesFound error"),
1762        }
1763    }
1764
1765    #[test]
1766    fn test_extract_scval_empty_entries() {
1767        let response = GetLedgerEntriesResponse {
1768            entries: Some(vec![]),
1769            latest_ledger: 100,
1770        };
1771
1772        let result = extract_scval_from_contract_data(&response, "test");
1773        assert!(result.is_err());
1774
1775        match result.unwrap_err() {
1776            StellarTransactionUtilsError::EmptyEntries(_) => {}
1777            _ => panic!("Expected EmptyEntries error"),
1778        }
1779    }
1780}
1781
1782// ============================================================================
1783// Extract u32 from ScVal Tests
1784// ============================================================================
1785
1786#[cfg(test)]
1787mod extract_u32_from_scval_tests {
1788    use super::*;
1789    use soroban_rs::xdr::{Int128Parts, ScVal, UInt128Parts};
1790
1791    #[test]
1792    fn test_extract_from_u32() {
1793        let val = ScVal::U32(42);
1794        assert_eq!(extract_u32_from_scval(&val, "test"), Some(42));
1795    }
1796
1797    #[test]
1798    fn test_extract_from_i32_positive() {
1799        let val = ScVal::I32(100);
1800        assert_eq!(extract_u32_from_scval(&val, "test"), Some(100));
1801    }
1802
1803    #[test]
1804    fn test_extract_from_i32_negative() {
1805        let val = ScVal::I32(-1);
1806        assert_eq!(extract_u32_from_scval(&val, "test"), None);
1807    }
1808
1809    #[test]
1810    fn test_extract_from_u64() {
1811        let val = ScVal::U64(1000);
1812        assert_eq!(extract_u32_from_scval(&val, "test"), Some(1000));
1813    }
1814
1815    #[test]
1816    fn test_extract_from_u64_overflow() {
1817        let val = ScVal::U64(u64::MAX);
1818        assert_eq!(extract_u32_from_scval(&val, "test"), None);
1819    }
1820
1821    #[test]
1822    fn test_extract_from_i64_positive() {
1823        let val = ScVal::I64(500);
1824        assert_eq!(extract_u32_from_scval(&val, "test"), Some(500));
1825    }
1826
1827    #[test]
1828    fn test_extract_from_i64_negative() {
1829        let val = ScVal::I64(-500);
1830        assert_eq!(extract_u32_from_scval(&val, "test"), None);
1831    }
1832
1833    #[test]
1834    fn test_extract_from_u128_small() {
1835        let val = ScVal::U128(UInt128Parts { hi: 0, lo: 255 });
1836        assert_eq!(extract_u32_from_scval(&val, "test"), Some(255));
1837    }
1838
1839    #[test]
1840    fn test_extract_from_u128_hi_set() {
1841        let val = ScVal::U128(UInt128Parts { hi: 1, lo: 0 });
1842        assert_eq!(extract_u32_from_scval(&val, "test"), None);
1843    }
1844
1845    #[test]
1846    fn test_extract_from_i128_small() {
1847        let val = ScVal::I128(Int128Parts { hi: 0, lo: 123 });
1848        assert_eq!(extract_u32_from_scval(&val, "test"), Some(123));
1849    }
1850
1851    #[test]
1852    fn test_extract_from_unsupported_type() {
1853        let val = ScVal::Bool(true);
1854        assert_eq!(extract_u32_from_scval(&val, "test"), None);
1855    }
1856}
1857
1858// ============================================================================
1859// Amount to UI Amount Tests
1860// ============================================================================
1861
1862#[cfg(test)]
1863mod amount_to_ui_amount_tests {
1864    use super::*;
1865
1866    #[test]
1867    fn test_zero_decimals() {
1868        assert_eq!(amount_to_ui_amount(100, 0), "100");
1869        assert_eq!(amount_to_ui_amount(0, 0), "0");
1870    }
1871
1872    #[test]
1873    fn test_with_decimals_no_padding() {
1874        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
1875        assert_eq!(amount_to_ui_amount(1500000, 6), "1.5");
1876        assert_eq!(amount_to_ui_amount(1234567, 6), "1.234567");
1877    }
1878
1879    #[test]
1880    fn test_with_decimals_needs_padding() {
1881        assert_eq!(amount_to_ui_amount(1, 6), "0.000001");
1882        assert_eq!(amount_to_ui_amount(100, 6), "0.0001");
1883        assert_eq!(amount_to_ui_amount(1000, 3), "1");
1884    }
1885
1886    #[test]
1887    fn test_trailing_zeros_removed() {
1888        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
1889        assert_eq!(amount_to_ui_amount(1500000, 7), "0.15");
1890        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
1891    }
1892
1893    #[test]
1894    fn test_zero_amount() {
1895        assert_eq!(amount_to_ui_amount(0, 6), "0");
1896        assert_eq!(amount_to_ui_amount(0, 0), "0");
1897    }
1898
1899    #[test]
1900    fn test_xlm_7_decimals() {
1901        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
1902        assert_eq!(amount_to_ui_amount(15000000, 7), "1.5");
1903        assert_eq!(amount_to_ui_amount(100, 7), "0.00001");
1904    }
1905}
1906
1907// // ============================================================================
1908// // Count Operations Tests
1909// // ============================================================================
1910
1911// #[cfg(test)]
1912#[cfg(test)]
1913mod count_operations_tests {
1914    use super::*;
1915    use soroban_rs::xdr::{
1916        Limits, MuxedAccount, Operation, OperationBody, PaymentOp, TransactionV1Envelope, Uint256,
1917        WriteXdr,
1918    };
1919
1920    #[test]
1921    fn test_count_operations_from_xdr() {
1922        use soroban_rs::xdr::{Memo, Preconditions, SequenceNumber, Transaction, TransactionExt};
1923
1924        // Create two payment operations
1925        let payment_op = Operation {
1926            source_account: None,
1927            body: OperationBody::Payment(PaymentOp {
1928                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
1929                asset: Asset::Native,
1930                amount: 100,
1931            }),
1932        };
1933
1934        let operations = vec![payment_op.clone(), payment_op].try_into().unwrap();
1935
1936        let tx = Transaction {
1937            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
1938            fee: 100,
1939            seq_num: SequenceNumber(1),
1940            cond: Preconditions::None,
1941            memo: Memo::None,
1942            operations,
1943            ext: TransactionExt::V0,
1944        };
1945
1946        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
1947            tx,
1948            signatures: vec![].try_into().unwrap(),
1949        });
1950
1951        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
1952        let count = count_operations_from_xdr(&xdr).unwrap();
1953
1954        assert_eq!(count, 2);
1955    }
1956
1957    #[test]
1958    fn test_count_operations_invalid_xdr() {
1959        let result = count_operations_from_xdr("invalid_xdr");
1960        assert!(result.is_err());
1961
1962        match result.unwrap_err() {
1963            StellarTransactionUtilsError::XdrParseFailed(_) => {}
1964            _ => panic!("Expected XdrParseFailed error"),
1965        }
1966    }
1967}
1968
1969// ============================================================================
1970// Estimate Base Fee Tests
1971// ============================================================================
1972
1973#[cfg(test)]
1974mod estimate_base_fee_tests {
1975    use super::*;
1976
1977    #[test]
1978    fn test_single_operation() {
1979        assert_eq!(estimate_base_fee(1), 100);
1980    }
1981
1982    #[test]
1983    fn test_multiple_operations() {
1984        assert_eq!(estimate_base_fee(5), 500);
1985        assert_eq!(estimate_base_fee(10), 1000);
1986    }
1987
1988    #[test]
1989    fn test_zero_operations() {
1990        // Should return fee for at least 1 operation
1991        assert_eq!(estimate_base_fee(0), 100);
1992    }
1993
1994    #[test]
1995    fn test_large_number_of_operations() {
1996        assert_eq!(estimate_base_fee(100), 10000);
1997    }
1998}
1999
2000// ============================================================================
2001// Create Fee Payment Operation Tests
2002// ============================================================================
2003
2004#[cfg(test)]
2005mod create_fee_payment_operation_tests {
2006    use super::*;
2007    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
2008
2009    #[test]
2010    fn test_create_native_payment() {
2011        let result = create_fee_payment_operation(TEST_ACCOUNT, "native", 1000);
2012        assert!(result.is_ok());
2013
2014        match result.unwrap() {
2015            OperationSpec::Payment {
2016                destination,
2017                amount,
2018                asset,
2019            } => {
2020                assert_eq!(destination, TEST_ACCOUNT);
2021                assert_eq!(amount, 1000);
2022                assert!(matches!(asset, AssetSpec::Native));
2023            }
2024            _ => panic!("Expected Payment operation"),
2025        }
2026    }
2027
2028    #[test]
2029    fn test_create_credit4_payment() {
2030        let result = create_fee_payment_operation(
2031            TEST_ACCOUNT,
2032            "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2033            5000,
2034        );
2035        assert!(result.is_ok());
2036
2037        match result.unwrap() {
2038            OperationSpec::Payment {
2039                destination,
2040                amount,
2041                asset,
2042            } => {
2043                assert_eq!(destination, TEST_ACCOUNT);
2044                assert_eq!(amount, 5000);
2045                match asset {
2046                    AssetSpec::Credit4 { code, issuer } => {
2047                        assert_eq!(code, "USDC");
2048                        assert_eq!(
2049                            issuer,
2050                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2051                        );
2052                    }
2053                    _ => panic!("Expected Credit4 asset"),
2054                }
2055            }
2056            _ => panic!("Expected Payment operation"),
2057        }
2058    }
2059
2060    #[test]
2061    fn test_create_credit12_payment() {
2062        let result = create_fee_payment_operation(
2063            TEST_ACCOUNT,
2064            "LONGASSETNAM:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2065            2000,
2066        );
2067        assert!(result.is_ok());
2068
2069        match result.unwrap() {
2070            OperationSpec::Payment {
2071                destination,
2072                amount,
2073                asset,
2074            } => {
2075                assert_eq!(destination, TEST_ACCOUNT);
2076                assert_eq!(amount, 2000);
2077                match asset {
2078                    AssetSpec::Credit12 { code, issuer } => {
2079                        assert_eq!(code, "LONGASSETNAM");
2080                        assert_eq!(
2081                            issuer,
2082                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2083                        );
2084                    }
2085                    _ => panic!("Expected Credit12 asset"),
2086                }
2087            }
2088            _ => panic!("Expected Payment operation"),
2089        }
2090    }
2091
2092    #[test]
2093    fn test_create_payment_empty_asset() {
2094        let result = create_fee_payment_operation(TEST_ACCOUNT, "", 1000);
2095        assert!(result.is_ok());
2096
2097        match result.unwrap() {
2098            OperationSpec::Payment { asset, .. } => {
2099                assert!(matches!(asset, AssetSpec::Native));
2100            }
2101            _ => panic!("Expected Payment operation"),
2102        }
2103    }
2104
2105    #[test]
2106    fn test_create_payment_invalid_format() {
2107        let result = create_fee_payment_operation(TEST_ACCOUNT, "INVALID_FORMAT", 1000);
2108        assert!(result.is_err());
2109
2110        match result.unwrap_err() {
2111            StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
2112            _ => panic!("Expected InvalidAssetFormat error"),
2113        }
2114    }
2115
2116    #[test]
2117    fn test_create_payment_asset_code_too_long() {
2118        let result = create_fee_payment_operation(
2119            TEST_ACCOUNT,
2120            "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2121            1000,
2122        );
2123        assert!(result.is_err());
2124
2125        match result.unwrap_err() {
2126            StellarTransactionUtilsError::AssetCodeTooLong(max_len, _) => {
2127                assert_eq!(max_len, 12);
2128            }
2129            _ => panic!("Expected AssetCodeTooLong error"),
2130        }
2131    }
2132}
2133
2134#[cfg(test)]
2135mod parse_account_id_tests {
2136    use super::*;
2137    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
2138
2139    #[test]
2140    fn test_parse_account_id_valid() {
2141        let result = parse_account_id(TEST_PK);
2142        assert!(result.is_ok());
2143
2144        let account_id = result.unwrap();
2145        match account_id.0 {
2146            soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(_) => {}
2147        }
2148    }
2149
2150    #[test]
2151    fn test_parse_account_id_invalid() {
2152        let result = parse_account_id("INVALID_ADDRESS");
2153        assert!(result.is_err());
2154
2155        match result.unwrap_err() {
2156            StellarTransactionUtilsError::InvalidAccountAddress(addr, _) => {
2157                assert_eq!(addr, "INVALID_ADDRESS");
2158            }
2159            _ => panic!("Expected InvalidAccountAddress error"),
2160        }
2161    }
2162
2163    #[test]
2164    fn test_parse_account_id_empty() {
2165        let result = parse_account_id("");
2166        assert!(result.is_err());
2167    }
2168
2169    #[test]
2170    fn test_parse_account_id_wrong_prefix() {
2171        // Contract address instead of account
2172        let result = parse_account_id("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM");
2173        assert!(result.is_err());
2174    }
2175}
2176
2177#[cfg(test)]
2178mod parse_transaction_and_count_operations_tests {
2179    use super::*;
2180    use crate::domain::transaction::stellar::test_helpers::{
2181        create_native_payment_operation, create_xdr_with_operations, TEST_PK, TEST_PK_2,
2182    };
2183    use serde_json::json;
2184
2185    fn create_test_xdr_with_operations(num_ops: usize) -> String {
2186        let payment_op = create_native_payment_operation(TEST_PK_2, 100);
2187        let operations = vec![payment_op; num_ops];
2188        create_xdr_with_operations(TEST_PK, operations, false)
2189    }
2190
2191    #[test]
2192    fn test_parse_xdr_string() {
2193        let xdr = create_test_xdr_with_operations(2);
2194        let json_value = json!(xdr);
2195
2196        let result = parse_transaction_and_count_operations(&json_value);
2197        assert!(result.is_ok());
2198        assert_eq!(result.unwrap(), 2);
2199    }
2200
2201    #[test]
2202    fn test_parse_operations_array() {
2203        let json_value = json!([
2204            {"type": "payment"},
2205            {"type": "payment"},
2206            {"type": "payment"}
2207        ]);
2208
2209        let result = parse_transaction_and_count_operations(&json_value);
2210        assert!(result.is_ok());
2211        assert_eq!(result.unwrap(), 3);
2212    }
2213
2214    #[test]
2215    fn test_parse_object_with_operations() {
2216        let json_value = json!({
2217            "operations": [
2218                {"type": "payment"},
2219                {"type": "payment"}
2220            ]
2221        });
2222
2223        let result = parse_transaction_and_count_operations(&json_value);
2224        assert!(result.is_ok());
2225        assert_eq!(result.unwrap(), 2);
2226    }
2227
2228    #[test]
2229    fn test_parse_object_with_transaction_xdr() {
2230        let xdr = create_test_xdr_with_operations(3);
2231        let json_value = json!({
2232            "transaction_xdr": xdr
2233        });
2234
2235        let result = parse_transaction_and_count_operations(&json_value);
2236        assert!(result.is_ok());
2237        assert_eq!(result.unwrap(), 3);
2238    }
2239
2240    #[test]
2241    fn test_parse_invalid_xdr() {
2242        let json_value = json!("INVALID_XDR");
2243
2244        let result = parse_transaction_and_count_operations(&json_value);
2245        assert!(result.is_err());
2246
2247        match result.unwrap_err() {
2248            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2249            _ => panic!("Expected XdrParseFailed error"),
2250        }
2251    }
2252
2253    #[test]
2254    fn test_parse_invalid_format() {
2255        let json_value = json!(123);
2256
2257        let result = parse_transaction_and_count_operations(&json_value);
2258        assert!(result.is_err());
2259
2260        match result.unwrap_err() {
2261            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2262            _ => panic!("Expected InvalidTransactionFormat error"),
2263        }
2264    }
2265
2266    #[test]
2267    fn test_parse_empty_operations() {
2268        let json_value = json!([]);
2269
2270        let result = parse_transaction_and_count_operations(&json_value);
2271        assert!(result.is_ok());
2272        assert_eq!(result.unwrap(), 0);
2273    }
2274}
2275
2276#[cfg(test)]
2277mod parse_transaction_envelope_tests {
2278    use super::*;
2279    use crate::domain::transaction::stellar::test_helpers::{
2280        create_unsigned_xdr, TEST_PK, TEST_PK_2,
2281    };
2282    use serde_json::json;
2283
2284    fn create_test_xdr() -> String {
2285        create_unsigned_xdr(TEST_PK, TEST_PK_2)
2286    }
2287
2288    #[test]
2289    fn test_parse_xdr_string() {
2290        let xdr = create_test_xdr();
2291        let json_value = json!(xdr);
2292
2293        let result = parse_transaction_envelope(&json_value);
2294        assert!(result.is_ok());
2295
2296        match result.unwrap() {
2297            TransactionEnvelope::Tx(_) => {}
2298            _ => panic!("Expected Tx envelope"),
2299        }
2300    }
2301
2302    #[test]
2303    fn test_parse_object_with_transaction_xdr() {
2304        let xdr = create_test_xdr();
2305        let json_value = json!({
2306            "transaction_xdr": xdr
2307        });
2308
2309        let result = parse_transaction_envelope(&json_value);
2310        assert!(result.is_ok());
2311
2312        match result.unwrap() {
2313            TransactionEnvelope::Tx(_) => {}
2314            _ => panic!("Expected Tx envelope"),
2315        }
2316    }
2317
2318    #[test]
2319    fn test_parse_invalid_xdr() {
2320        let json_value = json!("INVALID_XDR");
2321
2322        let result = parse_transaction_envelope(&json_value);
2323        assert!(result.is_err());
2324
2325        match result.unwrap_err() {
2326            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2327            _ => panic!("Expected XdrParseFailed error"),
2328        }
2329    }
2330
2331    #[test]
2332    fn test_parse_invalid_format() {
2333        let json_value = json!(123);
2334
2335        let result = parse_transaction_envelope(&json_value);
2336        assert!(result.is_err());
2337
2338        match result.unwrap_err() {
2339            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2340            _ => panic!("Expected InvalidTransactionFormat error"),
2341        }
2342    }
2343
2344    #[test]
2345    fn test_parse_object_without_xdr() {
2346        let json_value = json!({
2347            "some_field": "value"
2348        });
2349
2350        let result = parse_transaction_envelope(&json_value);
2351        assert!(result.is_err());
2352
2353        match result.unwrap_err() {
2354            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2355            _ => panic!("Expected InvalidTransactionFormat error"),
2356        }
2357    }
2358}
2359
2360#[cfg(test)]
2361mod add_operation_to_envelope_tests {
2362    use super::*;
2363    use soroban_rs::xdr::{
2364        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2365        Transaction, TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV1Envelope,
2366        Uint256,
2367    };
2368
2369    fn create_payment_op() -> Operation {
2370        Operation {
2371            source_account: None,
2372            body: OperationBody::Payment(PaymentOp {
2373                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2374                asset: Asset::Native,
2375                amount: 100,
2376            }),
2377        }
2378    }
2379
2380    #[test]
2381    fn test_add_operation_to_tx_v0() {
2382        let payment_op = create_payment_op();
2383        let operations = vec![payment_op.clone()].try_into().unwrap();
2384
2385        let tx = TransactionV0 {
2386            source_account_ed25519: Uint256([0u8; 32]),
2387            fee: 100,
2388            seq_num: SequenceNumber(1),
2389            time_bounds: None,
2390            memo: Memo::None,
2391            operations,
2392            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2393        };
2394
2395        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2396            tx,
2397            signatures: vec![].try_into().unwrap(),
2398        });
2399
2400        let new_op = create_payment_op();
2401        let result = add_operation_to_envelope(&mut envelope, new_op);
2402
2403        assert!(result.is_ok());
2404
2405        match envelope {
2406            TransactionEnvelope::TxV0(e) => {
2407                assert_eq!(e.tx.operations.len(), 2);
2408                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2409            }
2410            _ => panic!("Expected TxV0 envelope"),
2411        }
2412    }
2413
2414    #[test]
2415    fn test_add_operation_to_tx_v1() {
2416        let payment_op = create_payment_op();
2417        let operations = vec![payment_op.clone()].try_into().unwrap();
2418
2419        let tx = Transaction {
2420            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2421            fee: 100,
2422            seq_num: SequenceNumber(1),
2423            cond: Preconditions::None,
2424            memo: Memo::None,
2425            operations,
2426            ext: TransactionExt::V0,
2427        };
2428
2429        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2430            tx,
2431            signatures: vec![].try_into().unwrap(),
2432        });
2433
2434        let new_op = create_payment_op();
2435        let result = add_operation_to_envelope(&mut envelope, new_op);
2436
2437        assert!(result.is_ok());
2438
2439        match envelope {
2440            TransactionEnvelope::Tx(e) => {
2441                assert_eq!(e.tx.operations.len(), 2);
2442                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2443            }
2444            _ => panic!("Expected Tx envelope"),
2445        }
2446    }
2447
2448    #[test]
2449    fn test_add_operation_to_fee_bump_fails() {
2450        // Create a simple inner transaction
2451        let payment_op = create_payment_op();
2452        let operations = vec![payment_op].try_into().unwrap();
2453
2454        let tx = Transaction {
2455            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2456            fee: 100,
2457            seq_num: SequenceNumber(1),
2458            cond: Preconditions::None,
2459            memo: Memo::None,
2460            operations,
2461            ext: TransactionExt::V0,
2462        };
2463
2464        let inner_envelope = TransactionV1Envelope {
2465            tx,
2466            signatures: vec![].try_into().unwrap(),
2467        };
2468
2469        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
2470
2471        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
2472            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
2473            fee: 200,
2474            inner_tx,
2475            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
2476        };
2477
2478        let mut envelope =
2479            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
2480                tx: fee_bump_tx,
2481                signatures: vec![].try_into().unwrap(),
2482            });
2483
2484        let new_op = create_payment_op();
2485        let result = add_operation_to_envelope(&mut envelope, new_op);
2486
2487        assert!(result.is_err());
2488
2489        match result.unwrap_err() {
2490            StellarTransactionUtilsError::CannotModifyFeeBump => {}
2491            _ => panic!("Expected CannotModifyFeeBump error"),
2492        }
2493    }
2494}
2495
2496#[cfg(test)]
2497mod extract_time_bounds_tests {
2498    use super::*;
2499    use soroban_rs::xdr::{
2500        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2501        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
2502        TransactionV1Envelope, Uint256,
2503    };
2504
2505    fn create_payment_op() -> Operation {
2506        Operation {
2507            source_account: None,
2508            body: OperationBody::Payment(PaymentOp {
2509                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2510                asset: Asset::Native,
2511                amount: 100,
2512            }),
2513        }
2514    }
2515
2516    #[test]
2517    fn test_extract_time_bounds_from_tx_v0_with_bounds() {
2518        let payment_op = create_payment_op();
2519        let operations = vec![payment_op].try_into().unwrap();
2520
2521        let time_bounds = TimeBounds {
2522            min_time: TimePoint(0),
2523            max_time: TimePoint(1000),
2524        };
2525
2526        let tx = TransactionV0 {
2527            source_account_ed25519: Uint256([0u8; 32]),
2528            fee: 100,
2529            seq_num: SequenceNumber(1),
2530            time_bounds: Some(time_bounds.clone()),
2531            memo: Memo::None,
2532            operations,
2533            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2534        };
2535
2536        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2537            tx,
2538            signatures: vec![].try_into().unwrap(),
2539        });
2540
2541        let result = extract_time_bounds(&envelope);
2542        assert!(result.is_some());
2543
2544        let bounds = result.unwrap();
2545        assert_eq!(bounds.min_time.0, 0);
2546        assert_eq!(bounds.max_time.0, 1000);
2547    }
2548
2549    #[test]
2550    fn test_extract_time_bounds_from_tx_v0_without_bounds() {
2551        let payment_op = create_payment_op();
2552        let operations = vec![payment_op].try_into().unwrap();
2553
2554        let tx = TransactionV0 {
2555            source_account_ed25519: Uint256([0u8; 32]),
2556            fee: 100,
2557            seq_num: SequenceNumber(1),
2558            time_bounds: None,
2559            memo: Memo::None,
2560            operations,
2561            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2562        };
2563
2564        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2565            tx,
2566            signatures: vec![].try_into().unwrap(),
2567        });
2568
2569        let result = extract_time_bounds(&envelope);
2570        assert!(result.is_none());
2571    }
2572
2573    #[test]
2574    fn test_extract_time_bounds_from_tx_v1_with_time_precondition() {
2575        let payment_op = create_payment_op();
2576        let operations = vec![payment_op].try_into().unwrap();
2577
2578        let time_bounds = TimeBounds {
2579            min_time: TimePoint(0),
2580            max_time: TimePoint(2000),
2581        };
2582
2583        let tx = Transaction {
2584            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2585            fee: 100,
2586            seq_num: SequenceNumber(1),
2587            cond: Preconditions::Time(time_bounds.clone()),
2588            memo: Memo::None,
2589            operations,
2590            ext: TransactionExt::V0,
2591        };
2592
2593        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2594            tx,
2595            signatures: vec![].try_into().unwrap(),
2596        });
2597
2598        let result = extract_time_bounds(&envelope);
2599        assert!(result.is_some());
2600
2601        let bounds = result.unwrap();
2602        assert_eq!(bounds.min_time.0, 0);
2603        assert_eq!(bounds.max_time.0, 2000);
2604    }
2605
2606    #[test]
2607    fn test_extract_time_bounds_from_tx_v1_without_time_precondition() {
2608        let payment_op = create_payment_op();
2609        let operations = vec![payment_op].try_into().unwrap();
2610
2611        let tx = Transaction {
2612            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2613            fee: 100,
2614            seq_num: SequenceNumber(1),
2615            cond: Preconditions::None,
2616            memo: Memo::None,
2617            operations,
2618            ext: TransactionExt::V0,
2619        };
2620
2621        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2622            tx,
2623            signatures: vec![].try_into().unwrap(),
2624        });
2625
2626        let result = extract_time_bounds(&envelope);
2627        assert!(result.is_none());
2628    }
2629
2630    #[test]
2631    fn test_extract_time_bounds_from_fee_bump() {
2632        // Create inner transaction with time bounds
2633        let payment_op = create_payment_op();
2634        let operations = vec![payment_op].try_into().unwrap();
2635
2636        let time_bounds = TimeBounds {
2637            min_time: TimePoint(0),
2638            max_time: TimePoint(3000),
2639        };
2640
2641        let tx = Transaction {
2642            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2643            fee: 100,
2644            seq_num: SequenceNumber(1),
2645            cond: Preconditions::Time(time_bounds.clone()),
2646            memo: Memo::None,
2647            operations,
2648            ext: TransactionExt::V0,
2649        };
2650
2651        let inner_envelope = TransactionV1Envelope {
2652            tx,
2653            signatures: vec![].try_into().unwrap(),
2654        };
2655
2656        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
2657
2658        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
2659            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
2660            fee: 200,
2661            inner_tx,
2662            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
2663        };
2664
2665        let envelope =
2666            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
2667                tx: fee_bump_tx,
2668                signatures: vec![].try_into().unwrap(),
2669            });
2670
2671        let result = extract_time_bounds(&envelope);
2672        assert!(result.is_some());
2673
2674        let bounds = result.unwrap();
2675        assert_eq!(bounds.min_time.0, 0);
2676        assert_eq!(bounds.max_time.0, 3000);
2677    }
2678}
2679
2680#[cfg(test)]
2681mod set_time_bounds_tests {
2682    use super::*;
2683    use chrono::Utc;
2684    use soroban_rs::xdr::{
2685        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2686        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
2687        TransactionV1Envelope, Uint256,
2688    };
2689
2690    fn create_payment_op() -> Operation {
2691        Operation {
2692            source_account: None,
2693            body: OperationBody::Payment(PaymentOp {
2694                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2695                asset: Asset::Native,
2696                amount: 100,
2697            }),
2698        }
2699    }
2700
2701    #[test]
2702    fn test_set_time_bounds_on_tx_v0() {
2703        let payment_op = create_payment_op();
2704        let operations = vec![payment_op].try_into().unwrap();
2705
2706        let tx = TransactionV0 {
2707            source_account_ed25519: Uint256([0u8; 32]),
2708            fee: 100,
2709            seq_num: SequenceNumber(1),
2710            time_bounds: None,
2711            memo: Memo::None,
2712            operations,
2713            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2714        };
2715
2716        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2717            tx,
2718            signatures: vec![].try_into().unwrap(),
2719        });
2720
2721        let valid_until = Utc::now() + chrono::Duration::seconds(300);
2722        let result = set_time_bounds(&mut envelope, valid_until);
2723
2724        assert!(result.is_ok());
2725
2726        match envelope {
2727            TransactionEnvelope::TxV0(e) => {
2728                assert!(e.tx.time_bounds.is_some());
2729                let bounds = e.tx.time_bounds.unwrap();
2730                assert_eq!(bounds.min_time.0, 0);
2731                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
2732            }
2733            _ => panic!("Expected TxV0 envelope"),
2734        }
2735    }
2736
2737    #[test]
2738    fn test_set_time_bounds_on_tx_v1() {
2739        let payment_op = create_payment_op();
2740        let operations = vec![payment_op].try_into().unwrap();
2741
2742        let tx = Transaction {
2743            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2744            fee: 100,
2745            seq_num: SequenceNumber(1),
2746            cond: Preconditions::None,
2747            memo: Memo::None,
2748            operations,
2749            ext: TransactionExt::V0,
2750        };
2751
2752        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2753            tx,
2754            signatures: vec![].try_into().unwrap(),
2755        });
2756
2757        let valid_until = Utc::now() + chrono::Duration::seconds(300);
2758        let result = set_time_bounds(&mut envelope, valid_until);
2759
2760        assert!(result.is_ok());
2761
2762        match envelope {
2763            TransactionEnvelope::Tx(e) => match e.tx.cond {
2764                Preconditions::Time(bounds) => {
2765                    assert_eq!(bounds.min_time.0, 0);
2766                    assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
2767                }
2768                _ => panic!("Expected Time precondition"),
2769            },
2770            _ => panic!("Expected Tx envelope"),
2771        }
2772    }
2773
2774    #[test]
2775    fn test_set_time_bounds_on_fee_bump_fails() {
2776        // Create a simple inner transaction
2777        let payment_op = create_payment_op();
2778        let operations = vec![payment_op].try_into().unwrap();
2779
2780        let tx = Transaction {
2781            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2782            fee: 100,
2783            seq_num: SequenceNumber(1),
2784            cond: Preconditions::None,
2785            memo: Memo::None,
2786            operations,
2787            ext: TransactionExt::V0,
2788        };
2789
2790        let inner_envelope = TransactionV1Envelope {
2791            tx,
2792            signatures: vec![].try_into().unwrap(),
2793        };
2794
2795        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
2796
2797        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
2798            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
2799            fee: 200,
2800            inner_tx,
2801            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
2802        };
2803
2804        let mut envelope =
2805            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
2806                tx: fee_bump_tx,
2807                signatures: vec![].try_into().unwrap(),
2808            });
2809
2810        let valid_until = Utc::now() + chrono::Duration::seconds(300);
2811        let result = set_time_bounds(&mut envelope, valid_until);
2812
2813        assert!(result.is_err());
2814
2815        match result.unwrap_err() {
2816            StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump => {}
2817            _ => panic!("Expected CannotSetTimeBoundsOnFeeBump error"),
2818        }
2819    }
2820
2821    #[test]
2822    fn test_set_time_bounds_replaces_existing() {
2823        let payment_op = create_payment_op();
2824        let operations = vec![payment_op].try_into().unwrap();
2825
2826        let old_time_bounds = TimeBounds {
2827            min_time: TimePoint(100),
2828            max_time: TimePoint(1000),
2829        };
2830
2831        let tx = TransactionV0 {
2832            source_account_ed25519: Uint256([0u8; 32]),
2833            fee: 100,
2834            seq_num: SequenceNumber(1),
2835            time_bounds: Some(old_time_bounds),
2836            memo: Memo::None,
2837            operations,
2838            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2839        };
2840
2841        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2842            tx,
2843            signatures: vec![].try_into().unwrap(),
2844        });
2845
2846        let valid_until = Utc::now() + chrono::Duration::seconds(300);
2847        let result = set_time_bounds(&mut envelope, valid_until);
2848
2849        assert!(result.is_ok());
2850
2851        match envelope {
2852            TransactionEnvelope::TxV0(e) => {
2853                assert!(e.tx.time_bounds.is_some());
2854                let bounds = e.tx.time_bounds.unwrap();
2855                // Should replace with new bounds (min_time = 0, not 100)
2856                assert_eq!(bounds.min_time.0, 0);
2857                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
2858            }
2859            _ => panic!("Expected TxV0 envelope"),
2860        }
2861    }
2862}