openzeppelin_relayer/models/transaction/
repository.rs

1use super::evm::Speed;
2use crate::{
3    config::ServerConfig,
4    constants::{
5        DEFAULT_GAS_LIMIT, DEFAULT_TRANSACTION_SPEED, FINAL_TRANSACTION_STATUSES,
6        STELLAR_DEFAULT_MAX_FEE, STELLAR_DEFAULT_TRANSACTION_FEE,
7        STELLAR_SPONSORED_TRANSACTION_VALIDITY_MINUTES,
8    },
9    domain::{
10        evm::PriceParams,
11        stellar::validation::{validate_operations, validate_soroban_memo_restriction},
12        transaction::stellar::utils::extract_time_bounds,
13        xdr_utils::{is_signed, parse_transaction_xdr},
14        SignTransactionResponseEvm,
15    },
16    models::{
17        transaction::{
18            request::{evm::EvmTransactionRequest, stellar::StellarTransactionRequest},
19            solana::SolanaInstructionSpec,
20            stellar::{DecoratedSignature, MemoSpec, OperationSpec},
21        },
22        AddressError, EvmNetwork, NetworkRepoModel, NetworkTransactionRequest, NetworkType,
23        RelayerError, RelayerRepoModel, SignerError, StellarNetwork, StellarValidationError,
24        TransactionError, U256,
25    },
26    utils::{deserialize_optional_u128, serialize_optional_u128},
27};
28use alloy::{
29    consensus::{TxEip1559, TxLegacy},
30    primitives::{Address as AlloyAddress, Bytes, TxKind},
31    rpc::types::AccessList,
32};
33
34use chrono::{Duration, Utc};
35use serde::{Deserialize, Serialize};
36use soroban_rs::xdr::{TransactionEnvelope, TransactionV1Envelope, VecM};
37use std::{convert::TryFrom, str::FromStr};
38use strum::Display;
39
40use utoipa::ToSchema;
41use uuid::Uuid;
42
43use soroban_rs::xdr::Transaction as SorobanTransaction;
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, Display)]
46#[serde(rename_all = "lowercase")]
47pub enum TransactionStatus {
48    Canceled,
49    Pending,
50    Sent,
51    Submitted,
52    Mined,
53    Confirmed,
54    Failed,
55    Expired,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
59/// Metadata for a transaction
60pub struct TransactionMetadata {
61    /// Number of consecutive failures
62    #[serde(default)]
63    pub consecutive_failures: u32,
64    #[serde(default)]
65    pub total_failures: u32,
66    /// Number of submission retries triggered by Stellar insufficient-fee errors
67    #[serde(default)]
68    pub insufficient_fee_retries: u32,
69    /// Number of submission retries triggered by Stellar TRY_AGAIN_LATER responses
70    #[serde(default)]
71    pub try_again_later_retries: u32,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct TransactionUpdateRequest {
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub status: Option<TransactionStatus>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub status_reason: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub sent_at: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub confirmed_at: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub network_data: Option<NetworkTransactionData>,
86    /// Timestamp when gas price was determined
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub priced_at: Option<String>,
89    /// History of transaction hashes
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub hashes: Option<Vec<String>>,
92    /// Number of no-ops in the transaction
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub noop_count: Option<u32>,
95    /// Whether the transaction is canceled
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub is_canceled: Option<bool>,
98    /// Timestamp when this transaction should be deleted (for final states)
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub delete_at: Option<String>,
101    /// Status check metadata (failure counters for circuit breaker)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub metadata: Option<TransactionMetadata>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct TransactionRepoModel {
108    pub id: String,
109    pub relayer_id: String,
110    pub status: TransactionStatus,
111    pub status_reason: Option<String>,
112    pub created_at: String,
113    pub sent_at: Option<String>,
114    pub confirmed_at: Option<String>,
115    pub valid_until: Option<String>,
116    /// Timestamp when this transaction should be deleted (for final states)
117    pub delete_at: Option<String>,
118    pub network_data: NetworkTransactionData,
119    /// Timestamp when gas price was determined
120    pub priced_at: Option<String>,
121    /// History of transaction hashes
122    pub hashes: Vec<String>,
123    pub network_type: NetworkType,
124    pub noop_count: Option<u32>,
125    pub is_canceled: Option<bool>,
126    /// Status check metadata (failure counters for circuit breaker)
127    #[serde(default)]
128    pub metadata: Option<TransactionMetadata>,
129}
130
131impl TransactionRepoModel {
132    /// Validates the transaction repository model
133    ///
134    /// # Returns
135    /// * `Ok(())` if the transaction is valid
136    /// * `Err(TransactionError)` if validation fails
137    pub fn validate(&self) -> Result<(), TransactionError> {
138        Ok(())
139    }
140
141    /// Calculate when this transaction should be deleted based on its status and expiration hours
142    /// Supports fractional hours (e.g., 0.1 = 6 minutes).
143    fn calculate_delete_at(expiration_hours: f64) -> Option<String> {
144        // Convert fractional hours to seconds (e.g., 0.1 hours = 360 seconds)
145        let seconds = (expiration_hours * 3600.0) as i64;
146        let delete_time = Utc::now() + Duration::seconds(seconds);
147        Some(delete_time.to_rfc3339())
148    }
149
150    /// Update delete_at field if status changed to a final state
151    pub fn update_delete_at_if_final_status(&mut self) {
152        if self.delete_at.is_none() && FINAL_TRANSACTION_STATUSES.contains(&self.status) {
153            let expiration_hours = ServerConfig::get_transaction_expiration_hours();
154            self.delete_at = Self::calculate_delete_at(expiration_hours);
155        }
156    }
157
158    /// Apply partial updates to this transaction model
159    ///
160    /// This method encapsulates the business logic for updating transaction fields,
161    /// ensuring consistency across all repository implementations.
162    ///
163    /// # Arguments
164    /// * `update` - The partial update request containing the fields to update
165    pub fn apply_partial_update(&mut self, update: TransactionUpdateRequest) {
166        // Apply partial updates
167        if let Some(status) = update.status {
168            self.status = status;
169            self.update_delete_at_if_final_status();
170        }
171        if let Some(status_reason) = update.status_reason {
172            self.status_reason = Some(status_reason);
173        }
174        if let Some(sent_at) = update.sent_at {
175            self.sent_at = Some(sent_at);
176        }
177        if let Some(confirmed_at) = update.confirmed_at {
178            self.confirmed_at = Some(confirmed_at);
179        }
180        if let Some(network_data) = update.network_data {
181            self.network_data = network_data;
182        }
183        if let Some(priced_at) = update.priced_at {
184            self.priced_at = Some(priced_at);
185        }
186        if let Some(hashes) = update.hashes {
187            self.hashes = hashes;
188        }
189        if let Some(noop_count) = update.noop_count {
190            self.noop_count = Some(noop_count);
191        }
192        if let Some(is_canceled) = update.is_canceled {
193            self.is_canceled = Some(is_canceled);
194        }
195        if let Some(delete_at) = update.delete_at {
196            self.delete_at = Some(delete_at);
197        }
198        if let Some(metadata) = update.metadata {
199            self.metadata = Some(metadata);
200        }
201    }
202
203    /// Creates a TransactionUpdateRequest to reset this transaction to its pre-prepare state.
204    /// This is used when a transaction needs to be retried from the beginning (e.g., bad sequence error).
205    ///
206    /// For Stellar transactions:
207    /// - Resets status to Pending
208    /// - Clears sent_at and confirmed_at timestamps
209    /// - Resets hashes array
210    /// - Calls reset_to_pre_prepare_state on the StellarTransactionData
211    ///
212    /// For other networks, only resets the common fields.
213    pub fn create_reset_update_request(
214        &self,
215    ) -> Result<TransactionUpdateRequest, TransactionError> {
216        let network_data = match &self.network_data {
217            NetworkTransactionData::Stellar(stellar_data) => Some(NetworkTransactionData::Stellar(
218                stellar_data.clone().reset_to_pre_prepare_state(),
219            )),
220            // For other networks, we don't modify the network data
221            _ => None,
222        };
223
224        Ok(TransactionUpdateRequest {
225            status: Some(TransactionStatus::Pending),
226            status_reason: None,
227            sent_at: None,
228            confirmed_at: None,
229            network_data,
230            priced_at: None,
231            hashes: Some(vec![]),
232            noop_count: None,
233            is_canceled: None,
234            delete_at: None,
235            metadata: None,
236        })
237    }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241#[serde(tag = "network_data", content = "data")]
242#[allow(clippy::large_enum_variant)]
243pub enum NetworkTransactionData {
244    Evm(EvmTransactionData),
245    Solana(SolanaTransactionData),
246    Stellar(StellarTransactionData),
247}
248
249impl NetworkTransactionData {
250    pub fn get_evm_transaction_data(&self) -> Result<EvmTransactionData, TransactionError> {
251        match self {
252            NetworkTransactionData::Evm(data) => Ok(data.clone()),
253            _ => Err(TransactionError::InvalidType(
254                "Expected EVM transaction".to_string(),
255            )),
256        }
257    }
258
259    pub fn get_solana_transaction_data(&self) -> Result<SolanaTransactionData, TransactionError> {
260        match self {
261            NetworkTransactionData::Solana(data) => Ok(data.clone()),
262            _ => Err(TransactionError::InvalidType(
263                "Expected Solana transaction".to_string(),
264            )),
265        }
266    }
267
268    pub fn get_stellar_transaction_data(&self) -> Result<StellarTransactionData, TransactionError> {
269        match self {
270            NetworkTransactionData::Stellar(data) => Ok(data.clone()),
271            _ => Err(TransactionError::InvalidType(
272                "Expected Stellar transaction".to_string(),
273            )),
274        }
275    }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
279pub struct EvmTransactionDataSignature {
280    pub r: String,
281    pub s: String,
282    pub v: u8,
283    pub sig: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct EvmTransactionData {
288    #[serde(
289        serialize_with = "serialize_optional_u128",
290        deserialize_with = "deserialize_optional_u128",
291        default
292    )]
293    pub gas_price: Option<u128>,
294    pub gas_limit: Option<u64>,
295    pub nonce: Option<u64>,
296    pub value: U256,
297    pub data: Option<String>,
298    pub from: String,
299    pub to: Option<String>,
300    pub chain_id: u64,
301    pub hash: Option<String>,
302    pub signature: Option<EvmTransactionDataSignature>,
303    pub speed: Option<Speed>,
304    #[serde(
305        serialize_with = "serialize_optional_u128",
306        deserialize_with = "deserialize_optional_u128",
307        default
308    )]
309    pub max_fee_per_gas: Option<u128>,
310    #[serde(
311        serialize_with = "serialize_optional_u128",
312        deserialize_with = "deserialize_optional_u128",
313        default
314    )]
315    pub max_priority_fee_per_gas: Option<u128>,
316    pub raw: Option<Vec<u8>>,
317}
318
319impl EvmTransactionData {
320    /// Creates transaction data for replacement by combining existing transaction data with new request data.
321    ///
322    /// Preserves critical fields like chain_id, from address, and nonce while applying new transaction parameters.
323    /// Pricing fields are cleared and must be calculated separately.
324    ///
325    /// # Arguments
326    /// * `old_data` - The existing transaction data to preserve core fields from
327    /// * `request` - The new transaction request containing updated parameters
328    ///
329    /// # Returns
330    /// New `EvmTransactionData` configured for replacement transaction
331    pub fn for_replacement(old_data: &EvmTransactionData, request: &EvmTransactionRequest) -> Self {
332        Self {
333            // Preserve existing fields from old transaction
334            chain_id: old_data.chain_id,
335            from: old_data.from.clone(),
336            nonce: old_data.nonce, // Preserve original nonce for replacement
337
338            // Apply new fields from request
339            to: request.to.clone(),
340            value: request.value,
341            data: request.data.clone(),
342            gas_limit: request.gas_limit,
343            speed: request
344                .speed
345                .clone()
346                .or_else(|| old_data.speed.clone())
347                .or(Some(DEFAULT_TRANSACTION_SPEED)),
348
349            // Clear pricing fields - these will be calculated later
350            gas_price: None,
351            max_fee_per_gas: None,
352            max_priority_fee_per_gas: None,
353
354            // Reset signing fields
355            signature: None,
356            hash: None,
357            raw: None,
358        }
359    }
360
361    /// Updates the transaction data with calculated price parameters.
362    ///
363    /// # Arguments
364    /// * `price_params` - Calculated pricing parameters containing gas price and EIP-1559 fees
365    ///
366    /// # Returns
367    /// The updated `EvmTransactionData` with pricing information applied
368    pub fn with_price_params(mut self, price_params: PriceParams) -> Self {
369        self.gas_price = price_params.gas_price;
370        self.max_fee_per_gas = price_params.max_fee_per_gas;
371        self.max_priority_fee_per_gas = price_params.max_priority_fee_per_gas;
372
373        self
374    }
375
376    /// Updates the transaction data with an estimated gas limit.
377    ///
378    /// # Arguments
379    /// * `gas_limit` - The estimated gas limit for the transaction
380    ///
381    /// # Returns
382    /// The updated `EvmTransactionData` with the new gas limit
383    pub fn with_gas_estimate(mut self, gas_limit: u64) -> Self {
384        self.gas_limit = Some(gas_limit);
385        self
386    }
387
388    /// Updates the transaction data with a specific nonce value.
389    ///
390    /// # Arguments
391    /// * `nonce` - The nonce value to set for the transaction
392    ///
393    /// # Returns
394    /// The updated `EvmTransactionData` with the specified nonce
395    pub fn with_nonce(mut self, nonce: u64) -> Self {
396        self.nonce = Some(nonce);
397        self
398    }
399
400    /// Updates the transaction data with signature information from a signed transaction response.
401    ///
402    /// # Arguments
403    /// * `sig` - The signed transaction response containing signature, hash, and raw transaction data
404    ///
405    /// # Returns
406    /// The updated `EvmTransactionData` with signature information applied
407    pub fn with_signed_transaction_data(mut self, sig: SignTransactionResponseEvm) -> Self {
408        self.signature = Some(sig.signature);
409        self.hash = Some(sig.hash);
410        self.raw = Some(sig.raw);
411        self
412    }
413}
414
415#[cfg(test)]
416impl Default for EvmTransactionData {
417    fn default() -> Self {
418        Self {
419            from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(), // Standard Hardhat test address
420            to: Some("0x70997970C51812dc3A010C7d01b50e0d17dc79C8".to_string()), // Standard Hardhat test address
421            gas_price: Some(20000000000),
422            value: U256::from(1000000000000000000u128), // 1 ETH
423            data: Some("0x".to_string()),
424            nonce: Some(1),
425            chain_id: 1,
426            gas_limit: Some(DEFAULT_GAS_LIMIT),
427            hash: None,
428            signature: None,
429            speed: None,
430            max_fee_per_gas: None,
431            max_priority_fee_per_gas: None,
432            raw: None,
433        }
434    }
435}
436
437#[cfg(test)]
438impl Default for TransactionRepoModel {
439    fn default() -> Self {
440        Self {
441            id: "00000000-0000-0000-0000-000000000001".to_string(),
442            relayer_id: "00000000-0000-0000-0000-000000000002".to_string(),
443            status: TransactionStatus::Pending,
444            created_at: "2023-01-01T00:00:00Z".to_string(),
445            status_reason: None,
446            sent_at: None,
447            confirmed_at: None,
448            valid_until: None,
449            delete_at: None,
450            network_data: NetworkTransactionData::Evm(EvmTransactionData::default()),
451            network_type: NetworkType::Evm,
452            priced_at: None,
453            hashes: Vec::new(),
454            noop_count: None,
455            is_canceled: Some(false),
456            metadata: None,
457        }
458    }
459}
460
461pub trait EvmTransactionDataTrait {
462    fn is_legacy(&self) -> bool;
463    fn is_eip1559(&self) -> bool;
464    fn is_speed(&self) -> bool;
465}
466
467impl EvmTransactionDataTrait for EvmTransactionData {
468    fn is_legacy(&self) -> bool {
469        self.gas_price.is_some()
470    }
471
472    fn is_eip1559(&self) -> bool {
473        self.max_fee_per_gas.is_some() && self.max_priority_fee_per_gas.is_some()
474    }
475
476    fn is_speed(&self) -> bool {
477        self.speed.is_some()
478    }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, Default)]
482pub struct SolanaTransactionData {
483    /// Pre-built serialized transaction (base64) - mutually exclusive with instructions
484    pub transaction: Option<String>,
485    /// Instructions to build transaction from - mutually exclusive with transaction
486    pub instructions: Option<Vec<SolanaInstructionSpec>>,
487    /// Transaction signature after submission
488    pub signature: Option<String>,
489}
490
491impl SolanaTransactionData {
492    /// Creates a new `SolanaTransactionData` with an updated signature.
493    /// Moves the data to avoid unnecessary cloning.
494    pub fn with_signature(mut self, signature: String) -> Self {
495        self.signature = Some(signature);
496        self
497    }
498}
499
500/// Represents different input types for Stellar transactions
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub enum TransactionInput {
503    /// Operations to be built into a transaction
504    Operations(Vec<OperationSpec>),
505    /// Pre-built unsigned XDR that needs signing
506    UnsignedXdr(String),
507    /// Pre-built signed XDR that needs fee-bumping
508    SignedXdr { xdr: String, max_fee: i64 },
509    /// Soroban gas abstraction: FeeForwarder transaction with user's signed auth entry
510    /// The XDR is the FeeForwarder transaction from /build, and the signed_auth_entry
511    /// contains the user's signed SorobanAuthorizationEntry to be injected.
512    SorobanGasAbstraction {
513        xdr: String,
514        signed_auth_entry: String,
515    },
516}
517
518impl Default for TransactionInput {
519    fn default() -> Self {
520        TransactionInput::Operations(vec![])
521    }
522}
523
524impl TransactionInput {
525    /// Create a TransactionInput from a StellarTransactionRequest
526    pub fn from_stellar_request(
527        request: &StellarTransactionRequest,
528    ) -> Result<Self, TransactionError> {
529        // Handle Soroban gas abstraction mode (XDR + signed_auth_entry)
530        if let (Some(xdr), Some(signed_auth_entry)) =
531            (&request.transaction_xdr, &request.signed_auth_entry)
532        {
533            // Validation: signed_auth_entry and fee_bump are mutually exclusive
534            // (already validated in StellarTransactionRequest::validate(), but double-check here)
535            if request.fee_bump == Some(true) {
536                return Err(TransactionError::ValidationError(
537                    "Cannot use both signed_auth_entry and fee_bump".to_string(),
538                ));
539            }
540
541            return Ok(TransactionInput::SorobanGasAbstraction {
542                xdr: xdr.clone(),
543                signed_auth_entry: signed_auth_entry.clone(),
544            });
545        }
546
547        // Handle XDR mode
548        if let Some(xdr) = &request.transaction_xdr {
549            let envelope = parse_transaction_xdr(xdr, false)
550                .map_err(|e| TransactionError::ValidationError(e.to_string()))?;
551
552            return if request.fee_bump == Some(true) {
553                // Fee bump requires signed XDR
554                if !is_signed(&envelope) {
555                    Err(TransactionError::ValidationError(
556                        "Cannot request fee_bump with unsigned XDR".to_string(),
557                    ))
558                } else {
559                    let max_fee = request.max_fee.unwrap_or(STELLAR_DEFAULT_MAX_FEE);
560                    Ok(TransactionInput::SignedXdr {
561                        xdr: xdr.clone(),
562                        max_fee,
563                    })
564                }
565            } else {
566                // No fee bump - must be unsigned
567                if is_signed(&envelope) {
568                    Err(TransactionError::ValidationError(
569                        StellarValidationError::UnexpectedSignedXdr.to_string(),
570                    ))
571                } else {
572                    Ok(TransactionInput::UnsignedXdr(xdr.clone()))
573                }
574            };
575        }
576
577        // Handle operations mode
578        if let Some(operations) = &request.operations {
579            if operations.is_empty() {
580                return Err(TransactionError::ValidationError(
581                    "Operations must not be empty".to_string(),
582                ));
583            }
584
585            if request.fee_bump == Some(true) {
586                return Err(TransactionError::ValidationError(
587                    "Cannot request fee_bump with operations mode".to_string(),
588                ));
589            }
590
591            // Validate operations
592            validate_operations(operations)
593                .map_err(|e| TransactionError::ValidationError(e.to_string()))?;
594
595            // Validate Soroban memo restriction
596            validate_soroban_memo_restriction(operations, &request.memo)
597                .map_err(|e| TransactionError::ValidationError(e.to_string()))?;
598
599            return Ok(TransactionInput::Operations(operations.clone()));
600        }
601
602        // Neither XDR nor operations provided
603        Err(TransactionError::ValidationError(
604            "Must provide either operations or transaction_xdr".to_string(),
605        ))
606    }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct StellarTransactionData {
611    pub source_account: String,
612    pub fee: Option<u32>,
613    pub sequence_number: Option<i64>,
614    pub memo: Option<MemoSpec>,
615    pub valid_until: Option<String>,
616    pub network_passphrase: String,
617    pub signatures: Vec<DecoratedSignature>,
618    pub hash: Option<String>,
619    pub simulation_transaction_data: Option<String>,
620    pub transaction_input: TransactionInput,
621    pub signed_envelope_xdr: Option<String>,
622    pub transaction_result_xdr: Option<String>,
623}
624
625impl StellarTransactionData {
626    /// Resets the transaction data to its pre-prepare state by clearing all fields
627    /// that are populated during the prepare and submit phases.
628    ///
629    /// Fields preserved (from initial creation):
630    /// - source_account, network_passphrase, memo, valid_until, transaction_input
631    ///
632    /// Fields reset to None/empty:
633    /// - fee, sequence_number, signatures, signed_envelope_xdr, hash, simulation_transaction_data
634    pub fn reset_to_pre_prepare_state(mut self) -> Self {
635        // Reset all fields populated during prepare phase
636        self.fee = None;
637        self.sequence_number = None;
638        self.signatures = vec![];
639        self.signed_envelope_xdr = None;
640        self.simulation_transaction_data = None;
641
642        // Reset fields populated during submit phase
643        self.hash = None;
644
645        self
646    }
647
648    /// Updates the Stellar transaction data with a specific sequence number.
649    ///
650    /// # Arguments
651    /// * `sequence_number` - The sequence number for the Stellar account
652    ///
653    /// # Returns
654    /// The updated `StellarTransactionData` with the specified sequence number
655    pub fn with_sequence_number(mut self, sequence_number: i64) -> Self {
656        self.sequence_number = Some(sequence_number);
657        self
658    }
659
660    /// Updates the Stellar transaction data with the actual fee charged by the network.
661    ///
662    /// # Arguments
663    /// * `fee` - The actual fee charged in stroops
664    ///
665    /// # Returns
666    /// The updated `StellarTransactionData` with the specified fee
667    pub fn with_fee(mut self, fee: u32) -> Self {
668        self.fee = Some(fee);
669        self
670    }
671
672    /// Updates the Stellar transaction data with the transaction result XDR.
673    ///
674    /// # Arguments
675    /// * `transaction_result_xdr` - The XDR-encoded transaction result return value
676    ///
677    /// # Returns
678    /// The updated `StellarTransactionData` with the specified transaction result
679    pub fn with_transaction_result_xdr(mut self, transaction_result_xdr: String) -> Self {
680        self.transaction_result_xdr = Some(transaction_result_xdr);
681        self
682    }
683
684    /// Builds an unsigned envelope from any transaction input.
685    ///
686    /// Returns an envelope without signatures, suitable for simulation and fee calculation.
687    ///
688    /// # Returns
689    /// * `Ok(TransactionEnvelope)` containing the unsigned transaction
690    /// * `Err(SignerError)` if the transaction data cannot be converted
691    pub fn build_unsigned_envelope(&self) -> Result<TransactionEnvelope, SignerError> {
692        match &self.transaction_input {
693            TransactionInput::Operations(_) => {
694                // Build from operations without signatures
695                self.build_envelope_from_operations_unsigned()
696            }
697            TransactionInput::UnsignedXdr(xdr) => {
698                // Parse the XDR as-is (already unsigned)
699                self.parse_xdr_envelope(xdr)
700            }
701            TransactionInput::SignedXdr { xdr, .. } => {
702                // Parse the inner transaction (for fee-bump cases)
703                self.parse_xdr_envelope(xdr)
704            }
705            TransactionInput::SorobanGasAbstraction { xdr, .. } => {
706                // Parse the FeeForwarder transaction XDR
707                self.parse_xdr_envelope(xdr)
708            }
709        }
710    }
711
712    /// Gets the transaction envelope for simulation purposes.
713    ///
714    /// Convenience method that delegates to build_unsigned_envelope().
715    ///
716    /// # Returns
717    /// * `Ok(TransactionEnvelope)` containing the unsigned transaction
718    /// * `Err(SignerError)` if the transaction data cannot be converted
719    pub fn get_envelope_for_simulation(&self) -> Result<TransactionEnvelope, SignerError> {
720        self.build_unsigned_envelope()
721    }
722
723    /// Builds a signed envelope ready for submission to the network.
724    ///
725    /// Uses cached signed_envelope_xdr if available, otherwise builds from components.
726    ///
727    /// # Returns
728    /// * `Ok(TransactionEnvelope)` containing the signed transaction
729    /// * `Err(SignerError)` if the transaction data cannot be converted
730    pub fn build_signed_envelope(&self) -> Result<TransactionEnvelope, SignerError> {
731        // If we have a cached signed envelope, use it
732        if let Some(ref xdr) = self.signed_envelope_xdr {
733            return self.parse_xdr_envelope(xdr);
734        }
735
736        // Otherwise, build from components
737        match &self.transaction_input {
738            TransactionInput::Operations(_) => {
739                // Build from operations with signatures
740                self.build_envelope_from_operations_signed()
741            }
742            TransactionInput::UnsignedXdr(xdr) => {
743                // Parse and attach signatures
744                let envelope = self.parse_xdr_envelope(xdr)?;
745                self.attach_signatures_to_envelope(envelope)
746            }
747            TransactionInput::SignedXdr { xdr, .. } => {
748                // Already signed
749                self.parse_xdr_envelope(xdr)
750            }
751            TransactionInput::SorobanGasAbstraction { xdr, .. } => {
752                // For Soroban gas abstraction, the signed auth entry is injected during prepare
753                // Parse and attach the relayer's signature
754                let envelope = self.parse_xdr_envelope(xdr)?;
755                self.attach_signatures_to_envelope(envelope)
756            }
757        }
758    }
759
760    /// Gets the transaction envelope for submission to the network.
761    ///
762    /// Convenience method that delegates to build_signed_envelope().
763    ///
764    /// # Returns
765    /// * `Ok(TransactionEnvelope)` containing the signed transaction
766    /// * `Err(SignerError)` if the transaction data cannot be converted
767    pub fn get_envelope_for_submission(&self) -> Result<TransactionEnvelope, SignerError> {
768        self.build_signed_envelope()
769    }
770
771    // Helper method to build unsigned envelope from operations
772    fn build_envelope_from_operations_unsigned(&self) -> Result<TransactionEnvelope, SignerError> {
773        let tx = SorobanTransaction::try_from(self.clone())?;
774        Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
775            tx,
776            signatures: VecM::default(),
777        }))
778    }
779
780    // Helper method to build signed envelope from operations
781    fn build_envelope_from_operations_signed(&self) -> Result<TransactionEnvelope, SignerError> {
782        let tx = SorobanTransaction::try_from(self.clone())?;
783        let signatures = VecM::try_from(self.signatures.clone())
784            .map_err(|_| SignerError::ConversionError("too many signatures".into()))?;
785        Ok(TransactionEnvelope::Tx(TransactionV1Envelope {
786            tx,
787            signatures,
788        }))
789    }
790
791    // Helper method to parse XDR envelope
792    fn parse_xdr_envelope(&self, xdr: &str) -> Result<TransactionEnvelope, SignerError> {
793        use soroban_rs::xdr::{Limits, ReadXdr};
794        TransactionEnvelope::from_xdr_base64(xdr, Limits::none())
795            .map_err(|e| SignerError::ConversionError(format!("Invalid XDR: {e}")))
796    }
797
798    // Helper method to attach signatures to an envelope
799    fn attach_signatures_to_envelope(
800        &self,
801        envelope: TransactionEnvelope,
802    ) -> Result<TransactionEnvelope, SignerError> {
803        use soroban_rs::xdr::{Limits, ReadXdr, WriteXdr};
804
805        // Serialize and re-parse to get a mutable version
806        let envelope_xdr = envelope.to_xdr_base64(Limits::none()).map_err(|e| {
807            SignerError::ConversionError(format!("Failed to serialize envelope: {e}"))
808        })?;
809
810        let mut envelope = TransactionEnvelope::from_xdr_base64(&envelope_xdr, Limits::none())
811            .map_err(|e| SignerError::ConversionError(format!("Failed to parse envelope: {e}")))?;
812
813        let sigs = VecM::try_from(self.signatures.clone())
814            .map_err(|_| SignerError::ConversionError("too many signatures".into()))?;
815
816        match &mut envelope {
817            TransactionEnvelope::Tx(ref mut v1) => v1.signatures = sigs,
818            TransactionEnvelope::TxV0(ref mut v0) => v0.signatures = sigs,
819            TransactionEnvelope::TxFeeBump(_) => {
820                return Err(SignerError::ConversionError(
821                    "Cannot attach signatures to fee-bump transaction directly".into(),
822                ));
823            }
824        }
825
826        Ok(envelope)
827    }
828
829    /// Updates instance with the given signature appended to the signatures list.
830    ///
831    /// # Arguments
832    /// * `sig` - The decorated signature to append
833    ///
834    /// # Returns
835    /// The updated `StellarTransactionData` with the new signature added
836    pub fn attach_signature(mut self, sig: DecoratedSignature) -> Self {
837        self.signatures.push(sig);
838        self
839    }
840
841    /// Updates instance with the transaction hash populated.
842    ///
843    /// # Arguments
844    /// * `hash` - The transaction hash to set
845    ///
846    /// # Returns
847    /// The updated `StellarTransactionData` with the hash field set
848    pub fn with_hash(mut self, hash: String) -> Self {
849        self.hash = Some(hash);
850        self
851    }
852
853    /// Return a new instance with simulation data applied (fees and transaction extension).
854    pub fn with_simulation_data(
855        mut self,
856        sim_response: soroban_rs::stellar_rpc_client::SimulateTransactionResponse,
857        operations_count: u64,
858    ) -> Result<Self, SignerError> {
859        use tracing::info;
860
861        // Update fee based on simulation (using soroban-helpers formula)
862        let inclusion_fee = operations_count * STELLAR_DEFAULT_TRANSACTION_FEE as u64;
863        let resource_fee = sim_response.min_resource_fee;
864
865        let updated_fee = u32::try_from(inclusion_fee + resource_fee)
866            .map_err(|_| SignerError::ConversionError("Fee too high".to_string()))?
867            .max(STELLAR_DEFAULT_TRANSACTION_FEE);
868        self.fee = Some(updated_fee);
869
870        // Store simulation transaction data for TransactionExt::V1
871        self.simulation_transaction_data = Some(sim_response.transaction_data);
872
873        info!(
874            "Applied simulation fee: {} stroops and stored transaction extension data",
875            updated_fee
876        );
877        Ok(self)
878    }
879}
880
881/// Extract valid_until: request > XDR time_bounds > default (for operations) > None (for XDR)
882fn extract_stellar_valid_until(
883    stellar_request: &StellarTransactionRequest,
884    now: chrono::DateTime<Utc>,
885) -> Option<String> {
886    if let Some(vu) = &stellar_request.valid_until {
887        return Some(vu.clone());
888    }
889
890    if let Some(xdr) = &stellar_request.transaction_xdr {
891        if let Ok(envelope) = parse_transaction_xdr(xdr, false) {
892            if let Some(tb) = extract_time_bounds(&envelope) {
893                if tb.max_time.0 == 0 {
894                    return None; // unbounded
895                }
896                if let Ok(timestamp) = i64::try_from(tb.max_time.0) {
897                    if let Some(dt) = chrono::DateTime::from_timestamp(timestamp, 0) {
898                        return Some(dt.to_rfc3339());
899                    }
900                }
901            }
902        }
903        return None;
904    }
905
906    let default = now + Duration::minutes(STELLAR_SPONSORED_TRANSACTION_VALIDITY_MINUTES);
907    Some(default.to_rfc3339())
908}
909
910impl
911    TryFrom<(
912        &NetworkTransactionRequest,
913        &RelayerRepoModel,
914        &NetworkRepoModel,
915    )> for TransactionRepoModel
916{
917    type Error = RelayerError;
918
919    fn try_from(
920        (request, relayer_model, network_model): (
921            &NetworkTransactionRequest,
922            &RelayerRepoModel,
923            &NetworkRepoModel,
924        ),
925    ) -> Result<Self, Self::Error> {
926        let now = Utc::now().to_rfc3339();
927
928        match request {
929            NetworkTransactionRequest::Evm(evm_request) => {
930                let network = EvmNetwork::try_from(network_model.clone())?;
931                Ok(Self {
932                    id: Uuid::new_v4().to_string(),
933                    relayer_id: relayer_model.id.clone(),
934                    status: TransactionStatus::Pending,
935                    status_reason: None,
936                    created_at: now,
937                    sent_at: None,
938                    confirmed_at: None,
939                    valid_until: evm_request.valid_until.clone(),
940                    delete_at: None,
941                    network_type: NetworkType::Evm,
942                    network_data: NetworkTransactionData::Evm(EvmTransactionData {
943                        gas_price: evm_request.gas_price,
944                        gas_limit: evm_request.gas_limit,
945                        nonce: None,
946                        value: evm_request.value,
947                        data: evm_request.data.clone(),
948                        from: relayer_model.address.clone(),
949                        to: evm_request.to.clone(),
950                        chain_id: network.id(),
951                        hash: None,
952                        signature: None,
953                        speed: evm_request.speed.clone(),
954                        max_fee_per_gas: evm_request.max_fee_per_gas,
955                        max_priority_fee_per_gas: evm_request.max_priority_fee_per_gas,
956                        raw: None,
957                    }),
958                    priced_at: None,
959                    hashes: Vec::new(),
960                    noop_count: None,
961                    is_canceled: Some(false),
962                    metadata: None,
963                })
964            }
965            NetworkTransactionRequest::Solana(solana_request) => Ok(Self {
966                id: Uuid::new_v4().to_string(),
967                relayer_id: relayer_model.id.clone(),
968                status: TransactionStatus::Pending,
969                status_reason: None,
970                created_at: now,
971                sent_at: None,
972                confirmed_at: None,
973                valid_until: solana_request.valid_until.clone(),
974                delete_at: None,
975                network_type: NetworkType::Solana,
976                network_data: NetworkTransactionData::Solana(SolanaTransactionData {
977                    transaction: solana_request.transaction.clone().map(|t| t.into_inner()),
978                    instructions: solana_request.instructions.clone(),
979                    signature: None,
980                }),
981                priced_at: None,
982                hashes: Vec::new(),
983                noop_count: None,
984                is_canceled: Some(false),
985                metadata: None,
986            }),
987            NetworkTransactionRequest::Stellar(stellar_request) => {
988                // Store the source account before consuming the request
989                let source_account = stellar_request.source_account.clone();
990
991                let valid_until = extract_stellar_valid_until(stellar_request, Utc::now());
992
993                let transaction_input = TransactionInput::from_stellar_request(stellar_request)
994                    .map_err(|e| RelayerError::ValidationError(e.to_string()))?;
995
996                let stellar_data = StellarTransactionData {
997                    source_account: source_account.unwrap_or_else(|| relayer_model.address.clone()),
998                    memo: stellar_request.memo.clone(),
999                    valid_until: valid_until.clone(),
1000                    network_passphrase: StellarNetwork::try_from(network_model.clone())?.passphrase,
1001                    signatures: Vec::new(),
1002                    hash: None,
1003                    fee: None,
1004                    sequence_number: None,
1005                    simulation_transaction_data: None,
1006                    transaction_input,
1007                    signed_envelope_xdr: None,
1008                    transaction_result_xdr: None,
1009                };
1010
1011                Ok(Self {
1012                    id: Uuid::new_v4().to_string(),
1013                    relayer_id: relayer_model.id.clone(),
1014                    status: TransactionStatus::Pending,
1015                    status_reason: None,
1016                    created_at: now,
1017                    sent_at: None,
1018                    confirmed_at: None,
1019                    valid_until,
1020                    delete_at: None,
1021                    network_type: NetworkType::Stellar,
1022                    network_data: NetworkTransactionData::Stellar(stellar_data),
1023                    priced_at: None,
1024                    hashes: Vec::new(),
1025                    noop_count: None,
1026                    is_canceled: Some(false),
1027                    metadata: None,
1028                })
1029            }
1030        }
1031    }
1032}
1033
1034impl EvmTransactionData {
1035    /// Converts the transaction's 'to' field to an Alloy Address.
1036    ///
1037    /// # Returns
1038    /// * `Ok(Some(AlloyAddress))` if the 'to' field contains a valid address
1039    /// * `Ok(None)` if the 'to' field is None or empty (contract creation)
1040    /// * `Err(SignerError)` if the address format is invalid
1041    pub fn to_address(&self) -> Result<Option<AlloyAddress>, SignerError> {
1042        Ok(match self.to.as_deref().filter(|s| !s.is_empty()) {
1043            Some(addr_str) => Some(AlloyAddress::from_str(addr_str).map_err(|e| {
1044                AddressError::ConversionError(format!("Invalid 'to' address: {e}"))
1045            })?),
1046            None => None,
1047        })
1048    }
1049
1050    /// Converts the transaction's data field from hex string to bytes.
1051    ///
1052    /// # Returns
1053    /// * `Ok(Bytes)` containing the decoded transaction data
1054    /// * `Err(SignerError)` if the hex string is invalid
1055    pub fn data_to_bytes(&self) -> Result<Bytes, SignerError> {
1056        Bytes::from_str(self.data.as_deref().unwrap_or(""))
1057            .map_err(|e| SignerError::SigningError(format!("Invalid transaction data: {e}")))
1058    }
1059}
1060
1061impl TryFrom<NetworkTransactionData> for TxLegacy {
1062    type Error = SignerError;
1063
1064    fn try_from(tx: NetworkTransactionData) -> Result<Self, Self::Error> {
1065        match tx {
1066            NetworkTransactionData::Evm(tx) => {
1067                let tx_kind = match tx.to_address()? {
1068                    Some(addr) => TxKind::Call(addr),
1069                    None => TxKind::Create,
1070                };
1071
1072                Ok(Self {
1073                    chain_id: Some(tx.chain_id),
1074                    nonce: tx.nonce.unwrap_or(0),
1075                    gas_limit: tx.gas_limit.unwrap_or(DEFAULT_GAS_LIMIT),
1076                    gas_price: tx.gas_price.unwrap_or(0),
1077                    to: tx_kind,
1078                    value: tx.value,
1079                    input: tx.data_to_bytes()?,
1080                })
1081            }
1082            _ => Err(SignerError::SigningError(
1083                "Not an EVM transaction".to_string(),
1084            )),
1085        }
1086    }
1087}
1088
1089impl TryFrom<NetworkTransactionData> for TxEip1559 {
1090    type Error = SignerError;
1091
1092    fn try_from(tx: NetworkTransactionData) -> Result<Self, Self::Error> {
1093        match tx {
1094            NetworkTransactionData::Evm(tx) => {
1095                let tx_kind = match tx.to_address()? {
1096                    Some(addr) => TxKind::Call(addr),
1097                    None => TxKind::Create,
1098                };
1099
1100                Ok(Self {
1101                    chain_id: tx.chain_id,
1102                    nonce: tx.nonce.unwrap_or(0),
1103                    gas_limit: tx.gas_limit.unwrap_or(DEFAULT_GAS_LIMIT),
1104                    max_fee_per_gas: tx.max_fee_per_gas.unwrap_or(0),
1105                    max_priority_fee_per_gas: tx.max_priority_fee_per_gas.unwrap_or(0),
1106                    to: tx_kind,
1107                    value: tx.value,
1108                    access_list: AccessList::default(),
1109                    input: tx.data_to_bytes()?,
1110                })
1111            }
1112            _ => Err(SignerError::SigningError(
1113                "Not an EVM transaction".to_string(),
1114            )),
1115        }
1116    }
1117}
1118
1119impl TryFrom<&EvmTransactionData> for TxLegacy {
1120    type Error = SignerError;
1121
1122    fn try_from(tx: &EvmTransactionData) -> Result<Self, Self::Error> {
1123        let tx_kind = match tx.to_address()? {
1124            Some(addr) => TxKind::Call(addr),
1125            None => TxKind::Create,
1126        };
1127
1128        Ok(Self {
1129            chain_id: Some(tx.chain_id),
1130            nonce: tx.nonce.unwrap_or(0),
1131            gas_limit: tx.gas_limit.unwrap_or(DEFAULT_GAS_LIMIT),
1132            gas_price: tx.gas_price.unwrap_or(0),
1133            to: tx_kind,
1134            value: tx.value,
1135            input: tx.data_to_bytes()?,
1136        })
1137    }
1138}
1139
1140impl TryFrom<EvmTransactionData> for TxLegacy {
1141    type Error = SignerError;
1142
1143    fn try_from(tx: EvmTransactionData) -> Result<Self, Self::Error> {
1144        Self::try_from(&tx)
1145    }
1146}
1147
1148impl TryFrom<&EvmTransactionData> for TxEip1559 {
1149    type Error = SignerError;
1150
1151    fn try_from(tx: &EvmTransactionData) -> Result<Self, Self::Error> {
1152        let tx_kind = match tx.to_address()? {
1153            Some(addr) => TxKind::Call(addr),
1154            None => TxKind::Create,
1155        };
1156
1157        Ok(Self {
1158            chain_id: tx.chain_id,
1159            nonce: tx.nonce.unwrap_or(0),
1160            gas_limit: tx.gas_limit.unwrap_or(DEFAULT_GAS_LIMIT),
1161            max_fee_per_gas: tx.max_fee_per_gas.unwrap_or(0),
1162            max_priority_fee_per_gas: tx.max_priority_fee_per_gas.unwrap_or(0),
1163            to: tx_kind,
1164            value: tx.value,
1165            access_list: AccessList::default(),
1166            input: tx.data_to_bytes()?,
1167        })
1168    }
1169}
1170
1171impl TryFrom<EvmTransactionData> for TxEip1559 {
1172    type Error = SignerError;
1173
1174    fn try_from(tx: EvmTransactionData) -> Result<Self, Self::Error> {
1175        Self::try_from(&tx)
1176    }
1177}
1178
1179impl From<&[u8; 65]> for EvmTransactionDataSignature {
1180    fn from(bytes: &[u8; 65]) -> Self {
1181        Self {
1182            r: hex::encode(&bytes[0..32]),
1183            s: hex::encode(&bytes[32..64]),
1184            v: bytes[64],
1185            sig: hex::encode(bytes),
1186        }
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use lazy_static::lazy_static;
1193    use soroban_rs::xdr::{BytesM, Signature, SignatureHint};
1194    use std::sync::Mutex;
1195
1196    use super::*;
1197    use crate::{
1198        config::{
1199            EvmNetworkConfig, NetworkConfigCommon, SolanaNetworkConfig, StellarNetworkConfig,
1200        },
1201        models::{
1202            network::NetworkConfigData,
1203            relayer::{
1204                RelayerEvmPolicy, RelayerNetworkPolicy, RelayerSolanaPolicy, RelayerStellarPolicy,
1205            },
1206            transaction::stellar::AssetSpec,
1207            EncodedSerializedTransaction, StellarFeePaymentStrategy,
1208        },
1209    };
1210
1211    // Use a mutex to ensure tests don't run in parallel when modifying env vars
1212    lazy_static! {
1213        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
1214    }
1215
1216    #[test]
1217    fn test_signature_from_bytes() {
1218        let test_bytes: [u8; 65] = [
1219            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
1220            25, 26, 27, 28, 29, 30, 31, 32, // r (32 bytes)
1221            33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54,
1222            55, 56, 57, 58, 59, 60, 61, 62, 63, 64, // s (32 bytes)
1223            27, // v (1 byte)
1224        ];
1225
1226        let signature = EvmTransactionDataSignature::from(&test_bytes);
1227
1228        assert_eq!(signature.r.len(), 64); // 32 bytes in hex
1229        assert_eq!(signature.s.len(), 64); // 32 bytes in hex
1230        assert_eq!(signature.v, 27);
1231        assert_eq!(signature.sig.len(), 130); // 65 bytes in hex
1232    }
1233
1234    #[test]
1235    fn test_stellar_transaction_data_reset_to_pre_prepare_state() {
1236        let stellar_data = StellarTransactionData {
1237            source_account: "GTEST".to_string(),
1238            fee: Some(100),
1239            sequence_number: Some(42),
1240            memo: Some(MemoSpec::Text {
1241                value: "test memo".to_string(),
1242            }),
1243            valid_until: Some("2024-12-31".to_string()),
1244            network_passphrase: "Test Network".to_string(),
1245            signatures: vec![], // Simplified - empty for test
1246            hash: Some("test-hash".to_string()),
1247            simulation_transaction_data: Some("simulation-data".to_string()),
1248            transaction_input: TransactionInput::Operations(vec![OperationSpec::Payment {
1249                destination: "GDEST".to_string(),
1250                amount: 1000,
1251                asset: AssetSpec::Native,
1252            }]),
1253            signed_envelope_xdr: Some("signed-xdr".to_string()),
1254            transaction_result_xdr: None,
1255        };
1256
1257        let reset_data = stellar_data.clone().reset_to_pre_prepare_state();
1258
1259        // Fields that should be preserved
1260        assert_eq!(reset_data.source_account, stellar_data.source_account);
1261        assert_eq!(reset_data.memo, stellar_data.memo);
1262        assert_eq!(reset_data.valid_until, stellar_data.valid_until);
1263        assert_eq!(
1264            reset_data.network_passphrase,
1265            stellar_data.network_passphrase
1266        );
1267        assert!(matches!(
1268            reset_data.transaction_input,
1269            TransactionInput::Operations(_)
1270        ));
1271
1272        // Fields that should be reset
1273        assert_eq!(reset_data.fee, None);
1274        assert_eq!(reset_data.sequence_number, None);
1275        assert!(reset_data.signatures.is_empty());
1276        assert_eq!(reset_data.hash, None);
1277        assert_eq!(reset_data.simulation_transaction_data, None);
1278        assert_eq!(reset_data.signed_envelope_xdr, None);
1279    }
1280
1281    #[test]
1282    fn test_transaction_repo_model_create_reset_update_request() {
1283        let stellar_data = StellarTransactionData {
1284            source_account: "GTEST".to_string(),
1285            fee: Some(100),
1286            sequence_number: Some(42),
1287            memo: None,
1288            valid_until: None,
1289            network_passphrase: "Test Network".to_string(),
1290            signatures: vec![],
1291            hash: Some("test-hash".to_string()),
1292            simulation_transaction_data: None,
1293            transaction_input: TransactionInput::Operations(vec![]),
1294            signed_envelope_xdr: Some("signed-xdr".to_string()),
1295            transaction_result_xdr: None,
1296        };
1297
1298        let tx = TransactionRepoModel {
1299            id: "tx-1".to_string(),
1300            relayer_id: "relayer-1".to_string(),
1301            status: TransactionStatus::Failed,
1302            status_reason: Some("Bad sequence".to_string()),
1303            created_at: "2024-01-01".to_string(),
1304            sent_at: Some("2024-01-02".to_string()),
1305            confirmed_at: Some("2024-01-03".to_string()),
1306            valid_until: None,
1307            network_data: NetworkTransactionData::Stellar(stellar_data),
1308            priced_at: None,
1309            hashes: vec!["hash1".to_string(), "hash2".to_string()],
1310            network_type: NetworkType::Stellar,
1311            noop_count: None,
1312            is_canceled: None,
1313            delete_at: None,
1314            metadata: None,
1315        };
1316
1317        let update_req = tx.create_reset_update_request().unwrap();
1318
1319        // Check common fields
1320        assert_eq!(update_req.status, Some(TransactionStatus::Pending));
1321        assert_eq!(update_req.status_reason, None);
1322        assert_eq!(update_req.sent_at, None);
1323        assert_eq!(update_req.confirmed_at, None);
1324        assert_eq!(update_req.hashes, Some(vec![]));
1325
1326        // Check that network data was reset
1327        if let Some(NetworkTransactionData::Stellar(reset_data)) = update_req.network_data {
1328            assert_eq!(reset_data.fee, None);
1329            assert_eq!(reset_data.sequence_number, None);
1330            assert_eq!(reset_data.hash, None);
1331            assert_eq!(reset_data.signed_envelope_xdr, None);
1332        } else {
1333            panic!("Expected Stellar network data");
1334        }
1335    }
1336
1337    // Create a helper function to generate a sample EvmTransactionData for testing
1338    fn create_sample_evm_tx_data() -> EvmTransactionData {
1339        EvmTransactionData {
1340            gas_price: Some(20_000_000_000),
1341            gas_limit: Some(21000),
1342            nonce: Some(5),
1343            value: U256::from(1000000000000000000u128), // 1 ETH
1344            data: Some("0x".to_string()),
1345            from: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
1346            to: Some("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed".to_string()),
1347            chain_id: 1,
1348            hash: None,
1349            signature: None,
1350            speed: None,
1351            max_fee_per_gas: None,
1352            max_priority_fee_per_gas: None,
1353            raw: None,
1354        }
1355    }
1356
1357    // Tests for EvmTransactionData methods
1358    #[test]
1359    fn test_evm_tx_with_price_params() {
1360        let tx_data = create_sample_evm_tx_data();
1361        let price_params = PriceParams {
1362            gas_price: None,
1363            max_fee_per_gas: Some(30_000_000_000),
1364            max_priority_fee_per_gas: Some(2_000_000_000),
1365            is_min_bumped: None,
1366            extra_fee: None,
1367            total_cost: U256::ZERO,
1368        };
1369
1370        let updated_tx = tx_data.with_price_params(price_params);
1371
1372        assert_eq!(updated_tx.max_fee_per_gas, Some(30_000_000_000));
1373        assert_eq!(updated_tx.max_priority_fee_per_gas, Some(2_000_000_000));
1374    }
1375
1376    #[test]
1377    fn test_evm_tx_with_gas_estimate() {
1378        let tx_data = create_sample_evm_tx_data();
1379        let new_gas_limit = 30000;
1380
1381        let updated_tx = tx_data.with_gas_estimate(new_gas_limit);
1382
1383        assert_eq!(updated_tx.gas_limit, Some(new_gas_limit));
1384    }
1385
1386    #[test]
1387    fn test_evm_tx_with_nonce() {
1388        let tx_data = create_sample_evm_tx_data();
1389        let new_nonce = 10;
1390
1391        let updated_tx = tx_data.with_nonce(new_nonce);
1392
1393        assert_eq!(updated_tx.nonce, Some(new_nonce));
1394    }
1395
1396    #[test]
1397    fn test_evm_tx_with_signed_transaction_data() {
1398        let tx_data = create_sample_evm_tx_data();
1399
1400        let signature = EvmTransactionDataSignature {
1401            r: "r_value".to_string(),
1402            s: "s_value".to_string(),
1403            v: 27,
1404            sig: "signature_value".to_string(),
1405        };
1406
1407        let signed_tx_response = SignTransactionResponseEvm {
1408            signature,
1409            hash: "0xabcdef1234567890".to_string(),
1410            raw: vec![1, 2, 3, 4, 5],
1411        };
1412
1413        let updated_tx = tx_data.with_signed_transaction_data(signed_tx_response);
1414
1415        assert_eq!(updated_tx.signature.as_ref().unwrap().r, "r_value");
1416        assert_eq!(updated_tx.signature.as_ref().unwrap().s, "s_value");
1417        assert_eq!(updated_tx.signature.as_ref().unwrap().v, 27);
1418        assert_eq!(updated_tx.hash, Some("0xabcdef1234567890".to_string()));
1419        assert_eq!(updated_tx.raw, Some(vec![1, 2, 3, 4, 5]));
1420    }
1421
1422    #[test]
1423    fn test_evm_tx_to_address() {
1424        // Test with valid address
1425        let tx_data = create_sample_evm_tx_data();
1426        let address_result = tx_data.to_address();
1427        assert!(address_result.is_ok());
1428        let address_option = address_result.unwrap();
1429        assert!(address_option.is_some());
1430        assert_eq!(
1431            address_option.unwrap().to_string().to_lowercase(),
1432            "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed".to_lowercase()
1433        );
1434
1435        // Test with None address (contract creation)
1436        let mut contract_creation_tx = create_sample_evm_tx_data();
1437        contract_creation_tx.to = None;
1438        let address_result = contract_creation_tx.to_address();
1439        assert!(address_result.is_ok());
1440        assert!(address_result.unwrap().is_none());
1441
1442        // Test with empty address string
1443        let mut empty_address_tx = create_sample_evm_tx_data();
1444        empty_address_tx.to = Some("".to_string());
1445        let address_result = empty_address_tx.to_address();
1446        assert!(address_result.is_ok());
1447        assert!(address_result.unwrap().is_none());
1448
1449        // Test with invalid address
1450        let mut invalid_address_tx = create_sample_evm_tx_data();
1451        invalid_address_tx.to = Some("0xINVALID".to_string());
1452        let address_result = invalid_address_tx.to_address();
1453        assert!(address_result.is_err());
1454    }
1455
1456    #[test]
1457    fn test_evm_tx_data_to_bytes() {
1458        // Test with valid hex data
1459        let mut tx_data = create_sample_evm_tx_data();
1460        tx_data.data = Some("0x1234".to_string());
1461        let bytes_result = tx_data.data_to_bytes();
1462        assert!(bytes_result.is_ok());
1463        assert_eq!(bytes_result.unwrap().as_ref(), &[0x12, 0x34]);
1464
1465        // Test with empty data
1466        tx_data.data = Some("".to_string());
1467        assert!(tx_data.data_to_bytes().is_ok());
1468
1469        // Test with None data
1470        tx_data.data = None;
1471        assert!(tx_data.data_to_bytes().is_ok());
1472
1473        // Test with invalid hex data
1474        tx_data.data = Some("0xZZ".to_string());
1475        assert!(tx_data.data_to_bytes().is_err());
1476    }
1477
1478    // Tests for EvmTransactionDataTrait implementation
1479    #[test]
1480    fn test_evm_tx_is_legacy() {
1481        let mut tx_data = create_sample_evm_tx_data();
1482
1483        // Legacy transaction has gas_price
1484        assert!(tx_data.is_legacy());
1485
1486        // Not legacy if gas_price is None
1487        tx_data.gas_price = None;
1488        assert!(!tx_data.is_legacy());
1489    }
1490
1491    #[test]
1492    fn test_evm_tx_is_eip1559() {
1493        let mut tx_data = create_sample_evm_tx_data();
1494
1495        // Not EIP-1559 initially
1496        assert!(!tx_data.is_eip1559());
1497
1498        // Set EIP-1559 fields
1499        tx_data.max_fee_per_gas = Some(30_000_000_000);
1500        tx_data.max_priority_fee_per_gas = Some(2_000_000_000);
1501        assert!(tx_data.is_eip1559());
1502
1503        // Not EIP-1559 if one field is missing
1504        tx_data.max_priority_fee_per_gas = None;
1505        assert!(!tx_data.is_eip1559());
1506    }
1507
1508    #[test]
1509    fn test_evm_tx_is_speed() {
1510        let mut tx_data = create_sample_evm_tx_data();
1511
1512        // No speed initially
1513        assert!(!tx_data.is_speed());
1514
1515        // Set speed
1516        tx_data.speed = Some(Speed::Fast);
1517        assert!(tx_data.is_speed());
1518    }
1519
1520    // Tests for NetworkTransactionData methods
1521    #[test]
1522    fn test_network_tx_data_get_evm_transaction_data() {
1523        let evm_tx_data = create_sample_evm_tx_data();
1524        let network_data = NetworkTransactionData::Evm(evm_tx_data.clone());
1525
1526        // Should succeed for EVM data
1527        let result = network_data.get_evm_transaction_data();
1528        assert!(result.is_ok());
1529        assert_eq!(result.unwrap().chain_id, evm_tx_data.chain_id);
1530
1531        // Should fail for non-EVM data
1532        let solana_data = NetworkTransactionData::Solana(SolanaTransactionData {
1533            transaction: Some("transaction_123".to_string()),
1534            ..Default::default()
1535        });
1536        assert!(solana_data.get_evm_transaction_data().is_err());
1537    }
1538
1539    #[test]
1540    fn test_network_tx_data_get_solana_transaction_data() {
1541        let solana_tx_data = SolanaTransactionData {
1542            transaction: Some("transaction_123".to_string()),
1543            ..Default::default()
1544        };
1545        let network_data = NetworkTransactionData::Solana(solana_tx_data.clone());
1546
1547        // Should succeed for Solana data
1548        let result = network_data.get_solana_transaction_data();
1549        assert!(result.is_ok());
1550        assert_eq!(result.unwrap().transaction, solana_tx_data.transaction);
1551
1552        // Should fail for non-Solana data
1553        let evm_data = NetworkTransactionData::Evm(create_sample_evm_tx_data());
1554        assert!(evm_data.get_solana_transaction_data().is_err());
1555    }
1556
1557    #[test]
1558    fn test_network_tx_data_get_stellar_transaction_data() {
1559        let stellar_tx_data = StellarTransactionData {
1560            source_account: "account123".to_string(),
1561            fee: Some(100),
1562            sequence_number: Some(5),
1563            memo: Some(MemoSpec::Text {
1564                value: "Test memo".to_string(),
1565            }),
1566            valid_until: Some("2025-01-01T00:00:00Z".to_string()),
1567            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1568            signatures: Vec::new(),
1569            hash: Some("hash123".to_string()),
1570            simulation_transaction_data: None,
1571            transaction_input: TransactionInput::Operations(vec![OperationSpec::Payment {
1572                destination: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ".to_string(),
1573                amount: 100000000, // 10 XLM in stroops
1574                asset: AssetSpec::Native,
1575            }]),
1576            signed_envelope_xdr: None,
1577            transaction_result_xdr: None,
1578        };
1579        let network_data = NetworkTransactionData::Stellar(stellar_tx_data.clone());
1580
1581        // Should succeed for Stellar data
1582        let result = network_data.get_stellar_transaction_data();
1583        assert!(result.is_ok());
1584        assert_eq!(
1585            result.unwrap().source_account,
1586            stellar_tx_data.source_account
1587        );
1588
1589        // Should fail for non-Stellar data
1590        let evm_data = NetworkTransactionData::Evm(create_sample_evm_tx_data());
1591        assert!(evm_data.get_stellar_transaction_data().is_err());
1592    }
1593
1594    // Test for TryFrom<NetworkTransactionData> for TxLegacy
1595    #[test]
1596    fn test_try_from_network_tx_data_for_tx_legacy() {
1597        // Create a valid EVM transaction
1598        let evm_tx_data = create_sample_evm_tx_data();
1599        let network_data = NetworkTransactionData::Evm(evm_tx_data.clone());
1600
1601        // Should convert successfully
1602        let result = TxLegacy::try_from(network_data);
1603        assert!(result.is_ok());
1604        let tx_legacy = result.unwrap();
1605
1606        // Verify fields
1607        assert_eq!(tx_legacy.chain_id, Some(evm_tx_data.chain_id));
1608        assert_eq!(tx_legacy.nonce, evm_tx_data.nonce.unwrap());
1609        assert_eq!(tx_legacy.gas_limit, evm_tx_data.gas_limit.unwrap_or(21000));
1610        assert_eq!(tx_legacy.gas_price, evm_tx_data.gas_price.unwrap());
1611        assert_eq!(tx_legacy.value, evm_tx_data.value);
1612
1613        // Should fail for non-EVM data
1614        let solana_data = NetworkTransactionData::Solana(SolanaTransactionData {
1615            transaction: Some("transaction_123".to_string()),
1616            ..Default::default()
1617        });
1618        assert!(TxLegacy::try_from(solana_data).is_err());
1619    }
1620
1621    #[test]
1622    fn test_try_from_evm_tx_data_for_tx_legacy() {
1623        // Create a valid EVM transaction with legacy fields
1624        let evm_tx_data = create_sample_evm_tx_data();
1625
1626        // Should convert successfully
1627        let result = TxLegacy::try_from(evm_tx_data.clone());
1628        assert!(result.is_ok());
1629        let tx_legacy = result.unwrap();
1630
1631        // Verify fields
1632        assert_eq!(tx_legacy.chain_id, Some(evm_tx_data.chain_id));
1633        assert_eq!(tx_legacy.nonce, evm_tx_data.nonce.unwrap());
1634        assert_eq!(tx_legacy.gas_limit, evm_tx_data.gas_limit.unwrap_or(21000));
1635        assert_eq!(tx_legacy.gas_price, evm_tx_data.gas_price.unwrap());
1636        assert_eq!(tx_legacy.value, evm_tx_data.value);
1637    }
1638
1639    fn dummy_signature() -> DecoratedSignature {
1640        let hint = SignatureHint([0; 4]);
1641        let bytes: Vec<u8> = vec![0u8; 64];
1642        let bytes_m: BytesM<64> = bytes.try_into().expect("BytesM conversion");
1643        DecoratedSignature {
1644            hint,
1645            signature: Signature(bytes_m),
1646        }
1647    }
1648
1649    fn test_stellar_tx_data() -> StellarTransactionData {
1650        StellarTransactionData {
1651            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1652            fee: Some(100),
1653            sequence_number: Some(1),
1654            memo: None,
1655            valid_until: None,
1656            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1657            signatures: Vec::new(),
1658            hash: None,
1659            simulation_transaction_data: None,
1660            transaction_input: TransactionInput::Operations(vec![OperationSpec::Payment {
1661                destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1662                amount: 1000,
1663                asset: AssetSpec::Native,
1664            }]),
1665            signed_envelope_xdr: None,
1666            transaction_result_xdr: None,
1667        }
1668    }
1669
1670    #[test]
1671    fn test_with_sequence_number() {
1672        let tx = test_stellar_tx_data();
1673        let updated = tx.with_sequence_number(42);
1674        assert_eq!(updated.sequence_number, Some(42));
1675    }
1676
1677    #[test]
1678    fn test_get_envelope_for_simulation() {
1679        let tx = test_stellar_tx_data();
1680        let env = tx.get_envelope_for_simulation();
1681        assert!(env.is_ok());
1682        let env = env.unwrap();
1683        // Should be a TransactionV1Envelope with no signatures
1684        match env {
1685            soroban_rs::xdr::TransactionEnvelope::Tx(tx_env) => {
1686                assert_eq!(tx_env.signatures.len(), 0);
1687            }
1688            _ => {
1689                panic!("Expected TransactionEnvelope::Tx variant");
1690            }
1691        }
1692    }
1693
1694    #[test]
1695    fn test_get_envelope_for_submission() {
1696        let mut tx = test_stellar_tx_data();
1697        tx.signatures.push(dummy_signature());
1698        let env = tx.get_envelope_for_submission();
1699        assert!(env.is_ok());
1700        let env = env.unwrap();
1701        match env {
1702            soroban_rs::xdr::TransactionEnvelope::Tx(tx_env) => {
1703                assert_eq!(tx_env.signatures.len(), 1);
1704            }
1705            _ => {
1706                panic!("Expected TransactionEnvelope::Tx variant");
1707            }
1708        }
1709    }
1710
1711    #[test]
1712    fn test_attach_signature() {
1713        let tx = test_stellar_tx_data();
1714        let sig = dummy_signature();
1715        let updated = tx.attach_signature(sig.clone());
1716        assert_eq!(updated.signatures.len(), 1);
1717        assert_eq!(updated.signatures[0], sig);
1718    }
1719
1720    #[test]
1721    fn test_with_hash() {
1722        let tx = test_stellar_tx_data();
1723        let updated = tx.with_hash("hash123".to_string());
1724        assert_eq!(updated.hash, Some("hash123".to_string()));
1725    }
1726
1727    #[test]
1728    fn test_evm_tx_for_replacement() {
1729        let old_data = create_sample_evm_tx_data();
1730        let new_request = EvmTransactionRequest {
1731            to: Some("0xNewRecipient".to_string()),
1732            value: U256::from(2000000000000000000u64), // 2 ETH
1733            data: Some("0xNewData".to_string()),
1734            gas_limit: Some(25000),
1735            gas_price: Some(30000000000), // 30 Gwei (should be ignored)
1736            max_fee_per_gas: Some(40000000000), // Should be ignored
1737            max_priority_fee_per_gas: Some(2000000000), // Should be ignored
1738            speed: Some(Speed::Fast),
1739            valid_until: None,
1740        };
1741
1742        let result = EvmTransactionData::for_replacement(&old_data, &new_request);
1743
1744        // Should preserve old data fields
1745        assert_eq!(result.chain_id, old_data.chain_id);
1746        assert_eq!(result.from, old_data.from);
1747        assert_eq!(result.nonce, old_data.nonce);
1748
1749        // Should use new request fields
1750        assert_eq!(result.to, new_request.to);
1751        assert_eq!(result.value, new_request.value);
1752        assert_eq!(result.data, new_request.data);
1753        assert_eq!(result.gas_limit, new_request.gas_limit);
1754        assert_eq!(result.speed, new_request.speed);
1755
1756        // Should clear all pricing fields (regardless of what's in the request)
1757        assert_eq!(result.gas_price, None);
1758        assert_eq!(result.max_fee_per_gas, None);
1759        assert_eq!(result.max_priority_fee_per_gas, None);
1760
1761        // Should reset signing fields
1762        assert_eq!(result.signature, None);
1763        assert_eq!(result.hash, None);
1764        assert_eq!(result.raw, None);
1765    }
1766
1767    #[test]
1768    fn test_transaction_repo_model_validate() {
1769        let transaction = TransactionRepoModel::default();
1770        let result = transaction.validate();
1771        assert!(result.is_ok());
1772    }
1773
1774    #[test]
1775    fn test_try_from_network_transaction_request_evm() {
1776        use crate::models::{NetworkRepoModel, NetworkType, RelayerRepoModel};
1777
1778        let evm_request = NetworkTransactionRequest::Evm(EvmTransactionRequest {
1779            to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string()),
1780            value: U256::from(1000000000000000000u128),
1781            data: Some("0x1234".to_string()),
1782            gas_limit: Some(21000),
1783            gas_price: Some(20000000000),
1784            max_fee_per_gas: None,
1785            max_priority_fee_per_gas: None,
1786            speed: Some(Speed::Fast),
1787            valid_until: Some("2024-12-31T23:59:59Z".to_string()),
1788        });
1789
1790        let relayer_model = RelayerRepoModel {
1791            id: "relayer-id".to_string(),
1792            name: "Test Relayer".to_string(),
1793            network: "network-id".to_string(),
1794            paused: false,
1795            network_type: NetworkType::Evm,
1796            signer_id: "signer-id".to_string(),
1797            policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
1798            address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
1799            notification_id: None,
1800            system_disabled: false,
1801            custom_rpc_urls: None,
1802            ..Default::default()
1803        };
1804
1805        let network_model = NetworkRepoModel {
1806            id: "evm:ethereum".to_string(),
1807            name: "ethereum".to_string(),
1808            network_type: NetworkType::Evm,
1809            config: NetworkConfigData::Evm(EvmNetworkConfig {
1810                common: NetworkConfigCommon {
1811                    network: "ethereum".to_string(),
1812                    from: None,
1813                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1814                        "https://mainnet.infura.io".to_string(),
1815                    )]),
1816                    explorer_urls: Some(vec!["https://etherscan.io".to_string()]),
1817                    average_blocktime_ms: Some(12000),
1818                    is_testnet: Some(false),
1819                    tags: Some(vec!["mainnet".to_string()]),
1820                },
1821                chain_id: Some(1),
1822                required_confirmations: Some(12),
1823                features: None,
1824                symbol: Some("ETH".to_string()),
1825                gas_price_cache: None,
1826            }),
1827        };
1828
1829        let result = TransactionRepoModel::try_from((&evm_request, &relayer_model, &network_model));
1830        assert!(result.is_ok());
1831        let transaction = result.unwrap();
1832
1833        assert_eq!(transaction.relayer_id, relayer_model.id);
1834        assert_eq!(transaction.status, TransactionStatus::Pending);
1835        assert_eq!(transaction.network_type, NetworkType::Evm);
1836        assert_eq!(
1837            transaction.valid_until,
1838            Some("2024-12-31T23:59:59Z".to_string())
1839        );
1840        assert!(transaction.is_canceled == Some(false));
1841
1842        if let NetworkTransactionData::Evm(evm_data) = transaction.network_data {
1843            assert_eq!(evm_data.from, relayer_model.address);
1844            assert_eq!(
1845                evm_data.to,
1846                Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string())
1847            );
1848            assert_eq!(evm_data.value, U256::from(1000000000000000000u128));
1849            assert_eq!(evm_data.chain_id, 1);
1850            assert_eq!(evm_data.gas_limit, Some(21000));
1851            assert_eq!(evm_data.gas_price, Some(20000000000));
1852            assert_eq!(evm_data.speed, Some(Speed::Fast));
1853        } else {
1854            panic!("Expected EVM transaction data");
1855        }
1856    }
1857
1858    #[test]
1859    fn test_try_from_network_transaction_request_solana() {
1860        use crate::models::{
1861            NetworkRepoModel, NetworkTransactionRequest, NetworkType, RelayerRepoModel,
1862        };
1863
1864        let solana_request = NetworkTransactionRequest::Solana(
1865            crate::models::transaction::request::solana::SolanaTransactionRequest {
1866                transaction: Some(EncodedSerializedTransaction::new(
1867                    "transaction_123".to_string(),
1868                )),
1869                instructions: None,
1870                valid_until: None,
1871            },
1872        );
1873
1874        let relayer_model = RelayerRepoModel {
1875            id: "relayer-id".to_string(),
1876            name: "Test Solana Relayer".to_string(),
1877            network: "network-id".to_string(),
1878            paused: false,
1879            network_type: NetworkType::Solana,
1880            signer_id: "signer-id".to_string(),
1881            policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()),
1882            address: "solana_address".to_string(),
1883            notification_id: None,
1884            system_disabled: false,
1885            custom_rpc_urls: None,
1886            ..Default::default()
1887        };
1888
1889        let network_model = NetworkRepoModel {
1890            id: "solana:mainnet".to_string(),
1891            name: "mainnet".to_string(),
1892            network_type: NetworkType::Solana,
1893            config: NetworkConfigData::Solana(SolanaNetworkConfig {
1894                common: NetworkConfigCommon {
1895                    network: "mainnet".to_string(),
1896                    from: None,
1897                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1898                        "https://api.mainnet-beta.solana.com".to_string(),
1899                    )]),
1900                    explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]),
1901                    average_blocktime_ms: Some(400),
1902                    is_testnet: Some(false),
1903                    tags: Some(vec!["mainnet".to_string()]),
1904                },
1905            }),
1906        };
1907
1908        let result =
1909            TransactionRepoModel::try_from((&solana_request, &relayer_model, &network_model));
1910        assert!(result.is_ok());
1911        let transaction = result.unwrap();
1912
1913        assert_eq!(transaction.relayer_id, relayer_model.id);
1914        assert_eq!(transaction.status, TransactionStatus::Pending);
1915        assert_eq!(transaction.network_type, NetworkType::Solana);
1916        assert_eq!(transaction.valid_until, None);
1917
1918        if let NetworkTransactionData::Solana(solana_data) = transaction.network_data {
1919            assert_eq!(solana_data.transaction, Some("transaction_123".to_string()));
1920            assert_eq!(solana_data.signature, None);
1921        } else {
1922            panic!("Expected Solana transaction data");
1923        }
1924    }
1925
1926    #[test]
1927    fn test_try_from_network_transaction_request_stellar() {
1928        use crate::models::transaction::request::stellar::StellarTransactionRequest;
1929        use crate::models::{
1930            NetworkRepoModel, NetworkTransactionRequest, NetworkType, RelayerRepoModel,
1931        };
1932
1933        let stellar_request = NetworkTransactionRequest::Stellar(StellarTransactionRequest {
1934            source_account: Some(
1935                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1936            ),
1937            network: "mainnet".to_string(),
1938            operations: Some(vec![OperationSpec::Payment {
1939                destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1940                amount: 1000000,
1941                asset: AssetSpec::Native,
1942            }]),
1943            memo: Some(MemoSpec::Text {
1944                value: "Test memo".to_string(),
1945            }),
1946            valid_until: Some("2024-12-31T23:59:59Z".to_string()),
1947            transaction_xdr: None,
1948            fee_bump: None,
1949            max_fee: None,
1950            signed_auth_entry: None,
1951        });
1952
1953        let relayer_model = RelayerRepoModel {
1954            id: "relayer-id".to_string(),
1955            name: "Test Stellar Relayer".to_string(),
1956            network: "network-id".to_string(),
1957            paused: false,
1958            network_type: NetworkType::Stellar,
1959            signer_id: "signer-id".to_string(),
1960            policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()),
1961            address: "stellar_address".to_string(),
1962            notification_id: None,
1963            system_disabled: false,
1964            custom_rpc_urls: None,
1965            ..Default::default()
1966        };
1967
1968        let network_model = NetworkRepoModel {
1969            id: "stellar:mainnet".to_string(),
1970            name: "mainnet".to_string(),
1971            network_type: NetworkType::Stellar,
1972            config: NetworkConfigData::Stellar(StellarNetworkConfig {
1973                common: NetworkConfigCommon {
1974                    network: "mainnet".to_string(),
1975                    from: None,
1976                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1977                        "https://horizon.stellar.org".to_string(),
1978                    )]),
1979                    explorer_urls: Some(vec!["https://stellarchain.io".to_string()]),
1980                    average_blocktime_ms: Some(5000),
1981                    is_testnet: Some(false),
1982                    tags: Some(vec!["mainnet".to_string()]),
1983                },
1984                passphrase: Some("Public Global Stellar Network ; September 2015".to_string()),
1985                horizon_url: Some("https://horizon.stellar.org".to_string()),
1986            }),
1987        };
1988
1989        let result =
1990            TransactionRepoModel::try_from((&stellar_request, &relayer_model, &network_model));
1991        assert!(result.is_ok());
1992        let transaction = result.unwrap();
1993
1994        assert_eq!(transaction.relayer_id, relayer_model.id);
1995        assert_eq!(transaction.status, TransactionStatus::Pending);
1996        assert_eq!(transaction.network_type, NetworkType::Stellar);
1997        // valid_until should be set from the request
1998        assert_eq!(
1999            transaction.valid_until,
2000            Some("2024-12-31T23:59:59Z".to_string())
2001        );
2002
2003        if let NetworkTransactionData::Stellar(stellar_data) = transaction.network_data {
2004            assert_eq!(
2005                stellar_data.source_account,
2006                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
2007            );
2008            // Check that transaction_input contains the operations
2009            if let TransactionInput::Operations(ops) = &stellar_data.transaction_input {
2010                assert_eq!(ops.len(), 1);
2011                if let OperationSpec::Payment {
2012                    destination,
2013                    amount,
2014                    asset,
2015                } = &ops[0]
2016                {
2017                    assert_eq!(
2018                        destination,
2019                        "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
2020                    );
2021                    assert_eq!(amount, &1000000);
2022                    assert_eq!(asset, &AssetSpec::Native);
2023                } else {
2024                    panic!("Expected Payment operation");
2025                }
2026            } else {
2027                panic!("Expected Operations transaction input");
2028            }
2029            assert_eq!(
2030                stellar_data.memo,
2031                Some(MemoSpec::Text {
2032                    value: "Test memo".to_string()
2033                })
2034            );
2035            assert_eq!(
2036                stellar_data.valid_until,
2037                Some("2024-12-31T23:59:59Z".to_string())
2038            );
2039            assert_eq!(stellar_data.signatures.len(), 0);
2040            assert_eq!(stellar_data.hash, None);
2041            assert_eq!(stellar_data.fee, None);
2042            assert_eq!(stellar_data.sequence_number, None);
2043        } else {
2044            panic!("Expected Stellar transaction data");
2045        }
2046    }
2047
2048    #[test]
2049    fn test_try_from_network_transaction_data_for_tx_eip1559() {
2050        // Create a valid EVM transaction with EIP-1559 fields
2051        let mut evm_tx_data = create_sample_evm_tx_data();
2052        evm_tx_data.max_fee_per_gas = Some(30_000_000_000);
2053        evm_tx_data.max_priority_fee_per_gas = Some(2_000_000_000);
2054        let network_data = NetworkTransactionData::Evm(evm_tx_data.clone());
2055
2056        // Should convert successfully
2057        let result = TxEip1559::try_from(network_data);
2058        assert!(result.is_ok());
2059        let tx_eip1559 = result.unwrap();
2060
2061        // Verify fields
2062        assert_eq!(tx_eip1559.chain_id, evm_tx_data.chain_id);
2063        assert_eq!(tx_eip1559.nonce, evm_tx_data.nonce.unwrap());
2064        assert_eq!(tx_eip1559.gas_limit, evm_tx_data.gas_limit.unwrap_or(21000));
2065        assert_eq!(
2066            tx_eip1559.max_fee_per_gas,
2067            evm_tx_data.max_fee_per_gas.unwrap()
2068        );
2069        assert_eq!(
2070            tx_eip1559.max_priority_fee_per_gas,
2071            evm_tx_data.max_priority_fee_per_gas.unwrap()
2072        );
2073        assert_eq!(tx_eip1559.value, evm_tx_data.value);
2074        assert!(tx_eip1559.access_list.0.is_empty());
2075
2076        // Should fail for non-EVM data
2077        let solana_data = NetworkTransactionData::Solana(SolanaTransactionData {
2078            transaction: Some("transaction_123".to_string()),
2079            ..Default::default()
2080        });
2081        assert!(TxEip1559::try_from(solana_data).is_err());
2082    }
2083
2084    #[test]
2085    fn test_evm_transaction_data_defaults() {
2086        let default_data = EvmTransactionData::default();
2087
2088        assert_eq!(
2089            default_data.from,
2090            "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
2091        );
2092        assert_eq!(
2093            default_data.to,
2094            Some("0x70997970C51812dc3A010C7d01b50e0d17dc79C8".to_string())
2095        );
2096        assert_eq!(default_data.gas_price, Some(20000000000));
2097        assert_eq!(default_data.value, U256::from(1000000000000000000u128));
2098        assert_eq!(default_data.data, Some("0x".to_string()));
2099        assert_eq!(default_data.nonce, Some(1));
2100        assert_eq!(default_data.chain_id, 1);
2101        assert_eq!(default_data.gas_limit, Some(21000));
2102        assert_eq!(default_data.hash, None);
2103        assert_eq!(default_data.signature, None);
2104        assert_eq!(default_data.speed, None);
2105        assert_eq!(default_data.max_fee_per_gas, None);
2106        assert_eq!(default_data.max_priority_fee_per_gas, None);
2107        assert_eq!(default_data.raw, None);
2108    }
2109
2110    #[test]
2111    fn test_transaction_repo_model_defaults() {
2112        let default_model = TransactionRepoModel::default();
2113
2114        assert_eq!(default_model.id, "00000000-0000-0000-0000-000000000001");
2115        assert_eq!(
2116            default_model.relayer_id,
2117            "00000000-0000-0000-0000-000000000002"
2118        );
2119        assert_eq!(default_model.status, TransactionStatus::Pending);
2120        assert_eq!(default_model.created_at, "2023-01-01T00:00:00Z");
2121        assert_eq!(default_model.status_reason, None);
2122        assert_eq!(default_model.sent_at, None);
2123        assert_eq!(default_model.confirmed_at, None);
2124        assert_eq!(default_model.valid_until, None);
2125        assert_eq!(default_model.delete_at, None);
2126        assert_eq!(default_model.network_type, NetworkType::Evm);
2127        assert_eq!(default_model.priced_at, None);
2128        assert_eq!(default_model.hashes.len(), 0);
2129        assert_eq!(default_model.noop_count, None);
2130        assert_eq!(default_model.is_canceled, Some(false));
2131    }
2132
2133    #[test]
2134    fn test_evm_tx_for_replacement_with_speed_fallback() {
2135        let mut old_data = create_sample_evm_tx_data();
2136        old_data.speed = Some(Speed::SafeLow);
2137
2138        // Request with no speed - should use old data's speed
2139        let new_request = EvmTransactionRequest {
2140            to: Some("0xNewRecipient".to_string()),
2141            value: U256::from(2000000000000000000u64),
2142            data: Some("0xNewData".to_string()),
2143            gas_limit: Some(25000),
2144            gas_price: None,
2145            max_fee_per_gas: None,
2146            max_priority_fee_per_gas: None,
2147            speed: None,
2148            valid_until: None,
2149        };
2150
2151        let result = EvmTransactionData::for_replacement(&old_data, &new_request);
2152        assert_eq!(result.speed, Some(Speed::SafeLow));
2153
2154        // Old data with no speed - should use default
2155        let mut old_data_no_speed = create_sample_evm_tx_data();
2156        old_data_no_speed.speed = None;
2157
2158        let result2 = EvmTransactionData::for_replacement(&old_data_no_speed, &new_request);
2159        assert_eq!(result2.speed, Some(DEFAULT_TRANSACTION_SPEED));
2160    }
2161
2162    #[test]
2163    fn test_transaction_status_serialization() {
2164        use serde_json;
2165
2166        // Test serialization of different status values
2167        assert_eq!(
2168            serde_json::to_string(&TransactionStatus::Pending).unwrap(),
2169            "\"pending\""
2170        );
2171        assert_eq!(
2172            serde_json::to_string(&TransactionStatus::Sent).unwrap(),
2173            "\"sent\""
2174        );
2175        assert_eq!(
2176            serde_json::to_string(&TransactionStatus::Mined).unwrap(),
2177            "\"mined\""
2178        );
2179        assert_eq!(
2180            serde_json::to_string(&TransactionStatus::Failed).unwrap(),
2181            "\"failed\""
2182        );
2183        assert_eq!(
2184            serde_json::to_string(&TransactionStatus::Confirmed).unwrap(),
2185            "\"confirmed\""
2186        );
2187        assert_eq!(
2188            serde_json::to_string(&TransactionStatus::Canceled).unwrap(),
2189            "\"canceled\""
2190        );
2191        assert_eq!(
2192            serde_json::to_string(&TransactionStatus::Submitted).unwrap(),
2193            "\"submitted\""
2194        );
2195        assert_eq!(
2196            serde_json::to_string(&TransactionStatus::Expired).unwrap(),
2197            "\"expired\""
2198        );
2199    }
2200
2201    #[test]
2202    fn test_evm_tx_contract_creation() {
2203        // Test transaction data for contract creation (no 'to' address)
2204        let mut tx_data = create_sample_evm_tx_data();
2205        tx_data.to = None;
2206
2207        let tx_legacy = TxLegacy::try_from(&tx_data).unwrap();
2208        assert_eq!(tx_legacy.to, TxKind::Create);
2209
2210        let tx_eip1559 = TxEip1559::try_from(&tx_data).unwrap();
2211        assert_eq!(tx_eip1559.to, TxKind::Create);
2212    }
2213
2214    #[test]
2215    fn test_evm_tx_default_values_in_conversion() {
2216        // Test conversion with missing nonce and gas price
2217        let mut tx_data = create_sample_evm_tx_data();
2218        tx_data.nonce = None;
2219        tx_data.gas_price = None;
2220        tx_data.max_fee_per_gas = None;
2221        tx_data.max_priority_fee_per_gas = None;
2222
2223        let tx_legacy = TxLegacy::try_from(&tx_data).unwrap();
2224        assert_eq!(tx_legacy.nonce, 0); // Default nonce
2225        assert_eq!(tx_legacy.gas_price, 0); // Default gas price
2226
2227        let tx_eip1559 = TxEip1559::try_from(&tx_data).unwrap();
2228        assert_eq!(tx_eip1559.nonce, 0); // Default nonce
2229        assert_eq!(tx_eip1559.max_fee_per_gas, 0); // Default max fee
2230        assert_eq!(tx_eip1559.max_priority_fee_per_gas, 0); // Default max priority fee
2231    }
2232
2233    // Helper function to create test network and relayer models
2234    fn test_models() -> (NetworkRepoModel, RelayerRepoModel) {
2235        use crate::config::{NetworkConfigCommon, StellarNetworkConfig};
2236        use crate::constants::DEFAULT_STELLAR_MIN_BALANCE;
2237
2238        let network_config = NetworkConfigData::Stellar(StellarNetworkConfig {
2239            common: NetworkConfigCommon {
2240                network: "testnet".to_string(),
2241                from: None,
2242                rpc_urls: Some(vec![crate::models::RpcConfig::new(
2243                    "https://test.stellar.org".to_string(),
2244                )]),
2245                explorer_urls: None,
2246                average_blocktime_ms: Some(5000), // 5 seconds for Stellar
2247                is_testnet: Some(true),
2248                tags: None,
2249            },
2250            passphrase: Some("Test SDF Network ; September 2015".to_string()),
2251            horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
2252        });
2253
2254        let network_model = NetworkRepoModel {
2255            id: "stellar:testnet".to_string(),
2256            name: "testnet".to_string(),
2257            network_type: NetworkType::Stellar,
2258            config: network_config,
2259        };
2260
2261        let relayer_model = RelayerRepoModel {
2262            id: "test-relayer".to_string(),
2263            name: "Test Relayer".to_string(),
2264            network: "stellar:testnet".to_string(),
2265            paused: false,
2266            network_type: NetworkType::Stellar,
2267            signer_id: "test-signer".to_string(),
2268            policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy {
2269                max_fee: None,
2270                timeout_seconds: None,
2271                min_balance: Some(DEFAULT_STELLAR_MIN_BALANCE),
2272                concurrent_transactions: None,
2273                allowed_tokens: None,
2274                fee_payment_strategy: Some(StellarFeePaymentStrategy::Relayer),
2275                slippage_percentage: None,
2276                fee_margin_percentage: None,
2277                swap_config: None,
2278            }),
2279            address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2280            notification_id: None,
2281            system_disabled: false,
2282            custom_rpc_urls: None,
2283            ..Default::default()
2284        };
2285
2286        (network_model, relayer_model)
2287    }
2288
2289    #[test]
2290    fn test_stellar_transaction_data_serialization_roundtrip() {
2291        use crate::models::transaction::stellar::asset::AssetSpec;
2292        use crate::models::transaction::stellar::operation::OperationSpec;
2293        use soroban_rs::xdr::{BytesM, Signature, SignatureHint};
2294
2295        // Create a dummy signature
2296        let hint = SignatureHint([1, 2, 3, 4]);
2297        let sig_bytes: Vec<u8> = vec![5u8; 64];
2298        let sig_bytes_m: BytesM<64> = sig_bytes.try_into().unwrap();
2299        let dummy_signature = DecoratedSignature {
2300            hint,
2301            signature: Signature(sig_bytes_m),
2302        };
2303
2304        // Create a StellarTransactionData with operations, signatures, and other fields
2305        let original_data = StellarTransactionData {
2306            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2307            fee: Some(100),
2308            sequence_number: Some(12345),
2309            memo: None,
2310            valid_until: None,
2311            network_passphrase: "Test SDF Network ; September 2015".to_string(),
2312            signatures: vec![dummy_signature.clone()],
2313            hash: Some("test-hash".to_string()),
2314            simulation_transaction_data: Some("simulation-data".to_string()),
2315            transaction_input: TransactionInput::Operations(vec![OperationSpec::Payment {
2316                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
2317                amount: 1000,
2318                asset: AssetSpec::Native,
2319            }]),
2320            signed_envelope_xdr: Some("signed-xdr-data".to_string()),
2321            transaction_result_xdr: None,
2322        };
2323
2324        // Serialize to JSON
2325        let json = serde_json::to_string(&original_data).expect("Failed to serialize");
2326
2327        // Deserialize from JSON
2328        let deserialized_data: StellarTransactionData =
2329            serde_json::from_str(&json).expect("Failed to deserialize");
2330
2331        // Verify that transaction_input is preserved
2332        match (
2333            &original_data.transaction_input,
2334            &deserialized_data.transaction_input,
2335        ) {
2336            (TransactionInput::Operations(orig_ops), TransactionInput::Operations(deser_ops)) => {
2337                assert_eq!(orig_ops.len(), deser_ops.len());
2338                assert_eq!(orig_ops, deser_ops);
2339            }
2340            _ => panic!("Transaction input type mismatch"),
2341        }
2342
2343        // Verify signatures are preserved
2344        assert_eq!(
2345            original_data.signatures.len(),
2346            deserialized_data.signatures.len()
2347        );
2348        assert_eq!(original_data.signatures, deserialized_data.signatures);
2349
2350        // Verify other fields are preserved
2351        assert_eq!(
2352            original_data.source_account,
2353            deserialized_data.source_account
2354        );
2355        assert_eq!(original_data.fee, deserialized_data.fee);
2356        assert_eq!(
2357            original_data.sequence_number,
2358            deserialized_data.sequence_number
2359        );
2360        assert_eq!(
2361            original_data.network_passphrase,
2362            deserialized_data.network_passphrase
2363        );
2364        assert_eq!(original_data.hash, deserialized_data.hash);
2365        assert_eq!(
2366            original_data.simulation_transaction_data,
2367            deserialized_data.simulation_transaction_data
2368        );
2369        assert_eq!(
2370            original_data.signed_envelope_xdr,
2371            deserialized_data.signed_envelope_xdr
2372        );
2373    }
2374
2375    #[test]
2376    fn test_stellar_xdr_transaction_input_conversion() {
2377        let (network_model, relayer_model) = test_models();
2378
2379        // Test case 1: Operations mode (existing behavior)
2380        let stellar_request = StellarTransactionRequest {
2381            source_account: Some(
2382                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2383            ),
2384            network: "testnet".to_string(),
2385            operations: Some(vec![OperationSpec::Payment {
2386                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
2387                amount: 1000000,
2388                asset: AssetSpec::Native,
2389            }]),
2390            memo: None,
2391            valid_until: None,
2392            transaction_xdr: None,
2393            fee_bump: None,
2394            max_fee: None,
2395            signed_auth_entry: None,
2396        };
2397
2398        let request = NetworkTransactionRequest::Stellar(stellar_request);
2399        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2400        assert!(result.is_ok());
2401
2402        let tx_model = result.unwrap();
2403        if let NetworkTransactionData::Stellar(ref stellar_data) = tx_model.network_data {
2404            assert!(matches!(
2405                stellar_data.transaction_input,
2406                TransactionInput::Operations(_)
2407            ));
2408        } else {
2409            panic!("Expected Stellar transaction data");
2410        }
2411
2412        // Test case 2: Unsigned XDR mode
2413        // This is a valid unsigned transaction created with stellar CLI
2414        let unsigned_xdr = "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAGQAAHAkAAAADgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=";
2415        let stellar_request = StellarTransactionRequest {
2416            source_account: None,
2417            network: "testnet".to_string(),
2418            operations: Some(vec![]),
2419            memo: None,
2420            valid_until: None,
2421            transaction_xdr: Some(unsigned_xdr.to_string()),
2422            fee_bump: None,
2423            max_fee: None,
2424            signed_auth_entry: None,
2425        };
2426
2427        let request = NetworkTransactionRequest::Stellar(stellar_request);
2428        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2429        assert!(result.is_ok());
2430
2431        let tx_model = result.unwrap();
2432        if let NetworkTransactionData::Stellar(ref stellar_data) = tx_model.network_data {
2433            assert!(matches!(
2434                stellar_data.transaction_input,
2435                TransactionInput::UnsignedXdr(_)
2436            ));
2437        } else {
2438            panic!("Expected Stellar transaction data");
2439        }
2440
2441        // Test case 3: Signed XDR with fee_bump
2442        // Create a signed XDR by duplicating the test logic from xdr_tests
2443        let signed_xdr = {
2444            use soroban_rs::xdr::{Limits, TransactionEnvelope, TransactionV1Envelope, WriteXdr};
2445            use stellar_strkey::ed25519::PublicKey;
2446
2447            // Use the same transaction structure but add a dummy signature
2448            let source_pk =
2449                PublicKey::from_string("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
2450                    .unwrap();
2451            let dest_pk =
2452                PublicKey::from_string("GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ")
2453                    .unwrap();
2454
2455            let payment_op = soroban_rs::xdr::PaymentOp {
2456                destination: soroban_rs::xdr::MuxedAccount::Ed25519(soroban_rs::xdr::Uint256(
2457                    dest_pk.0,
2458                )),
2459                asset: soroban_rs::xdr::Asset::Native,
2460                amount: 1000000,
2461            };
2462
2463            let operation = soroban_rs::xdr::Operation {
2464                source_account: None,
2465                body: soroban_rs::xdr::OperationBody::Payment(payment_op),
2466            };
2467
2468            let operations: soroban_rs::xdr::VecM<soroban_rs::xdr::Operation, 100> =
2469                vec![operation].try_into().unwrap();
2470
2471            let tx = soroban_rs::xdr::Transaction {
2472                source_account: soroban_rs::xdr::MuxedAccount::Ed25519(soroban_rs::xdr::Uint256(
2473                    source_pk.0,
2474                )),
2475                fee: 100,
2476                seq_num: soroban_rs::xdr::SequenceNumber(1),
2477                cond: soroban_rs::xdr::Preconditions::None,
2478                memo: soroban_rs::xdr::Memo::None,
2479                operations,
2480                ext: soroban_rs::xdr::TransactionExt::V0,
2481            };
2482
2483            // Add a dummy signature
2484            let hint = soroban_rs::xdr::SignatureHint([0; 4]);
2485            let sig_bytes: Vec<u8> = vec![0u8; 64];
2486            let sig_bytes_m: soroban_rs::xdr::BytesM<64> = sig_bytes.try_into().unwrap();
2487            let sig = soroban_rs::xdr::DecoratedSignature {
2488                hint,
2489                signature: soroban_rs::xdr::Signature(sig_bytes_m),
2490            };
2491
2492            let envelope = TransactionV1Envelope {
2493                tx,
2494                signatures: vec![sig].try_into().unwrap(),
2495            };
2496
2497            let tx_envelope = TransactionEnvelope::Tx(envelope);
2498            tx_envelope.to_xdr_base64(Limits::none()).unwrap()
2499        };
2500        let stellar_request = StellarTransactionRequest {
2501            source_account: None,
2502            network: "testnet".to_string(),
2503            operations: Some(vec![]),
2504            memo: None,
2505            valid_until: None,
2506            transaction_xdr: Some(signed_xdr.to_string()),
2507            fee_bump: Some(true),
2508            max_fee: Some(20000000),
2509            signed_auth_entry: None,
2510        };
2511
2512        let request = NetworkTransactionRequest::Stellar(stellar_request);
2513        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2514        assert!(result.is_ok());
2515
2516        let tx_model = result.unwrap();
2517        if let NetworkTransactionData::Stellar(ref stellar_data) = tx_model.network_data {
2518            match &stellar_data.transaction_input {
2519                TransactionInput::SignedXdr { xdr, max_fee } => {
2520                    assert_eq!(xdr, &signed_xdr);
2521                    assert_eq!(*max_fee, 20000000);
2522                }
2523                _ => panic!("Expected SignedXdr transaction input"),
2524            }
2525        } else {
2526            panic!("Expected Stellar transaction data");
2527        }
2528
2529        // Test case 4: Signed XDR without fee_bump should fail
2530        let stellar_request = StellarTransactionRequest {
2531            source_account: None,
2532            network: "testnet".to_string(),
2533            operations: Some(vec![]),
2534            memo: None,
2535            valid_until: None,
2536            transaction_xdr: Some(signed_xdr.clone()),
2537            fee_bump: None,
2538            max_fee: None,
2539            signed_auth_entry: None,
2540        };
2541
2542        let request = NetworkTransactionRequest::Stellar(stellar_request);
2543        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2544        assert!(result.is_err());
2545        assert!(result
2546            .unwrap_err()
2547            .to_string()
2548            .contains("Expected unsigned XDR but received signed XDR"));
2549
2550        // Test case 5: Operations with fee_bump should fail
2551        let stellar_request = StellarTransactionRequest {
2552            source_account: Some(
2553                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2554            ),
2555            network: "testnet".to_string(),
2556            operations: Some(vec![OperationSpec::Payment {
2557                destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
2558                amount: 1000000,
2559                asset: AssetSpec::Native,
2560            }]),
2561            memo: None,
2562            valid_until: None,
2563            transaction_xdr: None,
2564            fee_bump: Some(true),
2565            max_fee: None,
2566            signed_auth_entry: None,
2567        };
2568
2569        let request = NetworkTransactionRequest::Stellar(stellar_request);
2570        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2571        assert!(result.is_err());
2572        assert!(result
2573            .unwrap_err()
2574            .to_string()
2575            .contains("Cannot request fee_bump with operations mode"));
2576    }
2577
2578    #[test]
2579    fn test_invoke_host_function_must_be_exclusive() {
2580        let (network_model, relayer_model) = test_models();
2581
2582        // Test case 1: Single InvokeHostFunction - should succeed
2583        let stellar_request = StellarTransactionRequest {
2584            source_account: Some(
2585                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2586            ),
2587            network: "testnet".to_string(),
2588            operations: Some(vec![OperationSpec::InvokeContract {
2589                contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2590                    .to_string(),
2591                function_name: "transfer".to_string(),
2592                args: vec![],
2593                auth: None,
2594            }]),
2595            memo: None,
2596            valid_until: None,
2597            transaction_xdr: None,
2598            fee_bump: None,
2599            max_fee: None,
2600            signed_auth_entry: None,
2601        };
2602
2603        let request = NetworkTransactionRequest::Stellar(stellar_request);
2604        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2605        assert!(result.is_ok(), "Single InvokeHostFunction should succeed");
2606
2607        // Test case 2: InvokeHostFunction mixed with Payment - should fail
2608        let stellar_request = StellarTransactionRequest {
2609            source_account: Some(
2610                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2611            ),
2612            network: "testnet".to_string(),
2613            operations: Some(vec![
2614                OperationSpec::Payment {
2615                    destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
2616                        .to_string(),
2617                    amount: 1000,
2618                    asset: AssetSpec::Native,
2619                },
2620                OperationSpec::InvokeContract {
2621                    contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2622                        .to_string(),
2623                    function_name: "transfer".to_string(),
2624                    args: vec![],
2625                    auth: None,
2626                },
2627            ]),
2628            memo: None,
2629            valid_until: None,
2630            transaction_xdr: None,
2631            fee_bump: None,
2632            max_fee: None,
2633            signed_auth_entry: None,
2634        };
2635
2636        let request = NetworkTransactionRequest::Stellar(stellar_request);
2637        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2638
2639        match result {
2640            Ok(_) => panic!("Expected Soroban operation mixed with Payment to fail"),
2641            Err(err) => {
2642                let err_str = err.to_string();
2643                assert!(
2644                    err_str.contains("Soroban operations must be exclusive"),
2645                    "Expected error about Soroban operation exclusivity, got: {err_str}"
2646                );
2647            }
2648        }
2649
2650        // Test case 3: Multiple InvokeHostFunction operations - should fail
2651        let stellar_request = StellarTransactionRequest {
2652            source_account: Some(
2653                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2654            ),
2655            network: "testnet".to_string(),
2656            operations: Some(vec![
2657                OperationSpec::InvokeContract {
2658                    contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2659                        .to_string(),
2660                    function_name: "transfer".to_string(),
2661                    args: vec![],
2662                    auth: None,
2663                },
2664                OperationSpec::InvokeContract {
2665                    contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2666                        .to_string(),
2667                    function_name: "approve".to_string(),
2668                    args: vec![],
2669                    auth: None,
2670                },
2671            ]),
2672            memo: None,
2673            valid_until: None,
2674            transaction_xdr: None,
2675            fee_bump: None,
2676            max_fee: None,
2677            signed_auth_entry: None,
2678        };
2679
2680        let request = NetworkTransactionRequest::Stellar(stellar_request);
2681        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2682
2683        match result {
2684            Ok(_) => panic!("Expected multiple Soroban operations to fail"),
2685            Err(err) => {
2686                let err_str = err.to_string();
2687                assert!(
2688                    err_str.contains("Transaction can contain at most one Soroban operation"),
2689                    "Expected error about multiple Soroban operations, got: {err_str}"
2690                );
2691            }
2692        }
2693
2694        // Test case 4: Multiple Payment operations - should succeed
2695        let stellar_request = StellarTransactionRequest {
2696            source_account: Some(
2697                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2698            ),
2699            network: "testnet".to_string(),
2700            operations: Some(vec![
2701                OperationSpec::Payment {
2702                    destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
2703                        .to_string(),
2704                    amount: 1000,
2705                    asset: AssetSpec::Native,
2706                },
2707                OperationSpec::Payment {
2708                    destination: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
2709                        .to_string(),
2710                    amount: 2000,
2711                    asset: AssetSpec::Native,
2712                },
2713            ]),
2714            memo: None,
2715            valid_until: None,
2716            transaction_xdr: None,
2717            fee_bump: None,
2718            max_fee: None,
2719            signed_auth_entry: None,
2720        };
2721
2722        let request = NetworkTransactionRequest::Stellar(stellar_request);
2723        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2724        assert!(result.is_ok(), "Multiple Payment operations should succeed");
2725
2726        // Test case 5: InvokeHostFunction with non-None memo - should fail
2727        let stellar_request = StellarTransactionRequest {
2728            source_account: Some(
2729                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2730            ),
2731            network: "testnet".to_string(),
2732            operations: Some(vec![OperationSpec::InvokeContract {
2733                contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2734                    .to_string(),
2735                function_name: "transfer".to_string(),
2736                args: vec![],
2737                auth: None,
2738            }]),
2739            memo: Some(MemoSpec::Text {
2740                value: "This should fail".to_string(),
2741            }),
2742            valid_until: None,
2743            transaction_xdr: None,
2744            fee_bump: None,
2745            max_fee: None,
2746            signed_auth_entry: None,
2747        };
2748
2749        let request = NetworkTransactionRequest::Stellar(stellar_request);
2750        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2751
2752        match result {
2753            Ok(_) => panic!("Expected InvokeHostFunction with non-None memo to fail"),
2754            Err(err) => {
2755                let err_str = err.to_string();
2756                assert!(
2757                    err_str.contains("Soroban operations cannot have a memo"),
2758                    "Expected error about memo restriction, got: {err_str}"
2759                );
2760            }
2761        }
2762
2763        // Test case 6: InvokeHostFunction with memo None - should succeed
2764        let stellar_request = StellarTransactionRequest {
2765            source_account: Some(
2766                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2767            ),
2768            network: "testnet".to_string(),
2769            operations: Some(vec![OperationSpec::InvokeContract {
2770                contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2771                    .to_string(),
2772                function_name: "transfer".to_string(),
2773                args: vec![],
2774                auth: None,
2775            }]),
2776            memo: Some(MemoSpec::None),
2777            valid_until: None,
2778            transaction_xdr: None,
2779            fee_bump: None,
2780            max_fee: None,
2781            signed_auth_entry: None,
2782        };
2783
2784        let request = NetworkTransactionRequest::Stellar(stellar_request);
2785        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2786        assert!(
2787            result.is_ok(),
2788            "InvokeHostFunction with MemoSpec::None should succeed"
2789        );
2790
2791        // Test case 7: InvokeHostFunction with no memo field - should succeed
2792        let stellar_request = StellarTransactionRequest {
2793            source_account: Some(
2794                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2795            ),
2796            network: "testnet".to_string(),
2797            operations: Some(vec![OperationSpec::InvokeContract {
2798                contract_address: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"
2799                    .to_string(),
2800                function_name: "transfer".to_string(),
2801                args: vec![],
2802                auth: None,
2803            }]),
2804            memo: None,
2805            valid_until: None,
2806            transaction_xdr: None,
2807            fee_bump: None,
2808            max_fee: None,
2809            signed_auth_entry: None,
2810        };
2811
2812        let request = NetworkTransactionRequest::Stellar(stellar_request);
2813        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2814        assert!(
2815            result.is_ok(),
2816            "InvokeHostFunction with no memo should succeed"
2817        );
2818
2819        // Test case 8: Payment operation with memo - should succeed
2820        let stellar_request = StellarTransactionRequest {
2821            source_account: Some(
2822                "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2823            ),
2824            network: "testnet".to_string(),
2825            operations: Some(vec![OperationSpec::Payment {
2826                destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2827                amount: 1000,
2828                asset: AssetSpec::Native,
2829            }]),
2830            memo: Some(MemoSpec::Text {
2831                value: "Payment memo is allowed".to_string(),
2832            }),
2833            valid_until: None,
2834            transaction_xdr: None,
2835            fee_bump: None,
2836            max_fee: None,
2837            signed_auth_entry: None,
2838        };
2839
2840        let request = NetworkTransactionRequest::Stellar(stellar_request);
2841        let result = TransactionRepoModel::try_from((&request, &relayer_model, &network_model));
2842        assert!(result.is_ok(), "Payment operation with memo should succeed");
2843    }
2844
2845    #[test]
2846    fn test_update_delete_at_if_final_status_does_not_update_when_delete_at_already_set() {
2847        let _lock = match ENV_MUTEX.lock() {
2848            Ok(guard) => guard,
2849            Err(poisoned) => poisoned.into_inner(),
2850        };
2851
2852        use std::env;
2853
2854        // Set custom expiration hours for test
2855        env::set_var("TRANSACTION_EXPIRATION_HOURS", "6");
2856
2857        let mut transaction = create_test_transaction();
2858        transaction.delete_at = Some("2024-01-01T00:00:00Z".to_string());
2859        transaction.status = TransactionStatus::Confirmed; // Final status
2860
2861        let original_delete_at = transaction.delete_at.clone();
2862
2863        transaction.update_delete_at_if_final_status();
2864
2865        // Should not change delete_at when it's already set
2866        assert_eq!(transaction.delete_at, original_delete_at);
2867
2868        // Cleanup
2869        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
2870    }
2871
2872    #[test]
2873    fn test_update_delete_at_if_final_status_does_not_update_when_status_not_final() {
2874        let _lock = match ENV_MUTEX.lock() {
2875            Ok(guard) => guard,
2876            Err(poisoned) => poisoned.into_inner(),
2877        };
2878
2879        use std::env;
2880
2881        // Set custom expiration hours for test
2882        env::set_var("TRANSACTION_EXPIRATION_HOURS", "6");
2883
2884        let mut transaction = create_test_transaction();
2885        transaction.delete_at = None;
2886        transaction.status = TransactionStatus::Pending; // Non-final status
2887
2888        transaction.update_delete_at_if_final_status();
2889
2890        // Should not set delete_at for non-final status
2891        assert!(transaction.delete_at.is_none());
2892
2893        // Cleanup
2894        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
2895    }
2896
2897    #[test]
2898    fn test_update_delete_at_if_final_status_sets_delete_at_for_final_statuses() {
2899        let _lock = match ENV_MUTEX.lock() {
2900            Ok(guard) => guard,
2901            Err(poisoned) => poisoned.into_inner(),
2902        };
2903
2904        use crate::config::ServerConfig;
2905        use chrono::{DateTime, Duration, Utc};
2906        use std::env;
2907
2908        // Set custom expiration hours for test
2909        env::set_var("TRANSACTION_EXPIRATION_HOURS", "3"); // Use 3 hours for this test
2910
2911        // Verify the env var is actually set correctly
2912        let actual_hours = ServerConfig::get_transaction_expiration_hours();
2913        assert_eq!(
2914            actual_hours, 3.0,
2915            "Environment variable should be set to 3 hours"
2916        );
2917
2918        let final_statuses = vec![
2919            TransactionStatus::Canceled,
2920            TransactionStatus::Confirmed,
2921            TransactionStatus::Failed,
2922            TransactionStatus::Expired,
2923        ];
2924
2925        for status in final_statuses {
2926            let mut transaction = create_test_transaction();
2927            transaction.delete_at = None;
2928            transaction.status = status.clone();
2929
2930            let before_update = Utc::now();
2931            transaction.update_delete_at_if_final_status();
2932
2933            // Should set delete_at for final status
2934            assert!(
2935                transaction.delete_at.is_some(),
2936                "delete_at should be set for status: {status:?}"
2937            );
2938
2939            // Verify the timestamp is reasonable
2940            let delete_at_str = transaction.delete_at.unwrap();
2941            let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
2942                .expect("delete_at should be valid RFC3339")
2943                .with_timezone(&Utc);
2944
2945            // Should be approximately 3 hours from before_update
2946            let duration_from_before = delete_at.signed_duration_since(before_update);
2947            let expected_duration = Duration::hours(3);
2948            let tolerance = Duration::minutes(5); // Allow 5 minutes tolerance
2949
2950            // Debug information
2951            let actual_hours_at_runtime = ServerConfig::get_transaction_expiration_hours();
2952
2953            assert!(
2954                duration_from_before >= expected_duration - tolerance &&
2955                duration_from_before <= expected_duration + tolerance,
2956                "delete_at should be approximately 3 hours from now for status: {status:?}. Duration from start: {duration_from_before:?}, Expected: {expected_duration:?}, Config hours at runtime: {actual_hours_at_runtime}"
2957            );
2958        }
2959
2960        // Cleanup
2961        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
2962    }
2963
2964    #[test]
2965    fn test_update_delete_at_if_final_status_uses_default_expiration_hours() {
2966        let _lock = match ENV_MUTEX.lock() {
2967            Ok(guard) => guard,
2968            Err(poisoned) => poisoned.into_inner(),
2969        };
2970
2971        use chrono::{DateTime, Duration, Utc};
2972        use std::env;
2973
2974        // Remove env var to test default behavior
2975        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
2976
2977        let mut transaction = create_test_transaction();
2978        transaction.delete_at = None;
2979        transaction.status = TransactionStatus::Confirmed;
2980
2981        let before_update = Utc::now();
2982        transaction.update_delete_at_if_final_status();
2983
2984        // Should set delete_at using default value (4 hours)
2985        assert!(transaction.delete_at.is_some());
2986
2987        let delete_at_str = transaction.delete_at.unwrap();
2988        let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
2989            .expect("delete_at should be valid RFC3339")
2990            .with_timezone(&Utc);
2991
2992        // Should be approximately 4 hours from before_update (default value)
2993        let duration_from_before = delete_at.signed_duration_since(before_update);
2994        let expected_duration = Duration::hours(4);
2995        let tolerance = Duration::minutes(5); // Allow 5 minutes tolerance
2996
2997        assert!(
2998            duration_from_before >= expected_duration - tolerance &&
2999            duration_from_before <= expected_duration + tolerance,
3000            "delete_at should be approximately 4 hours from now (default). Duration from start: {duration_from_before:?}, Expected: {expected_duration:?}"
3001        );
3002    }
3003
3004    #[test]
3005    fn test_update_delete_at_if_final_status_with_custom_expiration_hours() {
3006        let _lock = match ENV_MUTEX.lock() {
3007            Ok(guard) => guard,
3008            Err(poisoned) => poisoned.into_inner(),
3009        };
3010
3011        use chrono::{DateTime, Duration, Utc};
3012        use std::env;
3013
3014        // Test with various custom expiration hours
3015        let test_cases = vec![1, 2, 6, 12]; // 1 hour, 2 hours, 6 hours, 12 hours
3016
3017        for expiration_hours in test_cases {
3018            env::set_var("TRANSACTION_EXPIRATION_HOURS", expiration_hours.to_string());
3019
3020            let mut transaction = create_test_transaction();
3021            transaction.delete_at = None;
3022            transaction.status = TransactionStatus::Failed;
3023
3024            let before_update = Utc::now();
3025            transaction.update_delete_at_if_final_status();
3026
3027            assert!(
3028                transaction.delete_at.is_some(),
3029                "delete_at should be set for {expiration_hours} hours"
3030            );
3031
3032            let delete_at_str = transaction.delete_at.unwrap();
3033            let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
3034                .expect("delete_at should be valid RFC3339")
3035                .with_timezone(&Utc);
3036
3037            let duration_from_before = delete_at.signed_duration_since(before_update);
3038            let expected_duration = Duration::hours(expiration_hours as i64);
3039            let tolerance = Duration::minutes(5); // Allow 5 minutes tolerance
3040
3041            assert!(
3042                duration_from_before >= expected_duration - tolerance &&
3043                duration_from_before <= expected_duration + tolerance,
3044                "delete_at should be approximately {expiration_hours} hours from now. Duration from start: {duration_from_before:?}, Expected: {expected_duration:?}"
3045            );
3046        }
3047
3048        // Cleanup
3049        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3050    }
3051
3052    #[test]
3053    fn test_calculate_delete_at_with_various_hours() {
3054        use chrono::{DateTime, Utc};
3055
3056        let test_cases = vec![0, 1, 6, 12, 24, 48];
3057
3058        for hours in test_cases {
3059            let before_calc = Utc::now();
3060            let result = TransactionRepoModel::calculate_delete_at(hours as f64);
3061            let after_calc = Utc::now();
3062
3063            assert!(
3064                result.is_some(),
3065                "calculate_delete_at should return Some for {hours} hours"
3066            );
3067
3068            let delete_at_str = result.unwrap();
3069            let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
3070                .expect("Result should be valid RFC3339")
3071                .with_timezone(&Utc);
3072
3073            let expected_min =
3074                before_calc + chrono::Duration::hours(hours as i64) - chrono::Duration::seconds(1);
3075            let expected_max =
3076                after_calc + chrono::Duration::hours(hours as i64) + chrono::Duration::seconds(1);
3077
3078            assert!(
3079                delete_at >= expected_min && delete_at <= expected_max,
3080                "Calculated delete_at should be approximately {hours} hours from now. Got: {delete_at}, Expected between: {expected_min} and {expected_max}"
3081            );
3082        }
3083    }
3084
3085    #[test]
3086    fn test_update_delete_at_if_final_status_idempotent() {
3087        let _lock = match ENV_MUTEX.lock() {
3088            Ok(guard) => guard,
3089            Err(poisoned) => poisoned.into_inner(),
3090        };
3091
3092        use std::env;
3093
3094        env::set_var("TRANSACTION_EXPIRATION_HOURS", "8");
3095
3096        let mut transaction = create_test_transaction();
3097        transaction.delete_at = None;
3098        transaction.status = TransactionStatus::Confirmed;
3099
3100        // First call should set delete_at
3101        transaction.update_delete_at_if_final_status();
3102        let first_delete_at = transaction.delete_at.clone();
3103        assert!(first_delete_at.is_some());
3104
3105        // Second call should not change delete_at (idempotent)
3106        transaction.update_delete_at_if_final_status();
3107        assert_eq!(transaction.delete_at, first_delete_at);
3108
3109        // Third call should not change delete_at (idempotent)
3110        transaction.update_delete_at_if_final_status();
3111        assert_eq!(transaction.delete_at, first_delete_at);
3112
3113        // Cleanup
3114        env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3115    }
3116
3117    /// Helper function to create a test transaction for testing delete_at functionality
3118    fn create_test_transaction() -> TransactionRepoModel {
3119        TransactionRepoModel {
3120            id: "test-transaction-id".to_string(),
3121            relayer_id: "test-relayer-id".to_string(),
3122            status: TransactionStatus::Pending,
3123            status_reason: None,
3124            created_at: "2024-01-01T00:00:00Z".to_string(),
3125            sent_at: None,
3126            confirmed_at: None,
3127            valid_until: None,
3128            delete_at: None,
3129            network_data: NetworkTransactionData::Evm(EvmTransactionData {
3130                gas_price: None,
3131                gas_limit: Some(21000),
3132                nonce: Some(0),
3133                value: U256::from(0),
3134                data: None,
3135                from: "0x1234567890123456789012345678901234567890".to_string(),
3136                to: Some("0x0987654321098765432109876543210987654321".to_string()),
3137                chain_id: 1,
3138                hash: None,
3139                signature: None,
3140                speed: None,
3141                max_fee_per_gas: None,
3142                max_priority_fee_per_gas: None,
3143                raw: None,
3144            }),
3145            priced_at: None,
3146            hashes: vec![],
3147            network_type: NetworkType::Evm,
3148            noop_count: None,
3149            is_canceled: None,
3150            metadata: None,
3151        }
3152    }
3153
3154    #[test]
3155    fn test_apply_partial_update() {
3156        // Create a test transaction
3157        let mut transaction = create_test_transaction();
3158
3159        // Create a partial update request
3160        let update = TransactionUpdateRequest {
3161            status: Some(TransactionStatus::Confirmed),
3162            status_reason: Some("Transaction confirmed".to_string()),
3163            sent_at: Some("2023-01-01T12:00:00Z".to_string()),
3164            confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
3165            hashes: Some(vec!["0x123".to_string(), "0x456".to_string()]),
3166            is_canceled: Some(false),
3167            ..Default::default()
3168        };
3169
3170        // Apply the partial update
3171        transaction.apply_partial_update(update);
3172
3173        // Verify the updates were applied
3174        assert_eq!(transaction.status, TransactionStatus::Confirmed);
3175        assert_eq!(
3176            transaction.status_reason,
3177            Some("Transaction confirmed".to_string())
3178        );
3179        assert_eq!(
3180            transaction.sent_at,
3181            Some("2023-01-01T12:00:00Z".to_string())
3182        );
3183        assert_eq!(
3184            transaction.confirmed_at,
3185            Some("2023-01-01T12:05:00Z".to_string())
3186        );
3187        assert_eq!(
3188            transaction.hashes,
3189            vec!["0x123".to_string(), "0x456".to_string()]
3190        );
3191        assert_eq!(transaction.is_canceled, Some(false));
3192
3193        // Verify that delete_at was set because status changed to final
3194        assert!(transaction.delete_at.is_some());
3195    }
3196
3197    #[test]
3198    fn test_apply_partial_update_preserves_unchanged_fields() {
3199        // Create a test transaction with initial values
3200        let mut transaction = TransactionRepoModel {
3201            id: "test-tx".to_string(),
3202            relayer_id: "test-relayer".to_string(),
3203            status: TransactionStatus::Pending,
3204            status_reason: Some("Initial reason".to_string()),
3205            created_at: Utc::now().to_rfc3339(),
3206            sent_at: Some("2023-01-01T10:00:00Z".to_string()),
3207            confirmed_at: None,
3208            valid_until: None,
3209            delete_at: None,
3210            network_data: NetworkTransactionData::Evm(EvmTransactionData::default()),
3211            priced_at: None,
3212            hashes: vec!["0xoriginal".to_string()],
3213            network_type: NetworkType::Evm,
3214            noop_count: Some(5),
3215            is_canceled: Some(true),
3216            metadata: None,
3217        };
3218
3219        // Create a partial update that only changes status
3220        let update = TransactionUpdateRequest {
3221            status: Some(TransactionStatus::Sent),
3222            ..Default::default()
3223        };
3224
3225        // Apply the partial update
3226        transaction.apply_partial_update(update);
3227
3228        // Verify only status changed, other fields preserved
3229        assert_eq!(transaction.status, TransactionStatus::Sent);
3230        assert_eq!(
3231            transaction.status_reason,
3232            Some("Initial reason".to_string())
3233        );
3234        assert_eq!(
3235            transaction.sent_at,
3236            Some("2023-01-01T10:00:00Z".to_string())
3237        );
3238        assert_eq!(transaction.confirmed_at, None);
3239        assert_eq!(transaction.hashes, vec!["0xoriginal".to_string()]);
3240        assert_eq!(transaction.noop_count, Some(5));
3241        assert_eq!(transaction.is_canceled, Some(true));
3242
3243        // Status is not final, so delete_at should remain None
3244        assert!(transaction.delete_at.is_none());
3245    }
3246
3247    #[test]
3248    fn test_apply_partial_update_empty_update() {
3249        // Create a test transaction
3250        let mut transaction = create_test_transaction();
3251        let original_transaction = transaction.clone();
3252
3253        // Apply an empty update
3254        let update = TransactionUpdateRequest::default();
3255        transaction.apply_partial_update(update);
3256
3257        // Verify nothing changed
3258        assert_eq!(transaction.id, original_transaction.id);
3259        assert_eq!(transaction.status, original_transaction.status);
3260        assert_eq!(
3261            transaction.status_reason,
3262            original_transaction.status_reason
3263        );
3264        assert_eq!(transaction.sent_at, original_transaction.sent_at);
3265        assert_eq!(transaction.confirmed_at, original_transaction.confirmed_at);
3266        assert_eq!(transaction.hashes, original_transaction.hashes);
3267        assert_eq!(transaction.noop_count, original_transaction.noop_count);
3268        assert_eq!(transaction.is_canceled, original_transaction.is_canceled);
3269        assert_eq!(transaction.delete_at, original_transaction.delete_at);
3270    }
3271
3272    mod extract_stellar_valid_until_tests {
3273        use super::*;
3274        use crate::models::transaction::request::stellar::StellarTransactionRequest;
3275        use chrono::{Duration, Utc};
3276
3277        fn make_stellar_request(
3278            valid_until: Option<String>,
3279            transaction_xdr: Option<String>,
3280        ) -> StellarTransactionRequest {
3281            StellarTransactionRequest {
3282                source_account: Some(
3283                    "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
3284                ),
3285                network: "testnet".to_string(),
3286                operations: Some(vec![OperationSpec::Payment {
3287                    destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
3288                        .to_string(),
3289                    amount: 1000000,
3290                    asset: AssetSpec::Native,
3291                }]),
3292                memo: None,
3293                valid_until,
3294                transaction_xdr,
3295                fee_bump: None,
3296                max_fee: None,
3297                signed_auth_entry: None,
3298            }
3299        }
3300
3301        #[test]
3302        fn test_with_explicit_valid_until_from_request() {
3303            let request = make_stellar_request(Some("2025-12-31T23:59:59Z".to_string()), None);
3304            let now = Utc::now();
3305
3306            let result = extract_stellar_valid_until(&request, now);
3307
3308            assert_eq!(result, Some("2025-12-31T23:59:59Z".to_string()));
3309        }
3310
3311        #[test]
3312        fn test_operations_without_valid_until_uses_default() {
3313            let request = make_stellar_request(None, None);
3314            let now = Utc::now();
3315
3316            let result = extract_stellar_valid_until(&request, now);
3317
3318            // Should be now + STELLAR_SPONSORED_TRANSACTION_VALIDITY_MINUTES (2 min)
3319            assert!(result.is_some());
3320            let valid_until = result.unwrap();
3321            let parsed = chrono::DateTime::parse_from_rfc3339(&valid_until).unwrap();
3322            let expected_min = now + Duration::minutes(1);
3323            let expected_max = now + Duration::minutes(3);
3324            assert!(parsed.with_timezone(&Utc) > expected_min);
3325            assert!(parsed.with_timezone(&Utc) < expected_max);
3326        }
3327
3328        #[test]
3329        fn test_xdr_without_time_bounds_returns_none() {
3330            // Create a minimal unsigned XDR without time bounds
3331            // This is a base64 encoded transaction envelope without time bounds
3332            // For simplicity, we'll test with invalid XDR which should also return None
3333            let request = make_stellar_request(None, Some("invalid_xdr".to_string()));
3334            let now = Utc::now();
3335
3336            let result = extract_stellar_valid_until(&request, now);
3337
3338            // XDR parse failed or no time_bounds - should return None (unbounded)
3339            assert!(result.is_none());
3340        }
3341    }
3342}