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 exponential backoff based on total transaction age.
1305///
1306/// The interval doubles each time the total age doubles:
1307///   - age < base  → `None` (too early to resubmit)
1308///   - age 1-2x base → interval = base  (10s)
1309///   - age 2-4x base → interval = 2*base (20s)
1310///   - age 4-8x base → interval = 4*base (40s)
1311///   - ...capped at `max_interval`
1312///
1313/// Returns the backoff interval to compare against time since last submission (`sent_at`).
1314pub fn compute_resubmit_backoff_interval(
1315    total_age: chrono::Duration,
1316    base_interval_secs: i64,
1317    max_interval_secs: i64,
1318) -> Option<chrono::Duration> {
1319    let age_secs = total_age.num_seconds();
1320
1321    if age_secs < base_interval_secs {
1322        return None;
1323    }
1324
1325    // n = floor(log2(age / base)), so interval = base * 2^n
1326    let ratio = age_secs / base_interval_secs; // >= 1
1327    let n = (ratio as u64).ilog2(); // floor(log2(ratio))
1328    let interval = base_interval_secs.saturating_mul(1_i64.wrapping_shl(n));
1329    let capped = interval.min(max_interval_secs);
1330
1331    Some(chrono::Duration::seconds(capped))
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
1338    use crate::models::AssetSpec;
1339    use crate::models::{AuthSpec, ContractSource, WasmSource};
1340
1341    fn payment_op(destination: &str) -> OperationSpec {
1342        OperationSpec::Payment {
1343            destination: destination.to_string(),
1344            amount: 100,
1345            asset: AssetSpec::Native,
1346        }
1347    }
1348
1349    #[test]
1350    fn returns_false_for_only_payment_ops() {
1351        let ops = vec![payment_op(TEST_PK)];
1352        assert!(!needs_simulation(&ops));
1353    }
1354
1355    #[test]
1356    fn returns_true_for_invoke_contract_ops() {
1357        let ops = vec![OperationSpec::InvokeContract {
1358            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1359                .to_string(),
1360            function_name: "transfer".to_string(),
1361            args: vec![],
1362            auth: None,
1363        }];
1364        assert!(needs_simulation(&ops));
1365    }
1366
1367    #[test]
1368    fn returns_true_for_upload_wasm_ops() {
1369        let ops = vec![OperationSpec::UploadWasm {
1370            wasm: WasmSource::Hex {
1371                hex: "deadbeef".to_string(),
1372            },
1373            auth: None,
1374        }];
1375        assert!(needs_simulation(&ops));
1376    }
1377
1378    #[test]
1379    fn returns_true_for_create_contract_ops() {
1380        let ops = vec![OperationSpec::CreateContract {
1381            source: ContractSource::Address {
1382                address: TEST_PK.to_string(),
1383            },
1384            wasm_hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
1385                .to_string(),
1386            salt: None,
1387            constructor_args: None,
1388            auth: None,
1389        }];
1390        assert!(needs_simulation(&ops));
1391    }
1392
1393    #[test]
1394    fn returns_true_for_single_invoke_host_function() {
1395        let ops = vec![OperationSpec::InvokeContract {
1396            contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
1397                .to_string(),
1398            function_name: "transfer".to_string(),
1399            args: vec![],
1400            auth: Some(AuthSpec::SourceAccount),
1401        }];
1402        assert!(needs_simulation(&ops));
1403    }
1404
1405    #[test]
1406    fn returns_false_for_multiple_payment_ops() {
1407        let ops = vec![payment_op(TEST_PK), payment_op(TEST_PK)];
1408        assert!(!needs_simulation(&ops));
1409    }
1410
1411    mod next_sequence_u64_tests {
1412        use super::*;
1413
1414        #[test]
1415        fn test_increment() {
1416            assert_eq!(next_sequence_u64(0).unwrap(), 1);
1417
1418            assert_eq!(next_sequence_u64(12345).unwrap(), 12346);
1419        }
1420
1421        #[test]
1422        fn test_error_path_overflow_i64_max() {
1423            let result = next_sequence_u64(i64::MAX);
1424            assert!(result.is_err());
1425            match result.unwrap_err() {
1426                RelayerError::ProviderError(msg) => assert_eq!(msg, "sequence overflow"),
1427                _ => panic!("Unexpected error type"),
1428            }
1429        }
1430    }
1431
1432    mod i64_from_u64_tests {
1433        use super::*;
1434
1435        #[test]
1436        fn test_happy_path_conversion() {
1437            assert_eq!(i64_from_u64(0).unwrap(), 0);
1438            assert_eq!(i64_from_u64(12345).unwrap(), 12345);
1439            assert_eq!(i64_from_u64(i64::MAX as u64).unwrap(), i64::MAX);
1440        }
1441
1442        #[test]
1443        fn test_error_path_overflow_u64_max() {
1444            let result = i64_from_u64(u64::MAX);
1445            assert!(result.is_err());
1446            match result.unwrap_err() {
1447                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1448                _ => panic!("Unexpected error type"),
1449            }
1450        }
1451
1452        #[test]
1453        fn test_edge_case_just_above_i64_max() {
1454            // Smallest u64 value that will overflow i64
1455            let value = (i64::MAX as u64) + 1;
1456            let result = i64_from_u64(value);
1457            assert!(result.is_err());
1458            match result.unwrap_err() {
1459                RelayerError::ProviderError(msg) => assert_eq!(msg, "u64→i64 overflow"),
1460                _ => panic!("Unexpected error type"),
1461            }
1462        }
1463    }
1464
1465    mod is_bad_sequence_error_tests {
1466        use super::*;
1467
1468        #[test]
1469        fn test_detects_txbadseq() {
1470            assert!(is_bad_sequence_error(
1471                "Failed to send transaction: transaction submission failed: TxBadSeq"
1472            ));
1473            assert!(is_bad_sequence_error("Error: TxBadSeq"));
1474            assert!(is_bad_sequence_error("txbadseq"));
1475            assert!(is_bad_sequence_error("TXBADSEQ"));
1476        }
1477
1478        #[test]
1479        fn test_returns_false_for_other_errors() {
1480            assert!(!is_bad_sequence_error("network timeout"));
1481            assert!(!is_bad_sequence_error("insufficient balance"));
1482            assert!(!is_bad_sequence_error("tx_insufficient_fee"));
1483            assert!(!is_bad_sequence_error("bad_auth"));
1484            assert!(!is_bad_sequence_error(""));
1485        }
1486    }
1487
1488    mod decode_tx_result_code_tests {
1489        use super::*;
1490        use soroban_rs::xdr::{TransactionResult, TransactionResultResult, WriteXdr};
1491
1492        #[test]
1493        fn test_decodes_tx_bad_seq() {
1494            let result = TransactionResult {
1495                fee_charged: 100,
1496                result: TransactionResultResult::TxBadSeq,
1497                ext: soroban_rs::xdr::TransactionResultExt::V0,
1498            };
1499            let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1500            assert_eq!(decode_tx_result_code(&xdr), Some("TxBadSeq".to_string()));
1501        }
1502
1503        #[test]
1504        fn test_decodes_tx_insufficient_balance() {
1505            let result = TransactionResult {
1506                fee_charged: 100,
1507                result: TransactionResultResult::TxInsufficientBalance,
1508                ext: soroban_rs::xdr::TransactionResultExt::V0,
1509            };
1510            let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1511            assert_eq!(
1512                decode_tx_result_code(&xdr),
1513                Some("TxInsufficientBalance".to_string())
1514            );
1515        }
1516
1517        #[test]
1518        fn test_returns_none_for_invalid_xdr() {
1519            assert_eq!(decode_tx_result_code("not-valid-xdr"), None);
1520        }
1521
1522        #[test]
1523        fn test_returns_none_for_empty_string() {
1524            assert_eq!(decode_tx_result_code(""), None);
1525        }
1526    }
1527
1528    mod is_insufficient_fee_error_tests {
1529        use super::*;
1530
1531        #[test]
1532        fn test_detects_txinsufficientfee() {
1533            assert!(is_insufficient_fee_error("TxInsufficientFee"));
1534            assert!(is_insufficient_fee_error("txinsufficientfee"));
1535            assert!(is_insufficient_fee_error("TXINSUFFICIENTFEE"));
1536        }
1537
1538        #[test]
1539        fn test_returns_false_for_other_errors() {
1540            assert!(!is_insufficient_fee_error("network timeout"));
1541            assert!(!is_insufficient_fee_error("TxBadSeq"));
1542            assert!(!is_insufficient_fee_error("TxInsufficientBalance"));
1543            assert!(!is_insufficient_fee_error("TxBadAuth"));
1544            assert!(!is_insufficient_fee_error(""));
1545        }
1546    }
1547
1548    mod decode_transaction_result_code_tests {
1549        use super::*;
1550
1551        #[test]
1552        fn test_decodes_insufficient_fee_result_xdr() {
1553            let result_code = decode_transaction_result_code("AAAAAAAAY/n////3AAAAAA==").unwrap();
1554            assert_eq!(result_code, "TxInsufficientFee");
1555        }
1556
1557        #[test]
1558        fn test_returns_none_for_invalid_xdr() {
1559            assert!(decode_transaction_result_code("not-base64").is_none());
1560        }
1561    }
1562
1563    mod status_check_utils_tests {
1564        use crate::models::{
1565            NetworkTransactionData, StellarTransactionData, TransactionError, TransactionInput,
1566            TransactionRepoModel,
1567        };
1568        use crate::utils::mocks::mockutils::create_mock_transaction;
1569        use chrono::{Duration, Utc};
1570
1571        /// Helper to create a test transaction with a specific created_at timestamp
1572        fn create_test_tx_with_age(seconds_ago: i64) -> TransactionRepoModel {
1573            let created_at = (Utc::now() - Duration::seconds(seconds_ago)).to_rfc3339();
1574            let mut tx = create_mock_transaction();
1575            tx.id = format!("test-tx-{seconds_ago}");
1576            tx.created_at = created_at;
1577            tx.network_data = NetworkTransactionData::Stellar(StellarTransactionData {
1578                source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
1579                    .to_string(),
1580                fee: None,
1581                sequence_number: None,
1582                memo: None,
1583                valid_until: None,
1584                network_passphrase: "Test SDF Network ; September 2015".to_string(),
1585                signatures: vec![],
1586                hash: Some("test-hash-12345".to_string()),
1587                simulation_transaction_data: None,
1588                transaction_input: TransactionInput::Operations(vec![]),
1589                signed_envelope_xdr: None,
1590                transaction_result_xdr: None,
1591            });
1592            tx
1593        }
1594
1595        mod get_age_since_created_tests {
1596            use crate::domain::transaction::util::get_age_since_created;
1597
1598            use super::*;
1599
1600            #[test]
1601            fn test_returns_correct_age_for_recent_transaction() {
1602                let tx = create_test_tx_with_age(30); // 30 seconds ago
1603                let age = get_age_since_created(&tx).unwrap();
1604
1605                // Allow for small timing differences (within 1 second)
1606                assert!(age.num_seconds() >= 29 && age.num_seconds() <= 31);
1607            }
1608
1609            #[test]
1610            fn test_returns_correct_age_for_old_transaction() {
1611                let tx = create_test_tx_with_age(3600); // 1 hour ago
1612                let age = get_age_since_created(&tx).unwrap();
1613
1614                // Allow for small timing differences
1615                assert!(age.num_seconds() >= 3599 && age.num_seconds() <= 3601);
1616            }
1617
1618            #[test]
1619            fn test_returns_zero_age_for_just_created_transaction() {
1620                let tx = create_test_tx_with_age(0); // Just now
1621                let age = get_age_since_created(&tx).unwrap();
1622
1623                // Should be very close to 0
1624                assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
1625            }
1626
1627            #[test]
1628            fn test_handles_negative_age_gracefully() {
1629                // Create transaction with future timestamp (clock skew scenario)
1630                let created_at = (Utc::now() + Duration::seconds(10)).to_rfc3339();
1631                let mut tx = create_mock_transaction();
1632                tx.created_at = created_at;
1633
1634                let age = get_age_since_created(&tx).unwrap();
1635
1636                // Age should be negative
1637                assert!(age.num_seconds() < 0);
1638            }
1639
1640            #[test]
1641            fn test_returns_error_for_invalid_created_at() {
1642                let mut tx = create_mock_transaction();
1643                tx.created_at = "invalid-timestamp".to_string();
1644
1645                let result = get_age_since_created(&tx);
1646                assert!(result.is_err());
1647
1648                match result.unwrap_err() {
1649                    TransactionError::UnexpectedError(msg) => {
1650                        assert!(msg.contains("Invalid created_at timestamp"));
1651                    }
1652                    _ => panic!("Expected UnexpectedError"),
1653                }
1654            }
1655
1656            #[test]
1657            fn test_returns_error_for_empty_created_at() {
1658                let mut tx = create_mock_transaction();
1659                tx.created_at = "".to_string();
1660
1661                let result = get_age_since_created(&tx);
1662                assert!(result.is_err());
1663            }
1664
1665            #[test]
1666            fn test_handles_various_rfc3339_formats() {
1667                let mut tx = create_mock_transaction();
1668
1669                // Test with UTC timezone
1670                tx.created_at = "2025-01-01T12:00:00Z".to_string();
1671                assert!(get_age_since_created(&tx).is_ok());
1672
1673                // Test with offset timezone
1674                tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
1675                assert!(get_age_since_created(&tx).is_ok());
1676
1677                // Test with milliseconds
1678                tx.created_at = "2025-01-01T12:00:00.123Z".to_string();
1679                assert!(get_age_since_created(&tx).is_ok());
1680            }
1681        }
1682    }
1683
1684    #[test]
1685    fn test_create_signature_payload_functions() {
1686        use soroban_rs::xdr::{
1687            Hash, SequenceNumber, TransactionEnvelope, TransactionV0, TransactionV0Envelope,
1688            Uint256,
1689        };
1690
1691        // Test create_transaction_signature_payload
1692        let transaction = soroban_rs::xdr::Transaction {
1693            source_account: soroban_rs::xdr::MuxedAccount::Ed25519(Uint256([1u8; 32])),
1694            fee: 100,
1695            seq_num: SequenceNumber(123),
1696            cond: soroban_rs::xdr::Preconditions::None,
1697            memo: soroban_rs::xdr::Memo::None,
1698            operations: vec![].try_into().unwrap(),
1699            ext: soroban_rs::xdr::TransactionExt::V0,
1700        };
1701        let network_id = Hash([2u8; 32]);
1702
1703        let payload = create_transaction_signature_payload(&transaction, &network_id);
1704        assert_eq!(payload.network_id, network_id);
1705
1706        // Test create_signature_payload with V0 envelope
1707        let v0_tx = TransactionV0 {
1708            source_account_ed25519: Uint256([1u8; 32]),
1709            fee: 100,
1710            seq_num: SequenceNumber(123),
1711            time_bounds: None,
1712            memo: soroban_rs::xdr::Memo::None,
1713            operations: vec![].try_into().unwrap(),
1714            ext: soroban_rs::xdr::TransactionV0Ext::V0,
1715        };
1716        let v0_envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
1717            tx: v0_tx,
1718            signatures: vec![].try_into().unwrap(),
1719        });
1720
1721        let v0_payload = create_signature_payload(&v0_envelope, &network_id).unwrap();
1722        assert_eq!(v0_payload.network_id, network_id);
1723    }
1724
1725    mod convert_v0_to_v1_transaction_tests {
1726        use super::*;
1727        use soroban_rs::xdr::{SequenceNumber, TransactionV0, Uint256};
1728
1729        #[test]
1730        fn test_convert_v0_to_v1_transaction() {
1731            // Create a simple V0 transaction
1732            let v0_tx = TransactionV0 {
1733                source_account_ed25519: Uint256([1u8; 32]),
1734                fee: 100,
1735                seq_num: SequenceNumber(123),
1736                time_bounds: None,
1737                memo: soroban_rs::xdr::Memo::None,
1738                operations: vec![].try_into().unwrap(),
1739                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1740            };
1741
1742            // Convert to V1
1743            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1744
1745            // Check that conversion worked correctly
1746            assert_eq!(v1_tx.fee, v0_tx.fee);
1747            assert_eq!(v1_tx.seq_num, v0_tx.seq_num);
1748            assert_eq!(v1_tx.memo, v0_tx.memo);
1749            assert_eq!(v1_tx.operations, v0_tx.operations);
1750            assert!(matches!(v1_tx.ext, soroban_rs::xdr::TransactionExt::V0));
1751            assert!(matches!(v1_tx.cond, soroban_rs::xdr::Preconditions::None));
1752
1753            // Check source account conversion
1754            match v1_tx.source_account {
1755                soroban_rs::xdr::MuxedAccount::Ed25519(addr) => {
1756                    assert_eq!(addr, v0_tx.source_account_ed25519);
1757                }
1758                _ => panic!("Expected Ed25519 muxed account"),
1759            }
1760        }
1761
1762        #[test]
1763        fn test_convert_v0_to_v1_transaction_with_time_bounds() {
1764            // Create a V0 transaction with time bounds
1765            let time_bounds = soroban_rs::xdr::TimeBounds {
1766                min_time: soroban_rs::xdr::TimePoint(100),
1767                max_time: soroban_rs::xdr::TimePoint(200),
1768            };
1769
1770            let v0_tx = TransactionV0 {
1771                source_account_ed25519: Uint256([2u8; 32]),
1772                fee: 200,
1773                seq_num: SequenceNumber(456),
1774                time_bounds: Some(time_bounds.clone()),
1775                memo: soroban_rs::xdr::Memo::Text("test".try_into().unwrap()),
1776                operations: vec![].try_into().unwrap(),
1777                ext: soroban_rs::xdr::TransactionV0Ext::V0,
1778            };
1779
1780            // Convert to V1
1781            let v1_tx = convert_v0_to_v1_transaction(&v0_tx);
1782
1783            // Check that time bounds were correctly converted to preconditions
1784            match v1_tx.cond {
1785                soroban_rs::xdr::Preconditions::Time(tb) => {
1786                    assert_eq!(tb, time_bounds);
1787                }
1788                _ => panic!("Expected Time preconditions"),
1789            }
1790        }
1791    }
1792}
1793
1794#[cfg(test)]
1795mod parse_contract_address_tests {
1796    use super::*;
1797    use crate::domain::transaction::stellar::test_helpers::{
1798        TEST_CONTRACT, TEST_PK as TEST_ACCOUNT,
1799    };
1800
1801    #[test]
1802    fn test_parse_valid_contract_address() {
1803        let result = parse_contract_address(TEST_CONTRACT);
1804        assert!(result.is_ok());
1805
1806        let hash = result.unwrap();
1807        assert_eq!(hash.0.len(), 32);
1808    }
1809
1810    #[test]
1811    fn test_parse_invalid_contract_address() {
1812        let result = parse_contract_address("INVALID_CONTRACT");
1813        assert!(result.is_err());
1814
1815        match result.unwrap_err() {
1816            StellarTransactionUtilsError::InvalidContractAddress(addr, _) => {
1817                assert_eq!(addr, "INVALID_CONTRACT");
1818            }
1819            _ => panic!("Expected InvalidContractAddress error"),
1820        }
1821    }
1822
1823    #[test]
1824    fn test_parse_contract_address_wrong_prefix() {
1825        // Try with an account address instead of contract
1826        let result = parse_contract_address(TEST_ACCOUNT);
1827        assert!(result.is_err());
1828    }
1829
1830    #[test]
1831    fn test_parse_empty_contract_address() {
1832        let result = parse_contract_address("");
1833        assert!(result.is_err());
1834    }
1835}
1836
1837// ============================================================================
1838// Update Envelope Sequence and Envelope Fee Tests
1839// ============================================================================
1840
1841#[cfg(test)]
1842mod update_envelope_sequence_tests {
1843    use super::*;
1844    use soroban_rs::xdr::{
1845        FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionExt,
1846        FeeBumpTransactionInnerTx, Memo, MuxedAccount, Preconditions, SequenceNumber, Transaction,
1847        TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV0Ext,
1848        TransactionV1Envelope, Uint256, VecM,
1849    };
1850
1851    fn create_minimal_v1_envelope() -> TransactionEnvelope {
1852        let tx = Transaction {
1853            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
1854            fee: 100,
1855            seq_num: SequenceNumber(0),
1856            cond: Preconditions::None,
1857            memo: Memo::None,
1858            operations: VecM::default(),
1859            ext: TransactionExt::V0,
1860        };
1861        TransactionEnvelope::Tx(TransactionV1Envelope {
1862            tx,
1863            signatures: VecM::default(),
1864        })
1865    }
1866
1867    fn create_v0_envelope() -> TransactionEnvelope {
1868        let tx = TransactionV0 {
1869            source_account_ed25519: Uint256([0u8; 32]),
1870            fee: 100,
1871            seq_num: SequenceNumber(0),
1872            time_bounds: None,
1873            memo: Memo::None,
1874            operations: VecM::default(),
1875            ext: TransactionV0Ext::V0,
1876        };
1877        TransactionEnvelope::TxV0(TransactionV0Envelope {
1878            tx,
1879            signatures: VecM::default(),
1880        })
1881    }
1882
1883    fn create_fee_bump_envelope() -> TransactionEnvelope {
1884        let inner_tx = Transaction {
1885            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
1886            fee: 100,
1887            seq_num: SequenceNumber(0),
1888            cond: Preconditions::None,
1889            memo: Memo::None,
1890            operations: VecM::default(),
1891            ext: TransactionExt::V0,
1892        };
1893        let inner_envelope = TransactionV1Envelope {
1894            tx: inner_tx,
1895            signatures: VecM::default(),
1896        };
1897        let fee_bump_tx = FeeBumpTransaction {
1898            fee_source: MuxedAccount::Ed25519(Uint256([1u8; 32])),
1899            fee: 200,
1900            inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope),
1901            ext: FeeBumpTransactionExt::V0,
1902        };
1903        TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
1904            tx: fee_bump_tx,
1905            signatures: VecM::default(),
1906        })
1907    }
1908
1909    #[test]
1910    fn test_update_envelope_sequence() {
1911        let mut envelope = create_minimal_v1_envelope();
1912        update_envelope_sequence(&mut envelope, 12345).unwrap();
1913        if let TransactionEnvelope::Tx(v1) = &envelope {
1914            assert_eq!(v1.tx.seq_num.0, 12345);
1915        } else {
1916            panic!("Expected Tx envelope");
1917        }
1918    }
1919
1920    #[test]
1921    fn test_update_envelope_sequence_v0_returns_error() {
1922        let mut envelope = create_v0_envelope();
1923        let result = update_envelope_sequence(&mut envelope, 12345);
1924        assert!(result.is_err());
1925        match result.unwrap_err() {
1926            StellarTransactionUtilsError::V0TransactionsNotSupported => {}
1927            _ => panic!("Expected V0TransactionsNotSupported error"),
1928        }
1929    }
1930
1931    #[test]
1932    fn test_update_envelope_sequence_fee_bump_returns_error() {
1933        let mut envelope = create_fee_bump_envelope();
1934        let result = update_envelope_sequence(&mut envelope, 12345);
1935        assert!(result.is_err());
1936        match result.unwrap_err() {
1937            StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump => {}
1938            _ => panic!("Expected CannotUpdateSequenceOnFeeBump error"),
1939        }
1940    }
1941
1942    #[test]
1943    fn test_update_envelope_sequence_zero() {
1944        let mut envelope = create_minimal_v1_envelope();
1945        update_envelope_sequence(&mut envelope, 0).unwrap();
1946        if let TransactionEnvelope::Tx(v1) = &envelope {
1947            assert_eq!(v1.tx.seq_num.0, 0);
1948        } else {
1949            panic!("Expected Tx envelope");
1950        }
1951    }
1952
1953    #[test]
1954    fn test_update_envelope_sequence_max_value() {
1955        let mut envelope = create_minimal_v1_envelope();
1956        update_envelope_sequence(&mut envelope, i64::MAX).unwrap();
1957        if let TransactionEnvelope::Tx(v1) = &envelope {
1958            assert_eq!(v1.tx.seq_num.0, i64::MAX);
1959        } else {
1960            panic!("Expected Tx envelope");
1961        }
1962    }
1963
1964    #[test]
1965    fn test_envelope_fee_in_stroops_v1() {
1966        let envelope = create_minimal_v1_envelope();
1967        let fee = envelope_fee_in_stroops(&envelope).unwrap();
1968        assert_eq!(fee, 100);
1969    }
1970
1971    #[test]
1972    fn test_envelope_fee_in_stroops_v0_returns_error() {
1973        let envelope = create_v0_envelope();
1974        let result = envelope_fee_in_stroops(&envelope);
1975        assert!(result.is_err());
1976        match result.unwrap_err() {
1977            StellarTransactionUtilsError::InvalidTransactionFormat(msg) => {
1978                assert!(msg.contains("Expected V1"));
1979            }
1980            _ => panic!("Expected InvalidTransactionFormat error"),
1981        }
1982    }
1983
1984    #[test]
1985    fn test_envelope_fee_in_stroops_fee_bump_returns_error() {
1986        let envelope = create_fee_bump_envelope();
1987        let result = envelope_fee_in_stroops(&envelope);
1988        assert!(result.is_err());
1989    }
1990}
1991
1992// ============================================================================
1993// Contract Data Key Tests
1994// ============================================================================
1995
1996#[cfg(test)]
1997mod create_contract_data_key_tests {
1998    use super::*;
1999    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
2000    use stellar_strkey::ed25519::PublicKey;
2001
2002    #[test]
2003    fn test_create_key_without_address() {
2004        let result = create_contract_data_key("Balance", None);
2005        assert!(result.is_ok());
2006
2007        match result.unwrap() {
2008            ScVal::Symbol(sym) => {
2009                assert_eq!(sym.to_string(), "Balance");
2010            }
2011            _ => panic!("Expected Symbol ScVal"),
2012        }
2013    }
2014
2015    #[test]
2016    fn test_create_key_with_address() {
2017        let pk = PublicKey::from_string(TEST_ACCOUNT).unwrap();
2018        let uint256 = Uint256(pk.0);
2019        let account_id = AccountId(soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(uint256));
2020        let sc_address = ScAddress::Account(account_id);
2021
2022        let result = create_contract_data_key("Balance", Some(sc_address.clone()));
2023        assert!(result.is_ok());
2024
2025        match result.unwrap() {
2026            ScVal::Vec(Some(vec)) => {
2027                assert_eq!(vec.0.len(), 2);
2028                match &vec.0[0] {
2029                    ScVal::Symbol(sym) => assert_eq!(sym.to_string(), "Balance"),
2030                    _ => panic!("Expected Symbol as first element"),
2031                }
2032                match &vec.0[1] {
2033                    ScVal::Address(addr) => assert_eq!(addr, &sc_address),
2034                    _ => panic!("Expected Address as second element"),
2035                }
2036            }
2037            _ => panic!("Expected Vec ScVal"),
2038        }
2039    }
2040
2041    #[test]
2042    fn test_create_key_invalid_symbol() {
2043        // Test with symbol that's too long or has invalid characters
2044        let very_long_symbol = "a".repeat(100);
2045        let result = create_contract_data_key(&very_long_symbol, None);
2046        assert!(result.is_err());
2047
2048        match result.unwrap_err() {
2049            StellarTransactionUtilsError::SymbolCreationFailed(_, _) => {}
2050            _ => panic!("Expected SymbolCreationFailed error"),
2051        }
2052    }
2053
2054    #[test]
2055    fn test_create_key_decimals() {
2056        let result = create_contract_data_key("Decimals", None);
2057        assert!(result.is_ok());
2058    }
2059}
2060
2061// ============================================================================
2062// Extract ScVal from Contract Data Tests
2063// ============================================================================
2064
2065#[cfg(test)]
2066mod extract_scval_from_contract_data_tests {
2067    use super::*;
2068    use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
2069    use soroban_rs::xdr::{
2070        ContractDataDurability, ContractDataEntry, ExtensionPoint, Hash, LedgerEntry,
2071        LedgerEntryData, LedgerEntryExt, ScSymbol, ScVal, WriteXdr,
2072    };
2073
2074    #[test]
2075    fn test_extract_scval_success() {
2076        let contract_data = ContractDataEntry {
2077            ext: ExtensionPoint::V0,
2078            contract: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
2079            key: ScVal::Symbol(ScSymbol::try_from("test").unwrap()),
2080            durability: ContractDataDurability::Persistent,
2081            val: ScVal::U32(42),
2082        };
2083
2084        let ledger_entry = LedgerEntry {
2085            last_modified_ledger_seq: 100,
2086            data: LedgerEntryData::ContractData(contract_data),
2087            ext: LedgerEntryExt::V0,
2088        };
2089
2090        let xdr = ledger_entry
2091            .data
2092            .to_xdr_base64(soroban_rs::xdr::Limits::none())
2093            .unwrap();
2094
2095        let response = GetLedgerEntriesResponse {
2096            entries: Some(vec![LedgerEntryResult {
2097                key: "test_key".to_string(),
2098                xdr,
2099                last_modified_ledger: 100,
2100                live_until_ledger_seq_ledger_seq: None,
2101            }]),
2102            latest_ledger: 100,
2103        };
2104
2105        let result = extract_scval_from_contract_data(&response, "test");
2106        assert!(result.is_ok());
2107
2108        match result.unwrap() {
2109            ScVal::U32(val) => assert_eq!(val, 42),
2110            _ => panic!("Expected U32 ScVal"),
2111        }
2112    }
2113
2114    #[test]
2115    fn test_extract_scval_no_entries() {
2116        let response = GetLedgerEntriesResponse {
2117            entries: None,
2118            latest_ledger: 100,
2119        };
2120
2121        let result = extract_scval_from_contract_data(&response, "test");
2122        assert!(result.is_err());
2123
2124        match result.unwrap_err() {
2125            StellarTransactionUtilsError::NoEntriesFound(_) => {}
2126            _ => panic!("Expected NoEntriesFound error"),
2127        }
2128    }
2129
2130    #[test]
2131    fn test_extract_scval_empty_entries() {
2132        let response = GetLedgerEntriesResponse {
2133            entries: Some(vec![]),
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::EmptyEntries(_) => {}
2142            _ => panic!("Expected EmptyEntries error"),
2143        }
2144    }
2145}
2146
2147// ============================================================================
2148// Extract u32 from ScVal Tests
2149// ============================================================================
2150
2151#[cfg(test)]
2152mod extract_u32_from_scval_tests {
2153    use super::*;
2154    use soroban_rs::xdr::{Int128Parts, ScVal, UInt128Parts};
2155
2156    #[test]
2157    fn test_extract_from_u32() {
2158        let val = ScVal::U32(42);
2159        assert_eq!(extract_u32_from_scval(&val, "test"), Some(42));
2160    }
2161
2162    #[test]
2163    fn test_extract_from_i32_positive() {
2164        let val = ScVal::I32(100);
2165        assert_eq!(extract_u32_from_scval(&val, "test"), Some(100));
2166    }
2167
2168    #[test]
2169    fn test_extract_from_i32_negative() {
2170        let val = ScVal::I32(-1);
2171        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2172    }
2173
2174    #[test]
2175    fn test_extract_from_u64() {
2176        let val = ScVal::U64(1000);
2177        assert_eq!(extract_u32_from_scval(&val, "test"), Some(1000));
2178    }
2179
2180    #[test]
2181    fn test_extract_from_u64_overflow() {
2182        let val = ScVal::U64(u64::MAX);
2183        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2184    }
2185
2186    #[test]
2187    fn test_extract_from_i64_positive() {
2188        let val = ScVal::I64(500);
2189        assert_eq!(extract_u32_from_scval(&val, "test"), Some(500));
2190    }
2191
2192    #[test]
2193    fn test_extract_from_i64_negative() {
2194        let val = ScVal::I64(-500);
2195        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2196    }
2197
2198    #[test]
2199    fn test_extract_from_u128_small() {
2200        let val = ScVal::U128(UInt128Parts { hi: 0, lo: 255 });
2201        assert_eq!(extract_u32_from_scval(&val, "test"), Some(255));
2202    }
2203
2204    #[test]
2205    fn test_extract_from_u128_hi_set() {
2206        let val = ScVal::U128(UInt128Parts { hi: 1, lo: 0 });
2207        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2208    }
2209
2210    #[test]
2211    fn test_extract_from_i128_small() {
2212        let val = ScVal::I128(Int128Parts { hi: 0, lo: 123 });
2213        assert_eq!(extract_u32_from_scval(&val, "test"), Some(123));
2214    }
2215
2216    #[test]
2217    fn test_extract_from_unsupported_type() {
2218        let val = ScVal::Bool(true);
2219        assert_eq!(extract_u32_from_scval(&val, "test"), None);
2220    }
2221}
2222
2223// ============================================================================
2224// Amount to UI Amount Tests
2225// ============================================================================
2226
2227#[cfg(test)]
2228mod amount_to_ui_amount_tests {
2229    use super::*;
2230
2231    #[test]
2232    fn test_zero_decimals() {
2233        assert_eq!(amount_to_ui_amount(100, 0), "100");
2234        assert_eq!(amount_to_ui_amount(0, 0), "0");
2235    }
2236
2237    #[test]
2238    fn test_with_decimals_no_padding() {
2239        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
2240        assert_eq!(amount_to_ui_amount(1500000, 6), "1.5");
2241        assert_eq!(amount_to_ui_amount(1234567, 6), "1.234567");
2242    }
2243
2244    #[test]
2245    fn test_with_decimals_needs_padding() {
2246        assert_eq!(amount_to_ui_amount(1, 6), "0.000001");
2247        assert_eq!(amount_to_ui_amount(100, 6), "0.0001");
2248        assert_eq!(amount_to_ui_amount(1000, 3), "1");
2249    }
2250
2251    #[test]
2252    fn test_trailing_zeros_removed() {
2253        assert_eq!(amount_to_ui_amount(1000000, 6), "1");
2254        assert_eq!(amount_to_ui_amount(1500000, 7), "0.15");
2255        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
2256    }
2257
2258    #[test]
2259    fn test_zero_amount() {
2260        assert_eq!(amount_to_ui_amount(0, 6), "0");
2261        assert_eq!(amount_to_ui_amount(0, 0), "0");
2262    }
2263
2264    #[test]
2265    fn test_xlm_7_decimals() {
2266        assert_eq!(amount_to_ui_amount(10000000, 7), "1");
2267        assert_eq!(amount_to_ui_amount(15000000, 7), "1.5");
2268        assert_eq!(amount_to_ui_amount(100, 7), "0.00001");
2269    }
2270}
2271
2272// // ============================================================================
2273// // Count Operations Tests
2274// // ============================================================================
2275
2276// #[cfg(test)]
2277#[cfg(test)]
2278mod count_operations_tests {
2279    use super::*;
2280    use soroban_rs::xdr::{
2281        Limits, MuxedAccount, Operation, OperationBody, PaymentOp, TransactionV1Envelope, Uint256,
2282        WriteXdr,
2283    };
2284
2285    #[test]
2286    fn test_count_operations_from_xdr() {
2287        use soroban_rs::xdr::{Memo, Preconditions, SequenceNumber, Transaction, TransactionExt};
2288
2289        // Create two payment operations
2290        let payment_op = Operation {
2291            source_account: None,
2292            body: OperationBody::Payment(PaymentOp {
2293                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2294                asset: Asset::Native,
2295                amount: 100,
2296            }),
2297        };
2298
2299        let operations = vec![payment_op.clone(), payment_op].try_into().unwrap();
2300
2301        let tx = Transaction {
2302            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2303            fee: 100,
2304            seq_num: SequenceNumber(1),
2305            cond: Preconditions::None,
2306            memo: Memo::None,
2307            operations,
2308            ext: TransactionExt::V0,
2309        };
2310
2311        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2312            tx,
2313            signatures: vec![].try_into().unwrap(),
2314        });
2315
2316        let xdr = envelope.to_xdr_base64(Limits::none()).unwrap();
2317        let count = count_operations_from_xdr(&xdr).unwrap();
2318
2319        assert_eq!(count, 2);
2320    }
2321
2322    #[test]
2323    fn test_count_operations_invalid_xdr() {
2324        let result = count_operations_from_xdr("invalid_xdr");
2325        assert!(result.is_err());
2326
2327        match result.unwrap_err() {
2328            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2329            _ => panic!("Expected XdrParseFailed error"),
2330        }
2331    }
2332}
2333
2334// ============================================================================
2335// Estimate Base Fee Tests
2336// ============================================================================
2337
2338#[cfg(test)]
2339mod estimate_base_fee_tests {
2340    use super::*;
2341
2342    #[test]
2343    fn test_single_operation() {
2344        assert_eq!(estimate_base_fee(1), 100);
2345    }
2346
2347    #[test]
2348    fn test_multiple_operations() {
2349        assert_eq!(estimate_base_fee(5), 500);
2350        assert_eq!(estimate_base_fee(10), 1000);
2351    }
2352
2353    #[test]
2354    fn test_zero_operations() {
2355        // Should return fee for at least 1 operation
2356        assert_eq!(estimate_base_fee(0), 100);
2357    }
2358
2359    #[test]
2360    fn test_large_number_of_operations() {
2361        assert_eq!(estimate_base_fee(100), 10000);
2362    }
2363}
2364
2365// ============================================================================
2366// Create Fee Payment Operation Tests
2367// ============================================================================
2368
2369#[cfg(test)]
2370mod create_fee_payment_operation_tests {
2371    use super::*;
2372    use crate::domain::transaction::stellar::test_helpers::TEST_PK as TEST_ACCOUNT;
2373
2374    #[test]
2375    fn test_create_native_payment() {
2376        let result = create_fee_payment_operation(TEST_ACCOUNT, "native", 1000);
2377        assert!(result.is_ok());
2378
2379        match result.unwrap() {
2380            OperationSpec::Payment {
2381                destination,
2382                amount,
2383                asset,
2384            } => {
2385                assert_eq!(destination, TEST_ACCOUNT);
2386                assert_eq!(amount, 1000);
2387                assert!(matches!(asset, AssetSpec::Native));
2388            }
2389            _ => panic!("Expected Payment operation"),
2390        }
2391    }
2392
2393    #[test]
2394    fn test_create_credit4_payment() {
2395        let result = create_fee_payment_operation(
2396            TEST_ACCOUNT,
2397            "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2398            5000,
2399        );
2400        assert!(result.is_ok());
2401
2402        match result.unwrap() {
2403            OperationSpec::Payment {
2404                destination,
2405                amount,
2406                asset,
2407            } => {
2408                assert_eq!(destination, TEST_ACCOUNT);
2409                assert_eq!(amount, 5000);
2410                match asset {
2411                    AssetSpec::Credit4 { code, issuer } => {
2412                        assert_eq!(code, "USDC");
2413                        assert_eq!(
2414                            issuer,
2415                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2416                        );
2417                    }
2418                    _ => panic!("Expected Credit4 asset"),
2419                }
2420            }
2421            _ => panic!("Expected Payment operation"),
2422        }
2423    }
2424
2425    #[test]
2426    fn test_create_credit12_payment() {
2427        let result = create_fee_payment_operation(
2428            TEST_ACCOUNT,
2429            "LONGASSETNAM:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2430            2000,
2431        );
2432        assert!(result.is_ok());
2433
2434        match result.unwrap() {
2435            OperationSpec::Payment {
2436                destination,
2437                amount,
2438                asset,
2439            } => {
2440                assert_eq!(destination, TEST_ACCOUNT);
2441                assert_eq!(amount, 2000);
2442                match asset {
2443                    AssetSpec::Credit12 { code, issuer } => {
2444                        assert_eq!(code, "LONGASSETNAM");
2445                        assert_eq!(
2446                            issuer,
2447                            "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
2448                        );
2449                    }
2450                    _ => panic!("Expected Credit12 asset"),
2451                }
2452            }
2453            _ => panic!("Expected Payment operation"),
2454        }
2455    }
2456
2457    #[test]
2458    fn test_create_payment_empty_asset() {
2459        let result = create_fee_payment_operation(TEST_ACCOUNT, "", 1000);
2460        assert!(result.is_ok());
2461
2462        match result.unwrap() {
2463            OperationSpec::Payment { asset, .. } => {
2464                assert!(matches!(asset, AssetSpec::Native));
2465            }
2466            _ => panic!("Expected Payment operation"),
2467        }
2468    }
2469
2470    #[test]
2471    fn test_create_payment_invalid_format() {
2472        let result = create_fee_payment_operation(TEST_ACCOUNT, "INVALID_FORMAT", 1000);
2473        assert!(result.is_err());
2474
2475        match result.unwrap_err() {
2476            StellarTransactionUtilsError::InvalidAssetFormat(_) => {}
2477            _ => panic!("Expected InvalidAssetFormat error"),
2478        }
2479    }
2480
2481    #[test]
2482    fn test_create_payment_asset_code_too_long() {
2483        let result = create_fee_payment_operation(
2484            TEST_ACCOUNT,
2485            "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
2486            1000,
2487        );
2488        assert!(result.is_err());
2489
2490        match result.unwrap_err() {
2491            StellarTransactionUtilsError::AssetCodeTooLong(max_len, _) => {
2492                assert_eq!(max_len, 12);
2493            }
2494            _ => panic!("Expected AssetCodeTooLong error"),
2495        }
2496    }
2497}
2498
2499#[cfg(test)]
2500mod parse_account_id_tests {
2501    use super::*;
2502    use crate::domain::transaction::stellar::test_helpers::TEST_PK;
2503
2504    #[test]
2505    fn test_parse_account_id_valid() {
2506        let result = parse_account_id(TEST_PK);
2507        assert!(result.is_ok());
2508
2509        let account_id = result.unwrap();
2510        match account_id.0 {
2511            soroban_rs::xdr::PublicKey::PublicKeyTypeEd25519(_) => {}
2512        }
2513    }
2514
2515    #[test]
2516    fn test_parse_account_id_invalid() {
2517        let result = parse_account_id("INVALID_ADDRESS");
2518        assert!(result.is_err());
2519
2520        match result.unwrap_err() {
2521            StellarTransactionUtilsError::InvalidAccountAddress(addr, _) => {
2522                assert_eq!(addr, "INVALID_ADDRESS");
2523            }
2524            _ => panic!("Expected InvalidAccountAddress error"),
2525        }
2526    }
2527
2528    #[test]
2529    fn test_parse_account_id_empty() {
2530        let result = parse_account_id("");
2531        assert!(result.is_err());
2532    }
2533
2534    #[test]
2535    fn test_parse_account_id_wrong_prefix() {
2536        // Contract address instead of account
2537        let result = parse_account_id("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM");
2538        assert!(result.is_err());
2539    }
2540}
2541
2542#[cfg(test)]
2543mod parse_transaction_and_count_operations_tests {
2544    use super::*;
2545    use crate::domain::transaction::stellar::test_helpers::{
2546        create_native_payment_operation, create_xdr_with_operations, TEST_PK, TEST_PK_2,
2547    };
2548    use serde_json::json;
2549
2550    fn create_test_xdr_with_operations(num_ops: usize) -> String {
2551        let payment_op = create_native_payment_operation(TEST_PK_2, 100);
2552        let operations = vec![payment_op; num_ops];
2553        create_xdr_with_operations(TEST_PK, operations, false)
2554    }
2555
2556    #[test]
2557    fn test_parse_xdr_string() {
2558        let xdr = create_test_xdr_with_operations(2);
2559        let json_value = json!(xdr);
2560
2561        let result = parse_transaction_and_count_operations(&json_value);
2562        assert!(result.is_ok());
2563        assert_eq!(result.unwrap(), 2);
2564    }
2565
2566    #[test]
2567    fn test_parse_operations_array() {
2568        let json_value = json!([
2569            {"type": "payment"},
2570            {"type": "payment"},
2571            {"type": "payment"}
2572        ]);
2573
2574        let result = parse_transaction_and_count_operations(&json_value);
2575        assert!(result.is_ok());
2576        assert_eq!(result.unwrap(), 3);
2577    }
2578
2579    #[test]
2580    fn test_parse_object_with_operations() {
2581        let json_value = json!({
2582            "operations": [
2583                {"type": "payment"},
2584                {"type": "payment"}
2585            ]
2586        });
2587
2588        let result = parse_transaction_and_count_operations(&json_value);
2589        assert!(result.is_ok());
2590        assert_eq!(result.unwrap(), 2);
2591    }
2592
2593    #[test]
2594    fn test_parse_object_with_transaction_xdr() {
2595        let xdr = create_test_xdr_with_operations(3);
2596        let json_value = json!({
2597            "transaction_xdr": xdr
2598        });
2599
2600        let result = parse_transaction_and_count_operations(&json_value);
2601        assert!(result.is_ok());
2602        assert_eq!(result.unwrap(), 3);
2603    }
2604
2605    #[test]
2606    fn test_parse_invalid_xdr() {
2607        let json_value = json!("INVALID_XDR");
2608
2609        let result = parse_transaction_and_count_operations(&json_value);
2610        assert!(result.is_err());
2611
2612        match result.unwrap_err() {
2613            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2614            _ => panic!("Expected XdrParseFailed error"),
2615        }
2616    }
2617
2618    #[test]
2619    fn test_parse_invalid_format() {
2620        let json_value = json!(123);
2621
2622        let result = parse_transaction_and_count_operations(&json_value);
2623        assert!(result.is_err());
2624
2625        match result.unwrap_err() {
2626            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2627            _ => panic!("Expected InvalidTransactionFormat error"),
2628        }
2629    }
2630
2631    #[test]
2632    fn test_parse_empty_operations() {
2633        let json_value = json!([]);
2634
2635        let result = parse_transaction_and_count_operations(&json_value);
2636        assert!(result.is_ok());
2637        assert_eq!(result.unwrap(), 0);
2638    }
2639}
2640
2641#[cfg(test)]
2642mod parse_transaction_envelope_tests {
2643    use super::*;
2644    use crate::domain::transaction::stellar::test_helpers::{
2645        create_unsigned_xdr, TEST_PK, TEST_PK_2,
2646    };
2647    use serde_json::json;
2648
2649    fn create_test_xdr() -> String {
2650        create_unsigned_xdr(TEST_PK, TEST_PK_2)
2651    }
2652
2653    #[test]
2654    fn test_parse_xdr_string() {
2655        let xdr = create_test_xdr();
2656        let json_value = json!(xdr);
2657
2658        let result = parse_transaction_envelope(&json_value);
2659        assert!(result.is_ok());
2660
2661        match result.unwrap() {
2662            TransactionEnvelope::Tx(_) => {}
2663            _ => panic!("Expected Tx envelope"),
2664        }
2665    }
2666
2667    #[test]
2668    fn test_parse_object_with_transaction_xdr() {
2669        let xdr = create_test_xdr();
2670        let json_value = json!({
2671            "transaction_xdr": xdr
2672        });
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_invalid_xdr() {
2685        let json_value = json!("INVALID_XDR");
2686
2687        let result = parse_transaction_envelope(&json_value);
2688        assert!(result.is_err());
2689
2690        match result.unwrap_err() {
2691            StellarTransactionUtilsError::XdrParseFailed(_) => {}
2692            _ => panic!("Expected XdrParseFailed error"),
2693        }
2694    }
2695
2696    #[test]
2697    fn test_parse_invalid_format() {
2698        let json_value = json!(123);
2699
2700        let result = parse_transaction_envelope(&json_value);
2701        assert!(result.is_err());
2702
2703        match result.unwrap_err() {
2704            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2705            _ => panic!("Expected InvalidTransactionFormat error"),
2706        }
2707    }
2708
2709    #[test]
2710    fn test_parse_object_without_xdr() {
2711        let json_value = json!({
2712            "some_field": "value"
2713        });
2714
2715        let result = parse_transaction_envelope(&json_value);
2716        assert!(result.is_err());
2717
2718        match result.unwrap_err() {
2719            StellarTransactionUtilsError::InvalidTransactionFormat(_) => {}
2720            _ => panic!("Expected InvalidTransactionFormat error"),
2721        }
2722    }
2723}
2724
2725#[cfg(test)]
2726mod add_operation_to_envelope_tests {
2727    use super::*;
2728    use soroban_rs::xdr::{
2729        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2730        Transaction, TransactionExt, TransactionV0, TransactionV0Envelope, TransactionV1Envelope,
2731        Uint256,
2732    };
2733
2734    fn create_payment_op() -> Operation {
2735        Operation {
2736            source_account: None,
2737            body: OperationBody::Payment(PaymentOp {
2738                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2739                asset: Asset::Native,
2740                amount: 100,
2741            }),
2742        }
2743    }
2744
2745    #[test]
2746    fn test_add_operation_to_tx_v0() {
2747        let payment_op = create_payment_op();
2748        let operations = vec![payment_op.clone()].try_into().unwrap();
2749
2750        let tx = TransactionV0 {
2751            source_account_ed25519: Uint256([0u8; 32]),
2752            fee: 100,
2753            seq_num: SequenceNumber(1),
2754            time_bounds: None,
2755            memo: Memo::None,
2756            operations,
2757            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2758        };
2759
2760        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2761            tx,
2762            signatures: vec![].try_into().unwrap(),
2763        });
2764
2765        let new_op = create_payment_op();
2766        let result = add_operation_to_envelope(&mut envelope, new_op);
2767
2768        assert!(result.is_ok());
2769
2770        match envelope {
2771            TransactionEnvelope::TxV0(e) => {
2772                assert_eq!(e.tx.operations.len(), 2);
2773                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2774            }
2775            _ => panic!("Expected TxV0 envelope"),
2776        }
2777    }
2778
2779    #[test]
2780    fn test_add_operation_to_tx_v1() {
2781        let payment_op = create_payment_op();
2782        let operations = vec![payment_op.clone()].try_into().unwrap();
2783
2784        let tx = Transaction {
2785            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2786            fee: 100,
2787            seq_num: SequenceNumber(1),
2788            cond: Preconditions::None,
2789            memo: Memo::None,
2790            operations,
2791            ext: TransactionExt::V0,
2792        };
2793
2794        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2795            tx,
2796            signatures: vec![].try_into().unwrap(),
2797        });
2798
2799        let new_op = create_payment_op();
2800        let result = add_operation_to_envelope(&mut envelope, new_op);
2801
2802        assert!(result.is_ok());
2803
2804        match envelope {
2805            TransactionEnvelope::Tx(e) => {
2806                assert_eq!(e.tx.operations.len(), 2);
2807                assert_eq!(e.tx.fee, 200); // 100 stroops per operation
2808            }
2809            _ => panic!("Expected Tx envelope"),
2810        }
2811    }
2812
2813    #[test]
2814    fn test_add_operation_to_fee_bump_fails() {
2815        // Create a simple inner transaction
2816        let payment_op = create_payment_op();
2817        let operations = vec![payment_op].try_into().unwrap();
2818
2819        let tx = Transaction {
2820            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2821            fee: 100,
2822            seq_num: SequenceNumber(1),
2823            cond: Preconditions::None,
2824            memo: Memo::None,
2825            operations,
2826            ext: TransactionExt::V0,
2827        };
2828
2829        let inner_envelope = TransactionV1Envelope {
2830            tx,
2831            signatures: vec![].try_into().unwrap(),
2832        };
2833
2834        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
2835
2836        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
2837            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
2838            fee: 200,
2839            inner_tx,
2840            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
2841        };
2842
2843        let mut envelope =
2844            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
2845                tx: fee_bump_tx,
2846                signatures: vec![].try_into().unwrap(),
2847            });
2848
2849        let new_op = create_payment_op();
2850        let result = add_operation_to_envelope(&mut envelope, new_op);
2851
2852        assert!(result.is_err());
2853
2854        match result.unwrap_err() {
2855            StellarTransactionUtilsError::CannotModifyFeeBump => {}
2856            _ => panic!("Expected CannotModifyFeeBump error"),
2857        }
2858    }
2859}
2860
2861#[cfg(test)]
2862mod extract_time_bounds_tests {
2863    use super::*;
2864    use soroban_rs::xdr::{
2865        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
2866        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
2867        TransactionV1Envelope, Uint256,
2868    };
2869
2870    fn create_payment_op() -> Operation {
2871        Operation {
2872            source_account: None,
2873            body: OperationBody::Payment(PaymentOp {
2874                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
2875                asset: Asset::Native,
2876                amount: 100,
2877            }),
2878        }
2879    }
2880
2881    #[test]
2882    fn test_extract_time_bounds_from_tx_v0_with_bounds() {
2883        let payment_op = create_payment_op();
2884        let operations = vec![payment_op].try_into().unwrap();
2885
2886        let time_bounds = TimeBounds {
2887            min_time: TimePoint(0),
2888            max_time: TimePoint(1000),
2889        };
2890
2891        let tx = TransactionV0 {
2892            source_account_ed25519: Uint256([0u8; 32]),
2893            fee: 100,
2894            seq_num: SequenceNumber(1),
2895            time_bounds: Some(time_bounds.clone()),
2896            memo: Memo::None,
2897            operations,
2898            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2899        };
2900
2901        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2902            tx,
2903            signatures: vec![].try_into().unwrap(),
2904        });
2905
2906        let result = extract_time_bounds(&envelope);
2907        assert!(result.is_some());
2908
2909        let bounds = result.unwrap();
2910        assert_eq!(bounds.min_time.0, 0);
2911        assert_eq!(bounds.max_time.0, 1000);
2912    }
2913
2914    #[test]
2915    fn test_extract_time_bounds_from_tx_v0_without_bounds() {
2916        let payment_op = create_payment_op();
2917        let operations = vec![payment_op].try_into().unwrap();
2918
2919        let tx = TransactionV0 {
2920            source_account_ed25519: Uint256([0u8; 32]),
2921            fee: 100,
2922            seq_num: SequenceNumber(1),
2923            time_bounds: None,
2924            memo: Memo::None,
2925            operations,
2926            ext: soroban_rs::xdr::TransactionV0Ext::V0,
2927        };
2928
2929        let envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
2930            tx,
2931            signatures: vec![].try_into().unwrap(),
2932        });
2933
2934        let result = extract_time_bounds(&envelope);
2935        assert!(result.is_none());
2936    }
2937
2938    #[test]
2939    fn test_extract_time_bounds_from_tx_v1_with_time_precondition() {
2940        let payment_op = create_payment_op();
2941        let operations = vec![payment_op].try_into().unwrap();
2942
2943        let time_bounds = TimeBounds {
2944            min_time: TimePoint(0),
2945            max_time: TimePoint(2000),
2946        };
2947
2948        let tx = Transaction {
2949            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2950            fee: 100,
2951            seq_num: SequenceNumber(1),
2952            cond: Preconditions::Time(time_bounds.clone()),
2953            memo: Memo::None,
2954            operations,
2955            ext: TransactionExt::V0,
2956        };
2957
2958        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2959            tx,
2960            signatures: vec![].try_into().unwrap(),
2961        });
2962
2963        let result = extract_time_bounds(&envelope);
2964        assert!(result.is_some());
2965
2966        let bounds = result.unwrap();
2967        assert_eq!(bounds.min_time.0, 0);
2968        assert_eq!(bounds.max_time.0, 2000);
2969    }
2970
2971    #[test]
2972    fn test_extract_time_bounds_from_tx_v1_without_time_precondition() {
2973        let payment_op = create_payment_op();
2974        let operations = vec![payment_op].try_into().unwrap();
2975
2976        let tx = Transaction {
2977            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
2978            fee: 100,
2979            seq_num: SequenceNumber(1),
2980            cond: Preconditions::None,
2981            memo: Memo::None,
2982            operations,
2983            ext: TransactionExt::V0,
2984        };
2985
2986        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
2987            tx,
2988            signatures: vec![].try_into().unwrap(),
2989        });
2990
2991        let result = extract_time_bounds(&envelope);
2992        assert!(result.is_none());
2993    }
2994
2995    #[test]
2996    fn test_extract_time_bounds_from_fee_bump() {
2997        // Create inner transaction with time bounds
2998        let payment_op = create_payment_op();
2999        let operations = vec![payment_op].try_into().unwrap();
3000
3001        let time_bounds = TimeBounds {
3002            min_time: TimePoint(0),
3003            max_time: TimePoint(3000),
3004        };
3005
3006        let tx = Transaction {
3007            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3008            fee: 100,
3009            seq_num: SequenceNumber(1),
3010            cond: Preconditions::Time(time_bounds.clone()),
3011            memo: Memo::None,
3012            operations,
3013            ext: TransactionExt::V0,
3014        };
3015
3016        let inner_envelope = TransactionV1Envelope {
3017            tx,
3018            signatures: vec![].try_into().unwrap(),
3019        };
3020
3021        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
3022
3023        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
3024            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
3025            fee: 200,
3026            inner_tx,
3027            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
3028        };
3029
3030        let envelope =
3031            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
3032                tx: fee_bump_tx,
3033                signatures: vec![].try_into().unwrap(),
3034            });
3035
3036        let result = extract_time_bounds(&envelope);
3037        assert!(result.is_some());
3038
3039        let bounds = result.unwrap();
3040        assert_eq!(bounds.min_time.0, 0);
3041        assert_eq!(bounds.max_time.0, 3000);
3042    }
3043}
3044
3045#[cfg(test)]
3046mod set_time_bounds_tests {
3047    use super::*;
3048    use chrono::Utc;
3049    use soroban_rs::xdr::{
3050        Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, SequenceNumber,
3051        TimeBounds, TimePoint, Transaction, TransactionExt, TransactionV0, TransactionV0Envelope,
3052        TransactionV1Envelope, Uint256,
3053    };
3054
3055    fn create_payment_op() -> Operation {
3056        Operation {
3057            source_account: None,
3058            body: OperationBody::Payment(PaymentOp {
3059                destination: MuxedAccount::Ed25519(Uint256([1u8; 32])),
3060                asset: Asset::Native,
3061                amount: 100,
3062            }),
3063        }
3064    }
3065
3066    #[test]
3067    fn test_set_time_bounds_on_tx_v0() {
3068        let payment_op = create_payment_op();
3069        let operations = vec![payment_op].try_into().unwrap();
3070
3071        let tx = TransactionV0 {
3072            source_account_ed25519: Uint256([0u8; 32]),
3073            fee: 100,
3074            seq_num: SequenceNumber(1),
3075            time_bounds: None,
3076            memo: Memo::None,
3077            operations,
3078            ext: soroban_rs::xdr::TransactionV0Ext::V0,
3079        };
3080
3081        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
3082            tx,
3083            signatures: vec![].try_into().unwrap(),
3084        });
3085
3086        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3087        let result = set_time_bounds(&mut envelope, valid_until);
3088
3089        assert!(result.is_ok());
3090
3091        match envelope {
3092            TransactionEnvelope::TxV0(e) => {
3093                assert!(e.tx.time_bounds.is_some());
3094                let bounds = e.tx.time_bounds.unwrap();
3095                assert_eq!(bounds.min_time.0, 0);
3096                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3097            }
3098            _ => panic!("Expected TxV0 envelope"),
3099        }
3100    }
3101
3102    #[test]
3103    fn test_set_time_bounds_on_tx_v1() {
3104        let payment_op = create_payment_op();
3105        let operations = vec![payment_op].try_into().unwrap();
3106
3107        let tx = Transaction {
3108            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3109            fee: 100,
3110            seq_num: SequenceNumber(1),
3111            cond: Preconditions::None,
3112            memo: Memo::None,
3113            operations,
3114            ext: TransactionExt::V0,
3115        };
3116
3117        let mut envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
3118            tx,
3119            signatures: vec![].try_into().unwrap(),
3120        });
3121
3122        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3123        let result = set_time_bounds(&mut envelope, valid_until);
3124
3125        assert!(result.is_ok());
3126
3127        match envelope {
3128            TransactionEnvelope::Tx(e) => match e.tx.cond {
3129                Preconditions::Time(bounds) => {
3130                    assert_eq!(bounds.min_time.0, 0);
3131                    assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3132                }
3133                _ => panic!("Expected Time precondition"),
3134            },
3135            _ => panic!("Expected Tx envelope"),
3136        }
3137    }
3138
3139    #[test]
3140    fn test_set_time_bounds_on_fee_bump_fails() {
3141        // Create a simple inner transaction
3142        let payment_op = create_payment_op();
3143        let operations = vec![payment_op].try_into().unwrap();
3144
3145        let tx = Transaction {
3146            source_account: MuxedAccount::Ed25519(Uint256([0u8; 32])),
3147            fee: 100,
3148            seq_num: SequenceNumber(1),
3149            cond: Preconditions::None,
3150            memo: Memo::None,
3151            operations,
3152            ext: TransactionExt::V0,
3153        };
3154
3155        let inner_envelope = TransactionV1Envelope {
3156            tx,
3157            signatures: vec![].try_into().unwrap(),
3158        };
3159
3160        let inner_tx = soroban_rs::xdr::FeeBumpTransactionInnerTx::Tx(inner_envelope);
3161
3162        let fee_bump_tx = soroban_rs::xdr::FeeBumpTransaction {
3163            fee_source: MuxedAccount::Ed25519(Uint256([2u8; 32])),
3164            fee: 200,
3165            inner_tx,
3166            ext: soroban_rs::xdr::FeeBumpTransactionExt::V0,
3167        };
3168
3169        let mut envelope =
3170            TransactionEnvelope::TxFeeBump(soroban_rs::xdr::FeeBumpTransactionEnvelope {
3171                tx: fee_bump_tx,
3172                signatures: vec![].try_into().unwrap(),
3173            });
3174
3175        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3176        let result = set_time_bounds(&mut envelope, valid_until);
3177
3178        assert!(result.is_err());
3179
3180        match result.unwrap_err() {
3181            StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump => {}
3182            _ => panic!("Expected CannotSetTimeBoundsOnFeeBump error"),
3183        }
3184    }
3185
3186    #[test]
3187    fn test_set_time_bounds_replaces_existing() {
3188        let payment_op = create_payment_op();
3189        let operations = vec![payment_op].try_into().unwrap();
3190
3191        let old_time_bounds = TimeBounds {
3192            min_time: TimePoint(100),
3193            max_time: TimePoint(1000),
3194        };
3195
3196        let tx = TransactionV0 {
3197            source_account_ed25519: Uint256([0u8; 32]),
3198            fee: 100,
3199            seq_num: SequenceNumber(1),
3200            time_bounds: Some(old_time_bounds),
3201            memo: Memo::None,
3202            operations,
3203            ext: soroban_rs::xdr::TransactionV0Ext::V0,
3204        };
3205
3206        let mut envelope = TransactionEnvelope::TxV0(TransactionV0Envelope {
3207            tx,
3208            signatures: vec![].try_into().unwrap(),
3209        });
3210
3211        let valid_until = Utc::now() + chrono::Duration::seconds(300);
3212        let result = set_time_bounds(&mut envelope, valid_until);
3213
3214        assert!(result.is_ok());
3215
3216        match envelope {
3217            TransactionEnvelope::TxV0(e) => {
3218                assert!(e.tx.time_bounds.is_some());
3219                let bounds = e.tx.time_bounds.unwrap();
3220                // Should replace with new bounds (min_time = 0, not 100)
3221                assert_eq!(bounds.min_time.0, 0);
3222                assert_eq!(bounds.max_time.0, valid_until.timestamp() as u64);
3223            }
3224            _ => panic!("Expected TxV0 envelope"),
3225        }
3226    }
3227}
3228
3229// ============================================================================
3230// From<StellarTransactionUtilsError> for RelayerError Tests
3231// ============================================================================
3232
3233#[cfg(test)]
3234mod stellar_transaction_utils_error_conversion_tests {
3235    use super::*;
3236
3237    #[test]
3238    fn test_v0_transactions_not_supported_converts_to_validation_error() {
3239        let err = StellarTransactionUtilsError::V0TransactionsNotSupported;
3240        let relayer_err: RelayerError = err.into();
3241        match relayer_err {
3242            RelayerError::ValidationError(msg) => {
3243                assert_eq!(msg, "V0 transactions are not supported");
3244            }
3245            _ => panic!("Expected ValidationError"),
3246        }
3247    }
3248
3249    #[test]
3250    fn test_cannot_update_sequence_on_fee_bump_converts_to_validation_error() {
3251        let err = StellarTransactionUtilsError::CannotUpdateSequenceOnFeeBump;
3252        let relayer_err: RelayerError = err.into();
3253        match relayer_err {
3254            RelayerError::ValidationError(msg) => {
3255                assert_eq!(msg, "Cannot update sequence number on fee bump transaction");
3256            }
3257            _ => panic!("Expected ValidationError"),
3258        }
3259    }
3260
3261    #[test]
3262    fn test_cannot_set_time_bounds_on_fee_bump_converts_to_validation_error() {
3263        let err = StellarTransactionUtilsError::CannotSetTimeBoundsOnFeeBump;
3264        let relayer_err: RelayerError = err.into();
3265        match relayer_err {
3266            RelayerError::ValidationError(msg) => {
3267                assert_eq!(msg, "Cannot set time bounds on fee-bump transactions");
3268            }
3269            _ => panic!("Expected ValidationError"),
3270        }
3271    }
3272
3273    #[test]
3274    fn test_invalid_transaction_format_converts_to_validation_error() {
3275        let err = StellarTransactionUtilsError::InvalidTransactionFormat("bad format".to_string());
3276        let relayer_err: RelayerError = err.into();
3277        match relayer_err {
3278            RelayerError::ValidationError(msg) => {
3279                assert_eq!(msg, "bad format");
3280            }
3281            _ => panic!("Expected ValidationError"),
3282        }
3283    }
3284
3285    #[test]
3286    fn test_cannot_modify_fee_bump_converts_to_validation_error() {
3287        let err = StellarTransactionUtilsError::CannotModifyFeeBump;
3288        let relayer_err: RelayerError = err.into();
3289        match relayer_err {
3290            RelayerError::ValidationError(msg) => {
3291                assert_eq!(msg, "Cannot add operations to fee-bump transactions");
3292            }
3293            _ => panic!("Expected ValidationError"),
3294        }
3295    }
3296
3297    #[test]
3298    fn test_too_many_operations_converts_to_validation_error() {
3299        let err = StellarTransactionUtilsError::TooManyOperations(100);
3300        let relayer_err: RelayerError = err.into();
3301        match relayer_err {
3302            RelayerError::ValidationError(msg) => {
3303                assert!(msg.contains("Too many operations"));
3304                assert!(msg.contains("100"));
3305            }
3306            _ => panic!("Expected ValidationError"),
3307        }
3308    }
3309
3310    #[test]
3311    fn test_sequence_overflow_converts_to_internal_error() {
3312        let err = StellarTransactionUtilsError::SequenceOverflow("overflow msg".to_string());
3313        let relayer_err: RelayerError = err.into();
3314        match relayer_err {
3315            RelayerError::Internal(msg) => {
3316                assert_eq!(msg, "overflow msg");
3317            }
3318            _ => panic!("Expected Internal error"),
3319        }
3320    }
3321
3322    #[test]
3323    fn test_simulation_no_results_converts_to_internal_error() {
3324        let err = StellarTransactionUtilsError::SimulationNoResults;
3325        let relayer_err: RelayerError = err.into();
3326        match relayer_err {
3327            RelayerError::Internal(msg) => {
3328                assert!(msg.contains("no results"));
3329            }
3330            _ => panic!("Expected Internal error"),
3331        }
3332    }
3333
3334    #[test]
3335    fn test_asset_code_too_long_converts_to_validation_error() {
3336        let err =
3337            StellarTransactionUtilsError::AssetCodeTooLong(12, "VERYLONGASSETCODE".to_string());
3338        let relayer_err: RelayerError = err.into();
3339        match relayer_err {
3340            RelayerError::ValidationError(msg) => {
3341                assert!(msg.contains("Asset code too long"));
3342                assert!(msg.contains("12"));
3343            }
3344            _ => panic!("Expected ValidationError"),
3345        }
3346    }
3347
3348    #[test]
3349    fn test_invalid_asset_format_converts_to_validation_error() {
3350        let err = StellarTransactionUtilsError::InvalidAssetFormat("bad asset".to_string());
3351        let relayer_err: RelayerError = err.into();
3352        match relayer_err {
3353            RelayerError::ValidationError(msg) => {
3354                assert_eq!(msg, "bad asset");
3355            }
3356            _ => panic!("Expected ValidationError"),
3357        }
3358    }
3359
3360    #[test]
3361    fn test_invalid_account_address_converts_to_internal_error() {
3362        let err = StellarTransactionUtilsError::InvalidAccountAddress(
3363            "GABC".to_string(),
3364            "parse error".to_string(),
3365        );
3366        let relayer_err: RelayerError = err.into();
3367        match relayer_err {
3368            RelayerError::Internal(msg) => {
3369                assert_eq!(msg, "parse error");
3370            }
3371            _ => panic!("Expected Internal error"),
3372        }
3373    }
3374
3375    #[test]
3376    fn test_invalid_contract_address_converts_to_internal_error() {
3377        let err = StellarTransactionUtilsError::InvalidContractAddress(
3378            "CABC".to_string(),
3379            "contract parse error".to_string(),
3380        );
3381        let relayer_err: RelayerError = err.into();
3382        match relayer_err {
3383            RelayerError::Internal(msg) => {
3384                assert_eq!(msg, "contract parse error");
3385            }
3386            _ => panic!("Expected Internal error"),
3387        }
3388    }
3389
3390    #[test]
3391    fn test_symbol_creation_failed_converts_to_internal_error() {
3392        let err = StellarTransactionUtilsError::SymbolCreationFailed(
3393            "Balance".to_string(),
3394            "too long".to_string(),
3395        );
3396        let relayer_err: RelayerError = err.into();
3397        match relayer_err {
3398            RelayerError::Internal(msg) => {
3399                assert_eq!(msg, "too long");
3400            }
3401            _ => panic!("Expected Internal error"),
3402        }
3403    }
3404
3405    #[test]
3406    fn test_key_vector_creation_failed_converts_to_internal_error() {
3407        let err = StellarTransactionUtilsError::KeyVectorCreationFailed(
3408            "Balance".to_string(),
3409            "vec error".to_string(),
3410        );
3411        let relayer_err: RelayerError = err.into();
3412        match relayer_err {
3413            RelayerError::Internal(msg) => {
3414                assert_eq!(msg, "vec error");
3415            }
3416            _ => panic!("Expected Internal error"),
3417        }
3418    }
3419
3420    #[test]
3421    fn test_contract_data_query_persistent_failed_converts_to_internal_error() {
3422        let err = StellarTransactionUtilsError::ContractDataQueryPersistentFailed(
3423            "balance".to_string(),
3424            "rpc error".to_string(),
3425        );
3426        let relayer_err: RelayerError = err.into();
3427        match relayer_err {
3428            RelayerError::Internal(msg) => {
3429                assert_eq!(msg, "rpc error");
3430            }
3431            _ => panic!("Expected Internal error"),
3432        }
3433    }
3434
3435    #[test]
3436    fn test_contract_data_query_temporary_failed_converts_to_internal_error() {
3437        let err = StellarTransactionUtilsError::ContractDataQueryTemporaryFailed(
3438            "balance".to_string(),
3439            "temp error".to_string(),
3440        );
3441        let relayer_err: RelayerError = err.into();
3442        match relayer_err {
3443            RelayerError::Internal(msg) => {
3444                assert_eq!(msg, "temp error");
3445            }
3446            _ => panic!("Expected Internal error"),
3447        }
3448    }
3449
3450    #[test]
3451    fn test_ledger_entry_parse_failed_converts_to_internal_error() {
3452        let err = StellarTransactionUtilsError::LedgerEntryParseFailed(
3453            "entry".to_string(),
3454            "xdr error".to_string(),
3455        );
3456        let relayer_err: RelayerError = err.into();
3457        match relayer_err {
3458            RelayerError::Internal(msg) => {
3459                assert_eq!(msg, "xdr error");
3460            }
3461            _ => panic!("Expected Internal error"),
3462        }
3463    }
3464
3465    #[test]
3466    fn test_no_entries_found_converts_to_validation_error() {
3467        let err = StellarTransactionUtilsError::NoEntriesFound("balance".to_string());
3468        let relayer_err: RelayerError = err.into();
3469        match relayer_err {
3470            RelayerError::ValidationError(msg) => {
3471                assert!(msg.contains("No entries found"));
3472            }
3473            _ => panic!("Expected ValidationError"),
3474        }
3475    }
3476
3477    #[test]
3478    fn test_empty_entries_converts_to_validation_error() {
3479        let err = StellarTransactionUtilsError::EmptyEntries("balance".to_string());
3480        let relayer_err: RelayerError = err.into();
3481        match relayer_err {
3482            RelayerError::ValidationError(msg) => {
3483                assert!(msg.contains("Empty entries"));
3484            }
3485            _ => panic!("Expected ValidationError"),
3486        }
3487    }
3488
3489    #[test]
3490    fn test_unexpected_ledger_entry_type_converts_to_validation_error() {
3491        let err = StellarTransactionUtilsError::UnexpectedLedgerEntryType("balance".to_string());
3492        let relayer_err: RelayerError = err.into();
3493        match relayer_err {
3494            RelayerError::ValidationError(msg) => {
3495                assert!(msg.contains("Unexpected ledger entry type"));
3496            }
3497            _ => panic!("Expected ValidationError"),
3498        }
3499    }
3500
3501    #[test]
3502    fn test_invalid_issuer_length_converts_to_validation_error() {
3503        let err = StellarTransactionUtilsError::InvalidIssuerLength(56, "SHORT".to_string());
3504        let relayer_err: RelayerError = err.into();
3505        match relayer_err {
3506            RelayerError::ValidationError(msg) => {
3507                assert!(msg.contains("56"));
3508                assert!(msg.contains("SHORT"));
3509            }
3510            _ => panic!("Expected ValidationError"),
3511        }
3512    }
3513
3514    #[test]
3515    fn test_invalid_issuer_prefix_converts_to_validation_error() {
3516        let err = StellarTransactionUtilsError::InvalidIssuerPrefix('G', "CABC123".to_string());
3517        let relayer_err: RelayerError = err.into();
3518        match relayer_err {
3519            RelayerError::ValidationError(msg) => {
3520                assert!(msg.contains("'G'"));
3521                assert!(msg.contains("CABC123"));
3522            }
3523            _ => panic!("Expected ValidationError"),
3524        }
3525    }
3526
3527    #[test]
3528    fn test_account_fetch_failed_converts_to_provider_error() {
3529        let err = StellarTransactionUtilsError::AccountFetchFailed("fetch error".to_string());
3530        let relayer_err: RelayerError = err.into();
3531        match relayer_err {
3532            RelayerError::ProviderError(msg) => {
3533                assert_eq!(msg, "fetch error");
3534            }
3535            _ => panic!("Expected ProviderError"),
3536        }
3537    }
3538
3539    #[test]
3540    fn test_trustline_query_failed_converts_to_provider_error() {
3541        let err = StellarTransactionUtilsError::TrustlineQueryFailed(
3542            "USDC".to_string(),
3543            "rpc fail".to_string(),
3544        );
3545        let relayer_err: RelayerError = err.into();
3546        match relayer_err {
3547            RelayerError::ProviderError(msg) => {
3548                assert_eq!(msg, "rpc fail");
3549            }
3550            _ => panic!("Expected ProviderError"),
3551        }
3552    }
3553
3554    #[test]
3555    fn test_contract_invocation_failed_converts_to_provider_error() {
3556        let err = StellarTransactionUtilsError::ContractInvocationFailed(
3557            "transfer".to_string(),
3558            "invoke error".to_string(),
3559        );
3560        let relayer_err: RelayerError = err.into();
3561        match relayer_err {
3562            RelayerError::ProviderError(msg) => {
3563                assert_eq!(msg, "invoke error");
3564            }
3565            _ => panic!("Expected ProviderError"),
3566        }
3567    }
3568
3569    #[test]
3570    fn test_xdr_parse_failed_converts_to_internal_error() {
3571        let err = StellarTransactionUtilsError::XdrParseFailed("xdr parse fail".to_string());
3572        let relayer_err: RelayerError = err.into();
3573        match relayer_err {
3574            RelayerError::Internal(msg) => {
3575                assert_eq!(msg, "xdr parse fail");
3576            }
3577            _ => panic!("Expected Internal error"),
3578        }
3579    }
3580
3581    #[test]
3582    fn test_operation_extraction_failed_converts_to_internal_error() {
3583        let err =
3584            StellarTransactionUtilsError::OperationExtractionFailed("extract fail".to_string());
3585        let relayer_err: RelayerError = err.into();
3586        match relayer_err {
3587            RelayerError::Internal(msg) => {
3588                assert_eq!(msg, "extract fail");
3589            }
3590            _ => panic!("Expected Internal error"),
3591        }
3592    }
3593
3594    #[test]
3595    fn test_simulation_failed_converts_to_internal_error() {
3596        let err = StellarTransactionUtilsError::SimulationFailed("sim error".to_string());
3597        let relayer_err: RelayerError = err.into();
3598        match relayer_err {
3599            RelayerError::Internal(msg) => {
3600                assert_eq!(msg, "sim error");
3601            }
3602            _ => panic!("Expected Internal error"),
3603        }
3604    }
3605
3606    #[test]
3607    fn test_simulation_check_failed_converts_to_internal_error() {
3608        let err = StellarTransactionUtilsError::SimulationCheckFailed("check fail".to_string());
3609        let relayer_err: RelayerError = err.into();
3610        match relayer_err {
3611            RelayerError::Internal(msg) => {
3612                assert_eq!(msg, "check fail");
3613            }
3614            _ => panic!("Expected Internal error"),
3615        }
3616    }
3617
3618    #[test]
3619    fn test_dex_quote_failed_converts_to_internal_error() {
3620        let err = StellarTransactionUtilsError::DexQuoteFailed("dex error".to_string());
3621        let relayer_err: RelayerError = err.into();
3622        match relayer_err {
3623            RelayerError::Internal(msg) => {
3624                assert_eq!(msg, "dex error");
3625            }
3626            _ => panic!("Expected Internal error"),
3627        }
3628    }
3629
3630    #[test]
3631    fn test_empty_asset_code_converts_to_validation_error() {
3632        let err = StellarTransactionUtilsError::EmptyAssetCode("CODE:ISSUER".to_string());
3633        let relayer_err: RelayerError = err.into();
3634        match relayer_err {
3635            RelayerError::ValidationError(msg) => {
3636                assert!(msg.contains("Asset code cannot be empty"));
3637            }
3638            _ => panic!("Expected ValidationError"),
3639        }
3640    }
3641
3642    #[test]
3643    fn test_empty_issuer_address_converts_to_validation_error() {
3644        let err = StellarTransactionUtilsError::EmptyIssuerAddress("USDC:".to_string());
3645        let relayer_err: RelayerError = err.into();
3646        match relayer_err {
3647            RelayerError::ValidationError(msg) => {
3648                assert!(msg.contains("Issuer address cannot be empty"));
3649            }
3650            _ => panic!("Expected ValidationError"),
3651        }
3652    }
3653
3654    #[test]
3655    fn test_no_trustline_found_converts_to_validation_error() {
3656        let err =
3657            StellarTransactionUtilsError::NoTrustlineFound("USDC".to_string(), "GABC".to_string());
3658        let relayer_err: RelayerError = err.into();
3659        match relayer_err {
3660            RelayerError::ValidationError(msg) => {
3661                assert!(msg.contains("No trustline found"));
3662            }
3663            _ => panic!("Expected ValidationError"),
3664        }
3665    }
3666
3667    #[test]
3668    fn test_unsupported_trustline_version_converts_to_validation_error() {
3669        let err = StellarTransactionUtilsError::UnsupportedTrustlineVersion;
3670        let relayer_err: RelayerError = err.into();
3671        match relayer_err {
3672            RelayerError::ValidationError(msg) => {
3673                assert!(msg.contains("Unsupported trustline"));
3674            }
3675            _ => panic!("Expected ValidationError"),
3676        }
3677    }
3678
3679    #[test]
3680    fn test_unexpected_trustline_entry_type_converts_to_validation_error() {
3681        let err = StellarTransactionUtilsError::UnexpectedTrustlineEntryType;
3682        let relayer_err: RelayerError = err.into();
3683        match relayer_err {
3684            RelayerError::ValidationError(msg) => {
3685                assert!(msg.contains("Unexpected ledger entry type"));
3686            }
3687            _ => panic!("Expected ValidationError"),
3688        }
3689    }
3690
3691    #[test]
3692    fn test_balance_too_large_converts_to_validation_error() {
3693        let err = StellarTransactionUtilsError::BalanceTooLarge(1, 999);
3694        let relayer_err: RelayerError = err.into();
3695        match relayer_err {
3696            RelayerError::ValidationError(msg) => {
3697                assert!(msg.contains("Balance too large"));
3698            }
3699            _ => panic!("Expected ValidationError"),
3700        }
3701    }
3702
3703    #[test]
3704    fn test_negative_balance_i128_converts_to_validation_error() {
3705        let err = StellarTransactionUtilsError::NegativeBalanceI128(42);
3706        let relayer_err: RelayerError = err.into();
3707        match relayer_err {
3708            RelayerError::ValidationError(msg) => {
3709                assert!(msg.contains("Negative balance"));
3710            }
3711            _ => panic!("Expected ValidationError"),
3712        }
3713    }
3714
3715    #[test]
3716    fn test_negative_balance_i64_converts_to_validation_error() {
3717        let err = StellarTransactionUtilsError::NegativeBalanceI64(-5);
3718        let relayer_err: RelayerError = err.into();
3719        match relayer_err {
3720            RelayerError::ValidationError(msg) => {
3721                assert!(msg.contains("Negative balance"));
3722            }
3723            _ => panic!("Expected ValidationError"),
3724        }
3725    }
3726
3727    #[test]
3728    fn test_unexpected_balance_type_converts_to_validation_error() {
3729        let err = StellarTransactionUtilsError::UnexpectedBalanceType("Bool(true)".to_string());
3730        let relayer_err: RelayerError = err.into();
3731        match relayer_err {
3732            RelayerError::ValidationError(msg) => {
3733                assert!(msg.contains("Unexpected balance value type"));
3734            }
3735            _ => panic!("Expected ValidationError"),
3736        }
3737    }
3738
3739    #[test]
3740    fn test_unexpected_contract_data_entry_type_converts_to_validation_error() {
3741        let err = StellarTransactionUtilsError::UnexpectedContractDataEntryType;
3742        let relayer_err: RelayerError = err.into();
3743        match relayer_err {
3744            RelayerError::ValidationError(msg) => {
3745                assert!(msg.contains("Unexpected ledger entry type"));
3746            }
3747            _ => panic!("Expected ValidationError"),
3748        }
3749    }
3750
3751    #[test]
3752    fn test_native_asset_in_trustline_query_converts_to_validation_error() {
3753        let err = StellarTransactionUtilsError::NativeAssetInTrustlineQuery;
3754        let relayer_err: RelayerError = err.into();
3755        match relayer_err {
3756            RelayerError::ValidationError(msg) => {
3757                assert!(msg.contains("Native asset"));
3758            }
3759            _ => panic!("Expected ValidationError"),
3760        }
3761    }
3762}
3763
3764#[cfg(test)]
3765mod compute_resubmit_backoff_interval_tests {
3766    use super::compute_resubmit_backoff_interval;
3767    use chrono::Duration;
3768
3769    const BASE: i64 = 10;
3770    const MAX: i64 = 120;
3771
3772    #[test]
3773    fn returns_none_below_base() {
3774        assert!(compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX).is_none());
3775        assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX).is_none());
3776        assert!(compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX).is_none());
3777    }
3778
3779    #[test]
3780    fn base_interval_at_1x() {
3781        // age 10-19s: ratio=1, log2(1)=0, interval = 10 * 2^0 = 10s
3782        assert_eq!(
3783            compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX),
3784            Some(Duration::seconds(10))
3785        );
3786        assert_eq!(
3787            compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX),
3788            Some(Duration::seconds(10))
3789        );
3790    }
3791
3792    #[test]
3793    fn doubles_at_2x() {
3794        // age 20-39s: ratio=2-3, log2(2)=1, interval = 10 * 2^1 = 20s
3795        assert_eq!(
3796            compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX),
3797            Some(Duration::seconds(20))
3798        );
3799        assert_eq!(
3800            compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX),
3801            Some(Duration::seconds(20))
3802        );
3803    }
3804
3805    #[test]
3806    fn quadruples_at_4x() {
3807        // age 40-79s: ratio=4-7, log2(4)=2, interval = 10 * 2^2 = 40s
3808        assert_eq!(
3809            compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX),
3810            Some(Duration::seconds(40))
3811        );
3812        assert_eq!(
3813            compute_resubmit_backoff_interval(Duration::seconds(79), BASE, MAX),
3814            Some(Duration::seconds(40))
3815        );
3816    }
3817
3818    #[test]
3819    fn interval_at_8x() {
3820        // age 80-119s: ratio=8-11, log2(8)=3, interval = 10 * 2^3 = 80s
3821        assert_eq!(
3822            compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX),
3823            Some(Duration::seconds(80))
3824        );
3825        assert_eq!(
3826            compute_resubmit_backoff_interval(Duration::seconds(119), BASE, MAX),
3827            Some(Duration::seconds(80))
3828        );
3829    }
3830
3831    #[test]
3832    fn capped_at_max() {
3833        // age 160s: ratio=16, log2(16)=4, interval = 10*16 = 160 → capped at 120s
3834        assert_eq!(
3835            compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX),
3836            Some(Duration::seconds(MAX))
3837        );
3838        // age 1280s: ratio=128, log2(128)=7, interval = 10*128 = 1280 → capped at 120s
3839        assert_eq!(
3840            compute_resubmit_backoff_interval(Duration::seconds(1280), BASE, MAX),
3841            Some(Duration::seconds(MAX))
3842        );
3843    }
3844
3845    #[test]
3846    fn works_with_different_base_and_max() {
3847        // Verify the function is generic, not hardcoded to Stellar constants
3848        let base = 5;
3849        let max = 30;
3850        // age 5-9s: interval = 5s
3851        assert_eq!(
3852            compute_resubmit_backoff_interval(Duration::seconds(5), base, max),
3853            Some(Duration::seconds(5))
3854        );
3855        // age 10-19s: interval = 10s
3856        assert_eq!(
3857            compute_resubmit_backoff_interval(Duration::seconds(10), base, max),
3858            Some(Duration::seconds(10))
3859        );
3860        // age 40s: interval = 40 → capped at 30s
3861        assert_eq!(
3862            compute_resubmit_backoff_interval(Duration::seconds(40), base, max),
3863            Some(Duration::seconds(30))
3864        );
3865    }
3866}