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