openzeppelin_relayer/models/transaction/
repository.rs

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