openzeppelin_relayer/models/transaction/
repository.rs

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