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