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