1use chrono::{DateTime, Utc};
6use soroban_rs::xdr::{
7 Error, Hash, InnerTransactionResultResult, InvokeHostFunctionResult, Limits, OperationResult,
8 OperationResultTr, TransactionEnvelope, TransactionResultResult, WriteXdr,
9};
10use tracing::{debug, info, warn};
11
12use super::{is_final_state, StellarRelayerTransaction};
13use crate::constants::{
14 get_stellar_max_stuck_transaction_lifetime, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
15 STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
16};
17use crate::domain::transaction::stellar::prepare::common::send_submit_transaction_job;
18use crate::domain::transaction::stellar::utils::{
19 compute_resubmit_backoff_interval, extract_return_value_from_meta, extract_time_bounds,
20};
21use crate::domain::transaction::util::{get_age_since_created, get_age_since_sent_or_created};
22use crate::domain::xdr_utils::parse_transaction_xdr;
23use crate::{
24 constants::STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS,
25 jobs::{JobProducerTrait, StatusCheckContext, TransactionRequest},
26 models::{
27 NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
28 TransactionStatus, TransactionUpdateRequest,
29 },
30 repositories::{Repository, TransactionCounterTrait, TransactionRepository},
31 services::{
32 provider::StellarProviderTrait,
33 signer::{Signer, StellarSignTrait},
34 },
35};
36
37impl<R, T, J, S, P, C, D> StellarRelayerTransaction<R, T, J, S, P, C, D>
38where
39 R: Repository<RelayerRepoModel, String> + Send + Sync,
40 T: TransactionRepository + Send + Sync,
41 J: JobProducerTrait + Send + Sync,
42 S: Signer + StellarSignTrait + Send + Sync,
43 P: StellarProviderTrait + Send + Sync,
44 C: TransactionCounterTrait + Send + Sync,
45 D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static,
46{
47 pub async fn handle_transaction_status_impl(
55 &self,
56 tx: TransactionRepoModel,
57 context: Option<StatusCheckContext>,
58 ) -> Result<TransactionRepoModel, TransactionError> {
59 debug!(
60 tx_id = %tx.id,
61 relayer_id = %tx.relayer_id,
62 status = ?tx.status,
63 "handling transaction status"
64 );
65
66 if is_final_state(&tx.status) {
68 debug!(
69 tx_id = %tx.id,
70 relayer_id = %tx.relayer_id,
71 status = ?tx.status,
72 "transaction in final state, skipping status check"
73 );
74 return Ok(tx);
75 }
76
77 if let Some(ref ctx) = context {
79 if ctx.should_force_finalize() {
80 let reason = format!(
81 "Transaction status monitoring failed after {} consecutive errors (total: {}). \
82 Last status: {:?}. Unable to determine final on-chain state.",
83 ctx.consecutive_failures, ctx.total_failures, tx.status
84 );
85 warn!(
86 tx_id = %tx.id,
87 consecutive_failures = ctx.consecutive_failures,
88 total_failures = ctx.total_failures,
89 max_consecutive = ctx.max_consecutive_failures,
90 "circuit breaker triggered, forcing transaction to failed state"
91 );
92 return self.mark_as_failed(tx, reason).await;
96 }
97 }
98
99 match self.status_core(tx.clone()).await {
100 Ok(updated_tx) => {
101 debug!(
102 tx_id = %updated_tx.id,
103 status = ?updated_tx.status,
104 "status check completed successfully"
105 );
106 Ok(updated_tx)
107 }
108 Err(error) => {
109 debug!(
110 tx_id = %tx.id,
111 error = ?error,
112 "status check encountered error"
113 );
114
115 if error.is_concurrent_update_conflict() {
120 info!(
121 tx_id = %tx.id,
122 relayer_id = %tx.relayer_id,
123 "concurrent transaction update detected during status handling, reloading latest state"
124 );
125 return self
126 .transaction_repository()
127 .get_by_id(tx.id.clone())
128 .await
129 .map_err(TransactionError::from);
130 }
131
132 match error {
134 TransactionError::ValidationError(ref msg) => {
135 warn!(
138 tx_id = %tx.id,
139 error = %msg,
140 "validation error detected - marking transaction as failed"
141 );
142
143 self.mark_as_failed(tx, format!("Validation error: {msg}"))
144 .await
145 }
146 _ => {
147 warn!(
150 tx_id = %tx.id,
151 error = ?error,
152 "status check failed with retriable error, will retry"
153 );
154 Err(error)
155 }
156 }
157 }
158 }
159 }
160
161 async fn status_core(
164 &self,
165 tx: TransactionRepoModel,
166 ) -> Result<TransactionRepoModel, TransactionError> {
167 match tx.status {
168 TransactionStatus::Pending => self.handle_pending_state(tx).await,
169 TransactionStatus::Sent => self.handle_sent_state(tx).await,
170 _ => self.handle_submitted_state(tx).await,
171 }
172 }
173
174 pub fn parse_and_validate_hash(
177 &self,
178 tx: &TransactionRepoModel,
179 ) -> Result<Hash, TransactionError> {
180 let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
181
182 let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
183 TransactionError::ValidationError(format!(
184 "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
185 tx.id
186 ))
187 })?;
188
189 let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
190 TransactionError::UnexpectedError(format!(
191 "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
192 tx_hash_str, tx.id, e
193 ))
194 })?;
195
196 Ok(stellar_hash)
197 }
198
199 pub(super) async fn mark_as_failed(
201 &self,
202 tx: TransactionRepoModel,
203 reason: String,
204 ) -> Result<TransactionRepoModel, TransactionError> {
205 warn!(tx_id = %tx.id, reason = %reason, "marking transaction as failed");
206
207 let update_request = TransactionUpdateRequest {
208 status: Some(TransactionStatus::Failed),
209 status_reason: Some(reason),
210 ..Default::default()
211 };
212
213 let failed_tx = self
214 .finalize_transaction_state(tx.id.clone(), update_request)
215 .await?;
216
217 if let Err(e) = self.enqueue_next_pending_transaction(&tx.id).await {
219 warn!(error = %e, "failed to enqueue next pending transaction after failure");
220 }
221
222 Ok(failed_tx)
223 }
224
225 pub(super) async fn mark_as_expired(
227 &self,
228 tx: TransactionRepoModel,
229 reason: String,
230 ) -> Result<TransactionRepoModel, TransactionError> {
231 info!(tx_id = %tx.id, reason = %reason, "marking transaction as expired");
232
233 let update_request = TransactionUpdateRequest {
234 status: Some(TransactionStatus::Expired),
235 status_reason: Some(reason),
236 ..Default::default()
237 };
238
239 let expired_tx = self
240 .finalize_transaction_state(tx.id.clone(), update_request)
241 .await?;
242
243 if let Err(e) = self.enqueue_next_pending_transaction(&tx.id).await {
245 warn!(tx_id = %tx.id, relayer_id = %tx.relayer_id, error = %e, "failed to enqueue next pending transaction after expiration");
246 }
247
248 Ok(expired_tx)
249 }
250
251 pub(super) fn is_transaction_expired(
253 &self,
254 tx: &TransactionRepoModel,
255 ) -> Result<bool, TransactionError> {
256 if let Some(valid_until_str) = &tx.valid_until {
257 return Ok(Self::is_valid_until_string_expired(valid_until_str));
258 }
259
260 let stellar_data = tx.network_data.get_stellar_transaction_data()?;
262 if let Some(signed_xdr) = &stellar_data.signed_envelope_xdr {
263 if let Ok(envelope) = parse_transaction_xdr(signed_xdr, true) {
264 if let Some(tb) = extract_time_bounds(&envelope) {
265 if tb.max_time.0 == 0 {
266 return Ok(false); }
268 return Ok(Utc::now().timestamp() as u64 > tb.max_time.0);
269 }
270 }
271 }
272
273 Ok(false)
274 }
275
276 fn is_valid_until_string_expired(valid_until: &str) -> bool {
278 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(valid_until) {
279 return Utc::now() > dt.with_timezone(&Utc);
280 }
281 match valid_until.parse::<i64>() {
282 Ok(0) => false,
283 Ok(ts) => Utc::now().timestamp() > ts,
284 Err(_) => false,
285 }
286 }
287
288 pub async fn handle_stellar_success(
290 &self,
291 tx: TransactionRepoModel,
292 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
293 ) -> Result<TransactionRepoModel, TransactionError> {
294 let updated_network_data =
296 tx.network_data
297 .get_stellar_transaction_data()
298 .ok()
299 .map(|mut stellar_data| {
300 if let Some(tx_result) = provider_response.result.as_ref() {
302 stellar_data = stellar_data.with_fee(tx_result.fee_charged as u32);
303 }
304
305 if let Some(result_meta) = provider_response.result_meta.as_ref() {
307 if let Some(return_value) = extract_return_value_from_meta(result_meta) {
308 let xdr_base64 = return_value.to_xdr_base64(Limits::none());
309 if let Ok(xdr_base64) = xdr_base64 {
310 stellar_data = stellar_data.with_transaction_result_xdr(xdr_base64);
311 } else {
312 warn!("Failed to serialize return value to XDR base64");
313 }
314 }
315 }
316
317 NetworkTransactionData::Stellar(stellar_data)
318 });
319
320 let update_request = TransactionUpdateRequest {
321 status: Some(TransactionStatus::Confirmed),
322 confirmed_at: Some(Utc::now().to_rfc3339()),
323 network_data: updated_network_data,
324 ..Default::default()
325 };
326
327 let confirmed_tx = self
328 .finalize_transaction_state(tx.id.clone(), update_request)
329 .await?;
330
331 self.enqueue_next_pending_transaction(&tx.id).await?;
332
333 Ok(confirmed_tx)
334 }
335
336 pub async fn handle_stellar_failed(
338 &self,
339 tx: TransactionRepoModel,
340 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
341 ) -> Result<TransactionRepoModel, TransactionError> {
342 let result_code = provider_response
343 .result
344 .as_ref()
345 .map(|r| r.result.name())
346 .unwrap_or("unknown");
347
348 let (inner_result_code, op_result_code, inner_tx_hash, inner_fee_charged) =
350 match provider_response.result.as_ref().map(|r| &r.result) {
351 Some(TransactionResultResult::TxFeeBumpInnerFailed(pair)) => {
352 let inner = &pair.result.result;
353 let op = match inner {
354 InnerTransactionResultResult::TxFailed(ops) => {
355 first_failing_op(ops.as_slice())
356 }
357 _ => None,
358 };
359 (
360 Some(inner.name()),
361 op,
362 Some(hex::encode(pair.transaction_hash.0)),
363 pair.result.fee_charged,
364 )
365 }
366 Some(TransactionResultResult::TxFailed(ops)) => {
367 (None, first_failing_op(ops.as_slice()), None, 0)
368 }
369 _ => (None, None, None, 0),
370 };
371
372 let fee_charged = provider_response.result.as_ref().map(|r| r.fee_charged);
373 let fee_bid = provider_response.envelope.as_ref().map(extract_fee_bid);
374
375 warn!(
376 tx_id = %tx.id,
377 result_code,
378 inner_result_code = inner_result_code.unwrap_or("n/a"),
379 op_result_code = op_result_code.unwrap_or("n/a"),
380 inner_tx_hash = inner_tx_hash.as_deref().unwrap_or("n/a"),
381 inner_fee_charged,
382 fee_charged = ?fee_charged,
383 fee_bid = ?fee_bid,
384 "stellar transaction failed"
385 );
386
387 let status_reason = format!(
388 "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: {result_code}."
389 );
390
391 let update_request = TransactionUpdateRequest {
392 status: Some(TransactionStatus::Failed),
393 status_reason: Some(status_reason),
394 ..Default::default()
395 };
396
397 let updated_tx = self
398 .finalize_transaction_state(tx.id.clone(), update_request)
399 .await?;
400
401 self.enqueue_next_pending_transaction(&tx.id).await?;
402
403 Ok(updated_tx)
404 }
405
406 async fn check_expiration_and_max_lifetime(
409 &self,
410 tx: TransactionRepoModel,
411 failed_reason: String,
412 ) -> Option<Result<TransactionRepoModel, TransactionError>> {
413 let age = match get_age_since_created(&tx) {
414 Ok(age) => age,
415 Err(e) => return Some(Err(e)),
416 };
417
418 if let Ok(true) = self.is_transaction_expired(&tx) {
420 info!(tx_id = %tx.id, valid_until = ?tx.valid_until, "Transaction has expired");
421 return Some(
422 self.mark_as_expired(tx, "Transaction time_bounds expired".to_string())
423 .await,
424 );
425 }
426
427 if age > get_stellar_max_stuck_transaction_lifetime() {
429 warn!(tx_id = %tx.id, age_minutes = age.num_minutes(),
430 "Transaction exceeded max lifetime, marking as Failed");
431 return Some(self.mark_as_failed(tx, failed_reason).await);
432 }
433
434 None
435 }
436
437 async fn handle_sent_state(
440 &self,
441 tx: TransactionRepoModel,
442 ) -> Result<TransactionRepoModel, TransactionError> {
443 if let Some(result) = self
445 .check_expiration_and_max_lifetime(
446 tx.clone(),
447 "Transaction stuck in Sent status for too long".to_string(),
448 )
449 .await
450 {
451 return result;
452 }
453
454 let total_age = get_age_since_created(&tx)?;
458 if let Some(backoff_interval) = compute_resubmit_backoff_interval(
459 total_age,
460 STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
461 STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
462 ) {
463 let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
464 if age_since_last_submit > backoff_interval {
465 info!(
466 tx_id = %tx.id,
467 total_age_seconds = total_age.num_seconds(),
468 since_last_submit_seconds = age_since_last_submit.num_seconds(),
469 backoff_interval_seconds = backoff_interval.num_seconds(),
470 "re-enqueueing submit job for stuck Sent transaction"
471 );
472 send_submit_transaction_job(self.job_producer(), &tx, None).await?;
473 }
474 }
475
476 Ok(tx)
477 }
478
479 async fn handle_pending_state(
482 &self,
483 tx: TransactionRepoModel,
484 ) -> Result<TransactionRepoModel, TransactionError> {
485 if let Some(result) = self
487 .check_expiration_and_max_lifetime(
488 tx.clone(),
489 "Transaction stuck in Pending status for too long".to_string(),
490 )
491 .await
492 {
493 return result;
494 }
495
496 let age = self.get_time_since_created_at(&tx)?;
498
499 if age.num_seconds() >= STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS {
502 info!(
503 tx_id = %tx.id,
504 age_seconds = age.num_seconds(),
505 "pending transaction without hash may be stuck, scheduling recovery job"
506 );
507
508 let transaction_request = TransactionRequest::new(tx.id.clone(), tx.relayer_id.clone());
509 if let Err(e) = self
510 .job_producer()
511 .produce_transaction_request_job(transaction_request, None)
512 .await
513 {
514 warn!(
515 tx_id = %tx.id,
516 error = %e,
517 "failed to schedule recovery job for pending transaction"
518 );
519 }
520 } else {
521 debug!(
522 tx_id = %tx.id,
523 age_seconds = age.num_seconds(),
524 "pending transaction without hash too young for recovery check"
525 );
526 }
527
528 Ok(tx)
529 }
530
531 fn get_time_since_created_at(
534 &self,
535 tx: &TransactionRepoModel,
536 ) -> Result<chrono::Duration, TransactionError> {
537 match DateTime::parse_from_rfc3339(&tx.created_at) {
538 Ok(dt) => Ok(Utc::now().signed_duration_since(dt.with_timezone(&Utc))),
539 Err(e) => {
540 warn!(tx_id = %tx.id, ts = %tx.created_at, error = %e, "failed to parse created_at timestamp");
541 Err(TransactionError::UnexpectedError(format!(
542 "Invalid created_at timestamp for transaction {}: {}",
543 tx.id, e
544 )))
545 }
546 }
547 }
548
549 async fn handle_submitted_state(
553 &self,
554 tx: TransactionRepoModel,
555 ) -> Result<TransactionRepoModel, TransactionError> {
556 let stellar_hash = match self.parse_and_validate_hash(&tx) {
557 Ok(hash) => hash,
558 Err(e) => {
559 warn!(
561 tx_id = %tx.id,
562 status = ?tx.status,
563 error = ?e,
564 "failed to parse and validate hash for submitted transaction"
565 );
566 return self
567 .mark_as_failed(tx, format!("Failed to parse and validate hash: {e}"))
568 .await;
569 }
570 };
571
572 let provider_response = match self.provider().get_transaction(&stellar_hash).await {
573 Ok(response) => response,
574 Err(e) => {
575 warn!(error = ?e, "provider get_transaction failed");
576 return Err(TransactionError::from(e));
577 }
578 };
579
580 match provider_response.status.as_str().to_uppercase().as_str() {
581 "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
582 "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
583 _ => {
584 debug!(
585 tx_id = %tx.id,
586 relayer_id = %tx.relayer_id,
587 status = %provider_response.status,
588 "submitted transaction not yet final on-chain, will retry check later"
589 );
590
591 if let Some(result) = self
593 .check_expiration_and_max_lifetime(
594 tx.clone(),
595 "Transaction stuck in Submitted status for too long".to_string(),
596 )
597 .await
598 {
599 return result;
600 }
601
602 let total_age = get_age_since_created(&tx)?;
605 if let Some(backoff_interval) = compute_resubmit_backoff_interval(
606 total_age,
607 STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
608 STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
609 ) {
610 let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
611 if age_since_last_submit > backoff_interval {
612 info!(
613 tx_id = %tx.id,
614 relayer_id = %tx.relayer_id,
615 total_age_seconds = total_age.num_seconds(),
616 since_last_submit_seconds = age_since_last_submit.num_seconds(),
617 backoff_interval_seconds = backoff_interval.num_seconds(),
618 "resubmitting Submitted transaction to ensure mempool inclusion"
619 );
620 send_submit_transaction_job(self.job_producer(), &tx, None).await?;
621 }
622 }
623
624 Ok(tx)
625 }
626 }
627 }
628}
629
630fn extract_fee_bid(envelope: &TransactionEnvelope) -> i64 {
635 match envelope {
636 TransactionEnvelope::TxFeeBump(fb) => fb.tx.fee,
637 TransactionEnvelope::Tx(v1) => v1.tx.fee as i64,
638 TransactionEnvelope::TxV0(v0) => v0.tx.fee as i64,
639 }
640}
641
642fn first_failing_op(ops: &[OperationResult]) -> Option<&'static str> {
647 let op = ops.iter().find(|op| match op {
648 OperationResult::OpInner(tr) => match tr {
649 OperationResultTr::InvokeHostFunction(r) => {
650 !matches!(r, InvokeHostFunctionResult::Success(_))
651 }
652 OperationResultTr::ExtendFootprintTtl(r) => r.name() != "Success",
653 OperationResultTr::RestoreFootprint(r) => r.name() != "Success",
654 _ => false,
655 },
656 _ => true,
657 })?;
658 match op {
659 OperationResult::OpInner(tr) => match tr {
660 OperationResultTr::InvokeHostFunction(r) => Some(r.name()),
661 OperationResultTr::ExtendFootprintTtl(r) => Some(r.name()),
662 OperationResultTr::RestoreFootprint(r) => Some(r.name()),
663 _ => Some(tr.name()),
664 },
665 _ => Some(op.name()),
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::models::{NetworkTransactionData, RepositoryError};
673 use crate::repositories::PaginatedResult;
674 use chrono::Duration;
675 use mockall::predicate::eq;
676 use soroban_rs::stellar_rpc_client::GetTransactionResponse;
677
678 use crate::domain::transaction::stellar::test_helpers::*;
679
680 fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
681 GetTransactionResponse {
682 status: status.to_string(),
683 ledger: None,
684 envelope: None,
685 result: None,
686 result_meta: None,
687 events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
688 contract_events: vec![],
689 diagnostic_events: vec![],
690 transaction_events: vec![],
691 },
692 }
693 }
694
695 fn dummy_get_transaction_response_with_result_meta(
696 status: &str,
697 has_return_value: bool,
698 ) -> GetTransactionResponse {
699 use soroban_rs::xdr::{ScVal, SorobanTransactionMeta, TransactionMeta, TransactionMetaV3};
700
701 let result_meta = if has_return_value {
702 let return_value = ScVal::I32(42);
704 Some(TransactionMeta::V3(TransactionMetaV3 {
705 ext: soroban_rs::xdr::ExtensionPoint::V0,
706 tx_changes_before: soroban_rs::xdr::LedgerEntryChanges::default(),
707 operations: soroban_rs::xdr::VecM::default(),
708 tx_changes_after: soroban_rs::xdr::LedgerEntryChanges::default(),
709 soroban_meta: Some(SorobanTransactionMeta {
710 ext: soroban_rs::xdr::SorobanTransactionMetaExt::V0,
711 return_value,
712 events: soroban_rs::xdr::VecM::default(),
713 diagnostic_events: soroban_rs::xdr::VecM::default(),
714 }),
715 }))
716 } else {
717 None
718 };
719
720 GetTransactionResponse {
721 status: status.to_string(),
722 ledger: None,
723 envelope: None,
724 result: None,
725 result_meta,
726 events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
727 contract_events: vec![],
728 diagnostic_events: vec![],
729 transaction_events: vec![],
730 },
731 }
732 }
733
734 mod handle_transaction_status_tests {
735 use crate::services::provider::ProviderError;
736
737 use super::*;
738
739 #[tokio::test]
740 async fn handle_transaction_status_confirmed_triggers_next() {
741 let relayer = create_test_relayer();
742 let mut mocks = default_test_mocks();
743
744 let mut tx_to_handle = create_test_transaction(&relayer.id);
745 tx_to_handle.id = "tx-confirm-this".to_string();
746 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
747 let tx_hash_bytes = [1u8; 32];
748 let tx_hash_hex = hex::encode(tx_hash_bytes);
749 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
750 {
751 stellar_data.hash = Some(tx_hash_hex.clone());
752 } else {
753 panic!("Expected Stellar network data for tx_to_handle");
754 }
755 tx_to_handle.status = TransactionStatus::Submitted;
756
757 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
758
759 mocks
761 .provider
762 .expect_get_transaction()
763 .with(eq(expected_stellar_hash.clone()))
764 .times(1)
765 .returning(move |_| {
766 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
767 });
768
769 mocks
771 .tx_repo
772 .expect_partial_update()
773 .withf(move |id, update| {
774 id == "tx-confirm-this"
775 && update.status == Some(TransactionStatus::Confirmed)
776 && update.confirmed_at.is_some()
777 })
778 .times(1)
779 .returning(move |id, update| {
780 let mut updated_tx = tx_to_handle.clone(); updated_tx.id = id;
782 updated_tx.status = update.status.unwrap();
783 updated_tx.confirmed_at = update.confirmed_at;
784 Ok(updated_tx)
785 });
786
787 mocks
789 .job_producer
790 .expect_produce_send_notification_job()
791 .times(1)
792 .returning(|_, _| Box::pin(async { Ok(()) }));
793
794 let mut oldest_pending_tx = create_test_transaction(&relayer.id);
796 oldest_pending_tx.id = "tx-oldest-pending".to_string();
797 oldest_pending_tx.status = TransactionStatus::Pending;
798 let captured_oldest_pending_tx = oldest_pending_tx.clone();
799 let relayer_id_clone = relayer.id.clone();
800 mocks
801 .tx_repo
802 .expect_find_by_status_paginated()
803 .withf(move |relayer_id, statuses, query, oldest_first| {
804 *relayer_id == relayer_id_clone
805 && statuses == [TransactionStatus::Pending]
806 && query.page == 1
807 && query.per_page == 1
808 && *oldest_first
809 })
810 .times(1)
811 .returning(move |_, _, _, _| {
812 Ok(PaginatedResult {
813 items: vec![captured_oldest_pending_tx.clone()],
814 total: 1,
815 page: 1,
816 per_page: 1,
817 })
818 });
819
820 mocks
822 .job_producer
823 .expect_produce_transaction_request_job()
824 .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
825 .times(1)
826 .returning(|_, _| Box::pin(async { Ok(()) }));
827
828 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
829 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
830 initial_tx_for_handling.id = "tx-confirm-this".to_string();
831 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
832 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
833 initial_tx_for_handling.network_data
834 {
835 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
836 } else {
837 panic!("Expected Stellar network data for initial_tx_for_handling");
838 }
839 initial_tx_for_handling.status = TransactionStatus::Submitted;
840
841 let result = handler
842 .handle_transaction_status_impl(initial_tx_for_handling, None)
843 .await;
844
845 assert!(result.is_ok());
846 let handled_tx = result.unwrap();
847 assert_eq!(handled_tx.id, "tx-confirm-this");
848 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
849 assert!(handled_tx.confirmed_at.is_some());
850 }
851
852 #[tokio::test]
853 async fn handle_transaction_status_still_pending() {
854 let relayer = create_test_relayer();
855 let mut mocks = default_test_mocks();
856
857 let mut tx_to_handle = create_test_transaction(&relayer.id);
858 tx_to_handle.id = "tx-pending-check".to_string();
859 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
860 let tx_hash_bytes = [2u8; 32];
861 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
862 {
863 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
864 } else {
865 panic!("Expected Stellar network data");
866 }
867 tx_to_handle.status = TransactionStatus::Submitted; let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
870
871 mocks
873 .provider
874 .expect_get_transaction()
875 .with(eq(expected_stellar_hash.clone()))
876 .times(1)
877 .returning(move |_| {
878 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
879 });
880
881 mocks.tx_repo.expect_partial_update().never();
883
884 mocks
886 .job_producer
887 .expect_produce_send_notification_job()
888 .never();
889
890 mocks
892 .job_producer
893 .expect_produce_submit_transaction_job()
894 .times(1)
895 .returning(|_, _| Box::pin(async { Ok(()) }));
896
897 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
898 let original_tx_clone = tx_to_handle.clone();
899
900 let result = handler
901 .handle_transaction_status_impl(tx_to_handle, None)
902 .await;
903
904 assert!(result.is_ok());
905 let returned_tx = result.unwrap();
906 assert_eq!(returned_tx.id, original_tx_clone.id);
908 assert_eq!(returned_tx.status, original_tx_clone.status);
909 assert!(returned_tx.confirmed_at.is_none()); }
911
912 #[tokio::test]
913 async fn handle_transaction_status_failed() {
914 let relayer = create_test_relayer();
915 let mut mocks = default_test_mocks();
916
917 let mut tx_to_handle = create_test_transaction(&relayer.id);
918 tx_to_handle.id = "tx-fail-this".to_string();
919 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
920 let tx_hash_bytes = [3u8; 32];
921 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
922 {
923 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
924 } else {
925 panic!("Expected Stellar network data");
926 }
927 tx_to_handle.status = TransactionStatus::Submitted;
928
929 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
930
931 mocks
933 .provider
934 .expect_get_transaction()
935 .with(eq(expected_stellar_hash.clone()))
936 .times(1)
937 .returning(move |_| {
938 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
939 });
940
941 let relayer_id_for_mock = relayer.id.clone();
943 mocks
944 .tx_repo
945 .expect_partial_update()
946 .times(1)
947 .returning(move |id, update| {
948 let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
950 updated_tx.id = id;
951 updated_tx.status = update.status.unwrap();
952 updated_tx.status_reason = update.status_reason.clone();
953 Ok::<_, RepositoryError>(updated_tx)
954 });
955
956 mocks
958 .job_producer
959 .expect_produce_send_notification_job()
960 .times(1)
961 .returning(|_, _| Box::pin(async { Ok(()) }));
962
963 let relayer_id_clone = relayer.id.clone();
965 mocks
966 .tx_repo
967 .expect_find_by_status_paginated()
968 .withf(move |relayer_id, statuses, query, oldest_first| {
969 *relayer_id == relayer_id_clone
970 && statuses == [TransactionStatus::Pending]
971 && query.page == 1
972 && query.per_page == 1
973 && *oldest_first
974 })
975 .times(1)
976 .returning(move |_, _, _, _| {
977 Ok(PaginatedResult {
978 items: vec![],
979 total: 0,
980 page: 1,
981 per_page: 1,
982 })
983 }); mocks
987 .job_producer
988 .expect_produce_transaction_request_job()
989 .never();
990 mocks
992 .job_producer
993 .expect_produce_check_transaction_status_job()
994 .never();
995
996 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
997 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
998 initial_tx_for_handling.id = "tx-fail-this".to_string();
999 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1000 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
1001 initial_tx_for_handling.network_data
1002 {
1003 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1004 } else {
1005 panic!("Expected Stellar network data");
1006 }
1007 initial_tx_for_handling.status = TransactionStatus::Submitted;
1008
1009 let result = handler
1010 .handle_transaction_status_impl(initial_tx_for_handling, None)
1011 .await;
1012
1013 assert!(result.is_ok());
1014 let handled_tx = result.unwrap();
1015 assert_eq!(handled_tx.id, "tx-fail-this");
1016 assert_eq!(handled_tx.status, TransactionStatus::Failed);
1017 assert!(handled_tx.status_reason.is_some());
1018 assert_eq!(
1019 handled_tx.status_reason.unwrap(),
1020 "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: unknown."
1021 );
1022 }
1023
1024 #[tokio::test]
1025 async fn handle_transaction_status_provider_error() {
1026 let relayer = create_test_relayer();
1027 let mut mocks = default_test_mocks();
1028
1029 let mut tx_to_handle = create_test_transaction(&relayer.id);
1030 tx_to_handle.id = "tx-provider-error".to_string();
1031 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1032 let tx_hash_bytes = [4u8; 32];
1033 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1034 {
1035 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1036 } else {
1037 panic!("Expected Stellar network data");
1038 }
1039 tx_to_handle.status = TransactionStatus::Submitted;
1040
1041 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1042
1043 mocks
1045 .provider
1046 .expect_get_transaction()
1047 .with(eq(expected_stellar_hash.clone()))
1048 .times(1)
1049 .returning(move |_| {
1050 Box::pin(async { Err(ProviderError::Other("RPC boom".to_string())) })
1051 });
1052
1053 mocks.tx_repo.expect_partial_update().never();
1055
1056 mocks
1058 .job_producer
1059 .expect_produce_send_notification_job()
1060 .never();
1061 mocks
1063 .job_producer
1064 .expect_produce_transaction_request_job()
1065 .never();
1066
1067 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1068
1069 let result = handler
1070 .handle_transaction_status_impl(tx_to_handle, None)
1071 .await;
1072
1073 assert!(result.is_err());
1075 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1076 }
1077
1078 #[tokio::test]
1079 async fn handle_transaction_status_no_hashes() {
1080 let relayer = create_test_relayer();
1081 let mut mocks = default_test_mocks();
1082
1083 let mut tx_to_handle = create_test_transaction(&relayer.id);
1084 tx_to_handle.id = "tx-no-hashes".to_string();
1085 tx_to_handle.status = TransactionStatus::Submitted;
1086 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1087
1088 mocks.provider.expect_get_transaction().never();
1090
1091 mocks
1093 .tx_repo
1094 .expect_partial_update()
1095 .times(1)
1096 .returning(|_, update| {
1097 let mut updated_tx = create_test_transaction("test-relayer");
1098 updated_tx.status = update.status.unwrap_or(updated_tx.status);
1099 updated_tx.status_reason = update.status_reason.clone();
1100 Ok(updated_tx)
1101 });
1102
1103 mocks
1105 .job_producer
1106 .expect_produce_send_notification_job()
1107 .times(1)
1108 .returning(|_, _| Box::pin(async { Ok(()) }));
1109
1110 let relayer_id_clone = relayer.id.clone();
1112 mocks
1113 .tx_repo
1114 .expect_find_by_status_paginated()
1115 .withf(move |relayer_id, statuses, query, oldest_first| {
1116 *relayer_id == relayer_id_clone
1117 && statuses == [TransactionStatus::Pending]
1118 && query.page == 1
1119 && query.per_page == 1
1120 && *oldest_first
1121 })
1122 .times(1)
1123 .returning(move |_, _, _, _| {
1124 Ok(PaginatedResult {
1125 items: vec![],
1126 total: 0,
1127 page: 1,
1128 per_page: 1,
1129 })
1130 }); let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1133 let result = handler
1134 .handle_transaction_status_impl(tx_to_handle, None)
1135 .await;
1136
1137 assert!(result.is_ok(), "Expected Ok result");
1139 let updated_tx = result.unwrap();
1140 assert_eq!(updated_tx.status, TransactionStatus::Failed);
1141 assert!(
1142 updated_tx
1143 .status_reason
1144 .as_ref()
1145 .unwrap()
1146 .contains("Failed to parse and validate hash"),
1147 "Expected hash validation error in status_reason, got: {:?}",
1148 updated_tx.status_reason
1149 );
1150 }
1151
1152 #[tokio::test]
1153 async fn test_on_chain_failure_does_not_decrement_sequence() {
1154 let relayer = create_test_relayer();
1155 let mut mocks = default_test_mocks();
1156
1157 let mut tx_to_handle = create_test_transaction(&relayer.id);
1158 tx_to_handle.id = "tx-on-chain-fail".to_string();
1159 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1160 let tx_hash_bytes = [4u8; 32];
1161 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1162 {
1163 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1164 stellar_data.sequence_number = Some(100); }
1166 tx_to_handle.status = TransactionStatus::Submitted;
1167
1168 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1169
1170 mocks
1172 .provider
1173 .expect_get_transaction()
1174 .with(eq(expected_stellar_hash.clone()))
1175 .times(1)
1176 .returning(move |_| {
1177 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
1178 });
1179
1180 mocks.counter.expect_decrement().never();
1182
1183 mocks
1185 .tx_repo
1186 .expect_partial_update()
1187 .times(1)
1188 .returning(move |id, update| {
1189 let mut updated_tx = create_test_transaction("test");
1190 updated_tx.id = id;
1191 updated_tx.status = update.status.unwrap();
1192 updated_tx.status_reason = update.status_reason.clone();
1193 Ok::<_, RepositoryError>(updated_tx)
1194 });
1195
1196 mocks
1198 .job_producer
1199 .expect_produce_send_notification_job()
1200 .times(1)
1201 .returning(|_, _| Box::pin(async { Ok(()) }));
1202
1203 mocks
1205 .tx_repo
1206 .expect_find_by_status_paginated()
1207 .returning(move |_, _, _, _| {
1208 Ok(PaginatedResult {
1209 items: vec![],
1210 total: 0,
1211 page: 1,
1212 per_page: 1,
1213 })
1214 });
1215
1216 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1217 let initial_tx = tx_to_handle.clone();
1218
1219 let result = handler
1220 .handle_transaction_status_impl(initial_tx, None)
1221 .await;
1222
1223 assert!(result.is_ok());
1224 let handled_tx = result.unwrap();
1225 assert_eq!(handled_tx.id, "tx-on-chain-fail");
1226 assert_eq!(handled_tx.status, TransactionStatus::Failed);
1227 }
1228
1229 #[tokio::test]
1230 async fn test_on_chain_success_does_not_decrement_sequence() {
1231 let relayer = create_test_relayer();
1232 let mut mocks = default_test_mocks();
1233
1234 let mut tx_to_handle = create_test_transaction(&relayer.id);
1235 tx_to_handle.id = "tx-on-chain-success".to_string();
1236 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1237 let tx_hash_bytes = [5u8; 32];
1238 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1239 {
1240 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1241 stellar_data.sequence_number = Some(101); }
1243 tx_to_handle.status = TransactionStatus::Submitted;
1244
1245 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1246
1247 mocks
1249 .provider
1250 .expect_get_transaction()
1251 .with(eq(expected_stellar_hash.clone()))
1252 .times(1)
1253 .returning(move |_| {
1254 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1255 });
1256
1257 mocks.counter.expect_decrement().never();
1259
1260 mocks
1262 .tx_repo
1263 .expect_partial_update()
1264 .withf(move |id, update| {
1265 id == "tx-on-chain-success"
1266 && update.status == Some(TransactionStatus::Confirmed)
1267 && update.confirmed_at.is_some()
1268 })
1269 .times(1)
1270 .returning(move |id, update| {
1271 let mut updated_tx = create_test_transaction("test");
1272 updated_tx.id = id;
1273 updated_tx.status = update.status.unwrap();
1274 updated_tx.confirmed_at = update.confirmed_at;
1275 Ok(updated_tx)
1276 });
1277
1278 mocks
1280 .job_producer
1281 .expect_produce_send_notification_job()
1282 .times(1)
1283 .returning(|_, _| Box::pin(async { Ok(()) }));
1284
1285 mocks
1287 .tx_repo
1288 .expect_find_by_status_paginated()
1289 .returning(move |_, _, _, _| {
1290 Ok(PaginatedResult {
1291 items: vec![],
1292 total: 0,
1293 page: 1,
1294 per_page: 1,
1295 })
1296 });
1297
1298 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1299 let initial_tx = tx_to_handle.clone();
1300
1301 let result = handler
1302 .handle_transaction_status_impl(initial_tx, None)
1303 .await;
1304
1305 assert!(result.is_ok());
1306 let handled_tx = result.unwrap();
1307 assert_eq!(handled_tx.id, "tx-on-chain-success");
1308 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1309 }
1310
1311 #[tokio::test]
1312 async fn test_handle_transaction_status_with_xdr_error_requeues() {
1313 let relayer = create_test_relayer();
1315 let mut mocks = default_test_mocks();
1316
1317 let mut tx_to_handle = create_test_transaction(&relayer.id);
1318 tx_to_handle.id = "tx-xdr-error-requeue".to_string();
1319 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1320 let tx_hash_bytes = [8u8; 32];
1321 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1322 {
1323 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1324 }
1325 tx_to_handle.status = TransactionStatus::Submitted;
1326
1327 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1328
1329 mocks
1331 .provider
1332 .expect_get_transaction()
1333 .with(eq(expected_stellar_hash.clone()))
1334 .times(1)
1335 .returning(move |_| {
1336 Box::pin(async { Err(ProviderError::Other("Network timeout".to_string())) })
1337 });
1338
1339 mocks.tx_repo.expect_partial_update().never();
1341 mocks
1342 .job_producer
1343 .expect_produce_send_notification_job()
1344 .never();
1345
1346 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1347
1348 let result = handler
1349 .handle_transaction_status_impl(tx_to_handle, None)
1350 .await;
1351
1352 assert!(result.is_err());
1354 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1355 }
1356
1357 #[tokio::test]
1358 async fn handle_transaction_status_extracts_transaction_result_xdr() {
1359 let relayer = create_test_relayer();
1360 let mut mocks = default_test_mocks();
1361
1362 let mut tx_to_handle = create_test_transaction(&relayer.id);
1363 tx_to_handle.id = "tx-with-result".to_string();
1364 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1365 let tx_hash_bytes = [9u8; 32];
1366 let tx_hash_hex = hex::encode(tx_hash_bytes);
1367 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1368 {
1369 stellar_data.hash = Some(tx_hash_hex.clone());
1370 } else {
1371 panic!("Expected Stellar network data");
1372 }
1373 tx_to_handle.status = TransactionStatus::Submitted;
1374
1375 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1376
1377 mocks
1379 .provider
1380 .expect_get_transaction()
1381 .with(eq(expected_stellar_hash.clone()))
1382 .times(1)
1383 .returning(move |_| {
1384 Box::pin(async {
1385 Ok(dummy_get_transaction_response_with_result_meta(
1386 "SUCCESS", true,
1387 ))
1388 })
1389 });
1390
1391 let tx_to_handle_clone = tx_to_handle.clone();
1393 mocks
1394 .tx_repo
1395 .expect_partial_update()
1396 .withf(move |id, update| {
1397 id == "tx-with-result"
1398 && update.status == Some(TransactionStatus::Confirmed)
1399 && update.confirmed_at.is_some()
1400 && update.network_data.as_ref().is_some_and(|and| {
1401 if let NetworkTransactionData::Stellar(stellar_data) = and {
1402 stellar_data.transaction_result_xdr.is_some()
1404 } else {
1405 false
1406 }
1407 })
1408 })
1409 .times(1)
1410 .returning(move |id, update| {
1411 let mut updated_tx = tx_to_handle_clone.clone();
1412 updated_tx.id = id;
1413 updated_tx.status = update.status.unwrap();
1414 updated_tx.confirmed_at = update.confirmed_at;
1415 if let Some(network_data) = update.network_data {
1416 updated_tx.network_data = network_data;
1417 }
1418 Ok(updated_tx)
1419 });
1420
1421 mocks
1423 .job_producer
1424 .expect_produce_send_notification_job()
1425 .times(1)
1426 .returning(|_, _| Box::pin(async { Ok(()) }));
1427
1428 mocks
1430 .tx_repo
1431 .expect_find_by_status_paginated()
1432 .returning(move |_, _, _, _| {
1433 Ok(PaginatedResult {
1434 items: vec![],
1435 total: 0,
1436 page: 1,
1437 per_page: 1,
1438 })
1439 });
1440
1441 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1442 let result = handler
1443 .handle_transaction_status_impl(tx_to_handle, None)
1444 .await;
1445
1446 assert!(result.is_ok());
1447 let handled_tx = result.unwrap();
1448 assert_eq!(handled_tx.id, "tx-with-result");
1449 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1450
1451 if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1453 assert!(
1454 stellar_data.transaction_result_xdr.is_some(),
1455 "transaction_result_xdr should be stored when result_meta contains return_value"
1456 );
1457 } else {
1458 panic!("Expected Stellar network data");
1459 }
1460 }
1461
1462 #[tokio::test]
1463 async fn handle_transaction_status_no_result_meta_does_not_store_xdr() {
1464 let relayer = create_test_relayer();
1465 let mut mocks = default_test_mocks();
1466
1467 let mut tx_to_handle = create_test_transaction(&relayer.id);
1468 tx_to_handle.id = "tx-no-result-meta".to_string();
1469 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1470 let tx_hash_bytes = [10u8; 32];
1471 let tx_hash_hex = hex::encode(tx_hash_bytes);
1472 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1473 {
1474 stellar_data.hash = Some(tx_hash_hex.clone());
1475 } else {
1476 panic!("Expected Stellar network data");
1477 }
1478 tx_to_handle.status = TransactionStatus::Submitted;
1479
1480 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1481
1482 mocks
1484 .provider
1485 .expect_get_transaction()
1486 .with(eq(expected_stellar_hash.clone()))
1487 .times(1)
1488 .returning(move |_| {
1489 Box::pin(async {
1490 Ok(dummy_get_transaction_response_with_result_meta(
1491 "SUCCESS", false,
1492 ))
1493 })
1494 });
1495
1496 let tx_to_handle_clone = tx_to_handle.clone();
1498 mocks
1499 .tx_repo
1500 .expect_partial_update()
1501 .times(1)
1502 .returning(move |id, update| {
1503 let mut updated_tx = tx_to_handle_clone.clone();
1504 updated_tx.id = id;
1505 updated_tx.status = update.status.unwrap();
1506 updated_tx.confirmed_at = update.confirmed_at;
1507 if let Some(network_data) = update.network_data {
1508 updated_tx.network_data = network_data;
1509 }
1510 Ok(updated_tx)
1511 });
1512
1513 mocks
1515 .job_producer
1516 .expect_produce_send_notification_job()
1517 .times(1)
1518 .returning(|_, _| Box::pin(async { Ok(()) }));
1519
1520 mocks
1522 .tx_repo
1523 .expect_find_by_status_paginated()
1524 .returning(move |_, _, _, _| {
1525 Ok(PaginatedResult {
1526 items: vec![],
1527 total: 0,
1528 page: 1,
1529 per_page: 1,
1530 })
1531 });
1532
1533 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1534 let result = handler
1535 .handle_transaction_status_impl(tx_to_handle, None)
1536 .await;
1537
1538 assert!(result.is_ok());
1539 let handled_tx = result.unwrap();
1540
1541 if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1543 assert!(
1544 stellar_data.transaction_result_xdr.is_none(),
1545 "transaction_result_xdr should be None when result_meta is missing"
1546 );
1547 } else {
1548 panic!("Expected Stellar network data");
1549 }
1550 }
1551
1552 #[tokio::test]
1553 async fn test_sent_transaction_not_stuck_yet_returns_ok() {
1554 let relayer = create_test_relayer();
1556 let mut mocks = default_test_mocks();
1557
1558 let mut tx = create_test_transaction(&relayer.id);
1559 tx.id = "tx-sent-not-stuck".to_string();
1560 tx.status = TransactionStatus::Sent;
1561 tx.created_at = Utc::now().to_rfc3339();
1563 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1565 stellar_data.hash = None;
1566 }
1567
1568 mocks.provider.expect_get_transaction().never();
1570 mocks.tx_repo.expect_partial_update().never();
1571 mocks
1572 .job_producer
1573 .expect_produce_submit_transaction_job()
1574 .never();
1575
1576 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1577 let result = handler
1578 .handle_transaction_status_impl(tx.clone(), None)
1579 .await;
1580
1581 assert!(result.is_ok());
1582 let returned_tx = result.unwrap();
1583 assert_eq!(returned_tx.id, tx.id);
1585 assert_eq!(returned_tx.status, TransactionStatus::Sent);
1586 }
1587
1588 #[tokio::test]
1589 async fn test_stuck_sent_transaction_reenqueues_submit_job() {
1590 let relayer = create_test_relayer();
1593 let mut mocks = default_test_mocks();
1594
1595 let mut tx = create_test_transaction(&relayer.id);
1596 tx.id = "tx-stuck-with-xdr".to_string();
1597 tx.status = TransactionStatus::Sent;
1598 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1600 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1602 stellar_data.hash = None;
1603 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1604 }
1605
1606 mocks
1608 .job_producer
1609 .expect_produce_submit_transaction_job()
1610 .times(1)
1611 .returning(|_, _| Box::pin(async { Ok(()) }));
1612
1613 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1614 let result = handler
1615 .handle_transaction_status_impl(tx.clone(), None)
1616 .await;
1617
1618 assert!(result.is_ok());
1619 let returned_tx = result.unwrap();
1620 assert_eq!(returned_tx.status, TransactionStatus::Sent);
1622 }
1623
1624 #[tokio::test]
1625 async fn test_stuck_sent_transaction_expired_marks_expired() {
1626 let relayer = create_test_relayer();
1628 let mut mocks = default_test_mocks();
1629
1630 let mut tx = create_test_transaction(&relayer.id);
1631 tx.id = "tx-expired".to_string();
1632 tx.status = TransactionStatus::Sent;
1633 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1635 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
1637 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1638 stellar_data.hash = None;
1639 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1640 }
1641
1642 mocks
1644 .tx_repo
1645 .expect_partial_update()
1646 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
1647 .times(1)
1648 .returning(|id, update| {
1649 let mut updated = create_test_transaction("test");
1650 updated.id = id;
1651 updated.status = update.status.unwrap();
1652 updated.status_reason = update.status_reason.clone();
1653 Ok(updated)
1654 });
1655
1656 mocks
1658 .job_producer
1659 .expect_produce_submit_transaction_job()
1660 .never();
1661
1662 mocks
1664 .job_producer
1665 .expect_produce_send_notification_job()
1666 .times(1)
1667 .returning(|_, _| Box::pin(async { Ok(()) }));
1668
1669 mocks
1671 .tx_repo
1672 .expect_find_by_status_paginated()
1673 .returning(move |_, _, _, _| {
1674 Ok(PaginatedResult {
1675 items: vec![],
1676 total: 0,
1677 page: 1,
1678 per_page: 1,
1679 })
1680 });
1681
1682 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1683 let result = handler.handle_transaction_status_impl(tx, None).await;
1684
1685 assert!(result.is_ok());
1686 let expired_tx = result.unwrap();
1687 assert_eq!(expired_tx.status, TransactionStatus::Expired);
1688 assert!(expired_tx
1689 .status_reason
1690 .as_ref()
1691 .unwrap()
1692 .contains("expired"));
1693 }
1694
1695 #[tokio::test]
1696 async fn test_stuck_sent_transaction_max_lifetime_marks_failed() {
1697 let relayer = create_test_relayer();
1699 let mut mocks = default_test_mocks();
1700
1701 let mut tx = create_test_transaction(&relayer.id);
1702 tx.id = "tx-max-lifetime".to_string();
1703 tx.status = TransactionStatus::Sent;
1704 tx.created_at = (Utc::now() - Duration::minutes(35)).to_rfc3339();
1706 tx.valid_until = None;
1708 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1709 stellar_data.hash = None;
1710 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1711 }
1712
1713 mocks
1715 .tx_repo
1716 .expect_partial_update()
1717 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1718 .times(1)
1719 .returning(|id, update| {
1720 let mut updated = create_test_transaction("test");
1721 updated.id = id;
1722 updated.status = update.status.unwrap();
1723 updated.status_reason = update.status_reason.clone();
1724 Ok(updated)
1725 });
1726
1727 mocks
1729 .job_producer
1730 .expect_produce_submit_transaction_job()
1731 .never();
1732
1733 mocks
1735 .job_producer
1736 .expect_produce_send_notification_job()
1737 .times(1)
1738 .returning(|_, _| Box::pin(async { Ok(()) }));
1739
1740 mocks
1742 .tx_repo
1743 .expect_find_by_status_paginated()
1744 .returning(|_, _, _, _| {
1745 Ok(PaginatedResult {
1746 items: vec![],
1747 total: 0,
1748 page: 1,
1749 per_page: 1,
1750 })
1751 });
1752
1753 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1754 let result = handler.handle_transaction_status_impl(tx, None).await;
1755
1756 assert!(result.is_ok());
1757 let failed_tx = result.unwrap();
1758 assert_eq!(failed_tx.status, TransactionStatus::Failed);
1759 assert!(failed_tx
1761 .status_reason
1762 .as_ref()
1763 .unwrap()
1764 .contains("stuck in Sent status for too long"));
1765 }
1766 #[tokio::test]
1767 async fn handle_status_concurrent_update_conflict_reloads_latest_state() {
1768 let relayer = create_test_relayer();
1771 let mut mocks = default_test_mocks();
1772
1773 let mut tx = create_test_transaction(&relayer.id);
1774 tx.id = "tx-cas-conflict".to_string();
1775 tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1776 let tx_hash_bytes = [11u8; 32];
1777 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1778 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1779 }
1780 tx.status = TransactionStatus::Submitted;
1781
1782 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1783
1784 mocks
1786 .provider
1787 .expect_get_transaction()
1788 .with(eq(expected_stellar_hash))
1789 .times(1)
1790 .returning(move |_| {
1791 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1792 });
1793
1794 mocks
1796 .tx_repo
1797 .expect_partial_update()
1798 .times(1)
1799 .returning(|_id, _update| {
1800 Err(RepositoryError::ConcurrentUpdateConflict(
1801 "CAS mismatch".to_string(),
1802 ))
1803 });
1804
1805 let reloaded_tx = {
1807 let mut t = create_test_transaction(&relayer.id);
1808 t.id = "tx-cas-conflict".to_string();
1809 t.status = TransactionStatus::Confirmed;
1811 t
1812 };
1813 let reloaded_clone = reloaded_tx.clone();
1814 mocks
1815 .tx_repo
1816 .expect_get_by_id()
1817 .with(eq("tx-cas-conflict".to_string()))
1818 .times(1)
1819 .returning(move |_| Ok(reloaded_clone.clone()));
1820
1821 mocks
1823 .job_producer
1824 .expect_produce_send_notification_job()
1825 .never();
1826 mocks
1827 .job_producer
1828 .expect_produce_transaction_request_job()
1829 .never();
1830
1831 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1832 let result = handler.handle_transaction_status_impl(tx, None).await;
1833
1834 assert!(result.is_ok(), "CAS conflict should return Ok after reload");
1835 let returned_tx = result.unwrap();
1836 assert_eq!(returned_tx.id, "tx-cas-conflict");
1837 assert_eq!(returned_tx.status, TransactionStatus::Confirmed);
1839 }
1840 }
1841
1842 mod handle_pending_state_tests {
1843 use super::*;
1844 use crate::constants::get_stellar_max_stuck_transaction_lifetime;
1845 use crate::constants::STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS;
1846
1847 #[tokio::test]
1848 async fn test_pending_exceeds_max_lifetime_marks_failed() {
1849 let relayer = create_test_relayer();
1850 let mut mocks = default_test_mocks();
1851
1852 let mut tx = create_test_transaction(&relayer.id);
1853 tx.id = "tx-pending-old".to_string();
1854 tx.status = TransactionStatus::Pending;
1855 tx.created_at =
1857 (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
1858 .to_rfc3339();
1859
1860 mocks
1862 .tx_repo
1863 .expect_partial_update()
1864 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1865 .times(1)
1866 .returning(|id, update| {
1867 let mut updated = create_test_transaction("test");
1868 updated.id = id;
1869 updated.status = update.status.unwrap();
1870 updated.status_reason = update.status_reason.clone();
1871 Ok(updated)
1872 });
1873
1874 mocks
1876 .job_producer
1877 .expect_produce_send_notification_job()
1878 .times(1)
1879 .returning(|_, _| Box::pin(async { Ok(()) }));
1880
1881 mocks
1883 .tx_repo
1884 .expect_find_by_status_paginated()
1885 .returning(move |_, _, _, _| {
1886 Ok(PaginatedResult {
1887 items: vec![],
1888 total: 0,
1889 page: 1,
1890 per_page: 1,
1891 })
1892 });
1893
1894 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1895 let result = handler.handle_transaction_status_impl(tx, None).await;
1896
1897 assert!(result.is_ok());
1898 let failed_tx = result.unwrap();
1899 assert_eq!(failed_tx.status, TransactionStatus::Failed);
1900 assert!(failed_tx
1901 .status_reason
1902 .as_ref()
1903 .unwrap()
1904 .contains("stuck in Pending status for too long"));
1905 }
1906
1907 #[tokio::test]
1908 async fn test_pending_triggers_recovery_job_when_old_enough() {
1909 let relayer = create_test_relayer();
1910 let mut mocks = default_test_mocks();
1911
1912 let mut tx = create_test_transaction(&relayer.id);
1913 tx.id = "tx-pending-recovery".to_string();
1914 tx.status = TransactionStatus::Pending;
1915 tx.created_at = (Utc::now()
1917 - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS + 5))
1918 .to_rfc3339();
1919
1920 mocks
1922 .job_producer
1923 .expect_produce_transaction_request_job()
1924 .times(1)
1925 .returning(|_, _| Box::pin(async { Ok(()) }));
1926
1927 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1928 let result = handler.handle_transaction_status_impl(tx, None).await;
1929
1930 assert!(result.is_ok());
1931 let tx_result = result.unwrap();
1932 assert_eq!(tx_result.status, TransactionStatus::Pending);
1933 }
1934
1935 #[tokio::test]
1936 async fn test_pending_too_young_does_not_schedule_recovery() {
1937 let relayer = create_test_relayer();
1938 let mut mocks = default_test_mocks();
1939
1940 let mut tx = create_test_transaction(&relayer.id);
1941 tx.id = "tx-pending-young".to_string();
1942 tx.status = TransactionStatus::Pending;
1943 tx.created_at = (Utc::now()
1945 - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS - 5))
1946 .to_rfc3339();
1947
1948 mocks
1950 .job_producer
1951 .expect_produce_transaction_request_job()
1952 .never();
1953
1954 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1955 let result = handler.handle_transaction_status_impl(tx, None).await;
1956
1957 assert!(result.is_ok());
1958 let tx_result = result.unwrap();
1959 assert_eq!(tx_result.status, TransactionStatus::Pending);
1960 }
1961
1962 #[tokio::test]
1963 async fn test_sent_without_hash_handles_stuck_recovery() {
1964 use crate::constants::STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS;
1965
1966 let relayer = create_test_relayer();
1967 let mut mocks = default_test_mocks();
1968
1969 let mut tx = create_test_transaction(&relayer.id);
1970 tx.id = "tx-sent-no-hash".to_string();
1971 tx.status = TransactionStatus::Sent;
1972 tx.created_at = (Utc::now()
1974 - Duration::seconds(STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS)
1975 - Duration::seconds(1))
1976 .to_rfc3339();
1977 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1978 stellar_data.hash = None; }
1980
1981 mocks
1983 .job_producer
1984 .expect_produce_submit_transaction_job()
1985 .times(1)
1986 .returning(|_, _| Box::pin(async { Ok(()) }));
1987
1988 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1989 let result = handler.handle_transaction_status_impl(tx, None).await;
1990
1991 assert!(result.is_ok());
1992 let tx_result = result.unwrap();
1993 assert_eq!(tx_result.status, TransactionStatus::Sent);
1994 }
1995
1996 #[tokio::test]
1997 async fn test_submitted_without_hash_marks_failed() {
1998 let relayer = create_test_relayer();
1999 let mut mocks = default_test_mocks();
2000
2001 let mut tx = create_test_transaction(&relayer.id);
2002 tx.id = "tx-submitted-no-hash".to_string();
2003 tx.status = TransactionStatus::Submitted;
2004 tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2005 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2006 stellar_data.hash = None; }
2008
2009 mocks
2011 .tx_repo
2012 .expect_partial_update()
2013 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2014 .times(1)
2015 .returning(|id, update| {
2016 let mut updated = create_test_transaction("test");
2017 updated.id = id;
2018 updated.status = update.status.unwrap();
2019 updated.status_reason = update.status_reason.clone();
2020 Ok(updated)
2021 });
2022
2023 mocks
2025 .job_producer
2026 .expect_produce_send_notification_job()
2027 .times(1)
2028 .returning(|_, _| Box::pin(async { Ok(()) }));
2029
2030 mocks
2032 .tx_repo
2033 .expect_find_by_status_paginated()
2034 .returning(move |_, _, _, _| {
2035 Ok(PaginatedResult {
2036 items: vec![],
2037 total: 0,
2038 page: 1,
2039 per_page: 1,
2040 })
2041 });
2042
2043 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2044 let result = handler.handle_transaction_status_impl(tx, None).await;
2045
2046 assert!(result.is_ok());
2047 let failed_tx = result.unwrap();
2048 assert_eq!(failed_tx.status, TransactionStatus::Failed);
2049 assert!(failed_tx
2050 .status_reason
2051 .as_ref()
2052 .unwrap()
2053 .contains("Failed to parse and validate hash"));
2054 }
2055
2056 #[tokio::test]
2057 async fn test_submitted_exceeds_max_lifetime_marks_failed() {
2058 let relayer = create_test_relayer();
2059 let mut mocks = default_test_mocks();
2060
2061 let mut tx = create_test_transaction(&relayer.id);
2062 tx.id = "tx-submitted-old".to_string();
2063 tx.status = TransactionStatus::Submitted;
2064 tx.created_at =
2066 (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
2067 .to_rfc3339();
2068 let tx_hash_bytes = [6u8; 32];
2070 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2071 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2072 }
2073
2074 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2075
2076 mocks
2078 .provider
2079 .expect_get_transaction()
2080 .with(eq(expected_stellar_hash.clone()))
2081 .times(1)
2082 .returning(move |_| {
2083 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2084 });
2085
2086 mocks
2088 .tx_repo
2089 .expect_partial_update()
2090 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2091 .times(1)
2092 .returning(|id, update| {
2093 let mut updated = create_test_transaction("test");
2094 updated.id = id;
2095 updated.status = update.status.unwrap();
2096 updated.status_reason = update.status_reason.clone();
2097 Ok(updated)
2098 });
2099
2100 mocks
2102 .job_producer
2103 .expect_produce_send_notification_job()
2104 .times(1)
2105 .returning(|_, _| Box::pin(async { Ok(()) }));
2106
2107 mocks
2109 .tx_repo
2110 .expect_find_by_status_paginated()
2111 .returning(move |_, _, _, _| {
2112 Ok(PaginatedResult {
2113 items: vec![],
2114 total: 0,
2115 page: 1,
2116 per_page: 1,
2117 })
2118 });
2119
2120 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2121 let result = handler.handle_transaction_status_impl(tx, None).await;
2122
2123 assert!(result.is_ok());
2124 let failed_tx = result.unwrap();
2125 assert_eq!(failed_tx.status, TransactionStatus::Failed);
2126 assert!(failed_tx
2127 .status_reason
2128 .as_ref()
2129 .unwrap()
2130 .contains("stuck in Submitted status for too long"));
2131 }
2132
2133 #[tokio::test]
2134 async fn test_submitted_expired_marks_expired() {
2135 let relayer = create_test_relayer();
2136 let mut mocks = default_test_mocks();
2137
2138 let mut tx = create_test_transaction(&relayer.id);
2139 tx.id = "tx-submitted-expired".to_string();
2140 tx.status = TransactionStatus::Submitted;
2141 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2142 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2144 let tx_hash_bytes = [7u8; 32];
2146 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2147 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2148 }
2149
2150 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2151
2152 mocks
2154 .provider
2155 .expect_get_transaction()
2156 .with(eq(expected_stellar_hash.clone()))
2157 .times(1)
2158 .returning(move |_| {
2159 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2160 });
2161
2162 mocks
2164 .tx_repo
2165 .expect_partial_update()
2166 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2167 .times(1)
2168 .returning(|id, update| {
2169 let mut updated = create_test_transaction("test");
2170 updated.id = id;
2171 updated.status = update.status.unwrap();
2172 updated.status_reason = update.status_reason.clone();
2173 Ok(updated)
2174 });
2175
2176 mocks
2178 .job_producer
2179 .expect_produce_send_notification_job()
2180 .times(1)
2181 .returning(|_, _| Box::pin(async { Ok(()) }));
2182
2183 mocks
2185 .tx_repo
2186 .expect_find_by_status_paginated()
2187 .returning(move |_, _, _, _| {
2188 Ok(PaginatedResult {
2189 items: vec![],
2190 total: 0,
2191 page: 1,
2192 per_page: 1,
2193 })
2194 });
2195
2196 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2197 let result = handler.handle_transaction_status_impl(tx, None).await;
2198
2199 assert!(result.is_ok());
2200 let expired_tx = result.unwrap();
2201 assert_eq!(expired_tx.status, TransactionStatus::Expired);
2202 assert!(expired_tx
2203 .status_reason
2204 .as_ref()
2205 .unwrap()
2206 .contains("expired"));
2207 }
2208
2209 #[tokio::test]
2210 async fn test_handle_submitted_state_resubmits_after_timeout() {
2211 let relayer = create_test_relayer();
2213 let mut mocks = default_test_mocks();
2214
2215 let mut tx = create_test_transaction(&relayer.id);
2216 tx.id = "tx-submitted-resubmit".to_string();
2217 tx.status = TransactionStatus::Submitted;
2218 let sixteen_seconds_ago = (Utc::now() - Duration::seconds(16)).to_rfc3339();
2219 tx.created_at = sixteen_seconds_ago.clone();
2220 tx.sent_at = Some(sixteen_seconds_ago);
2221 let tx_hash_bytes = [8u8; 32];
2223 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2224 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2225 }
2226
2227 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2228
2229 mocks
2231 .provider
2232 .expect_get_transaction()
2233 .with(eq(expected_stellar_hash.clone()))
2234 .times(1)
2235 .returning(move |_| {
2236 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2237 });
2238
2239 mocks
2241 .job_producer
2242 .expect_produce_submit_transaction_job()
2243 .times(1)
2244 .returning(|_, _| Box::pin(async { Ok(()) }));
2245
2246 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2247 let result = handler.handle_transaction_status_impl(tx, None).await;
2248
2249 assert!(result.is_ok());
2250 let tx_result = result.unwrap();
2251 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2252 }
2253
2254 #[tokio::test]
2255 async fn test_handle_submitted_state_backoff_increases_interval() {
2256 let relayer = create_test_relayer();
2260 let mut mocks = default_test_mocks();
2261
2262 let mut tx = create_test_transaction(&relayer.id);
2263 tx.id = "tx-submitted-backoff".to_string();
2264 tx.status = TransactionStatus::Submitted;
2265 tx.created_at = (Utc::now() - Duration::seconds(30)).to_rfc3339();
2266 tx.sent_at = Some((Utc::now() - Duration::seconds(15)).to_rfc3339());
2267 let tx_hash_bytes = [11u8; 32];
2268 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2269 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2270 }
2271
2272 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2273
2274 mocks
2275 .provider
2276 .expect_get_transaction()
2277 .with(eq(expected_stellar_hash.clone()))
2278 .times(1)
2279 .returning(move |_| {
2280 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2281 });
2282
2283 mocks
2285 .job_producer
2286 .expect_produce_submit_transaction_job()
2287 .never();
2288
2289 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2290 let result = handler.handle_transaction_status_impl(tx, None).await;
2291
2292 assert!(result.is_ok());
2293 let tx_result = result.unwrap();
2294 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2295 }
2296
2297 #[tokio::test]
2298 async fn test_handle_submitted_state_backoff_resubmits_when_interval_exceeded() {
2299 let relayer = create_test_relayer();
2303 let mut mocks = default_test_mocks();
2304
2305 let mut tx = create_test_transaction(&relayer.id);
2306 tx.id = "tx-submitted-backoff-resubmit".to_string();
2307 tx.status = TransactionStatus::Submitted;
2308 tx.created_at = (Utc::now() - Duration::seconds(25)).to_rfc3339();
2309 tx.sent_at = Some((Utc::now() - Duration::seconds(21)).to_rfc3339());
2310 let tx_hash_bytes = [12u8; 32];
2311 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2312 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2313 }
2314
2315 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2316
2317 mocks
2318 .provider
2319 .expect_get_transaction()
2320 .with(eq(expected_stellar_hash.clone()))
2321 .times(1)
2322 .returning(move |_| {
2323 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2324 });
2325
2326 mocks
2328 .job_producer
2329 .expect_produce_submit_transaction_job()
2330 .times(1)
2331 .returning(|_, _| Box::pin(async { Ok(()) }));
2332
2333 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2334 let result = handler.handle_transaction_status_impl(tx, None).await;
2335
2336 assert!(result.is_ok());
2337 let tx_result = result.unwrap();
2338 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2339 }
2340
2341 #[tokio::test]
2342 async fn test_handle_submitted_state_recent_sent_at_prevents_resubmit() {
2343 let relayer = create_test_relayer();
2348 let mut mocks = default_test_mocks();
2349
2350 let mut tx = create_test_transaction(&relayer.id);
2351 tx.id = "tx-submitted-recent-sent".to_string();
2352 tx.status = TransactionStatus::Submitted;
2353 tx.created_at = (Utc::now() - Duration::seconds(60)).to_rfc3339();
2354 tx.sent_at = Some((Utc::now() - Duration::seconds(5)).to_rfc3339());
2355 let tx_hash_bytes = [13u8; 32];
2356 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2357 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2358 }
2359
2360 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2361
2362 mocks
2363 .provider
2364 .expect_get_transaction()
2365 .with(eq(expected_stellar_hash.clone()))
2366 .times(1)
2367 .returning(move |_| {
2368 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2369 });
2370
2371 mocks
2373 .job_producer
2374 .expect_produce_submit_transaction_job()
2375 .never();
2376
2377 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2378 let result = handler.handle_transaction_status_impl(tx, None).await;
2379
2380 assert!(result.is_ok());
2381 let tx_result = result.unwrap();
2382 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2383 }
2384
2385 #[tokio::test]
2386 async fn test_handle_submitted_state_no_resubmit_before_timeout() {
2387 let relayer = create_test_relayer();
2388 let mut mocks = default_test_mocks();
2389
2390 let mut tx = create_test_transaction(&relayer.id);
2391 tx.id = "tx-submitted-young".to_string();
2392 tx.status = TransactionStatus::Submitted;
2393 tx.created_at = Utc::now().to_rfc3339();
2395 let tx_hash_bytes = [9u8; 32];
2397 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2398 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2399 }
2400
2401 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2402
2403 mocks
2405 .provider
2406 .expect_get_transaction()
2407 .with(eq(expected_stellar_hash.clone()))
2408 .times(1)
2409 .returning(move |_| {
2410 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2411 });
2412
2413 mocks
2415 .job_producer
2416 .expect_produce_submit_transaction_job()
2417 .never();
2418
2419 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2420 let result = handler.handle_transaction_status_impl(tx, None).await;
2421
2422 assert!(result.is_ok());
2423 let tx_result = result.unwrap();
2424 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2425 }
2426
2427 #[tokio::test]
2428 async fn test_handle_submitted_state_expired_before_resubmit() {
2429 let relayer = create_test_relayer();
2430 let mut mocks = default_test_mocks();
2431
2432 let mut tx = create_test_transaction(&relayer.id);
2433 tx.id = "tx-submitted-expired-no-resubmit".to_string();
2434 tx.status = TransactionStatus::Submitted;
2435 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2436 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2438 let tx_hash_bytes = [10u8; 32];
2440 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2441 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2442 }
2443
2444 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2445
2446 mocks
2448 .provider
2449 .expect_get_transaction()
2450 .with(eq(expected_stellar_hash.clone()))
2451 .times(1)
2452 .returning(move |_| {
2453 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2454 });
2455
2456 mocks
2458 .tx_repo
2459 .expect_partial_update()
2460 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2461 .times(1)
2462 .returning(|id, update| {
2463 let mut updated = create_test_transaction("test");
2464 updated.id = id;
2465 updated.status = update.status.unwrap();
2466 updated.status_reason = update.status_reason.clone();
2467 Ok(updated)
2468 });
2469
2470 mocks
2472 .job_producer
2473 .expect_produce_submit_transaction_job()
2474 .never();
2475
2476 mocks
2478 .job_producer
2479 .expect_produce_send_notification_job()
2480 .times(1)
2481 .returning(|_, _| Box::pin(async { Ok(()) }));
2482
2483 mocks
2485 .tx_repo
2486 .expect_find_by_status_paginated()
2487 .returning(move |_, _, _, _| {
2488 Ok(PaginatedResult {
2489 items: vec![],
2490 total: 0,
2491 page: 1,
2492 per_page: 1,
2493 })
2494 });
2495
2496 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2497 let result = handler.handle_transaction_status_impl(tx, None).await;
2498
2499 assert!(result.is_ok());
2500 let expired_tx = result.unwrap();
2501 assert_eq!(expired_tx.status, TransactionStatus::Expired);
2502 assert!(expired_tx
2503 .status_reason
2504 .as_ref()
2505 .unwrap()
2506 .contains("expired"));
2507 }
2508 }
2509
2510 mod is_valid_until_expired_tests {
2511 use super::*;
2512 use crate::{
2513 jobs::MockJobProducerTrait,
2514 repositories::{
2515 MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository,
2516 },
2517 services::{
2518 provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait,
2519 },
2520 };
2521 use chrono::{Duration, Utc};
2522
2523 type TestHandler = StellarRelayerTransaction<
2525 MockRelayerRepository,
2526 MockTransactionRepository,
2527 MockJobProducerTrait,
2528 MockStellarCombinedSigner,
2529 MockStellarProviderTrait,
2530 MockTransactionCounterTrait,
2531 MockStellarDexServiceTrait,
2532 >;
2533
2534 #[test]
2535 fn test_rfc3339_expired() {
2536 let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2537 assert!(TestHandler::is_valid_until_string_expired(&past));
2538 }
2539
2540 #[test]
2541 fn test_rfc3339_not_expired() {
2542 let future = (Utc::now() + Duration::hours(1)).to_rfc3339();
2543 assert!(!TestHandler::is_valid_until_string_expired(&future));
2544 }
2545
2546 #[test]
2547 fn test_numeric_timestamp_expired() {
2548 let past_timestamp = (Utc::now() - Duration::hours(1)).timestamp().to_string();
2549 assert!(TestHandler::is_valid_until_string_expired(&past_timestamp));
2550 }
2551
2552 #[test]
2553 fn test_numeric_timestamp_not_expired() {
2554 let future_timestamp = (Utc::now() + Duration::hours(1)).timestamp().to_string();
2555 assert!(!TestHandler::is_valid_until_string_expired(
2556 &future_timestamp
2557 ));
2558 }
2559
2560 #[test]
2561 fn test_zero_timestamp_unbounded() {
2562 assert!(!TestHandler::is_valid_until_string_expired("0"));
2564 }
2565
2566 #[test]
2567 fn test_invalid_format_not_expired() {
2568 assert!(!TestHandler::is_valid_until_string_expired("not-a-date"));
2570 }
2571 }
2572
2573 mod circuit_breaker_tests {
2575 use super::*;
2576 use crate::jobs::StatusCheckContext;
2577 use crate::models::NetworkType;
2578
2579 fn create_triggered_context() -> StatusCheckContext {
2581 StatusCheckContext::new(
2582 110, 150, 160, 100, 300, NetworkType::Stellar,
2588 )
2589 }
2590
2591 fn create_safe_context() -> StatusCheckContext {
2593 StatusCheckContext::new(
2594 10, 20, 25, 100, 300, NetworkType::Stellar,
2600 )
2601 }
2602
2603 fn create_total_triggered_context() -> StatusCheckContext {
2605 StatusCheckContext::new(
2606 20, 310, 350, 100, 300, NetworkType::Stellar,
2612 )
2613 }
2614
2615 #[tokio::test]
2616 async fn test_circuit_breaker_submitted_marks_as_failed() {
2617 let relayer = create_test_relayer();
2618 let mut mocks = default_test_mocks();
2619
2620 let mut tx_to_handle = create_test_transaction(&relayer.id);
2621 tx_to_handle.status = TransactionStatus::Submitted;
2622 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2623
2624 mocks
2626 .tx_repo
2627 .expect_partial_update()
2628 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2629 .times(1)
2630 .returning(|_, update| {
2631 let mut updated_tx = create_test_transaction("test-relayer");
2632 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2633 updated_tx.status_reason = update.status_reason.clone();
2634 Ok(updated_tx)
2635 });
2636
2637 mocks
2639 .job_producer
2640 .expect_produce_send_notification_job()
2641 .returning(|_, _| Box::pin(async { Ok(()) }));
2642
2643 mocks
2645 .tx_repo
2646 .expect_find_by_status_paginated()
2647 .returning(|_, _, _, _| {
2648 Ok(PaginatedResult {
2649 items: vec![],
2650 total: 0,
2651 page: 1,
2652 per_page: 1,
2653 })
2654 });
2655
2656 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2657 let ctx = create_triggered_context();
2658
2659 let result = handler
2660 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2661 .await;
2662
2663 assert!(result.is_ok());
2664 let tx = result.unwrap();
2665 assert_eq!(tx.status, TransactionStatus::Failed);
2666 assert!(tx.status_reason.is_some());
2667 assert!(tx.status_reason.unwrap().contains("consecutive errors"));
2668 }
2669
2670 #[tokio::test]
2671 async fn test_circuit_breaker_pending_marks_as_failed() {
2672 let relayer = create_test_relayer();
2673 let mut mocks = default_test_mocks();
2674
2675 let mut tx_to_handle = create_test_transaction(&relayer.id);
2676 tx_to_handle.status = TransactionStatus::Pending;
2677 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2678
2679 mocks
2681 .tx_repo
2682 .expect_partial_update()
2683 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2684 .times(1)
2685 .returning(|_, update| {
2686 let mut updated_tx = create_test_transaction("test-relayer");
2687 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2688 updated_tx.status_reason = update.status_reason.clone();
2689 Ok(updated_tx)
2690 });
2691
2692 mocks
2693 .job_producer
2694 .expect_produce_send_notification_job()
2695 .returning(|_, _| Box::pin(async { Ok(()) }));
2696
2697 mocks
2698 .tx_repo
2699 .expect_find_by_status_paginated()
2700 .returning(|_, _, _, _| {
2701 Ok(PaginatedResult {
2702 items: vec![],
2703 total: 0,
2704 page: 1,
2705 per_page: 1,
2706 })
2707 });
2708
2709 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2710 let ctx = create_triggered_context();
2711
2712 let result = handler
2713 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2714 .await;
2715
2716 assert!(result.is_ok());
2717 let tx = result.unwrap();
2718 assert_eq!(tx.status, TransactionStatus::Failed);
2719 }
2720
2721 #[tokio::test]
2722 async fn test_circuit_breaker_total_failures_triggers() {
2723 let relayer = create_test_relayer();
2724 let mut mocks = default_test_mocks();
2725
2726 let mut tx_to_handle = create_test_transaction(&relayer.id);
2727 tx_to_handle.status = TransactionStatus::Submitted;
2728 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2729
2730 mocks
2731 .tx_repo
2732 .expect_partial_update()
2733 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2734 .times(1)
2735 .returning(|_, update| {
2736 let mut updated_tx = create_test_transaction("test-relayer");
2737 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2738 updated_tx.status_reason = update.status_reason.clone();
2739 Ok(updated_tx)
2740 });
2741
2742 mocks
2743 .job_producer
2744 .expect_produce_send_notification_job()
2745 .returning(|_, _| Box::pin(async { Ok(()) }));
2746
2747 mocks
2748 .tx_repo
2749 .expect_find_by_status_paginated()
2750 .returning(|_, _, _, _| {
2751 Ok(PaginatedResult {
2752 items: vec![],
2753 total: 0,
2754 page: 1,
2755 per_page: 1,
2756 })
2757 });
2758
2759 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2760 let ctx = create_total_triggered_context();
2762
2763 let result = handler
2764 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2765 .await;
2766
2767 assert!(result.is_ok());
2768 let tx = result.unwrap();
2769 assert_eq!(tx.status, TransactionStatus::Failed);
2770 }
2771
2772 #[tokio::test]
2773 async fn test_circuit_breaker_below_threshold_continues() {
2774 let relayer = create_test_relayer();
2775 let mut mocks = default_test_mocks();
2776
2777 let mut tx_to_handle = create_test_transaction(&relayer.id);
2778 tx_to_handle.status = TransactionStatus::Submitted;
2779 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2780 let tx_hash_bytes = [1u8; 32];
2781 let tx_hash_hex = hex::encode(tx_hash_bytes);
2782 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2783 {
2784 stellar_data.hash = Some(tx_hash_hex.clone());
2785 }
2786
2787 mocks
2789 .provider
2790 .expect_get_transaction()
2791 .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2792
2793 mocks
2794 .tx_repo
2795 .expect_partial_update()
2796 .returning(|_, update| {
2797 let mut updated_tx = create_test_transaction("test-relayer");
2798 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2799 Ok(updated_tx)
2800 });
2801
2802 mocks
2803 .job_producer
2804 .expect_produce_send_notification_job()
2805 .returning(|_, _| Box::pin(async { Ok(()) }));
2806
2807 mocks
2808 .tx_repo
2809 .expect_find_by_status_paginated()
2810 .returning(|_, _, _, _| {
2811 Ok(PaginatedResult {
2812 items: vec![],
2813 total: 0,
2814 page: 1,
2815 per_page: 1,
2816 })
2817 });
2818
2819 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2820 let ctx = create_safe_context();
2821
2822 let result = handler
2823 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2824 .await;
2825
2826 assert!(result.is_ok());
2827 let tx = result.unwrap();
2828 assert_eq!(tx.status, TransactionStatus::Confirmed);
2830 }
2831
2832 #[tokio::test]
2833 async fn test_circuit_breaker_final_state_early_return() {
2834 let relayer = create_test_relayer();
2835 let mocks = default_test_mocks();
2836
2837 let mut tx_to_handle = create_test_transaction(&relayer.id);
2839 tx_to_handle.status = TransactionStatus::Confirmed;
2840
2841 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2842 let ctx = create_triggered_context();
2843
2844 let result = handler
2846 .handle_transaction_status_impl(tx_to_handle.clone(), Some(ctx))
2847 .await;
2848
2849 assert!(result.is_ok());
2850 assert_eq!(result.unwrap().id, tx_to_handle.id);
2851 }
2852
2853 #[tokio::test]
2854 async fn test_circuit_breaker_no_context_continues() {
2855 let relayer = create_test_relayer();
2856 let mut mocks = default_test_mocks();
2857
2858 let mut tx_to_handle = create_test_transaction(&relayer.id);
2859 tx_to_handle.status = TransactionStatus::Submitted;
2860 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2861 let tx_hash_bytes = [1u8; 32];
2862 let tx_hash_hex = hex::encode(tx_hash_bytes);
2863 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2864 {
2865 stellar_data.hash = Some(tx_hash_hex.clone());
2866 }
2867
2868 mocks
2870 .provider
2871 .expect_get_transaction()
2872 .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2873
2874 mocks
2875 .tx_repo
2876 .expect_partial_update()
2877 .returning(|_, update| {
2878 let mut updated_tx = create_test_transaction("test-relayer");
2879 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2880 Ok(updated_tx)
2881 });
2882
2883 mocks
2884 .job_producer
2885 .expect_produce_send_notification_job()
2886 .returning(|_, _| Box::pin(async { Ok(()) }));
2887
2888 mocks
2889 .tx_repo
2890 .expect_find_by_status_paginated()
2891 .returning(|_, _, _, _| {
2892 Ok(PaginatedResult {
2893 items: vec![],
2894 total: 0,
2895 page: 1,
2896 per_page: 1,
2897 })
2898 });
2899
2900 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2901
2902 let result = handler
2904 .handle_transaction_status_impl(tx_to_handle, None)
2905 .await;
2906
2907 assert!(result.is_ok());
2908 let tx = result.unwrap();
2909 assert_eq!(tx.status, TransactionStatus::Confirmed);
2910 }
2911 }
2912
2913 mod failure_detail_helper_tests {
2914 use super::*;
2915 use soroban_rs::xdr::{InvokeHostFunctionResult, OperationResult, OperationResultTr, VecM};
2916
2917 #[test]
2918 fn first_failing_op_finds_trapped() {
2919 let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2920 OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Trapped),
2921 )]
2922 .try_into()
2923 .unwrap();
2924 assert_eq!(first_failing_op(ops.as_slice()), Some("Trapped"));
2925 }
2926
2927 #[test]
2928 fn first_failing_op_skips_success() {
2929 let ops: VecM<OperationResult> = vec![
2930 OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2931 InvokeHostFunctionResult::Success(soroban_rs::xdr::Hash([0u8; 32])),
2932 )),
2933 OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2934 InvokeHostFunctionResult::ResourceLimitExceeded,
2935 )),
2936 ]
2937 .try_into()
2938 .unwrap();
2939 assert_eq!(
2940 first_failing_op(ops.as_slice()),
2941 Some("ResourceLimitExceeded")
2942 );
2943 }
2944
2945 #[test]
2946 fn first_failing_op_all_success_returns_none() {
2947 let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2948 OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Success(
2949 soroban_rs::xdr::Hash([0u8; 32]),
2950 )),
2951 )]
2952 .try_into()
2953 .unwrap();
2954 assert_eq!(first_failing_op(ops.as_slice()), None);
2955 }
2956
2957 #[test]
2958 fn first_failing_op_empty_returns_none() {
2959 assert_eq!(first_failing_op(&[]), None);
2960 }
2961
2962 #[test]
2963 fn first_failing_op_op_bad_auth() {
2964 let ops: VecM<OperationResult> = vec![OperationResult::OpBadAuth].try_into().unwrap();
2965 assert_eq!(first_failing_op(ops.as_slice()), Some("OpBadAuth"));
2966 }
2967 }
2968}