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, TransactionMeta, TransactionResult, 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("V0 transactions are not supported")]
71    V0TransactionsNotSupported,
72
73    #[error("Cannot update sequence number on fee bump transaction")]
74    CannotUpdateSequenceOnFeeBump,
75
76    #[error("Invalid transaction format: {0}")]
77    InvalidTransactionFormat(String),
78
79    #[error("Invalid account address '{0}': {1}")]
80    InvalidAccountAddress(String, String),
81
82    #[error("Invalid contract address '{0}': {1}")]
83    InvalidContractAddress(String, String),
84
85    #[error("Failed to create {0} symbol: {1:?}")]
86    SymbolCreationFailed(String, String),
87
88    #[error("Failed to create {0} key vector: {1:?}")]
89    KeyVectorCreationFailed(String, String),
90
91    #[error("Failed to query contract data (Persistent) for {0}: {1}")]
92    ContractDataQueryPersistentFailed(String, String),
93
94    #[error("Failed to query contract data (Temporary) for {0}: {1}")]
95    ContractDataQueryTemporaryFailed(String, String),
96
97    #[error("Failed to parse ledger entry XDR for {0}: {1}")]
98    LedgerEntryParseFailed(String, String),
99
100    #[error("No entries found for {0}")]
101    NoEntriesFound(String),
102
103    #[error("Empty entries for {0}")]
104    EmptyEntries(String),
105
106    #[error("Unexpected ledger entry type for {0} (expected ContractData)")]
107    UnexpectedLedgerEntryType(String),
108
109    // Token-specific errors
110    #[error("Asset code cannot be empty in asset identifier: {0}")]
111    EmptyAssetCode(String),
112
113    #[error("Issuer address cannot be empty in asset identifier: {0}")]
114    EmptyIssuerAddress(String),
115
116    #[error("Invalid issuer address length (expected {0} characters): {1}")]
117    InvalidIssuerLength(usize, String),
118
119    #[error("Invalid issuer address format (must start with '{0}'): {1}")]
120    InvalidIssuerPrefix(char, String),
121
122    #[error("Failed to fetch account for balance: {0}")]
123    AccountFetchFailed(String),
124
125    #[error("Failed to query trustline for asset {0}: {1}")]
126    TrustlineQueryFailed(String, String),
127
128    #[error("No trustline found for asset {0} on account {1}")]
129    NoTrustlineFound(String, String),
130
131    #[error("Unsupported trustline entry version")]
132    UnsupportedTrustlineVersion,
133
134    #[error("Unexpected ledger entry type for trustline query")]
135    UnexpectedTrustlineEntryType,
136
137    #[error("Balance too large (i128 hi={0}, lo={1}) to fit in u64")]
138    BalanceTooLarge(i64, u64),
139
140    #[error("Negative balance not allowed: i128 lo={0}")]
141    NegativeBalanceI128(u64),
142
143    #[error("Negative balance not allowed: i64={0}")]
144    NegativeBalanceI64(i64),
145
146    #[error("Unexpected balance value type in contract data: {0:?}. Expected I128, U64, or I64")]
147    UnexpectedBalanceType(String),
148
149    #[error("Unexpected ledger entry type for contract data query")]
150    UnexpectedContractDataEntryType,
151
152    #[error("Native asset should be handled before trustline query")]
153    NativeAssetInTrustlineQuery,
154
155    #[error("Failed to invoke contract function '{0}': {1}")]
156    ContractInvocationFailed(String, String),
157}
158
159impl From<StellarTransactionUtilsError> for RelayerError {
160    fn from(error: StellarTransactionUtilsError) -> Self {
161        match &error {
162            StellarTransactionUtilsError::SequenceOverflow(msg)
163            | StellarTransactionUtilsError::SimulationCheckFailed(msg)
164            | StellarTransactionUtilsError::SimulationFailed(msg)
165            | StellarTransactionUtilsError::XdrParseFailed(msg)
166            | StellarTransactionUtilsError::OperationExtractionFailed(msg)
167            | StellarTransactionUtilsError::DexQuoteFailed(msg) => {
168                RelayerError::Internal(msg.clone())
169            }
170            StellarTransactionUtilsError::SimulationNoResults => RelayerError::Internal(
171                "Transaction simulation failed: no results returned".to_string(),
172            ),
173            StellarTransactionUtilsError::InvalidAssetFormat(msg)
174            | StellarTransactionUtilsError::InvalidTransactionFormat(msg) => {
175                RelayerError::ValidationError(msg.clone())
176            }
177            StellarTransactionUtilsError::AssetCodeTooLong(max_len, code) => {
178                RelayerError::ValidationError(format!(
179                    "Asset code too long (max {max_len} characters): {code}"
180                ))
181            }
182            StellarTransactionUtilsError::TooManyOperations(max) => {
183                RelayerError::ValidationError(format!("Too many operations (max {max})"))
184            }
185            StellarTransactionUtilsError::CannotModifyFeeBump => RelayerError::ValidationError(
186                "Cannot add operations to fee-bump transactions".to_string(),
187            ),
188            StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump => {
189                RelayerError::ValidationError(
190                    "Cannot set time bounds on fee-bump transactions".to_string(),
191                )
192            }
193            StellarTransactionUtilsError::V0TransactionsNotSupported => {
194                RelayerError::ValidationError("V0 transactions are not supported".to_string())
195            }
196            StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => {
197                RelayerError::ValidationError(
198                    "Cannot update sequence number on fee bump transaction".to_string(),
199                )
200            }
201            StellarTransactionUtilsError::InvalidAccountAddress(_, msg)
202            | StellarTransactionUtilsError::InvalidContractAddress(_, msg)
203            | StellarTransactionUtilsError::SymbolCreationFailed(_, msg)
204            | StellarTransactionUtilsError::KeyVectorCreationFailed(_, msg)
205            | StellarTransactionUtilsError::ContractDataQueryPersistentFailed(_, msg)
206            | StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(_, msg)
207            | StellarTransactionUtilsError::LedgerEntryParseFailed(_, msg) => {
208                RelayerError::Internal(msg.clone())
209            }
210            StellarTransactionUtilsError::NoEntriesFound(_)
211            | StellarTransactionUtilsError::EmptyEntries(_)
212            | StellarTransactionUtilsError::UnexpectedLedgerEntryType(_)
213            | StellarTransactionUtilsError::EmptyAssetCode(_)
214            | StellarTransactionUtilsError::EmptyIssuerAddress(_)
215            | StellarTransactionUtilsError::NoTrustlineFound(_, _)
216            | StellarTransactionUtilsError::UnsupportedTrustlineVersion
217            | StellarTransactionUtilsError::UnexpectedTrustlineEntryType
218            | StellarTransactionUtilsError::BalanceTooLarge(_, _)
219            | StellarTransactionUtilsError::NegativeBalanceI128(_)
220            | StellarTransactionUtilsError::NegativeBalanceI64(_)
221            | StellarTransactionUtilsError::UnexpectedBalanceType(_)
222            | StellarTransactionUtilsError::UnexpectedContractDataEntryType
223            | StellarTransactionUtilsError::NativeAssetInTrustlineQuery => {
224                RelayerError::ValidationError(error.to_string())
225            }
226            StellarTransactionUtilsError::InvalidIssuerLength(expected, actual) => {
227                RelayerError::ValidationError(format!(
228                    "Invalid issuer address length (expected {expected} characters): {actual}"
229                ))
230            }
231            StellarTransactionUtilsError::InvalidIssuerPrefix(prefix, addr) => {
232                RelayerError::ValidationError(format!(
233                    "Invalid issuer address format (must start with '{prefix}'): {addr}"
234                ))
235            }
236            StellarTransactionUtilsError::AccountFetchFailed(msg)
237            | StellarTransactionUtilsError::TrustlineQueryFailed(_, msg)
238            | StellarTransactionUtilsError::ContractInvocationFailed(_, msg) => {
239                RelayerError::ProviderError(msg.clone())
240            }
241        }
242    }
243}
244
245/// Returns true if any operation needs simulation (contract invocation, creation, or wasm upload).
246pub fn needs_simulation(operations: &[OperationSpec]) -> bool {
247    operations.iter().any(|op| {
248        matches!(
249            op,
250            OperationSpec::InvokeContract { .. }
251                | OperationSpec::CreateContract { .. }
252                | OperationSpec::UploadWasm { .. }
253        )
254    })
255}
256
257pub fn next_sequence_u64(seq_num: i64) -> Result<u64, RelayerError> {
258    let next_i64 = seq_num
259        .checked_add(1)
260        .ok_or_else(|| RelayerError::ProviderError("sequence overflow".into()))?;
261    u64::try_from(next_i64)
262        .map_err(|_| RelayerError::ProviderError("sequence overflows u64".into()))
263}
264
265pub fn i64_from_u64(value: u64) -> Result<i64, RelayerError> {
266    i64::try_from(value).map_err(|_| RelayerError::ProviderError("u64→i64 overflow".into()))
267}
268
269/// Decodes a base64-encoded `TransactionResult` XDR into a human-readable result code name.
270///
271/// Returns the variant name of the `TransactionResultResult` (e.g., `"TxBadSeq"`,
272/// `"TxInsufficientBalance"`, `"TxFailed"`). Returns `None` if the XDR cannot be decoded.
273pub fn decode_tx_result_code(error_result_xdr: &str) -> Option<String> {
274    TransactionResult::from_xdr_base64(error_result_xdr, Limits::none())
275        .ok()
276        .map(|r| r.result.name().to_string())
277}
278
279/// Detects if an error is due to a bad sequence number.
280/// Checks both string matching on the error message and XDR decoding when available.
281pub fn is_bad_sequence_error(error_msg: &str) -> bool {
282    let error_lower = error_msg.to_lowercase();
283    error_lower.contains("txbadseq")
284}
285
286/// Decodes a Stellar `TransactionResult` XDR payload and returns the result code name.
287///
288/// The RPC `sendTransaction` ERROR response exposes `errorResultXdr` as base64-encoded XDR,
289/// so callers must decode it before checking for specific result codes.
290pub fn decode_transaction_result_code(xdr_base64: &str) -> Option<String> {
291    use soroban_rs::xdr::{Limits, TransactionResult};
292
293    let result = TransactionResult::from_xdr_base64(xdr_base64, Limits::none()).ok()?;
294    Some(result.result.name().to_string())
295}
296
297/// Detects if a decoded transaction result code indicates an insufficient fee.
298pub fn is_insufficient_fee_error(result_code: &str) -> bool {
299    result_code.eq_ignore_ascii_case("TxInsufficientFee")
300        || result_code.eq_ignore_ascii_case("tx_insufficient_fee")
301}
302
303/// Fetches the current sequence number from the blockchain and calculates the next usable sequence.
304/// This is a shared helper that can be used by both stellar_relayer and stellar_transaction.
305///
306/// # Returns
307/// The next usable sequence number (on-chain sequence + 1)
308pub async fn fetch_next_sequence_from_chain<P>(
309    provider: &P,
310    relayer_address: &str,
311) -> Result<u64, String>
312where
313    P: StellarProviderTrait,
314{
315    debug!(
316        "Fetching sequence from chain for address: {}",
317        relayer_address
318    );
319
320    // Fetch account info from chain
321    let account = provider.get_account(relayer_address).await.map_err(|e| {
322        warn!(
323            address = %relayer_address,
324            error = %e,
325            "get_account failed in fetch_next_sequence_from_chain"
326        );
327        format!("Failed to fetch account from chain: {e}")
328    })?;
329
330    let on_chain_seq = account.seq_num.0; // Extract the i64 value
331    let next_usable = next_sequence_u64(on_chain_seq)
332        .map_err(|e| format!("Failed to calculate next sequence: {e}"))?;
333
334    debug!(
335        "Fetched sequence from chain: on-chain={}, next usable={}",
336        on_chain_seq, next_usable
337    );
338    Ok(next_usable)
339}
340
341/// Convert a V0 transaction to V1 format for signing.
342/// This is needed because the signature payload for V0 transactions uses V1 format internally.
343pub fn convert_v0_to_v1_transaction(
344    v0_tx: &soroban_rs::xdr::TransactionV0,
345) -> soroban_rs::xdr::Transaction {
346    soroban_rs::xdr::Transaction {
347        source_account: soroban_rs::xdr::MuxedAccount::Ed25519(
348            v0_tx.source_account_ed25519.clone(),
349        ),
350        fee: v0_tx.fee,
351        seq_num: v0_tx.seq_num.clone(),
352        cond: match v0_tx.time_bounds.clone() {
353            Some(tb) => soroban_rs::xdr::Preconditions::Time(tb),
354            None => soroban_rs::xdr::Preconditions::None,
355        },
356        memo: v0_tx.memo.clone(),
357        operations: v0_tx.operations.clone(),
358        ext: soroban_rs::xdr::TransactionExt::V0,
359    }
360}
361
362/// Create a signature payload for the given envelope type
363pub fn create_signature_payload(
364    envelope: &soroban_rs::xdr::TransactionEnvelope,
365    network_id: &soroban_rs::xdr::Hash,
366) -> Result<soroban_rs::xdr::TransactionSignaturePayload, RelayerError> {
367    let tagged_transaction = match envelope {
368        soroban_rs::xdr::TransactionEnvelope::TxV0(e) => {
369            // For V0, convert to V1 transaction format for signing
370            let v1_tx = convert_v0_to_v1_transaction(&e.tx);
371            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(v1_tx)
372        }
373        soroban_rs::xdr::TransactionEnvelope::Tx(e) => {
374            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(e.tx.clone())
375        }
376        soroban_rs::xdr::TransactionEnvelope::TxFeeBump(e) => {
377            soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::TxFeeBump(e.tx.clone())
378        }
379    };
380
381    Ok(soroban_rs::xdr::TransactionSignaturePayload {
382        network_id: network_id.clone(),
383        tagged_transaction,
384    })
385}
386
387/// Create signature payload for a transaction directly (for operations-based signing)
388pub fn create_transaction_signature_payload(
389    transaction: &soroban_rs::xdr::Transaction,
390    network_id: &soroban_rs::xdr::Hash,
391) -> soroban_rs::xdr::TransactionSignaturePayload {
392    soroban_rs::xdr::TransactionSignaturePayload {
393        network_id: network_id.clone(),
394        tagged_transaction: soroban_rs::xdr::TransactionSignaturePayloadTaggedTransaction::Tx(
395            transaction.clone(),
396        ),
397    }
398}
399
400/// Update the sequence number in a transaction envelope.
401///
402/// Only V1 (Tx) envelopes are supported; V0 and fee-bump envelopes return an error.
403pub fn update_envelope_sequence(
404    envelope: &mut TransactionEnvelope,
405    sequence: i64,
406) -> Result<(), StellarTransactionUtilsError> {
407    match envelope {
408        TransactionEnvelope::Tx(v1) => {
409            v1.tx.seq_num = soroban_rs::xdr::SequenceNumber(sequence);
410            Ok(())
411        }
412        TransactionEnvelope::TxV0(_) => {
413            Err(StellarTransactionUtilsError::V0TransactionsNotSupported)
414        }
415        TransactionEnvelope::TxFeeBump(_) => {
416            Err(StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump)
417        }
418    }
419}
420
421/// Extract the fee (in stroops) from a V1 transaction envelope.
422pub fn envelope_fee_in_stroops(
423    envelope: &TransactionEnvelope,
424) -> Result<u64, StellarTransactionUtilsError> {
425    match envelope {
426        TransactionEnvelope::Tx(env) => Ok(u64::from(env.tx.fee)),
427        _ => Err(StellarTransactionUtilsError::InvalidTransactionFormat(
428            "Expected V1 transaction envelope".to_string(),
429        )),
430    }
431}
432
433// ============================================================================
434// Account and Contract Address Utilities
435// ============================================================================
436
437/// Parse a Stellar account address string into an AccountId XDR type.
438///
439/// # Arguments
440///
441/// * `account_id` - Stellar account address (must be valid PublicKey)
442///
443/// # Returns
444///
445/// AccountId XDR type or error if address is invalid
446pub fn parse_account_id(account_id: &str) -> Result<AccountId, StellarTransactionUtilsError> {
447    let account_pk = PublicKey::from_str(account_id).map_err(|e| {
448        StellarTransactionUtilsError::InvalidAccountAddress(account_id.to_string(), e.to_string())
449    })?;
450    let account_uint256 = Uint256(account_pk.0);
451    let account_xdr_pk = XdrPublicKey::PublicKeyTypeEd25519(account_uint256);
452    Ok(AccountId(account_xdr_pk))
453}
454
455/// Parse a contract address string into a ContractId and extract the hash.
456///
457/// # Arguments
458///
459/// * `contract_address` - Contract address in StrKey format
460///
461/// # Returns
462///
463/// Contract hash (Hash) or error if address is invalid
464pub fn parse_contract_address(
465    contract_address: &str,
466) -> Result<Hash, StellarTransactionUtilsError> {
467    let contract_id = ContractId::from_str(contract_address).map_err(|e| {
468        StellarTransactionUtilsError::InvalidContractAddress(
469            contract_address.to_string(),
470            e.to_string(),
471        )
472    })?;
473    Ok(contract_id.0)
474}
475
476// ============================================================================
477// Contract Data Utilities
478// ============================================================================
479
480/// Create an ScVal key for contract data queries.
481///
482/// Creates a ScVal::Vec containing a symbol and optional address.
483/// Used for SEP-41 token interface keys like "Balance" and "Decimals".
484///
485/// # Arguments
486///
487/// * `symbol` - Symbol name (e.g., "Balance", "Decimals")
488/// * `address` - Optional ScAddress to include in the key
489///
490/// # Returns
491///
492/// ScVal::Vec key or error if creation fails
493pub fn create_contract_data_key(
494    symbol: &str,
495    address: Option<ScAddress>,
496) -> Result<ScVal, StellarTransactionUtilsError> {
497    if address.is_none() {
498        let sym = ScSymbol::try_from(symbol).map_err(|e| {
499            StellarTransactionUtilsError::SymbolCreationFailed(symbol.to_string(), format!("{e:?}"))
500        })?;
501        return Ok(ScVal::Symbol(sym));
502    }
503
504    let mut key_items: Vec<ScVal> =
505        vec![ScVal::Symbol(ScSymbol::try_from(symbol).map_err(|e| {
506            StellarTransactionUtilsError::SymbolCreationFailed(symbol.to_string(), format!("{e:?}"))
507        })?)];
508
509    if let Some(addr) = address {
510        key_items.push(ScVal::Address(addr));
511    }
512
513    let key_vec: VecM<ScVal, { u32::MAX }> = VecM::try_from(key_items).map_err(|e| {
514        StellarTransactionUtilsError::KeyVectorCreationFailed(symbol.to_string(), format!("{e:?}"))
515    })?;
516
517    Ok(ScVal::Vec(Some(soroban_rs::xdr::ScVec(key_vec))))
518}
519
520/// Query contract data with Persistent/Temporary durability fallback.
521///
522/// Queries contract data storage, trying Persistent durability first,
523/// then falling back to Temporary if not found. This handles both
524/// production tokens (Persistent) and test tokens (Temporary).
525///
526/// # Arguments
527///
528/// * `provider` - Stellar provider for querying ledger entries
529/// * `contract_hash` - Contract hash (Hash)
530/// * `key` - ScVal key to query
531/// * `error_context` - Context string for error messages
532///
533/// # Returns
534///
535/// GetLedgerEntriesResponse or error if query fails
536pub async fn query_contract_data_with_fallback<P>(
537    provider: &P,
538    contract_hash: Hash,
539    key: ScVal,
540    error_context: &str,
541) -> Result<soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse, StellarTransactionUtilsError>
542where
543    P: StellarProviderTrait + Send + Sync,
544{
545    let contract_address_sc =
546        soroban_rs::xdr::ScAddress::Contract(soroban_rs::xdr::ContractId(contract_hash));
547
548    let mut ledger_key = LedgerKey::ContractData(LedgerKeyContractData {
549        contract: contract_address_sc.clone(),
550        key: key.clone(),
551        durability: soroban_rs::xdr::ContractDataDurability::Persistent,
552    });
553
554    // Query ledger entry with Persistent durability
555    let mut ledger_entries = provider
556        .get_ledger_entries(&[ledger_key.clone()])
557        .await
558        .map_err(|e| {
559            StellarTransactionUtilsError::ContractDataQueryPersistentFailed(
560                error_context.to_string(),
561                e.to_string(),
562            )
563        })?;
564
565    // If not found, try Temporary durability
566    if ledger_entries
567        .entries
568        .as_ref()
569        .map(|e| e.is_empty())
570        .unwrap_or(true)
571    {
572        ledger_key = LedgerKey::ContractData(LedgerKeyContractData {
573            contract: contract_address_sc,
574            key,
575            durability: soroban_rs::xdr::ContractDataDurability::Temporary,
576        });
577        ledger_entries = provider
578            .get_ledger_entries(&[ledger_key])
579            .await
580            .map_err(|e| {
581                StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(
582                    error_context.to_string(),
583                    e.to_string(),
584                )
585            })?;
586    }
587
588    Ok(ledger_entries)
589}
590
591/// Parse a ledger entry from base64 XDR string.
592///
593/// Handles both LedgerEntry and LedgerEntryChange formats. If the XDR is a
594/// LedgerEntryChange, extracts the LedgerEntry from it.
595///
596/// # Arguments
597///
598/// * `xdr_string` - Base64-encoded XDR string
599/// * `context` - Context string for error messages
600///
601/// # Returns
602///
603/// Parsed LedgerEntry or error if parsing fails
604pub fn parse_ledger_entry_from_xdr(
605    xdr_string: &str,
606    context: &str,
607) -> Result<LedgerEntryData, StellarTransactionUtilsError> {
608    let trimmed_xdr = xdr_string.trim();
609
610    // Ensure valid base64
611    if general_purpose::STANDARD.decode(trimmed_xdr).is_err() {
612        return Err(StellarTransactionUtilsError::LedgerEntryParseFailed(
613            context.to_string(),
614            "Invalid base64".to_string(),
615        ));
616    }
617
618    // Parse as LedgerEntryData (what Soroban RPC actually returns)
619    match LedgerEntryData::from_xdr_base64(trimmed_xdr, Limits::none()) {
620        Ok(data) => Ok(data),
621        Err(e) => Err(StellarTransactionUtilsError::LedgerEntryParseFailed(
622            context.to_string(),
623            format!("Failed to parse LedgerEntryData: {e}"),
624        )),
625    }
626}
627
628/// Extract ScVal from contract data entry.
629///
630/// Parses the first entry from GetLedgerEntriesResponse and extracts
631/// the ScVal from ContractDataEntry.
632///
633/// # Arguments
634///
635/// * `ledger_entries` - Response from get_ledger_entries
636/// * `context` - Context string for error messages and logging
637///
638/// # Returns
639///
640/// ScVal from contract data or error if extraction fails
641pub fn extract_scval_from_contract_data(
642    ledger_entries: &soroban_rs::stellar_rpc_client::GetLedgerEntriesResponse,
643    context: &str,
644) -> Result<ScVal, StellarTransactionUtilsError> {
645    let entries = ledger_entries
646        .entries
647        .as_ref()
648        .ok_or_else(|| StellarTransactionUtilsError::NoEntriesFound(context.into()))?;
649
650    if entries.is_empty() {
651        return Err(StellarTransactionUtilsError::EmptyEntries(context.into()));
652    }
653
654    let entry_xdr = &entries[0].xdr;
655    let entry = parse_ledger_entry_from_xdr(entry_xdr, context)?;
656
657    match entry {
658        LedgerEntryData::ContractData(ContractDataEntry { val, .. }) => Ok(val.clone()),
659
660        _ => Err(StellarTransactionUtilsError::UnexpectedLedgerEntryType(
661            context.into(),
662        )),
663    }
664}
665
666/// Extracts the return value from TransactionMeta if available.
667///
668/// Supports both V3 and V4 TransactionMeta versions for backward compatibility.
669/// - V3: soroban_meta.return_value (ScVal, required)
670/// - V4: soroban_meta.return_value (Option<ScVal>, optional)
671///
672/// # Arguments
673///
674/// * `result_meta` - TransactionMeta to extract return value from
675///
676/// # Returns
677///
678/// Some(&ScVal) if return value is available, None otherwise
679pub fn extract_return_value_from_meta(result_meta: &TransactionMeta) -> Option<&ScVal> {
680    match result_meta {
681        TransactionMeta::V3(meta_v3) => meta_v3.soroban_meta.as_ref().map(|m| &m.return_value),
682        TransactionMeta::V4(meta_v4) => meta_v4
683            .soroban_meta
684            .as_ref()
685            .and_then(|m| m.return_value.as_ref()),
686        _ => None,
687    }
688}
689
690/// Extract a u32 value from an ScVal.
691///
692/// Handles multiple ScVal types that can represent numeric values.
693///
694/// # Arguments
695///
696/// * `val` - ScVal to extract from
697/// * `context` - Context string (for logging)
698///
699/// # Returns
700///
701/// Some(u32) if extraction succeeds, None otherwise
702pub fn extract_u32_from_scval(val: &ScVal, context: &str) -> Option<u32> {
703    let result = match val {
704        ScVal::U32(n) => Ok(*n),
705        ScVal::I32(n) => (*n).try_into().map_err(|_| "Negative I32"),
706        ScVal::U64(n) => (*n).try_into().map_err(|_| "U64 overflow"),
707        ScVal::I64(n) => (*n).try_into().map_err(|_| "I64 overflow/negative"),
708        ScVal::U128(n) => {
709            if n.hi == 0 {
710                n.lo.try_into().map_err(|_| "U128 lo overflow")
711            } else {
712                Err("U128 hi set")
713            }
714        }
715        ScVal::I128(n) => {
716            if n.hi == 0 {
717                n.lo.try_into().map_err(|_| "I128 lo overflow")
718            } else {
719                Err("I128 hi set/negative")
720            }
721        }
722        _ => Err("Unsupported ScVal type"),
723    };
724
725    match result {
726        Ok(v) => Some(v),
727        Err(msg) => {
728            warn!(context = %context, val = ?val, "Failed to extract u32: {}", msg);
729            None
730        }
731    }
732}
733
734// ============================================================================
735// Gas Abstraction Utility Functions
736// ============================================================================
737
738/// Convert raw token amount to UI amount based on decimals
739///
740/// Uses pure integer arithmetic to avoid floating-point precision errors.
741/// This is safer for financial calculations where precision is critical.
742pub fn amount_to_ui_amount(amount: u64, decimals: u8) -> String {
743    if decimals == 0 {
744        return amount.to_string();
745    }
746
747    let amount_str = amount.to_string();
748    let len = amount_str.len();
749    let decimals_usize = decimals as usize;
750
751    let combined = if len > decimals_usize {
752        let split_idx = len - decimals_usize;
753        let whole = &amount_str[..split_idx];
754        let frac = &amount_str[split_idx..];
755        format!("{whole}.{frac}")
756    } else {
757        // Need to pad with leading zeros
758        let zeros = "0".repeat(decimals_usize - len);
759        format!("0.{zeros}{amount_str}")
760    };
761
762    // Trim trailing zeros
763    let mut trimmed = combined.trim_end_matches('0').to_string();
764    if trimmed.ends_with('.') {
765        trimmed.pop();
766    }
767
768    // If we stripped everything (e.g. amount 0), return "0"
769    if trimmed.is_empty() {
770        "0".to_string()
771    } else {
772        trimmed
773    }
774}
775
776/// Count operations in a transaction envelope from XDR base64 string
777///
778/// Parses the XDR string, extracts operations, and returns the count.
779pub fn count_operations_from_xdr(xdr: &str) -> Result<usize, StellarTransactionUtilsError> {
780    let envelope = TransactionEnvelope::from_xdr_base64(xdr, Limits::none()).map_err(|e| {
781        StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
782    })?;
783
784    let operations = extract_operations(&envelope).map_err(|e| {
785        StellarTransactionUtilsError::OperationExtractionFailed(format!(
786            "Failed to extract operations: {e}"
787        ))
788    })?;
789
790    Ok(operations.len())
791}
792
793/// Parse transaction and count operations
794///
795/// Supports both XDR (base64 string) and operations array formats
796pub fn parse_transaction_and_count_operations(
797    transaction_json: &serde_json::Value,
798) -> Result<usize, StellarTransactionUtilsError> {
799    // Try to parse as XDR string first
800    if let Some(xdr_str) = transaction_json.as_str() {
801        let envelope =
802            TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
803                StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
804            })?;
805
806        let operations = extract_operations(&envelope).map_err(|e| {
807            StellarTransactionUtilsError::OperationExtractionFailed(format!(
808                "Failed to extract operations: {e}"
809            ))
810        })?;
811
812        return Ok(operations.len());
813    }
814
815    // Try to parse as operations array
816    if let Some(ops_array) = transaction_json.as_array() {
817        return Ok(ops_array.len());
818    }
819
820    // Try to parse as object with operations field
821    if let Some(obj) = transaction_json.as_object() {
822        if let Some(ops) = obj.get("operations") {
823            if let Some(ops_array) = ops.as_array() {
824                return Ok(ops_array.len());
825            }
826        }
827        if let Some(xdr_str) = obj.get("transaction_xdr").and_then(|v| v.as_str()) {
828            let envelope =
829                TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
830                    StellarTransactionUtilsError::XdrParseFailed(format!(
831                        "Failed to parse XDR: {e}"
832                    ))
833                })?;
834
835            let operations = extract_operations(&envelope).map_err(|e| {
836                StellarTransactionUtilsError::OperationExtractionFailed(format!(
837                    "Failed to extract operations: {e}"
838                ))
839            })?;
840
841            return Ok(operations.len());
842        }
843    }
844
845    Err(StellarTransactionUtilsError::InvalidTransactionFormat(
846        "Transaction must be either XDR string or operations array".to_string(),
847    ))
848}
849
850/// Fee quote structure containing fee estimates in both tokens and stroops
851#[derive(Debug)]
852pub struct FeeQuote {
853    pub fee_in_token: u64,
854    pub fee_in_token_ui: String,
855    pub fee_in_stroops: u64,
856    pub conversion_rate: f64,
857}
858
859/// Estimate the base transaction fee in XLM (stroops)
860///
861/// For Stellar, the base fee is typically 100 stroops per operation.
862pub fn estimate_base_fee(num_operations: usize) -> u64 {
863    (num_operations.max(1) as u64) * STELLAR_DEFAULT_TRANSACTION_FEE as u64
864}
865
866/// Estimate transaction fee in XLM (stroops) based on envelope content
867///
868/// This function intelligently estimates fees by:
869/// 1. Checking if the transaction needs simulation (contains Soroban operations)
870/// 2. If simulation is needed, performs simulation and uses `min_resource_fee` from the response
871/// 3. If simulation is not needed, counts operations and uses `estimate_base_fee`
872///
873/// # Arguments
874/// * `envelope` - The transaction envelope to estimate fees for
875/// * `provider` - Stellar provider for simulation (required if simulation is needed)
876/// * `operations_override` - Optional override for operations count (useful when operations will be added, e.g., +1 for fee payment)
877///
878/// # Returns
879/// Estimated fee in stroops (XLM)
880pub async fn estimate_fee<P>(
881    envelope: &TransactionEnvelope,
882    provider: &P,
883    operations_override: Option<usize>,
884) -> Result<u64, StellarTransactionUtilsError>
885where
886    P: StellarProviderTrait + Send + Sync,
887{
888    // Check if simulation is needed
889    let needs_sim = xdr_needs_simulation(envelope).map_err(|e| {
890        StellarTransactionUtilsError::SimulationCheckFailed(format!(
891            "Failed to check if simulation is needed: {e}"
892        ))
893    })?;
894
895    if needs_sim {
896        debug!("Transaction contains Soroban operations, simulating to get accurate fee");
897
898        // For simulation, we simulate the envelope as-is
899        let simulation_result = provider
900            .simulate_transaction_envelope(envelope)
901            .await
902            .map_err(|e| {
903                StellarTransactionUtilsError::SimulationFailed(format!(
904                    "Failed to simulate transaction: {e}"
905                ))
906            })?;
907
908        // Check simulation success
909        if simulation_result.results.is_empty() {
910            return Err(StellarTransactionUtilsError::SimulationNoResults);
911        }
912
913        // Use min_resource_fee from simulation (this includes all fees for Soroban operations)
914        // If operations_override is provided, we add the base fee for additional operations
915        let resource_fee = simulation_result.min_resource_fee as u64;
916        let inclusion_fee = STELLAR_DEFAULT_TRANSACTION_FEE as u64;
917        let required_fee = inclusion_fee + resource_fee;
918
919        debug!("Simulation returned fee: {} stroops", required_fee);
920        Ok(required_fee)
921    } else {
922        // No simulation needed, count operations and estimate base fee
923        let num_operations = if let Some(override_count) = operations_override {
924            override_count
925        } else {
926            let operations = extract_operations(envelope).map_err(|e| {
927                StellarTransactionUtilsError::OperationExtractionFailed(format!(
928                    "Failed to extract operations: {e}"
929                ))
930            })?;
931            operations.len()
932        };
933
934        let fee = estimate_base_fee(num_operations);
935        debug!(
936            "No simulation needed, estimated fee from {} operations: {} stroops",
937            num_operations, fee
938        );
939        Ok(fee)
940    }
941}
942
943/// Convert XLM fee to token amount using DEX service
944///
945/// This function converts an XLM fee (in stroops) to the equivalent amount in the requested token
946/// using the DEX service. For native XLM, no conversion is needed.
947/// Optionally applies a fee margin percentage to the XLM fee before conversion.
948///
949/// # Arguments
950/// * `dex_service` - DEX service for token conversion quotes
951/// * `policy` - Stellar relayer policy for slippage and token decimals
952/// * `xlm_fee` - Fee amount in XLM stroops (already estimated)
953/// * `fee_token` - Token identifier (e.g., "native" or "USDC:GA5Z...")
954///
955/// # Returns
956/// A tuple containing:
957/// * `FeeQuote` - Fee quote with amounts in both token and XLM
958/// * `u64` - Buffered XLM fee (with margin applied if specified)
959pub async fn convert_xlm_fee_to_token<D>(
960    dex_service: &D,
961    policy: &RelayerStellarPolicy,
962    xlm_fee: u64,
963    fee_token: &str,
964) -> Result<FeeQuote, StellarTransactionUtilsError>
965where
966    D: StellarDexServiceTrait + Send + Sync,
967{
968    // Handle native XLM - no conversion needed
969    if fee_token == "native" || fee_token.is_empty() {
970        debug!("Converting XLM fee to native XLM: {}", xlm_fee);
971        let buffered_fee = if let Some(margin) = policy.fee_margin_percentage {
972            (xlm_fee as f64 * (1.0 + margin as f64 / 100.0)) as u64
973        } else {
974            xlm_fee
975        };
976
977        return Ok(FeeQuote {
978            fee_in_token: buffered_fee,
979            fee_in_token_ui: amount_to_ui_amount(buffered_fee, 7),
980            fee_in_stroops: buffered_fee,
981            conversion_rate: 1.0,
982        });
983    }
984
985    debug!("Converting XLM fee to token: {}", fee_token);
986
987    // Apply fee margin if specified in policy
988    let buffered_xlm_fee = if let Some(margin) = policy.fee_margin_percentage {
989        (xlm_fee as f64 * (1.0 + margin as f64 / 100.0)) as u64
990    } else {
991        xlm_fee
992    };
993
994    // Get slippage from policy or use default
995    let slippage = policy
996        .get_allowed_token_entry(fee_token)
997        .and_then(|token| {
998            token
999                .swap_config
1000                .as_ref()
1001                .and_then(|config| config.slippage_percentage)
1002        })
1003        .or(policy.slippage_percentage)
1004        .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE);
1005
1006    // Get quote from DEX service
1007    // Get token decimals from policy or default to 7
1008    let token_decimals = policy.get_allowed_token_decimals(fee_token);
1009    let quote = dex_service
1010        .get_xlm_to_token_quote(fee_token, buffered_xlm_fee, slippage, token_decimals)
1011        .await
1012        .map_err(|e| {
1013            StellarTransactionUtilsError::DexQuoteFailed(format!("Failed to get quote: {e}"))
1014        })?;
1015
1016    debug!(
1017        "Quote from DEX: input={} stroops XLM, output={} stroops token, input_asset={}, output_asset={}",
1018        quote.in_amount, quote.out_amount, quote.input_asset, quote.output_asset
1019    );
1020
1021    // Calculate conversion rate
1022    let conversion_rate = if buffered_xlm_fee > 0 {
1023        quote.out_amount as f64 / buffered_xlm_fee as f64
1024    } else {
1025        0.0
1026    };
1027
1028    let fee_quote = FeeQuote {
1029        fee_in_token: quote.out_amount,
1030        fee_in_token_ui: amount_to_ui_amount(quote.out_amount, token_decimals.unwrap_or(7)),
1031        fee_in_stroops: buffered_xlm_fee,
1032        conversion_rate,
1033    };
1034
1035    debug!(
1036        "Final fee quote: fee_in_token={} stroops ({} {}), fee_in_stroops={} stroops XLM, conversion_rate={}",
1037        fee_quote.fee_in_token, fee_quote.fee_in_token_ui, fee_token, fee_quote.fee_in_stroops, fee_quote.conversion_rate
1038    );
1039
1040    Ok(fee_quote)
1041}
1042
1043/// Parse transaction envelope from JSON value
1044pub fn parse_transaction_envelope(
1045    transaction_json: &serde_json::Value,
1046) -> Result<TransactionEnvelope, StellarTransactionUtilsError> {
1047    // Try to parse as XDR string first
1048    if let Some(xdr_str) = transaction_json.as_str() {
1049        return TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
1050            StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
1051        });
1052    }
1053
1054    // Try to parse as object with transaction_xdr field
1055    if let Some(obj) = transaction_json.as_object() {
1056        if let Some(xdr_str) = obj.get("transaction_xdr").and_then(|v| v.as_str()) {
1057            return TransactionEnvelope::from_xdr_base64(xdr_str, Limits::none()).map_err(|e| {
1058                StellarTransactionUtilsError::XdrParseFailed(format!("Failed to parse XDR: {e}"))
1059            });
1060        }
1061    }
1062
1063    Err(StellarTransactionUtilsError::InvalidTransactionFormat(
1064        "Transaction must be XDR string or object with transaction_xdr field".to_string(),
1065    ))
1066}
1067
1068/// Create fee payment operation
1069pub fn create_fee_payment_operation(
1070    destination: &str,
1071    asset_id: &str,
1072    amount: i64,
1073) -> Result<OperationSpec, StellarTransactionUtilsError> {
1074    // Parse asset identifier
1075    let asset = if asset_id == "native" || asset_id.is_empty() {
1076        AssetSpec::Native
1077    } else {
1078        // Parse "CODE:ISSUER" format
1079        if let Some(colon_pos) = asset_id.find(':') {
1080            let code = asset_id[..colon_pos].to_string();
1081            let issuer = asset_id[colon_pos + 1..].to_string();
1082
1083            // Determine if it's Credit4 or Credit12 based on code length
1084            if code.len() <= 4 {
1085                AssetSpec::Credit4 { code, issuer }
1086            } else if code.len() <= 12 {
1087                AssetSpec::Credit12 { code, issuer }
1088            } else {
1089                return Err(StellarTransactionUtilsError::AssetCodeTooLong(
1090                    12, // Stellar max asset code length
1091                    code,
1092                ));
1093            }
1094        } else {
1095            return Err(StellarTransactionUtilsError::InvalidAssetFormat(format!(
1096                "Invalid asset identifier format. Expected 'native' or 'CODE:ISSUER', got: {asset_id}"
1097            )));
1098        }
1099    };
1100
1101    Ok(OperationSpec::Payment {
1102        destination: destination.to_string(),
1103        amount,
1104        asset,
1105    })
1106}
1107
1108/// Add operation to transaction envelope
1109pub fn add_operation_to_envelope(
1110    envelope: &mut TransactionEnvelope,
1111    operation: Operation,
1112) -> Result<(), StellarTransactionUtilsError> {
1113    match envelope {
1114        TransactionEnvelope::TxV0(ref mut e) => {
1115            // Extract existing operations
1116            let mut ops: Vec<Operation> = e.tx.operations.iter().cloned().collect();
1117            ops.push(operation);
1118
1119            // Convert back to VecM
1120            let operations: VecM<Operation, 100> = ops.try_into().map_err(|_| {
1121                StellarTransactionUtilsError::TooManyOperations(STELLAR_MAX_OPERATIONS)
1122            })?;
1123
1124            e.tx.operations = operations;
1125
1126            // Update fee to account for new operation
1127            e.tx.fee = (e.tx.operations.len() as u32) * STELLAR_DEFAULT_TRANSACTION_FEE;
1128            // 100 stroops per operation
1129        }
1130        TransactionEnvelope::Tx(ref mut e) => {
1131            // Extract existing operations
1132            let mut ops: Vec<Operation> = e.tx.operations.iter().cloned().collect();
1133            ops.push(operation);
1134
1135            // Convert back to VecM
1136            let operations: VecM<Operation, 100> = ops.try_into().map_err(|_| {
1137                StellarTransactionUtilsError::TooManyOperations(STELLAR_MAX_OPERATIONS)
1138            })?;
1139
1140            e.tx.operations = operations;
1141
1142            // Update fee to account for new operation
1143            e.tx.fee = (e.tx.operations.len() as u32) * STELLAR_DEFAULT_TRANSACTION_FEE;
1144            // 100 stroops per operation
1145        }
1146        TransactionEnvelope::TxFeeBump(_) => {
1147            return Err(StellarTransactionUtilsError::CannotModifyFeeBump);
1148        }
1149    }
1150    Ok(())
1151}
1152
1153/// Extract time bounds from a transaction envelope
1154///
1155/// Handles both regular transactions (TxV0, Tx) and fee-bump transactions
1156/// (extracts from inner transaction).
1157///
1158/// # Arguments
1159/// * `envelope` - The transaction envelope to extract time bounds from
1160///
1161/// # Returns
1162/// Some(TimeBounds) if present, None otherwise
1163pub fn extract_time_bounds(envelope: &TransactionEnvelope) -> Option<&TimeBounds> {
1164    match envelope {
1165        TransactionEnvelope::TxV0(e) => e.tx.time_bounds.as_ref(),
1166        TransactionEnvelope::Tx(e) => match &e.tx.cond {
1167            Preconditions::Time(tb) => Some(tb),
1168            Preconditions::V2(v2) => v2.time_bounds.as_ref(),
1169            Preconditions::None => None,
1170        },
1171        TransactionEnvelope::TxFeeBump(fb) => {
1172            // Extract from inner transaction
1173            match &fb.tx.inner_tx {
1174                soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_tx) => {
1175                    match &inner_tx.tx.cond {
1176                        Preconditions::Time(tb) => Some(tb),
1177                        Preconditions::V2(v2) => v2.time_bounds.as_ref(),
1178                        Preconditions::None => None,
1179                    }
1180                }
1181            }
1182        }
1183    }
1184}
1185
1186/// Set time bounds on transaction envelope
1187pub fn set_time_bounds(
1188    envelope: &mut TransactionEnvelope,
1189    valid_until: DateTime<Utc>,
1190) -> Result<(), StellarTransactionUtilsError> {
1191    let max_time = valid_until.timestamp() as u64;
1192    let time_bounds = TimeBounds {
1193        min_time: TimePoint(0),
1194        max_time: TimePoint(max_time),
1195    };
1196
1197    match envelope {
1198        TransactionEnvelope::TxV0(ref mut e) => {
1199            e.tx.time_bounds = Some(time_bounds);
1200        }
1201        TransactionEnvelope::Tx(ref mut e) => {
1202            e.tx.cond = Preconditions::Time(time_bounds);
1203        }
1204        TransactionEnvelope::TxFeeBump(_) => {
1205            return Err(StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump);
1206        }
1207    }
1208    Ok(())
1209}
1210
1211/// Extract asset identifier from CreditAlphanum4
1212fn credit_alphanum4_to_asset_id(
1213    alpha4: &AlphaNum4,
1214) -> Result<String, StellarTransactionUtilsError> {
1215    // Extract code (trim null bytes)
1216    let code_bytes = alpha4.asset_code.0;
1217    let code_len = code_bytes.iter().position(|&b| b == 0).unwrap_or(4);
1218    let code = String::from_utf8(code_bytes[..code_len].to_vec()).map_err(|e| {
1219        StellarTransactionUtilsError::InvalidAssetFormat(format!("Invalid asset code: {e}"))
1220    })?;
1221
1222    // Extract issuer
1223    let issuer = match &alpha4.issuer.0 {
1224        XdrPublicKey::PublicKeyTypeEd25519(uint256) => {
1225            let bytes: [u8; 32] = uint256.0;
1226            let pk = PublicKey(bytes);
1227            pk.to_string()
1228        }
1229    };
1230
1231    Ok(format!("{code}:{issuer}"))
1232}
1233
1234/// Extract asset identifier from CreditAlphanum12
1235fn credit_alphanum12_to_asset_id(
1236    alpha12: &AlphaNum12,
1237) -> Result<String, StellarTransactionUtilsError> {
1238    // Extract code (trim null bytes)
1239    let code_bytes = alpha12.asset_code.0;
1240    let code_len = code_bytes.iter().position(|&b| b == 0).unwrap_or(12);
1241    let code = String::from_utf8(code_bytes[..code_len].to_vec()).map_err(|e| {
1242        StellarTransactionUtilsError::InvalidAssetFormat(format!("Invalid asset code: {e}"))
1243    })?;
1244
1245    // Extract issuer
1246    let issuer = match &alpha12.issuer.0 {
1247        XdrPublicKey::PublicKeyTypeEd25519(uint256) => {
1248            let bytes: [u8; 32] = uint256.0;
1249            let pk = PublicKey(bytes);
1250            pk.to_string()
1251        }
1252    };
1253
1254    Ok(format!("{code}:{issuer}"))
1255}
1256
1257/// Convert ChangeTrustAsset XDR to asset identifier string
1258///
1259/// Returns `Some(asset_id)` for CreditAlphanum4 and CreditAlphanum12 assets,
1260/// or `None` for Native or PoolShare (which don't have asset identifiers).
1261///
1262/// # Arguments
1263///
1264/// * `change_trust_asset` - The ChangeTrustAsset to convert
1265///
1266/// # Returns
1267///
1268/// Asset identifier string in "CODE:ISSUER" format, or None for Native/PoolShare
1269pub fn change_trust_asset_to_asset_id(
1270    change_trust_asset: &ChangeTrustAsset,
1271) -> Result<Option<String>, StellarTransactionUtilsError> {
1272    match change_trust_asset {
1273        ChangeTrustAsset::Native | ChangeTrustAsset::PoolShare(_) => Ok(None),
1274        ChangeTrustAsset::CreditAlphanum4(alpha4) => {
1275            // Convert to Asset and use the unified function
1276            let asset = Asset::CreditAlphanum4(alpha4.clone());
1277            asset_to_asset_id(&asset).map(Some)
1278        }
1279        ChangeTrustAsset::CreditAlphanum12(alpha12) => {
1280            // Convert to Asset and use the unified function
1281            let asset = Asset::CreditAlphanum12(alpha12.clone());
1282            asset_to_asset_id(&asset).map(Some)
1283        }
1284    }
1285}
1286
1287/// Convert Asset XDR to asset identifier string
1288///
1289/// # Arguments
1290///
1291/// * `asset` - The Asset to convert
1292///
1293/// # Returns
1294///
1295/// Asset identifier string ("native" for Native, or "CODE:ISSUER" for credit assets)
1296pub fn asset_to_asset_id(asset: &Asset) -> Result<String, StellarTransactionUtilsError> {
1297    match asset {
1298        Asset::Native => Ok("native".to_string()),
1299        Asset::CreditAlphanum4(alpha4) => credit_alphanum4_to_asset_id(alpha4),
1300        Asset::CreditAlphanum12(alpha12) => credit_alphanum12_to_asset_id(alpha12),
1301    }
1302}
1303
1304/// Computes the resubmit interval with backoff based on total transaction age.
1305///
1306/// The interval grows by `growth_factor` each time the age crosses the next tier boundary:
1307///   - age < base         → `None` (too early to resubmit)
1308///   - age in [1x, 1.5x)  → interval = base (e.g. 10s)
1309///   - age in [1.5x, 2.25x) → interval = base × factor (e.g. 15s)
1310///   - age in [2.25x, 3.375x) → interval = base × factor² (e.g. 22s)
1311///   - ...capped at `max_interval`
1312///
1313/// With base=10, factor=1.5, max=120:
1314///   10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped)
1315///
1316/// Returns the backoff interval to compare against time since last submission (`sent_at`).
1317pub fn compute_resubmit_backoff_interval(
1318    total_age: chrono::Duration,
1319    base_interval_secs: i64,
1320    max_interval_secs: i64,
1321    growth_factor: f64,
1322) -> Option<chrono::Duration> {
1323    let age_secs = total_age.num_seconds();
1324
1325    if age_secs < base_interval_secs || base_interval_secs <= 0 || max_interval_secs <= 0 {
1326        return None;
1327    }
1328
1329    // Guard: factor must be > 1.0 to produce growth; fall back to min(base, max).
1330    if growth_factor <= 1.0 {
1331        return Some(chrono::Duration::seconds(
1332            base_interval_secs.min(max_interval_secs),
1333        ));
1334    }
1335
1336    // Each tier boundary = previous boundary × growth_factor.
1337    // The interval at each tier = previous interval × growth_factor, capped at max.
1338    let mut interval = base_interval_secs as f64;
1339    let mut tier_end = base_interval_secs as f64 * growth_factor;
1340
1341    while tier_end <= age_secs as f64 {
1342        interval = (interval * growth_factor).min(max_interval_secs as f64);
1343        tier_end *= growth_factor;
1344    }
1345
1346    let capped = (interval as i64).min(max_interval_secs);
1347    Some(chrono::Duration::seconds(capped))
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
1354    use crate::models::AssetSpec;
1355    use crate::models::{AuthSpec, ContractSource, WasmSource};
1356
1357    fn payment_op(destination: &str) -> OperationSpec {
1358        OperationSpec::Payment {
1359            destination: destination.to_string(),
1360            amount: 100,
1361            asset: AssetSpec::Native,
1362        }
1363    }
1364
1365    #[test]
1366    fn returns_false_for_only_payment_ops() {
1367        let ops = vec![payment_op(TEST_PK)];
1368        assert!(!needs_simulation(&ops));
1369    }
1370
1371    #[test]
1372    fn returns_true_for_invoke_contract_ops() {
1373        let ops = vec![OperationSpec::InvokeContract {
1374            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1375                .to_string(),
1376            function_name: "transfer".to_string(),
1377            args: vec![],
1378            auth: None,
1379        }];
1380        assert!(needs_simulation(&ops));
1381    }
1382
1383    #[test]
1384    fn returns_true_for_upload_wasm_ops() {
1385        let ops = vec![OperationSpec::UploadWasm {
1386            wasm: WasmSource::Hex {
1387                hex: "deadbeef".to_string(),
1388            },
1389            auth: None,
1390        }];
1391        assert!(needs_simulation(&ops));
1392    }
1393
1394    #[test]
1395    fn returns_true_for_create_contract_ops() {
1396        let ops = vec![OperationSpec::CreateContract {
1397            source: ContractSource::Address {
1398                address: TEST_PK.to_string(),
1399            },
1400            wasm_hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
1401                .to_string(),
1402            salt: None,
1403            constructor_args: None,
1404            auth: None,
1405        }];
1406        assert!(needs_simulation(&ops));
1407    }
1408
1409    #[test]
1410    fn returns_true_for_single_invoke_host_function() {
1411        let ops = vec![OperationSpec::InvokeContract {
1412            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1413                .to_string(),
1414            function_name: "transfer".to_string(),
1415            args: vec![],
1416            auth: Some(AuthSpec::SourceAccount),
1417        }];
1418        assert!(needs_simulation(&ops));
1419    }
1420
1421    #[test]
1422    fn returns_false_for_multiple_payment_ops() {
1423        let ops = vec![payment_op(TEST_PK), payment_op(TEST_PK)];
1424        assert!(!needs_simulation(&ops));
1425    }
1426
1427    mod next_sequence_u64_tests {
1428        use super::*;
1429
1430        #[test]
1431        fn test_increment() {
1432            assert_eq!(next_sequence_u64(0).unwrap(), 1);
1433
1434            assert_eq!(next_sequence_u64(12345).unwrap(), 12346);
1435        }
1436
1437        #[test]
1438        fn test_error_path_overflow_i64_max() {
1439            let result = next_sequence_u64(i64::MAX);
1440            assert!(result.is_err());
1441            match result.unwrap_err() {
1442                RelayerError::ProviderError(msg) => assert_eq!(msg, "sequence overflow"),
1443                _ => panic!("Unexpected error type"),
1444            }
1445        }
1446    }
1447
1448    mod i64_from_u64_tests {
1449        use super::*;
1450
1451        #[test]
1452        fn test_happy_path_conversion() {
1453            assert_eq!(i64_from_u64(0).unwrap(), 0);
1454            assert_eq!(i64_from_u64(12345).unwrap(), 12345);
1455            assert_eq!(i64_from_u64(i64::MAX as u64).unwrap(), i64::MAX);
1456        }
1457
1458        #[test]
1459        fn test_error_path_overflow_u64_max() {
1460            let result = i64_from_u64(u64::MAX);
1461            assert!(result.is_err());
1462            match result.unwrap_err() {
1463                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1464                _ => panic!("Unexpected error type"),
1465            }
1466        }
1467
1468        #[test]
1469        fn test_edge_case_just_above_i64_max() {
1470            // Smallest u64 value that will overflow i64
1471            let value = (i64::MAX as u64) + 1;
1472            let result = i64_from_u64(value);
1473            assert!(result.is_err());
1474            match result.unwrap_err() {
1475                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1476                _ => panic!("Unexpected error type"),
1477            }
1478        }
1479    }
1480
1481    mod is_bad_sequence_error_tests {
1482        use super::*;
1483
1484        #[test]
1485        fn test_detects_txbadseq() {
1486            assert!(is_bad_sequence_error(
1487                "Failed to send transaction: transaction submission failed: TxBadSeq"
1488            ));
1489            assert!(is_bad_sequence_error("Error: TxBadSeq"));
1490            assert!(is_bad_sequence_error("txbadseq"));
1491            assert!(is_bad_sequence_error("TXBADSEQ"));
1492        }
1493
1494        #[test]
1495        fn test_returns_false_for_other_errors() {
1496            assert!(!is_bad_sequence_error("network timeout"));
1497            assert!(!is_bad_sequence_error("insufficient balance"));
1498            assert!(!is_bad_sequence_error("tx_insufficient_fee"));
1499            assert!(!is_bad_sequence_error("bad_auth"));
1500            assert!(!is_bad_sequence_error(""));
1501        }
1502    }
1503
1504    mod decode_tx_result_code_tests {
1505        use super::*;
1506        use soroban_rs::xdr::{TransactionResult, TransactionResultResult, WriteXdr};
1507
1508        #[test]
1509        fn test_decodes_tx_bad_seq() {
1510            let result = TransactionResult {
1511                fee_charged: 100,
1512                result: TransactionResultResult::TxBadSeq,
1513                ext: soroban_rs::xdr::TransactionResultExt::V0,
1514            };
1515            let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1516            assert_eq!(decode_tx_result_code(&xdr), Some("TxBadSeq".to_string()));
1517        }
1518
1519        #[test]
1520        fn test_decodes_tx_insufficient_balance() {
1521            let result = TransactionResult {
1522                fee_charged: 100,
1523                result: TransactionResultResult::TxInsufficientBalance,
1524                ext: soroban_rs::xdr::TransactionResultExt::V0,
1525            };
1526            let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1527            assert_eq!(
1528                decode_tx_result_code(&xdr),
1529                Some("TxInsufficientBalance".to_string())
1530            );
1531        }
1532
1533        #[test]
1534        fn test_returns_none_for_invalid_xdr() {
1535            assert_eq!(decode_tx_result_code("not-valid-xdr"), None);
1536        }
1537
1538        #[test]
1539        fn test_returns_none_for_empty_string() {
1540            assert_eq!(decode_tx_result_code(""), None);
1541        }
1542    }
1543
1544    mod is_insufficient_fee_error_tests {
1545        use super::*;
1546
1547        #[test]
1548        fn test_detects_txinsufficientfee() {
1549            assert!(is_insufficient_fee_error("TxInsufficientFee"));
1550            assert!(is_insufficient_fee_error("txinsufficientfee"));
1551            assert!(is_insufficient_fee_error("TXINSUFFICIENTFEE"));
1552        }
1553
1554        #[test]
1555        fn test_returns_false_for_other_errors() {
1556            assert!(!is_insufficient_fee_error("network timeout"));
1557            assert!(!is_insufficient_fee_error("TxBadSeq"));
1558            assert!(!is_insufficient_fee_error("TxInsufficientBalance"));
1559            assert!(!is_insufficient_fee_error("TxBadAuth"));
1560            assert!(!is_insufficient_fee_error(""));
1561        }
1562    }
1563
1564    mod decode_transaction_result_code_tests {
1565        use super::*;
1566
1567        #[test]
1568        fn test_decodes_insufficient_fee_result_xdr() {
1569            let result_code = decode_transaction_result_code("AAAAAAAAY/n////3AAAAAA==").unwrap();
1570            assert_eq!(result_code, "TxInsufficientFee");
1571        }
1572
1573        #[test]
1574        fn test_returns_none_for_invalid_xdr() {
1575            assert!(decode_transaction_result_code("not-base64").is_none());
1576        }
1577    }
1578
1579    mod status_check_utils_tests {
1580        use crate::models::{
1581            NetworkTransactionData, StellarTransactionData, TransactionError, TransactionInput,
1582            TransactionRepoModel,
1583        };
1584        use crate::utils::mocks::mockutils::create_mock_transaction;
1585        use chrono::{Duration, Utc};
1586
1587        /// Helper to create a test transaction with a specific created_at timestamp
1588        fn create_test_tx_with_age(seconds_ago: i64) -> TransactionRepoModel {
1589            let created_at = (Utc::now() - Duration::seconds(seconds_ago)).to_rfc3339();
1590            let mut tx = create_mock_transaction();
1591            tx.id = format!("test-tx-{seconds_ago}");
1592            tx.created_at = created_at;
1593            tx.network_data = NetworkTransactionData::Stellar(StellarTransactionData {
1594                source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
1595                    .to_string(),
1596                fee: None,
1597                sequence_number: None,
1598                memo: None,
1599                valid_until: None,
1600                network_passphrase: "Test SDF Network ; September 2015".to_string(),
1601                signatures: vec![],
1602                hash: Some("test-hash-12345".to_string()),
1603                simulation_transaction_data: None,
1604                transaction_input: TransactionInput::Operations(vec![]),
1605                signed_envelope_xdr: None,
1606                transaction_result_xdr: None,
1607            });
1608            tx
1609        }
1610
1611        mod get_age_since_created_tests {
1612            use crate::domain::transaction::util::get_age_since_created;
1613
1614            use super::*;
1615
1616            #[test]
1617            fn test_returns_correct_age_for_recent_transaction() {
1618                let tx = create_test_tx_with_age(30); // 30 seconds ago
1619                let age = get_age_since_created(&tx).unwrap();
1620
1621                // Allow for small timing differences (within 1 second)
1622                assert!(age.num_seconds() >= 29 && age.num_seconds() <= 31);
1623            }
1624
1625            #[test]
1626            fn test_returns_correct_age_for_old_transaction() {
1627                let tx = create_test_tx_with_age(3600); // 1 hour ago
1628                let age = get_age_since_created(&tx).unwrap();
1629
1630                // Allow for small timing differences
1631                assert!(age.num_seconds() >= 3599 && age.num_seconds() <= 3601);
1632            }
1633
1634            #[test]
1635            fn test_returns_zero_age_for_just_created_transaction() {
1636                let tx = create_test_tx_with_age(0); // Just now
1637                let age = get_age_since_created(&tx).unwrap();
1638
1639                // Should be very close to 0
1640                assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
1641            }
1642
1643            #[test]
1644            fn test_handles_negative_age_gracefully() {
1645                // Create transaction with future timestamp (clock skew scenario)
1646                let created_at = (Utc::now() + Duration::seconds(10)).to_rfc3339();
1647                let mut tx = create_mock_transaction();
1648                tx.created_at = created_at;
1649
1650                let age = get_age_since_created(&tx).unwrap();
1651
1652                // Age should be negative
1653                assert!(age.num_seconds() < 0);
1654            }
1655
1656            #[test]
1657            fn test_returns_error_for_invalid_created_at() {
1658                let mut tx = create_mock_transaction();
1659                tx.created_at = "invalid-timestamp".to_string();
1660
1661                let result = get_age_since_created(&tx);
1662                assert!(result.is_err());
1663
1664                match result.unwrap_err() {
1665                    TransactionError::UnexpectedError(msg) => {
1666                        assert!(msg.contains("Invalid created_at timestamp"));
1667                    }
1668                    _ => panic!("Expected UnexpectedError"),
1669                }
1670            }
1671
1672            #[test]
1673            fn test_returns_error_for_empty_created_at() {
1674                let mut tx = create_mock_transaction();
1675                tx.created_at = "".to_string();
1676
1677                let result = get_age_since_created(&tx);
1678                assert!(result.is_err());
1679            }
1680
1681            #[test]
1682            fn test_handles_various_rfc3339_formats() {
1683                let mut tx = create_mock_transaction();
1684
1685                // Test with UTC timezone
1686                tx.created_at = "2025-01-01T12:00:00Z".to_string();
1687                assert!(get_age_since_created(&tx).is_ok());
1688
1689                // Test with offset timezone
1690                tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
1691                assert!(get_age_since_created(&tx).is_ok());
1692
1693                // Test with milliseconds
1694                tx.created_at = "2025-01-01T12:00:00.123Z".to_string();
1695                assert!(get_age_since_created(&tx).is_ok());
1696            }
1697        }
1698    }
1699
1700    #[test]
1701    fn test_create_signature_payload_functions() {
1702        use soroban_rs::xdr::{
1703            Hash, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope,
1704            Uint256,
1705        };
1706
1707        // Test create_transaction_signature_payload
1708        let transaction = soroban_rs::xdr::Transaction {
1709            source_account: soroban_rs::xdr::MuxedAccount::Ed25519(Uint256([1u8; 32])),
1710            fee: 100,
1711            seq_num: SequenceNumber(123),
1712            cond: soroban_rs::xdr::Preconditions::None,
1713            memo: soroban_rs::xdr::Memo::None,
1714            operations: vec![].try_into().unwrap(),
1715            ext: soroban_rs::xdr::TransactionExt::V0,
1716        };
1717        let network_id = Hash([2u8; 32]);
1718
1719        let payload = create_transaction_signature_payload(&transaction, &network_id);
1720        assert_eq!(payload.network_id, network_id);
1721
1722        // Test create_signature_payload with V0 envelope
1723        let v0_tx = TransactionV0 {
1724            source_account_ed25519: Uint256([1u8; 32]),
1725            fee: 100,
1726            seq_num: SequenceNumber(123),
1727            time_bounds: None,
1728            memo: soroban_rs::xdr::Memo::None,
1729            operations: vec![].try_into().unwrap(),
1730            ext: soroban_rs::xdr::TransactionV0Ext::V0,
1731        };
1732        let v0_envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
1733            tx: v0_tx,
1734            signatures: vec![].try_into().unwrap(),
1735        });
1736
1737        let v0_payload = create_signature_payload(&v0_envelope, &network_id).unwrap();
1738        assert_eq!(v0_payload.network_id, network_id);
1739    }
1740
1741    mod convert_v0_to_v1_transaction_tests {
1742        use super::*;
1743        use soroban_rs::xdr::{SequenceNumber, TransactionV0, Uint256};
1744
1745        #[test]
1746        fn test_convert_v0_to_v1_transaction() {
1747            // Create a simple V0 transaction
1748            let v0_tx = TransactionV0 {
1749                source_account_ed25519: Uint256([1u8; 32]),
1750                fee: 100,
1751                seq_num: SequenceNumber(123),
1752                time_bounds: None,
1753                memo: soroban_rs::xdr::Memo::None,
1754                operations: vec![].try_into().unwrap(),
1755                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1756            };
1757
1758            // Convert to V1
1759            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1760
1761            // Check that conversion worked correctly
1762            assert_eq!(v1_tx.fee, v0_tx.fee);
1763            assert_eq!(v1_tx.seq_num, v0_tx.seq_num);
1764            assert_eq!(v1_tx.memo, v0_tx.memo);
1765            assert_eq!(v1_tx.operations, v0_tx.operations);
1766            assert!(matches!(v1_tx.ext, soroban_rs::xdr::TransactionExt::V0));
1767            assert!(matches!(v1_tx.cond, soroban_rs::xdr::Preconditions::None));
1768
1769            // Check source account conversion
1770            match v1_tx.source_account {
1771                soroban_rs::xdr::MuxedAccount::Ed25519(addr) => {
1772                    assert_eq!(addr, v0_tx.source_account_ed25519);
1773                }
1774                _ => panic!("Expected Ed25519 muxed account"),
1775            }
1776        }
1777
1778        #[test]
1779        fn test_convert_v0_to_v1_transaction_with_time_bounds() {
1780            // Create a V0 transaction with time bounds
1781            let time_bounds = soroban_rs::xdr::TimeBounds {
1782                min_time: soroban_rs::xdr::TimePoint(100),
1783                max_time: soroban_rs::xdr::TimePoint(200),
1784            };
1785
1786            let v0_tx = TransactionV0 {
1787                source_account_ed25519: Uint256([2u8; 32]),
1788                fee: 200,
1789                seq_num: SequenceNumber(456),
1790                time_bounds: Some(time_bounds.clone()),
1791                memo: soroban_rs::xdr::Memo::Text("test".try_into().unwrap()),
1792                operations: vec![].try_into().unwrap(),
1793                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1794            };
1795
1796            // Convert to V1
1797            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1798
1799            // Check that time bounds were correctly converted to preconditions
1800            match v1_tx.cond {
1801                soroban_rs::xdr::Preconditions::Time(tb) => {
1802                    assert_eq!(tb, time_bounds);
1803                }
1804                _ => panic!("Expected Time preconditions"),
1805            }
1806        }
1807    }
1808}
1809
1810#[cfg(test)]
1811mod parse_contract_address_tests {
1812    use super::*;
1813    use crate::domain::transaction::stellar::test_helpers::{
1814        TEST_CONTRACT, TEST_PK as TEST_ACCOUNT,
1815    };
1816
1817    #[test]
1818    fn test_parse_valid_contract_address() {
1819        let result = parse_contract_address(TEST_CONTRACT);
1820        assert!(result.is_ok());
1821
1822        let hash = result.unwrap();
1823        assert_eq!(hash.0.len(), 32);
1824    }
1825
1826    #[test]
1827    fn test_parse_invalid_contract_address() {
1828        let result = parse_contract_address("INVALID_CONTRACT");
1829        assert!(result.is_err());
1830
1831        match result.unwrap_err() {
1832            StellarTransactionUtilsError::InvalidContractAddress(addr, _) => {
1833                assert_eq!(addr, "INVALID_CONTRACT");
1834            }
1835            _ => panic!("Expected InvalidContractAddress error"),
1836        }
1837    }
1838
1839    #[test]
1840    fn test_parse_contract_address_wrong_prefix() {
1841        // Try with an account address instead of contract
1842        let result = parse_contract_address(TEST_ACCOUNT);
1843        assert!(result.is_err());
1844    }
1845
1846    #[test]
1847    fn test_parse_empty_contract_address() {
1848        let result = parse_contract_address("");
1849        assert!(result.is_err());
1850    }
1851}
1852
1853// ============================================================================
1854// Update Envelope Sequence and Envelope Fee Tests
1855// ============================================================================
1856
1857#[cfg(test)]
1858mod update_envelope_sequence_tests {
1859    use super::*;
1860    use soroban_rs::xdr::{
1861        FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt,
1862        FeeBumpTransactionInnerTx, Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction,
1863        TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext,
1864        TransactionV1Envelope, Uint256, VecM,
1865    };
1866
1867    fn create_minimal_v1_envelope() -> TransactionEnvelope {
1868        let tx = Transaction {
1869            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
1870            fee: 100,
1871            seq_num: SequenceNumber(0),
1872            cond: Preconditions::None,
1873            memo: Memo::None,
1874            operations: VecM::default(),
1875            ext: TransactionExt::V0,
1876        };
1877        TransactionEnvelope::Tx(TransactionV1Envelope {
1878            tx,
1879            signatures: VecM::default(),
1880        })
1881    }
1882
1883    fn create_v0_envelope() -> TransactionEnvelope {
1884        let tx = TransactionV0 {
1885            source_account_ed25519: Uint256([0u8; 32]),
1886            fee: 100,
1887            seq_num: SequenceNumber(0),
1888            time_bounds: None,
1889            memo: Memo::None,
1890            operations: VecM::default(),
1891            ext: TransactionV0Ext::V0,
1892        };
1893        TransactionEnvelope::TxV0(TransactionV0Envelope {
1894            tx,
1895            signatures: VecM::default(),
1896        })
1897    }
1898
1899    fn create_fee_bump_envelope() -> TransactionEnvelope {
1900        let inner_tx = Transaction {
1901            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
1902            fee: 100,
1903            seq_num: SequenceNumber(0),
1904            cond: Preconditions::None,
1905            memo: Memo::None,
1906            operations: VecM::default(),
1907            ext: TransactionExt::V0,
1908        };
1909        let inner_envelope = TransactionV1Envelope {
1910            tx: inner_tx,
1911            signatures: VecM::default(),
1912        };
1913        let fee_bump_tx = FeeBumpTransaction {
1914            fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])),
1915            fee: 200,
1916            inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope),
1917            ext: FeeBumpTransactionExt::V0,
1918        };
1919        TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
1920            tx: fee_bump_tx,
1921            signatures: VecM::default(),
1922        })
1923    }
1924
1925    #[test]
1926    fn test_update_envelope_sequence() {
1927        let mut envelope = create_minimal_v1_envelope();
1928        update_envelope_sequence(&mut envelope, 12345).unwrap();
1929        if let TransactionEnvelope::Tx(v1) = &envelope {
1930            assert_eq!(v1.tx.seq_num.0, 12345);
1931        } else {
1932            panic!("Expected Tx envelope");
1933        }
1934    }
1935
1936    #[test]
1937    fn test_update_envelope_sequence_v0_returns_error() {
1938        let mut envelope = create_v0_envelope();
1939        let result = update_envelope_sequence(&mut envelope, 12345);
1940        assert!(result.is_err());
1941        match result.unwrap_err() {
1942            StellarTransactionUtilsError::V0TransactionsNotSupported => {}
1943            _ => panic!("Expected V0TransactionsNotSupported error"),
1944        }
1945    }
1946
1947    #[test]
1948    fn test_update_envelope_sequence_fee_bump_returns_error() {
1949        let mut envelope = create_fee_bump_envelope();
1950        let result = update_envelope_sequence(&mut envelope, 12345);
1951        assert!(result.is_err());
1952        match result.unwrap_err() {
1953            StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => {}
1954            _ => panic!("Expected CannotUpdateSequenceOnFeeBump error"),
1955        }
1956    }
1957
1958    #[test]
1959    fn test_update_envelope_sequence_zero() {
1960        let mut envelope = create_minimal_v1_envelope();
1961        update_envelope_sequence(&mut envelope, 0).unwrap();
1962        if let TransactionEnvelope::Tx(v1) = &envelope {
1963            assert_eq!(v1.tx.seq_num.0, 0);
1964        } else {
1965            panic!("Expected Tx envelope");
1966        }
1967    }
1968
1969    #[test]
1970    fn test_update_envelope_sequence_max_value() {
1971        let mut envelope = create_minimal_v1_envelope();
1972        update_envelope_sequence(&mut envelope, i64::MAX).unwrap();
1973        if let TransactionEnvelope::Tx(v1) = &envelope {
1974            assert_eq!(v1.tx.seq_num.0, i64::MAX);
1975        } else {
1976            panic!("Expected Tx envelope");
1977        }
1978    }
1979
1980    #[test]
1981    fn test_envelope_fee_in_stroops_v1() {
1982        let envelope = create_minimal_v1_envelope();
1983        let fee = envelope_fee_in_stroops(&envelope).unwrap();
1984        assert_eq!(fee, 100);
1985    }
1986
1987    #[test]
1988    fn test_envelope_fee_in_stroops_v0_returns_error() {
1989        let envelope = create_v0_envelope();
1990        let result = envelope_fee_in_stroops(&envelope);
1991        assert!(result.is_err());
1992        match result.unwrap_err() {
1993            StellarTransactionUtilsError::InvalidTransactionFormat(msg) => {
1994                assert!(msg.contains("Expected V1"));
1995            }
1996            _ => panic!("Expected InvalidTransactionFormat error"),
1997        }
1998    }
1999
2000    #[test]
2001    fn test_envelope_fee_in_stroops_fee_bump_returns_error() {
2002        let envelope = create_fee_bump_envelope();
2003        let result = envelope_fee_in_stroops(&envelope);
2004        assert!(result.is_err());
2005    }
2006}
2007
2008// ============================================================================
2009// Contract Data Key Tests
2010// ============================================================================
2011
2012#[cfg(test)]
2013mod create_contract_data_key_tests {
2014    use super::*;
2015    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
2016    use stellar_strkey::ed25519::PublicKey;
2017
2018    #[test]
2019    fn test_create_key_without_address() {
2020        let result = create_contract_data_key("Balance", None);
2021        assert!(result.is_ok());
2022
2023        match result.unwrap() {
2024            ScVal::Symbol(sym) => {
2025                assert_eq!(sym.to_string(), "Balance");
2026            }
2027            _ => panic!("Expected Symbol ScVal"),
2028        }
2029    }
2030
2031    #[test]
2032    fn test_create_key_with_address() {
2033        let pk = PublicKey::from_string(TEST_ACCOUNT).unwrap();
2034        let uint256 = Uint256(pk.0);
2035        let account_id = AccountId(soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(uint256));
2036        let sc_address = ScAddress::Account(account_id);
2037
2038        let result = create_contract_data_key("Balance", Some(sc_address.clone()));
2039        assert!(result.is_ok());
2040
2041        match result.unwrap() {
2042            ScVal::Vec(Some(vec)) => {
2043                assert_eq!(vec.0.len(), 2);
2044                match &vec.0[0] {
2045                    ScVal::Symbol(sym) => assert_eq!(sym.to_string(), "Balance"),
2046                    _ => panic!("Expected Symbol as first element"),
2047                }
2048                match &vec.0[1] {
2049                    ScVal::Address(addr) => assert_eq!(addr, &sc_address),
2050                    _ => panic!("Expected Address as second element"),
2051                }
2052            }
2053            _ => panic!("Expected Vec ScVal"),
2054        }
2055    }
2056
2057    #[test]
2058    fn test_create_key_invalid_symbol() {
2059        // Test with symbol that's too long or has invalid characters
2060        let very_long_symbol = "a".repeat(100);
2061        let result = create_contract_data_key(&very_long_symbol, None);
2062        assert!(result.is_err());
2063
2064        match result.unwrap_err() {
2065            StellarTransactionUtilsError::SymbolCreationFailed(_, _) => {}
2066            _ => panic!("Expected SymbolCreationFailed error"),
2067        }
2068    }
2069
2070    #[test]
2071    fn test_create_key_decimals() {
2072        let result = create_contract_data_key("Decimals", None);
2073        assert!(result.is_ok());
2074    }
2075}
2076
2077// ============================================================================
2078// Extract ScVal from Contract Data Tests
2079// ============================================================================
2080
2081#[cfg(test)]
2082mod extract_scval_from_contract_data_tests {
2083    use super::*;
2084    use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2085    use soroban_rs::xdr::{
2086        ContractDataDurability, ContractDataEntry, ExtensionPoint, Hash, LedgerEntry,
2087        LedgerEntryData, LedgerEntryExt, ScSymbol, ScVal, WriteXdr,
2088    };
2089
2090    #[test]
2091    fn test_extract_scval_success() {
2092        let contract_data = ContractDataEntry {
2093            ext: ExtensionPoint::V0,
2094            contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
2095            key: ScVal::Symbol(ScSymbol::try_from("test").unwrap()),
2096            durability: ContractDataDurability::Persistent,
2097            val: ScVal::U32(42),
2098        };
2099
2100        let ledger_entry = LedgerEntry {
2101            last_modified_ledger_seq: 100,
2102            data: LedgerEntryData::ContractData(contract_data),
2103            ext: LedgerEntryExt::V0,
2104        };
2105
2106        let xdr = ledger_entry
2107            .data
2108            .to_xdr_base64(soroban_rs::xdr::Limits::none())
2109            .unwrap();
2110
2111        let response = GetLedgerEntriesResponse {
2112            entries: Some(vec![LedgerEntryResult {
2113                key: "test_key".to_string(),
2114                xdr,
2115                last_modified_ledger: 100,
2116                live_until_ledger_seq_ledger_seq: None,
2117            }]),
2118            latest_ledger: 100,
2119        };
2120
2121        let result = extract_scval_from_contract_data(&response, "test");
2122        assert!(result.is_ok());
2123
2124        match result.unwrap() {
2125            ScVal::U32(val) => assert_eq!(val, 42),
2126            _ => panic!("Expected U32 ScVal"),
2127        }
2128    }
2129
2130    #[test]
2131    fn test_extract_scval_no_entries() {
2132        let response = GetLedgerEntriesResponse {
2133            entries: None,
2134            latest_ledger: 100,
2135        };
2136
2137        let result = extract_scval_from_contract_data(&response, "test");
2138        assert!(result.is_err());
2139
2140        match result.unwrap_err() {
2141            StellarTransactionUtilsError::NoEntriesFound(_) => {}
2142            _ => panic!("Expected NoEntriesFound error"),
2143        }
2144    }
2145
2146    #[test]
2147    fn test_extract_scval_empty_entries() {
2148        let response = GetLedgerEntriesResponse {
2149            entries: Some(vec![]),
2150            latest_ledger: 100,
2151        };
2152
2153        let result = extract_scval_from_contract_data(&response, "test");
2154        assert!(result.is_err());
2155
2156        match result.unwrap_err() {
2157            StellarTransactionUtilsError::EmptyEntries(_) => {}
2158            _ => panic!("Expected EmptyEntries error"),
2159        }
2160    }
2161}
2162
2163// ============================================================================
2164// Extract u32 from ScVal Tests
2165// ============================================================================
2166
2167#[cfg(test)]
2168mod extract_u32_from_scval_tests {
2169    use super::*;
2170    use soroban_rs::xdr::{Int128Parts, ScVal, UInt128Parts};
2171
2172    #[test]
2173    fn test_extract_from_u32() {
2174        let val = ScVal::U32(42);
2175        assert_eq!(extract_u32_from_scval(&val, "test"), Some(42));
2176    }
2177
2178    #[test]
2179    fn test_extract_from_i32_positive() {
2180        let val = ScVal::I32(100);
2181        assert_eq!(extract_u32_from_scval(&val, "test"), Some(100));
2182    }
2183
2184    #[test]
2185    fn test_extract_from_i32_negative() {
2186        let val = ScVal::I32(-1);
2187        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2188    }
2189
2190    #[test]
2191    fn test_extract_from_u64() {
2192        let val = ScVal::U64(1000);
2193        assert_eq!(extract_u32_from_scval(&val, "test"), Some(1000));
2194    }
2195
2196    #[test]
2197    fn test_extract_from_u64_overflow() {
2198        let val = ScVal::U64(u64::MAX);
2199        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2200    }
2201
2202    #[test]
2203    fn test_extract_from_i64_positive() {
2204        let val = ScVal::I64(500);
2205        assert_eq!(extract_u32_from_scval(&val, "test"), Some(500));
2206    }
2207
2208    #[test]
2209    fn test_extract_from_i64_negative() {
2210        let val = ScVal::I64(-500);
2211        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2212    }
2213
2214    #[test]
2215    fn test_extract_from_u128_small() {
2216        let val = ScVal::U128(UInt128Parts { hi: 0, lo: 255 });
2217        assert_eq!(extract_u32_from_scval(&val, "test"), Some(255));
2218    }
2219
2220    #[test]
2221    fn test_extract_from_u128_hi_set() {
2222        let val = ScVal::U128(UInt128Parts { hi: 1, lo: 0 });
2223        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2224    }
2225
2226    #[test]
2227    fn test_extract_from_i128_small() {
2228        let val = ScVal::I128(Int128Parts { hi: 0, lo: 123 });
2229        assert_eq!(extract_u32_from_scval(&val, "test"), Some(123));
2230    }
2231
2232    #[test]
2233    fn test_extract_from_unsupported_type() {
2234        let val = ScVal::Bool(true);
2235        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2236    }
2237}
2238
2239// ============================================================================
2240// Amount to UI Amount Tests
2241// ============================================================================
2242
2243#[cfg(test)]
2244mod amount_to_ui_amount_tests {
2245    use super::*;
2246
2247    #[test]
2248    fn test_zero_decimals() {
2249        assert_eq!(amount_to_ui_amount(100, 0), "100");
2250        assert_eq!(amount_to_ui_amount(0, 0), "0");
2251    }
2252
2253    #[test]
2254    fn test_with_decimals_no_padding() {
2255        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
2256        assert_eq!(amount_to_ui_amount(1500000, 6), "1.5");
2257        assert_eq!(amount_to_ui_amount(1234567, 6), "1.234567");
2258    }
2259
2260    #[test]
2261    fn test_with_decimals_needs_padding() {
2262        assert_eq!(amount_to_ui_amount(1, 6), "0.000001");
2263        assert_eq!(amount_to_ui_amount(100, 6), "0.0001");
2264        assert_eq!(amount_to_ui_amount(1000, 3), "1");
2265    }
2266
2267    #[test]
2268    fn test_trailing_zeros_removed() {
2269        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
2270        assert_eq!(amount_to_ui_amount(1500000, 7), "0.15");
2271        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
2272    }
2273
2274    #[test]
2275    fn test_zero_amount() {
2276        assert_eq!(amount_to_ui_amount(0, 6), "0");
2277        assert_eq!(amount_to_ui_amount(0, 0), "0");
2278    }
2279
2280    #[test]
2281    fn test_xlm_7_decimals() {
2282        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
2283        assert_eq!(amount_to_ui_amount(15000000, 7), "1.5");
2284        assert_eq!(amount_to_ui_amount(100, 7), "0.00001");
2285    }
2286}
2287
2288// // ============================================================================
2289// // Count Operations Tests
2290// // ============================================================================
2291
2292// #[cfg(test)]
2293#[cfg(test)]
2294mod count_operations_tests {
2295    use super::*;
2296    use soroban_rs::xdr::{
2297        Limits, MuxedAccount, Operation, OperationBody, PaymentOp, TransactionV1Envelope, Uint256,
2298        WriteXdr,
2299    };
2300
2301    #[test]
2302    fn test_count_operations_from_xdr() {
2303        use soroban_rs::xdr::{Memo, Preconditions, SequenceNumber, Transaction, TransactionExt};
2304
2305        // Create two payment operations
2306        let payment_op = Operation {
2307            source_account: None,
2308            body: OperationBody::Payment(PaymentOp {
2309                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2310                asset: Asset::Native,
2311                amount: 100,
2312            }),
2313        };
2314
2315        let operations = vec![payment_op.clone(), payment_op].try_into().unwrap();
2316
2317        let tx = Transaction {
2318            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2319            fee: 100,
2320            seq_num: SequenceNumber(1),
2321            cond: Preconditions::None,
2322            memo: Memo::None,
2323            operations,
2324            ext: TransactionExt::V0,
2325        };
2326
2327        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2328            tx,
2329            signatures: vec![].try_into().unwrap(),
2330        });
2331
2332        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2333        let count = count_operations_from_xdr(&xdr).unwrap();
2334
2335        assert_eq!(count, 2);
2336    }
2337
2338    #[test]
2339    fn test_count_operations_invalid_xdr() {
2340        let result = count_operations_from_xdr("invalid_xdr");
2341        assert!(result.is_err());
2342
2343        match result.unwrap_err() {
2344            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2345            _ => panic!("Expected XdrParseFailed error"),
2346        }
2347    }
2348}
2349
2350// ============================================================================
2351// Estimate Base Fee Tests
2352// ============================================================================
2353
2354#[cfg(test)]
2355mod estimate_base_fee_tests {
2356    use super::*;
2357
2358    #[test]
2359    fn test_single_operation() {
2360        assert_eq!(estimate_base_fee(1), 100);
2361    }
2362
2363    #[test]
2364    fn test_multiple_operations() {
2365        assert_eq!(estimate_base_fee(5), 500);
2366        assert_eq!(estimate_base_fee(10), 1000);
2367    }
2368
2369    #[test]
2370    fn test_zero_operations() {
2371        // Should return fee for at least 1 operation
2372        assert_eq!(estimate_base_fee(0), 100);
2373    }
2374
2375    #[test]
2376    fn test_large_number_of_operations() {
2377        assert_eq!(estimate_base_fee(100), 10000);
2378    }
2379}
2380
2381// ============================================================================
2382// Create Fee Payment Operation Tests
2383// ============================================================================
2384
2385#[cfg(test)]
2386mod create_fee_payment_operation_tests {
2387    use super::*;
2388    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
2389
2390    #[test]
2391    fn test_create_native_payment() {
2392        let result = create_fee_payment_operation(TEST_ACCOUNT, "native", 1000);
2393        assert!(result.is_ok());
2394
2395        match result.unwrap() {
2396            OperationSpec::Payment {
2397                destination,
2398                amount,
2399                asset,
2400            } => {
2401                assert_eq!(destination, TEST_ACCOUNT);
2402                assert_eq!(amount, 1000);
2403                assert!(matches!(asset, AssetSpec::Native));
2404            }
2405            _ => panic!("Expected Payment operation"),
2406        }
2407    }
2408
2409    #[test]
2410    fn test_create_credit4_payment() {
2411        let result = create_fee_payment_operation(
2412            TEST_ACCOUNT,
2413            "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2414            5000,
2415        );
2416        assert!(result.is_ok());
2417
2418        match result.unwrap() {
2419            OperationSpec::Payment {
2420                destination,
2421                amount,
2422                asset,
2423            } => {
2424                assert_eq!(destination, TEST_ACCOUNT);
2425                assert_eq!(amount, 5000);
2426                match asset {
2427                    AssetSpec::Credit4 { code, issuer } => {
2428                        assert_eq!(code, "USDC");
2429                        assert_eq!(
2430                            issuer,
2431                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2432                        );
2433                    }
2434                    _ => panic!("Expected Credit4 asset"),
2435                }
2436            }
2437            _ => panic!("Expected Payment operation"),
2438        }
2439    }
2440
2441    #[test]
2442    fn test_create_credit12_payment() {
2443        let result = create_fee_payment_operation(
2444            TEST_ACCOUNT,
2445            "LONGASSETNAM:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2446            2000,
2447        );
2448        assert!(result.is_ok());
2449
2450        match result.unwrap() {
2451            OperationSpec::Payment {
2452                destination,
2453                amount,
2454                asset,
2455            } => {
2456                assert_eq!(destination, TEST_ACCOUNT);
2457                assert_eq!(amount, 2000);
2458                match asset {
2459                    AssetSpec::Credit12 { code, issuer } => {
2460                        assert_eq!(code, "LONGASSETNAM");
2461                        assert_eq!(
2462                            issuer,
2463                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2464                        );
2465                    }
2466                    _ => panic!("Expected Credit12 asset"),
2467                }
2468            }
2469            _ => panic!("Expected Payment operation"),
2470        }
2471    }
2472
2473    #[test]
2474    fn test_create_payment_empty_asset() {
2475        let result = create_fee_payment_operation(TEST_ACCOUNT, "", 1000);
2476        assert!(result.is_ok());
2477
2478        match result.unwrap() {
2479            OperationSpec::Payment { asset, .. } => {
2480                assert!(matches!(asset, AssetSpec::Native));
2481            }
2482            _ => panic!("Expected Payment operation"),
2483        }
2484    }
2485
2486    #[test]
2487    fn test_create_payment_invalid_format() {
2488        let result = create_fee_payment_operation(TEST_ACCOUNT, "INVALID_FORMAT", 1000);
2489        assert!(result.is_err());
2490
2491        match result.unwrap_err() {
2492            StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
2493            _ => panic!("Expected InvalidAssetFormat error"),
2494        }
2495    }
2496
2497    #[test]
2498    fn test_create_payment_asset_code_too_long() {
2499        let result = create_fee_payment_operation(
2500            TEST_ACCOUNT,
2501            "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2502            1000,
2503        );
2504        assert!(result.is_err());
2505
2506        match result.unwrap_err() {
2507            StellarTransactionUtilsError::AssetCodeTooLong(max_len, _) => {
2508                assert_eq!(max_len, 12);
2509            }
2510            _ => panic!("Expected AssetCodeTooLong error"),
2511        }
2512    }
2513}
2514
2515#[cfg(test)]
2516mod parse_account_id_tests {
2517    use super::*;
2518    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
2519
2520    #[test]
2521    fn test_parse_account_id_valid() {
2522        let result = parse_account_id(TEST_PK);
2523        assert!(result.is_ok());
2524
2525        let account_id = result.unwrap();
2526        match account_id.0 {
2527            soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(_) => {}
2528        }
2529    }
2530
2531    #[test]
2532    fn test_parse_account_id_invalid() {
2533        let result = parse_account_id("INVALID_ADDRESS");
2534        assert!(result.is_err());
2535
2536        match result.unwrap_err() {
2537            StellarTransactionUtilsError::InvalidAccountAddress(addr, _) => {
2538                assert_eq!(addr, "INVALID_ADDRESS");
2539            }
2540            _ => panic!("Expected InvalidAccountAddress error"),
2541        }
2542    }
2543
2544    #[test]
2545    fn test_parse_account_id_empty() {
2546        let result = parse_account_id("");
2547        assert!(result.is_err());
2548    }
2549
2550    #[test]
2551    fn test_parse_account_id_wrong_prefix() {
2552        // Contract address instead of account
2553        let result = parse_account_id("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM");
2554        assert!(result.is_err());
2555    }
2556}
2557
2558#[cfg(test)]
2559mod parse_transaction_and_count_operations_tests {
2560    use super::*;
2561    use crate::domain::transaction::stellar::test_helpers::{
2562        create_native_payment_operation, create_xdr_with_operations, TEST_PK, TEST_PK_2,
2563    };
2564    use serde_json::json;
2565
2566    fn create_test_xdr_with_operations(num_ops: usize) -> String {
2567        let payment_op = create_native_payment_operation(TEST_PK_2, 100);
2568        let operations = vec![payment_op; num_ops];
2569        create_xdr_with_operations(TEST_PK, operations, false)
2570    }
2571
2572    #[test]
2573    fn test_parse_xdr_string() {
2574        let xdr = create_test_xdr_with_operations(2);
2575        let json_value = json!(xdr);
2576
2577        let result = parse_transaction_and_count_operations(&json_value);
2578        assert!(result.is_ok());
2579        assert_eq!(result.unwrap(), 2);
2580    }
2581
2582    #[test]
2583    fn test_parse_operations_array() {
2584        let json_value = json!([
2585            {"type": "payment"},
2586            {"type": "payment"},
2587            {"type": "payment"}
2588        ]);
2589
2590        let result = parse_transaction_and_count_operations(&json_value);
2591        assert!(result.is_ok());
2592        assert_eq!(result.unwrap(), 3);
2593    }
2594
2595    #[test]
2596    fn test_parse_object_with_operations() {
2597        let json_value = json!({
2598            "operations": [
2599                {"type": "payment"},
2600                {"type": "payment"}
2601            ]
2602        });
2603
2604        let result = parse_transaction_and_count_operations(&json_value);
2605        assert!(result.is_ok());
2606        assert_eq!(result.unwrap(), 2);
2607    }
2608
2609    #[test]
2610    fn test_parse_object_with_transaction_xdr() {
2611        let xdr = create_test_xdr_with_operations(3);
2612        let json_value = json!({
2613            "transaction_xdr": xdr
2614        });
2615
2616        let result = parse_transaction_and_count_operations(&json_value);
2617        assert!(result.is_ok());
2618        assert_eq!(result.unwrap(), 3);
2619    }
2620
2621    #[test]
2622    fn test_parse_invalid_xdr() {
2623        let json_value = json!("INVALID_XDR");
2624
2625        let result = parse_transaction_and_count_operations(&json_value);
2626        assert!(result.is_err());
2627
2628        match result.unwrap_err() {
2629            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2630            _ => panic!("Expected XdrParseFailed error"),
2631        }
2632    }
2633
2634    #[test]
2635    fn test_parse_invalid_format() {
2636        let json_value = json!(123);
2637
2638        let result = parse_transaction_and_count_operations(&json_value);
2639        assert!(result.is_err());
2640
2641        match result.unwrap_err() {
2642            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2643            _ => panic!("Expected InvalidTransactionFormat error"),
2644        }
2645    }
2646
2647    #[test]
2648    fn test_parse_empty_operations() {
2649        let json_value = json!([]);
2650
2651        let result = parse_transaction_and_count_operations(&json_value);
2652        assert!(result.is_ok());
2653        assert_eq!(result.unwrap(), 0);
2654    }
2655}
2656
2657#[cfg(test)]
2658mod parse_transaction_envelope_tests {
2659    use super::*;
2660    use crate::domain::transaction::stellar::test_helpers::{
2661        create_unsigned_xdr, TEST_PK, TEST_PK_2,
2662    };
2663    use serde_json::json;
2664
2665    fn create_test_xdr() -> String {
2666        create_unsigned_xdr(TEST_PK, TEST_PK_2)
2667    }
2668
2669    #[test]
2670    fn test_parse_xdr_string() {
2671        let xdr = create_test_xdr();
2672        let json_value = json!(xdr);
2673
2674        let result = parse_transaction_envelope(&json_value);
2675        assert!(result.is_ok());
2676
2677        match result.unwrap() {
2678            TransactionEnvelope::Tx(_) => {}
2679            _ => panic!("Expected Tx envelope"),
2680        }
2681    }
2682
2683    #[test]
2684    fn test_parse_object_with_transaction_xdr() {
2685        let xdr = create_test_xdr();
2686        let json_value = json!({
2687            "transaction_xdr": xdr
2688        });
2689
2690        let result = parse_transaction_envelope(&json_value);
2691        assert!(result.is_ok());
2692
2693        match result.unwrap() {
2694            TransactionEnvelope::Tx(_) => {}
2695            _ => panic!("Expected Tx envelope"),
2696        }
2697    }
2698
2699    #[test]
2700    fn test_parse_invalid_xdr() {
2701        let json_value = json!("INVALID_XDR");
2702
2703        let result = parse_transaction_envelope(&json_value);
2704        assert!(result.is_err());
2705
2706        match result.unwrap_err() {
2707            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2708            _ => panic!("Expected XdrParseFailed error"),
2709        }
2710    }
2711
2712    #[test]
2713    fn test_parse_invalid_format() {
2714        let json_value = json!(123);
2715
2716        let result = parse_transaction_envelope(&json_value);
2717        assert!(result.is_err());
2718
2719        match result.unwrap_err() {
2720            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2721            _ => panic!("Expected InvalidTransactionFormat error"),
2722        }
2723    }
2724
2725    #[test]
2726    fn test_parse_object_without_xdr() {
2727        let json_value = json!({
2728            "some_field": "value"
2729        });
2730
2731        let result = parse_transaction_envelope(&json_value);
2732        assert!(result.is_err());
2733
2734        match result.unwrap_err() {
2735            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2736            _ => panic!("Expected InvalidTransactionFormat error"),
2737        }
2738    }
2739}
2740
2741#[cfg(test)]
2742mod add_operation_to_envelope_tests {
2743    use super::*;
2744    use soroban_rs::xdr::{
2745        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2746        Transaction, TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV1Envelope,
2747        Uint256,
2748    };
2749
2750    fn create_payment_op() -> Operation {
2751        Operation {
2752            source_account: None,
2753            body: OperationBody::Payment(PaymentOp {
2754                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2755                asset: Asset::Native,
2756                amount: 100,
2757            }),
2758        }
2759    }
2760
2761    #[test]
2762    fn test_add_operation_to_tx_v0() {
2763        let payment_op = create_payment_op();
2764        let operations = vec![payment_op.clone()].try_into().unwrap();
2765
2766        let tx = TransactionV0 {
2767            source_account_ed25519: Uint256([0u8; 32]),
2768            fee: 100,
2769            seq_num: SequenceNumber(1),
2770            time_bounds: None,
2771            memo: Memo::None,
2772            operations,
2773            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2774        };
2775
2776        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2777            tx,
2778            signatures: vec![].try_into().unwrap(),
2779        });
2780
2781        let new_op = create_payment_op();
2782        let result = add_operation_to_envelope(&mut envelope, new_op);
2783
2784        assert!(result.is_ok());
2785
2786        match envelope {
2787            TransactionEnvelope::TxV0(e) => {
2788                assert_eq!(e.tx.operations.len(), 2);
2789                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2790            }
2791            _ => panic!("Expected TxV0 envelope"),
2792        }
2793    }
2794
2795    #[test]
2796    fn test_add_operation_to_tx_v1() {
2797        let payment_op = create_payment_op();
2798        let operations = vec![payment_op.clone()].try_into().unwrap();
2799
2800        let tx = Transaction {
2801            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2802            fee: 100,
2803            seq_num: SequenceNumber(1),
2804            cond: Preconditions::None,
2805            memo: Memo::None,
2806            operations,
2807            ext: TransactionExt::V0,
2808        };
2809
2810        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2811            tx,
2812            signatures: vec![].try_into().unwrap(),
2813        });
2814
2815        let new_op = create_payment_op();
2816        let result = add_operation_to_envelope(&mut envelope, new_op);
2817
2818        assert!(result.is_ok());
2819
2820        match envelope {
2821            TransactionEnvelope::Tx(e) => {
2822                assert_eq!(e.tx.operations.len(), 2);
2823                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2824            }
2825            _ => panic!("Expected Tx envelope"),
2826        }
2827    }
2828
2829    #[test]
2830    fn test_add_operation_to_fee_bump_fails() {
2831        // Create a simple inner transaction
2832        let payment_op = create_payment_op();
2833        let operations = vec![payment_op].try_into().unwrap();
2834
2835        let tx = Transaction {
2836            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2837            fee: 100,
2838            seq_num: SequenceNumber(1),
2839            cond: Preconditions::None,
2840            memo: Memo::None,
2841            operations,
2842            ext: TransactionExt::V0,
2843        };
2844
2845        let inner_envelope = TransactionV1Envelope {
2846            tx,
2847            signatures: vec![].try_into().unwrap(),
2848        };
2849
2850        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
2851
2852        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
2853            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
2854            fee: 200,
2855            inner_tx,
2856            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
2857        };
2858
2859        let mut envelope =
2860            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
2861                tx: fee_bump_tx,
2862                signatures: vec![].try_into().unwrap(),
2863            });
2864
2865        let new_op = create_payment_op();
2866        let result = add_operation_to_envelope(&mut envelope, new_op);
2867
2868        assert!(result.is_err());
2869
2870        match result.unwrap_err() {
2871            StellarTransactionUtilsError::CannotModifyFeeBump => {}
2872            _ => panic!("Expected CannotModifyFeeBump error"),
2873        }
2874    }
2875}
2876
2877#[cfg(test)]
2878mod extract_time_bounds_tests {
2879    use super::*;
2880    use soroban_rs::xdr::{
2881        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2882        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
2883        TransactionV1Envelope, Uint256,
2884    };
2885
2886    fn create_payment_op() -> Operation {
2887        Operation {
2888            source_account: None,
2889            body: OperationBody::Payment(PaymentOp {
2890                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2891                asset: Asset::Native,
2892                amount: 100,
2893            }),
2894        }
2895    }
2896
2897    #[test]
2898    fn test_extract_time_bounds_from_tx_v0_with_bounds() {
2899        let payment_op = create_payment_op();
2900        let operations = vec![payment_op].try_into().unwrap();
2901
2902        let time_bounds = TimeBounds {
2903            min_time: TimePoint(0),
2904            max_time: TimePoint(1000),
2905        };
2906
2907        let tx = TransactionV0 {
2908            source_account_ed25519: Uint256([0u8; 32]),
2909            fee: 100,
2910            seq_num: SequenceNumber(1),
2911            time_bounds: Some(time_bounds.clone()),
2912            memo: Memo::None,
2913            operations,
2914            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2915        };
2916
2917        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2918            tx,
2919            signatures: vec![].try_into().unwrap(),
2920        });
2921
2922        let result = extract_time_bounds(&envelope);
2923        assert!(result.is_some());
2924
2925        let bounds = result.unwrap();
2926        assert_eq!(bounds.min_time.0, 0);
2927        assert_eq!(bounds.max_time.0, 1000);
2928    }
2929
2930    #[test]
2931    fn test_extract_time_bounds_from_tx_v0_without_bounds() {
2932        let payment_op = create_payment_op();
2933        let operations = vec![payment_op].try_into().unwrap();
2934
2935        let tx = TransactionV0 {
2936            source_account_ed25519: Uint256([0u8; 32]),
2937            fee: 100,
2938            seq_num: SequenceNumber(1),
2939            time_bounds: None,
2940            memo: Memo::None,
2941            operations,
2942            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2943        };
2944
2945        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2946            tx,
2947            signatures: vec![].try_into().unwrap(),
2948        });
2949
2950        let result = extract_time_bounds(&envelope);
2951        assert!(result.is_none());
2952    }
2953
2954    #[test]
2955    fn test_extract_time_bounds_from_tx_v1_with_time_precondition() {
2956        let payment_op = create_payment_op();
2957        let operations = vec![payment_op].try_into().unwrap();
2958
2959        let time_bounds = TimeBounds {
2960            min_time: TimePoint(0),
2961            max_time: TimePoint(2000),
2962        };
2963
2964        let tx = Transaction {
2965            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2966            fee: 100,
2967            seq_num: SequenceNumber(1),
2968            cond: Preconditions::Time(time_bounds.clone()),
2969            memo: Memo::None,
2970            operations,
2971            ext: TransactionExt::V0,
2972        };
2973
2974        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2975            tx,
2976            signatures: vec![].try_into().unwrap(),
2977        });
2978
2979        let result = extract_time_bounds(&envelope);
2980        assert!(result.is_some());
2981
2982        let bounds = result.unwrap();
2983        assert_eq!(bounds.min_time.0, 0);
2984        assert_eq!(bounds.max_time.0, 2000);
2985    }
2986
2987    #[test]
2988    fn test_extract_time_bounds_from_tx_v1_without_time_precondition() {
2989        let payment_op = create_payment_op();
2990        let operations = vec![payment_op].try_into().unwrap();
2991
2992        let tx = Transaction {
2993            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2994            fee: 100,
2995            seq_num: SequenceNumber(1),
2996            cond: Preconditions::None,
2997            memo: Memo::None,
2998            operations,
2999            ext: TransactionExt::V0,
3000        };
3001
3002        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
3003            tx,
3004            signatures: vec![].try_into().unwrap(),
3005        });
3006
3007        let result = extract_time_bounds(&envelope);
3008        assert!(result.is_none());
3009    }
3010
3011    #[test]
3012    fn test_extract_time_bounds_from_fee_bump() {
3013        // Create inner transaction with time bounds
3014        let payment_op = create_payment_op();
3015        let operations = vec![payment_op].try_into().unwrap();
3016
3017        let time_bounds = TimeBounds {
3018            min_time: TimePoint(0),
3019            max_time: TimePoint(3000),
3020        };
3021
3022        let tx = Transaction {
3023            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3024            fee: 100,
3025            seq_num: SequenceNumber(1),
3026            cond: Preconditions::Time(time_bounds.clone()),
3027            memo: Memo::None,
3028            operations,
3029            ext: TransactionExt::V0,
3030        };
3031
3032        let inner_envelope = TransactionV1Envelope {
3033            tx,
3034            signatures: vec![].try_into().unwrap(),
3035        };
3036
3037        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
3038
3039        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
3040            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
3041            fee: 200,
3042            inner_tx,
3043            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
3044        };
3045
3046        let envelope =
3047            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
3048                tx: fee_bump_tx,
3049                signatures: vec![].try_into().unwrap(),
3050            });
3051
3052        let result = extract_time_bounds(&envelope);
3053        assert!(result.is_some());
3054
3055        let bounds = result.unwrap();
3056        assert_eq!(bounds.min_time.0, 0);
3057        assert_eq!(bounds.max_time.0, 3000);
3058    }
3059}
3060
3061#[cfg(test)]
3062mod set_time_bounds_tests {
3063    use super::*;
3064    use chrono::Utc;
3065    use soroban_rs::xdr::{
3066        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
3067        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
3068        TransactionV1Envelope, Uint256,
3069    };
3070
3071    fn create_payment_op() -> Operation {
3072        Operation {
3073            source_account: None,
3074            body: OperationBody::Payment(PaymentOp {
3075                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
3076                asset: Asset::Native,
3077                amount: 100,
3078            }),
3079        }
3080    }
3081
3082    #[test]
3083    fn test_set_time_bounds_on_tx_v0() {
3084        let payment_op = create_payment_op();
3085        let operations = vec![payment_op].try_into().unwrap();
3086
3087        let tx = TransactionV0 {
3088            source_account_ed25519: Uint256([0u8; 32]),
3089            fee: 100,
3090            seq_num: SequenceNumber(1),
3091            time_bounds: None,
3092            memo: Memo::None,
3093            operations,
3094            ext: soroban_rs::xdr::TransactionV0Ext::V0,
3095        };
3096
3097        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
3098            tx,
3099            signatures: vec![].try_into().unwrap(),
3100        });
3101
3102        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3103        let result = set_time_bounds(&mut envelope, valid_until);
3104
3105        assert!(result.is_ok());
3106
3107        match envelope {
3108            TransactionEnvelope::TxV0(e) => {
3109                assert!(e.tx.time_bounds.is_some());
3110                let bounds = e.tx.time_bounds.unwrap();
3111                assert_eq!(bounds.min_time.0, 0);
3112                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3113            }
3114            _ => panic!("Expected TxV0 envelope"),
3115        }
3116    }
3117
3118    #[test]
3119    fn test_set_time_bounds_on_tx_v1() {
3120        let payment_op = create_payment_op();
3121        let operations = vec![payment_op].try_into().unwrap();
3122
3123        let tx = Transaction {
3124            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3125            fee: 100,
3126            seq_num: SequenceNumber(1),
3127            cond: Preconditions::None,
3128            memo: Memo::None,
3129            operations,
3130            ext: TransactionExt::V0,
3131        };
3132
3133        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
3134            tx,
3135            signatures: vec![].try_into().unwrap(),
3136        });
3137
3138        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3139        let result = set_time_bounds(&mut envelope, valid_until);
3140
3141        assert!(result.is_ok());
3142
3143        match envelope {
3144            TransactionEnvelope::Tx(e) => match e.tx.cond {
3145                Preconditions::Time(bounds) => {
3146                    assert_eq!(bounds.min_time.0, 0);
3147                    assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3148                }
3149                _ => panic!("Expected Time precondition"),
3150            },
3151            _ => panic!("Expected Tx envelope"),
3152        }
3153    }
3154
3155    #[test]
3156    fn test_set_time_bounds_on_fee_bump_fails() {
3157        // Create a simple inner transaction
3158        let payment_op = create_payment_op();
3159        let operations = vec![payment_op].try_into().unwrap();
3160
3161        let tx = Transaction {
3162            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3163            fee: 100,
3164            seq_num: SequenceNumber(1),
3165            cond: Preconditions::None,
3166            memo: Memo::None,
3167            operations,
3168            ext: TransactionExt::V0,
3169        };
3170
3171        let inner_envelope = TransactionV1Envelope {
3172            tx,
3173            signatures: vec![].try_into().unwrap(),
3174        };
3175
3176        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
3177
3178        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
3179            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
3180            fee: 200,
3181            inner_tx,
3182            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
3183        };
3184
3185        let mut envelope =
3186            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
3187                tx: fee_bump_tx,
3188                signatures: vec![].try_into().unwrap(),
3189            });
3190
3191        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3192        let result = set_time_bounds(&mut envelope, valid_until);
3193
3194        assert!(result.is_err());
3195
3196        match result.unwrap_err() {
3197            StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump => {}
3198            _ => panic!("Expected CannotSetTimeBoundsOnFeeBump error"),
3199        }
3200    }
3201
3202    #[test]
3203    fn test_set_time_bounds_replaces_existing() {
3204        let payment_op = create_payment_op();
3205        let operations = vec![payment_op].try_into().unwrap();
3206
3207        let old_time_bounds = TimeBounds {
3208            min_time: TimePoint(100),
3209            max_time: TimePoint(1000),
3210        };
3211
3212        let tx = TransactionV0 {
3213            source_account_ed25519: Uint256([0u8; 32]),
3214            fee: 100,
3215            seq_num: SequenceNumber(1),
3216            time_bounds: Some(old_time_bounds),
3217            memo: Memo::None,
3218            operations,
3219            ext: soroban_rs::xdr::TransactionV0Ext::V0,
3220        };
3221
3222        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
3223            tx,
3224            signatures: vec![].try_into().unwrap(),
3225        });
3226
3227        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3228        let result = set_time_bounds(&mut envelope, valid_until);
3229
3230        assert!(result.is_ok());
3231
3232        match envelope {
3233            TransactionEnvelope::TxV0(e) => {
3234                assert!(e.tx.time_bounds.is_some());
3235                let bounds = e.tx.time_bounds.unwrap();
3236                // Should replace with new bounds (min_time = 0, not 100)
3237                assert_eq!(bounds.min_time.0, 0);
3238                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3239            }
3240            _ => panic!("Expected TxV0 envelope"),
3241        }
3242    }
3243}
3244
3245// ============================================================================
3246// From<StellarTransactionUtilsError> for RelayerError Tests
3247// ============================================================================
3248
3249#[cfg(test)]
3250mod stellar_transaction_utils_error_conversion_tests {
3251    use super::*;
3252
3253    #[test]
3254    fn test_v0_transactions_not_supported_converts_to_validation_error() {
3255        let err = StellarTransactionUtilsError::V0TransactionsNotSupported;
3256        let relayer_err: RelayerError = err.into();
3257        match relayer_err {
3258            RelayerError::ValidationError(msg) => {
3259                assert_eq!(msg, "V0 transactions are not supported");
3260            }
3261            _ => panic!("Expected ValidationError"),
3262        }
3263    }
3264
3265    #[test]
3266    fn test_cannot_update_sequence_on_fee_bump_converts_to_validation_error() {
3267        let err = StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump;
3268        let relayer_err: RelayerError = err.into();
3269        match relayer_err {
3270            RelayerError::ValidationError(msg) => {
3271                assert_eq!(msg, "Cannot update sequence number on fee bump transaction");
3272            }
3273            _ => panic!("Expected ValidationError"),
3274        }
3275    }
3276
3277    #[test]
3278    fn test_cannot_set_time_bounds_on_fee_bump_converts_to_validation_error() {
3279        let err = StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump;
3280        let relayer_err: RelayerError = err.into();
3281        match relayer_err {
3282            RelayerError::ValidationError(msg) => {
3283                assert_eq!(msg, "Cannot set time bounds on fee-bump transactions");
3284            }
3285            _ => panic!("Expected ValidationError"),
3286        }
3287    }
3288
3289    #[test]
3290    fn test_invalid_transaction_format_converts_to_validation_error() {
3291        let err = StellarTransactionUtilsError::InvalidTransactionFormat("bad format".to_string());
3292        let relayer_err: RelayerError = err.into();
3293        match relayer_err {
3294            RelayerError::ValidationError(msg) => {
3295                assert_eq!(msg, "bad format");
3296            }
3297            _ => panic!("Expected ValidationError"),
3298        }
3299    }
3300
3301    #[test]
3302    fn test_cannot_modify_fee_bump_converts_to_validation_error() {
3303        let err = StellarTransactionUtilsError::CannotModifyFeeBump;
3304        let relayer_err: RelayerError = err.into();
3305        match relayer_err {
3306            RelayerError::ValidationError(msg) => {
3307                assert_eq!(msg, "Cannot add operations to fee-bump transactions");
3308            }
3309            _ => panic!("Expected ValidationError"),
3310        }
3311    }
3312
3313    #[test]
3314    fn test_too_many_operations_converts_to_validation_error() {
3315        let err = StellarTransactionUtilsError::TooManyOperations(100);
3316        let relayer_err: RelayerError = err.into();
3317        match relayer_err {
3318            RelayerError::ValidationError(msg) => {
3319                assert!(msg.contains("Too many operations"));
3320                assert!(msg.contains("100"));
3321            }
3322            _ => panic!("Expected ValidationError"),
3323        }
3324    }
3325
3326    #[test]
3327    fn test_sequence_overflow_converts_to_internal_error() {
3328        let err = StellarTransactionUtilsError::SequenceOverflow("overflow msg".to_string());
3329        let relayer_err: RelayerError = err.into();
3330        match relayer_err {
3331            RelayerError::Internal(msg) => {
3332                assert_eq!(msg, "overflow msg");
3333            }
3334            _ => panic!("Expected Internal error"),
3335        }
3336    }
3337
3338    #[test]
3339    fn test_simulation_no_results_converts_to_internal_error() {
3340        let err = StellarTransactionUtilsError::SimulationNoResults;
3341        let relayer_err: RelayerError = err.into();
3342        match relayer_err {
3343            RelayerError::Internal(msg) => {
3344                assert!(msg.contains("no results"));
3345            }
3346            _ => panic!("Expected Internal error"),
3347        }
3348    }
3349
3350    #[test]
3351    fn test_asset_code_too_long_converts_to_validation_error() {
3352        let err =
3353            StellarTransactionUtilsError::AssetCodeTooLong(12, "VERYLONGASSETCODE".to_string());
3354        let relayer_err: RelayerError = err.into();
3355        match relayer_err {
3356            RelayerError::ValidationError(msg) => {
3357                assert!(msg.contains("Asset code too long"));
3358                assert!(msg.contains("12"));
3359            }
3360            _ => panic!("Expected ValidationError"),
3361        }
3362    }
3363
3364    #[test]
3365    fn test_invalid_asset_format_converts_to_validation_error() {
3366        let err = StellarTransactionUtilsError::InvalidAssetFormat("bad asset".to_string());
3367        let relayer_err: RelayerError = err.into();
3368        match relayer_err {
3369            RelayerError::ValidationError(msg) => {
3370                assert_eq!(msg, "bad asset");
3371            }
3372            _ => panic!("Expected ValidationError"),
3373        }
3374    }
3375
3376    #[test]
3377    fn test_invalid_account_address_converts_to_internal_error() {
3378        let err = StellarTransactionUtilsError::InvalidAccountAddress(
3379            "GABC".to_string(),
3380            "parse error".to_string(),
3381        );
3382        let relayer_err: RelayerError = err.into();
3383        match relayer_err {
3384            RelayerError::Internal(msg) => {
3385                assert_eq!(msg, "parse error");
3386            }
3387            _ => panic!("Expected Internal error"),
3388        }
3389    }
3390
3391    #[test]
3392    fn test_invalid_contract_address_converts_to_internal_error() {
3393        let err = StellarTransactionUtilsError::InvalidContractAddress(
3394            "CABC".to_string(),
3395            "contract parse error".to_string(),
3396        );
3397        let relayer_err: RelayerError = err.into();
3398        match relayer_err {
3399            RelayerError::Internal(msg) => {
3400                assert_eq!(msg, "contract parse error");
3401            }
3402            _ => panic!("Expected Internal error"),
3403        }
3404    }
3405
3406    #[test]
3407    fn test_symbol_creation_failed_converts_to_internal_error() {
3408        let err = StellarTransactionUtilsError::SymbolCreationFailed(
3409            "Balance".to_string(),
3410            "too long".to_string(),
3411        );
3412        let relayer_err: RelayerError = err.into();
3413        match relayer_err {
3414            RelayerError::Internal(msg) => {
3415                assert_eq!(msg, "too long");
3416            }
3417            _ => panic!("Expected Internal error"),
3418        }
3419    }
3420
3421    #[test]
3422    fn test_key_vector_creation_failed_converts_to_internal_error() {
3423        let err = StellarTransactionUtilsError::KeyVectorCreationFailed(
3424            "Balance".to_string(),
3425            "vec error".to_string(),
3426        );
3427        let relayer_err: RelayerError = err.into();
3428        match relayer_err {
3429            RelayerError::Internal(msg) => {
3430                assert_eq!(msg, "vec error");
3431            }
3432            _ => panic!("Expected Internal error"),
3433        }
3434    }
3435
3436    #[test]
3437    fn test_contract_data_query_persistent_failed_converts_to_internal_error() {
3438        let err = StellarTransactionUtilsError::ContractDataQueryPersistentFailed(
3439            "balance".to_string(),
3440            "rpc error".to_string(),
3441        );
3442        let relayer_err: RelayerError = err.into();
3443        match relayer_err {
3444            RelayerError::Internal(msg) => {
3445                assert_eq!(msg, "rpc error");
3446            }
3447            _ => panic!("Expected Internal error"),
3448        }
3449    }
3450
3451    #[test]
3452    fn test_contract_data_query_temporary_failed_converts_to_internal_error() {
3453        let err = StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(
3454            "balance".to_string(),
3455            "temp error".to_string(),
3456        );
3457        let relayer_err: RelayerError = err.into();
3458        match relayer_err {
3459            RelayerError::Internal(msg) => {
3460                assert_eq!(msg, "temp error");
3461            }
3462            _ => panic!("Expected Internal error"),
3463        }
3464    }
3465
3466    #[test]
3467    fn test_ledger_entry_parse_failed_converts_to_internal_error() {
3468        let err = StellarTransactionUtilsError::LedgerEntryParseFailed(
3469            "entry".to_string(),
3470            "xdr error".to_string(),
3471        );
3472        let relayer_err: RelayerError = err.into();
3473        match relayer_err {
3474            RelayerError::Internal(msg) => {
3475                assert_eq!(msg, "xdr error");
3476            }
3477            _ => panic!("Expected Internal error"),
3478        }
3479    }
3480
3481    #[test]
3482    fn test_no_entries_found_converts_to_validation_error() {
3483        let err = StellarTransactionUtilsError::NoEntriesFound("balance".to_string());
3484        let relayer_err: RelayerError = err.into();
3485        match relayer_err {
3486            RelayerError::ValidationError(msg) => {
3487                assert!(msg.contains("No entries found"));
3488            }
3489            _ => panic!("Expected ValidationError"),
3490        }
3491    }
3492
3493    #[test]
3494    fn test_empty_entries_converts_to_validation_error() {
3495        let err = StellarTransactionUtilsError::EmptyEntries("balance".to_string());
3496        let relayer_err: RelayerError = err.into();
3497        match relayer_err {
3498            RelayerError::ValidationError(msg) => {
3499                assert!(msg.contains("Empty entries"));
3500            }
3501            _ => panic!("Expected ValidationError"),
3502        }
3503    }
3504
3505    #[test]
3506    fn test_unexpected_ledger_entry_type_converts_to_validation_error() {
3507        let err = StellarTransactionUtilsError::UnexpectedLedgerEntryType("balance".to_string());
3508        let relayer_err: RelayerError = err.into();
3509        match relayer_err {
3510            RelayerError::ValidationError(msg) => {
3511                assert!(msg.contains("Unexpected ledger entry type"));
3512            }
3513            _ => panic!("Expected ValidationError"),
3514        }
3515    }
3516
3517    #[test]
3518    fn test_invalid_issuer_length_converts_to_validation_error() {
3519        let err = StellarTransactionUtilsError::InvalidIssuerLength(56, "SHORT".to_string());
3520        let relayer_err: RelayerError = err.into();
3521        match relayer_err {
3522            RelayerError::ValidationError(msg) => {
3523                assert!(msg.contains("56"));
3524                assert!(msg.contains("SHORT"));
3525            }
3526            _ => panic!("Expected ValidationError"),
3527        }
3528    }
3529
3530    #[test]
3531    fn test_invalid_issuer_prefix_converts_to_validation_error() {
3532        let err = StellarTransactionUtilsError::InvalidIssuerPrefix('G', "CABC123".to_string());
3533        let relayer_err: RelayerError = err.into();
3534        match relayer_err {
3535            RelayerError::ValidationError(msg) => {
3536                assert!(msg.contains("'G'"));
3537                assert!(msg.contains("CABC123"));
3538            }
3539            _ => panic!("Expected ValidationError"),
3540        }
3541    }
3542
3543    #[test]
3544    fn test_account_fetch_failed_converts_to_provider_error() {
3545        let err = StellarTransactionUtilsError::AccountFetchFailed("fetch error".to_string());
3546        let relayer_err: RelayerError = err.into();
3547        match relayer_err {
3548            RelayerError::ProviderError(msg) => {
3549                assert_eq!(msg, "fetch error");
3550            }
3551            _ => panic!("Expected ProviderError"),
3552        }
3553    }
3554
3555    #[test]
3556    fn test_trustline_query_failed_converts_to_provider_error() {
3557        let err = StellarTransactionUtilsError::TrustlineQueryFailed(
3558            "USDC".to_string(),
3559            "rpc fail".to_string(),
3560        );
3561        let relayer_err: RelayerError = err.into();
3562        match relayer_err {
3563            RelayerError::ProviderError(msg) => {
3564                assert_eq!(msg, "rpc fail");
3565            }
3566            _ => panic!("Expected ProviderError"),
3567        }
3568    }
3569
3570    #[test]
3571    fn test_contract_invocation_failed_converts_to_provider_error() {
3572        let err = StellarTransactionUtilsError::ContractInvocationFailed(
3573            "transfer".to_string(),
3574            "invoke error".to_string(),
3575        );
3576        let relayer_err: RelayerError = err.into();
3577        match relayer_err {
3578            RelayerError::ProviderError(msg) => {
3579                assert_eq!(msg, "invoke error");
3580            }
3581            _ => panic!("Expected ProviderError"),
3582        }
3583    }
3584
3585    #[test]
3586    fn test_xdr_parse_failed_converts_to_internal_error() {
3587        let err = StellarTransactionUtilsError::XdrParseFailed("xdr parse fail".to_string());
3588        let relayer_err: RelayerError = err.into();
3589        match relayer_err {
3590            RelayerError::Internal(msg) => {
3591                assert_eq!(msg, "xdr parse fail");
3592            }
3593            _ => panic!("Expected Internal error"),
3594        }
3595    }
3596
3597    #[test]
3598    fn test_operation_extraction_failed_converts_to_internal_error() {
3599        let err =
3600            StellarTransactionUtilsError::OperationExtractionFailed("extract fail".to_string());
3601        let relayer_err: RelayerError = err.into();
3602        match relayer_err {
3603            RelayerError::Internal(msg) => {
3604                assert_eq!(msg, "extract fail");
3605            }
3606            _ => panic!("Expected Internal error"),
3607        }
3608    }
3609
3610    #[test]
3611    fn test_simulation_failed_converts_to_internal_error() {
3612        let err = StellarTransactionUtilsError::SimulationFailed("sim error".to_string());
3613        let relayer_err: RelayerError = err.into();
3614        match relayer_err {
3615            RelayerError::Internal(msg) => {
3616                assert_eq!(msg, "sim error");
3617            }
3618            _ => panic!("Expected Internal error"),
3619        }
3620    }
3621
3622    #[test]
3623    fn test_simulation_check_failed_converts_to_internal_error() {
3624        let err = StellarTransactionUtilsError::SimulationCheckFailed("check fail".to_string());
3625        let relayer_err: RelayerError = err.into();
3626        match relayer_err {
3627            RelayerError::Internal(msg) => {
3628                assert_eq!(msg, "check fail");
3629            }
3630            _ => panic!("Expected Internal error"),
3631        }
3632    }
3633
3634    #[test]
3635    fn test_dex_quote_failed_converts_to_internal_error() {
3636        let err = StellarTransactionUtilsError::DexQuoteFailed("dex error".to_string());
3637        let relayer_err: RelayerError = err.into();
3638        match relayer_err {
3639            RelayerError::Internal(msg) => {
3640                assert_eq!(msg, "dex error");
3641            }
3642            _ => panic!("Expected Internal error"),
3643        }
3644    }
3645
3646    #[test]
3647    fn test_empty_asset_code_converts_to_validation_error() {
3648        let err = StellarTransactionUtilsError::EmptyAssetCode("CODE:ISSUER".to_string());
3649        let relayer_err: RelayerError = err.into();
3650        match relayer_err {
3651            RelayerError::ValidationError(msg) => {
3652                assert!(msg.contains("Asset code cannot be empty"));
3653            }
3654            _ => panic!("Expected ValidationError"),
3655        }
3656    }
3657
3658    #[test]
3659    fn test_empty_issuer_address_converts_to_validation_error() {
3660        let err = StellarTransactionUtilsError::EmptyIssuerAddress("USDC:".to_string());
3661        let relayer_err: RelayerError = err.into();
3662        match relayer_err {
3663            RelayerError::ValidationError(msg) => {
3664                assert!(msg.contains("Issuer address cannot be empty"));
3665            }
3666            _ => panic!("Expected ValidationError"),
3667        }
3668    }
3669
3670    #[test]
3671    fn test_no_trustline_found_converts_to_validation_error() {
3672        let err =
3673            StellarTransactionUtilsError::NoTrustlineFound("USDC".to_string(), "GABC".to_string());
3674        let relayer_err: RelayerError = err.into();
3675        match relayer_err {
3676            RelayerError::ValidationError(msg) => {
3677                assert!(msg.contains("No trustline found"));
3678            }
3679            _ => panic!("Expected ValidationError"),
3680        }
3681    }
3682
3683    #[test]
3684    fn test_unsupported_trustline_version_converts_to_validation_error() {
3685        let err = StellarTransactionUtilsError::UnsupportedTrustlineVersion;
3686        let relayer_err: RelayerError = err.into();
3687        match relayer_err {
3688            RelayerError::ValidationError(msg) => {
3689                assert!(msg.contains("Unsupported trustline"));
3690            }
3691            _ => panic!("Expected ValidationError"),
3692        }
3693    }
3694
3695    #[test]
3696    fn test_unexpected_trustline_entry_type_converts_to_validation_error() {
3697        let err = StellarTransactionUtilsError::UnexpectedTrustlineEntryType;
3698        let relayer_err: RelayerError = err.into();
3699        match relayer_err {
3700            RelayerError::ValidationError(msg) => {
3701                assert!(msg.contains("Unexpected ledger entry type"));
3702            }
3703            _ => panic!("Expected ValidationError"),
3704        }
3705    }
3706
3707    #[test]
3708    fn test_balance_too_large_converts_to_validation_error() {
3709        let err = StellarTransactionUtilsError::BalanceTooLarge(1, 999);
3710        let relayer_err: RelayerError = err.into();
3711        match relayer_err {
3712            RelayerError::ValidationError(msg) => {
3713                assert!(msg.contains("Balance too large"));
3714            }
3715            _ => panic!("Expected ValidationError"),
3716        }
3717    }
3718
3719    #[test]
3720    fn test_negative_balance_i128_converts_to_validation_error() {
3721        let err = StellarTransactionUtilsError::NegativeBalanceI128(42);
3722        let relayer_err: RelayerError = err.into();
3723        match relayer_err {
3724            RelayerError::ValidationError(msg) => {
3725                assert!(msg.contains("Negative balance"));
3726            }
3727            _ => panic!("Expected ValidationError"),
3728        }
3729    }
3730
3731    #[test]
3732    fn test_negative_balance_i64_converts_to_validation_error() {
3733        let err = StellarTransactionUtilsError::NegativeBalanceI64(-5);
3734        let relayer_err: RelayerError = err.into();
3735        match relayer_err {
3736            RelayerError::ValidationError(msg) => {
3737                assert!(msg.contains("Negative balance"));
3738            }
3739            _ => panic!("Expected ValidationError"),
3740        }
3741    }
3742
3743    #[test]
3744    fn test_unexpected_balance_type_converts_to_validation_error() {
3745        let err = StellarTransactionUtilsError::UnexpectedBalanceType("Bool(true)".to_string());
3746        let relayer_err: RelayerError = err.into();
3747        match relayer_err {
3748            RelayerError::ValidationError(msg) => {
3749                assert!(msg.contains("Unexpected balance value type"));
3750            }
3751            _ => panic!("Expected ValidationError"),
3752        }
3753    }
3754
3755    #[test]
3756    fn test_unexpected_contract_data_entry_type_converts_to_validation_error() {
3757        let err = StellarTransactionUtilsError::UnexpectedContractDataEntryType;
3758        let relayer_err: RelayerError = err.into();
3759        match relayer_err {
3760            RelayerError::ValidationError(msg) => {
3761                assert!(msg.contains("Unexpected ledger entry type"));
3762            }
3763            _ => panic!("Expected ValidationError"),
3764        }
3765    }
3766
3767    #[test]
3768    fn test_native_asset_in_trustline_query_converts_to_validation_error() {
3769        let err = StellarTransactionUtilsError::NativeAssetInTrustlineQuery;
3770        let relayer_err: RelayerError = err.into();
3771        match relayer_err {
3772            RelayerError::ValidationError(msg) => {
3773                assert!(msg.contains("Native asset"));
3774            }
3775            _ => panic!("Expected ValidationError"),
3776        }
3777    }
3778}
3779
3780#[cfg(test)]
3781mod compute_resubmit_backoff_interval_tests {
3782    use super::compute_resubmit_backoff_interval;
3783    use chrono::Duration;
3784
3785    const BASE: i64 = 10;
3786    const MAX: i64 = 120;
3787    const FACTOR: f64 = 1.5;
3788
3789    #[test]
3790    fn returns_none_below_base() {
3791        assert!(
3792            compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX, FACTOR).is_none()
3793        );
3794        assert!(
3795            compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, FACTOR).is_none()
3796        );
3797        assert!(
3798            compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX, FACTOR).is_none()
3799        );
3800    }
3801
3802    #[test]
3803    fn base_interval_at_first_tier() {
3804        // age 10-14s: interval = 10s (tier boundary at 10 * 1.5 = 15)
3805        assert_eq!(
3806            compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, FACTOR),
3807            Some(Duration::seconds(10))
3808        );
3809        assert_eq!(
3810            compute_resubmit_backoff_interval(Duration::seconds(14), BASE, MAX, FACTOR),
3811            Some(Duration::seconds(10))
3812        );
3813    }
3814
3815    #[test]
3816    fn grows_by_factor_at_second_tier() {
3817        // age 15-21s: interval = 10 * 1.5 = 15s (tier boundary at 15 * 1.5 = 22.5)
3818        assert_eq!(
3819            compute_resubmit_backoff_interval(Duration::seconds(15), BASE, MAX, FACTOR),
3820            Some(Duration::seconds(15))
3821        );
3822        assert_eq!(
3823            compute_resubmit_backoff_interval(Duration::seconds(22), BASE, MAX, FACTOR),
3824            Some(Duration::seconds(15))
3825        );
3826    }
3827
3828    #[test]
3829    fn grows_by_factor_squared_at_third_tier() {
3830        // age 23-33s: interval = 10 * 1.5^2 = 22.5 ≈ 22s (tier boundary at 22.5 * 1.5 = 33.75)
3831        assert_eq!(
3832            compute_resubmit_backoff_interval(Duration::seconds(23), BASE, MAX, FACTOR),
3833            Some(Duration::seconds(22))
3834        );
3835        assert_eq!(
3836            compute_resubmit_backoff_interval(Duration::seconds(33), BASE, MAX, FACTOR),
3837            Some(Duration::seconds(22))
3838        );
3839    }
3840
3841    #[test]
3842    fn fourth_tier() {
3843        // age 34-50s: interval = 10 * 1.5^3 = 33.75 → 33s (truncated)
3844        assert_eq!(
3845            compute_resubmit_backoff_interval(Duration::seconds(34), BASE, MAX, FACTOR),
3846            Some(Duration::seconds(33))
3847        );
3848        assert_eq!(
3849            compute_resubmit_backoff_interval(Duration::seconds(50), BASE, MAX, FACTOR),
3850            Some(Duration::seconds(33))
3851        );
3852    }
3853
3854    #[test]
3855    fn capped_at_max() {
3856        // At high ages the interval should be capped at MAX (120s)
3857        assert_eq!(
3858            compute_resubmit_backoff_interval(Duration::seconds(300), BASE, MAX, FACTOR),
3859            Some(Duration::seconds(MAX))
3860        );
3861        assert_eq!(
3862            compute_resubmit_backoff_interval(Duration::seconds(1000), BASE, MAX, FACTOR),
3863            Some(Duration::seconds(MAX))
3864        );
3865    }
3866
3867    #[test]
3868    fn works_with_factor_2_doubling() {
3869        // With factor=2.0, behavior matches classic doubling: 10 → 20 → 40 → 80 → 120
3870        let factor = 2.0;
3871        assert_eq!(
3872            compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, factor),
3873            Some(Duration::seconds(10))
3874        );
3875        assert_eq!(
3876            compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX, factor),
3877            Some(Duration::seconds(10))
3878        );
3879        assert_eq!(
3880            compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX, factor),
3881            Some(Duration::seconds(20))
3882        );
3883        assert_eq!(
3884            compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX, factor),
3885            Some(Duration::seconds(20))
3886        );
3887        assert_eq!(
3888            compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX, factor),
3889            Some(Duration::seconds(40))
3890        );
3891        assert_eq!(
3892            compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX, factor),
3893            Some(Duration::seconds(80))
3894        );
3895        assert_eq!(
3896            compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX, factor),
3897            Some(Duration::seconds(MAX))
3898        );
3899    }
3900
3901    #[test]
3902    fn works_with_different_base_and_max() {
3903        // Verify the function is generic, not hardcoded to Stellar constants
3904        let base = 5;
3905        let max = 30;
3906        // age 5-7s: interval = 5s
3907        assert_eq!(
3908            compute_resubmit_backoff_interval(Duration::seconds(5), base, max, FACTOR),
3909            Some(Duration::seconds(5))
3910        );
3911        // age 8-11s: interval = 5 * 1.5 = 7.5 → 7s (truncated)
3912        assert_eq!(
3913            compute_resubmit_backoff_interval(Duration::seconds(8), base, max, FACTOR),
3914            Some(Duration::seconds(7))
3915        );
3916        // age 100s: capped at 30s
3917        assert_eq!(
3918            compute_resubmit_backoff_interval(Duration::seconds(100), base, max, FACTOR),
3919            Some(Duration::seconds(30))
3920        );
3921    }
3922
3923    #[test]
3924    fn factor_at_or_below_one_returns_min_base_max() {
3925        // growth_factor <= 1.0 would cause an infinite loop; guard returns min(base, max) instead
3926        assert_eq!(
3927            compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 1.0),
3928            Some(Duration::seconds(std::cmp::min(BASE, MAX)))
3929        );
3930        assert_eq!(
3931            compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 0.5),
3932            Some(Duration::seconds(std::cmp::min(BASE, MAX)))
3933        );
3934        // When base > max, returns max
3935        assert_eq!(
3936            compute_resubmit_backoff_interval(Duration::seconds(200), 200, MAX, 1.0),
3937            Some(Duration::seconds(MAX))
3938        );
3939        // Still returns None below base
3940        assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, 1.0).is_none());
3941    }
3942
3943    #[test]
3944    fn non_positive_base_or_max_returns_none() {
3945        // base_interval_secs <= 0 would cause infinite loop; guard returns None
3946        assert!(
3947            compute_resubmit_backoff_interval(Duration::seconds(100), 0, MAX, FACTOR).is_none()
3948        );
3949        assert!(
3950            compute_resubmit_backoff_interval(Duration::seconds(100), -5, MAX, FACTOR).is_none()
3951        );
3952        // max_interval_secs <= 0 also returns None
3953        assert!(
3954            compute_resubmit_backoff_interval(Duration::seconds(100), BASE, 0, FACTOR).is_none()
3955        );
3956    }
3957}