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