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