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_GROWTH_FACTOR, 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 STELLAR_RESUBMIT_GROWTH_FACTOR,
463 ) {
464 let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
465 if age_since_last_submit > backoff_interval {
466 info!(
467 tx_id = %tx.id,
468 total_age_seconds = total_age.num_seconds(),
469 since_last_submit_seconds = age_since_last_submit.num_seconds(),
470 backoff_interval_seconds = backoff_interval.num_seconds(),
471 "re-enqueueing submit job for stuck Sent transaction"
472 );
473 send_submit_transaction_job(self.job_producer(), &tx, None).await?;
474 }
475 }
476
477 Ok(tx)
478 }
479
480 async fn handle_pending_state(
483 &self,
484 tx: TransactionRepoModel,
485 ) -> Result<TransactionRepoModel, TransactionError> {
486 if let Some(result) = self
488 .check_expiration_and_max_lifetime(
489 tx.clone(),
490 "Transaction stuck in Pending status for too long".to_string(),
491 )
492 .await
493 {
494 return result;
495 }
496
497 let age = self.get_time_since_created_at(&tx)?;
499
500 if age.num_seconds() >= STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS {
503 info!(
504 tx_id = %tx.id,
505 age_seconds = age.num_seconds(),
506 "pending transaction without hash may be stuck, scheduling recovery job"
507 );
508
509 let transaction_request = TransactionRequest::new(tx.id.clone(), tx.relayer_id.clone());
510 if let Err(e) = self
511 .job_producer()
512 .produce_transaction_request_job(transaction_request, None)
513 .await
514 {
515 warn!(
516 tx_id = %tx.id,
517 error = %e,
518 "failed to schedule recovery job for pending transaction"
519 );
520 }
521 } else {
522 debug!(
523 tx_id = %tx.id,
524 age_seconds = age.num_seconds(),
525 "pending transaction without hash too young for recovery check"
526 );
527 }
528
529 Ok(tx)
530 }
531
532 fn get_time_since_created_at(
535 &self,
536 tx: &TransactionRepoModel,
537 ) -> Result<chrono::Duration, TransactionError> {
538 match DateTime::parse_from_rfc3339(&tx.created_at) {
539 Ok(dt) => Ok(Utc::now().signed_duration_since(dt.with_timezone(&Utc))),
540 Err(e) => {
541 warn!(tx_id = %tx.id, ts = %tx.created_at, error = %e, "failed to parse created_at timestamp");
542 Err(TransactionError::UnexpectedError(format!(
543 "Invalid created_at timestamp for transaction {}: {}",
544 tx.id, e
545 )))
546 }
547 }
548 }
549
550 async fn handle_submitted_state(
554 &self,
555 tx: TransactionRepoModel,
556 ) -> Result<TransactionRepoModel, TransactionError> {
557 let stellar_hash = match self.parse_and_validate_hash(&tx) {
558 Ok(hash) => hash,
559 Err(e) => {
560 warn!(
562 tx_id = %tx.id,
563 status = ?tx.status,
564 error = ?e,
565 "failed to parse and validate hash for submitted transaction"
566 );
567 return self
568 .mark_as_failed(tx, format!("Failed to parse and validate hash: {e}"))
569 .await;
570 }
571 };
572
573 let provider_response = match self.provider().get_transaction(&stellar_hash).await {
574 Ok(response) => response,
575 Err(e) => {
576 warn!(error = ?e, "provider get_transaction failed");
577 return Err(TransactionError::from(e));
578 }
579 };
580
581 match provider_response.status.as_str().to_uppercase().as_str() {
582 "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
583 "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
584 _ => {
585 debug!(
586 tx_id = %tx.id,
587 relayer_id = %tx.relayer_id,
588 status = %provider_response.status,
589 "submitted transaction not yet final on-chain, will retry check later"
590 );
591
592 if let Some(result) = self
594 .check_expiration_and_max_lifetime(
595 tx.clone(),
596 "Transaction stuck in Submitted status for too long".to_string(),
597 )
598 .await
599 {
600 return result;
601 }
602
603 let total_age = get_age_since_created(&tx)?;
606 if let Some(backoff_interval) = compute_resubmit_backoff_interval(
607 total_age,
608 STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
609 STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
610 STELLAR_RESUBMIT_GROWTH_FACTOR,
611 ) {
612 let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
613 if age_since_last_submit > backoff_interval {
614 info!(
615 tx_id = %tx.id,
616 relayer_id = %tx.relayer_id,
617 total_age_seconds = total_age.num_seconds(),
618 since_last_submit_seconds = age_since_last_submit.num_seconds(),
619 backoff_interval_seconds = backoff_interval.num_seconds(),
620 "resubmitting Submitted transaction to ensure mempool inclusion"
621 );
622 send_submit_transaction_job(self.job_producer(), &tx, None).await?;
623 }
624 }
625
626 Ok(tx)
627 }
628 }
629 }
630}
631
632fn extract_fee_bid(envelope: &TransactionEnvelope) -> i64 {
637 match envelope {
638 TransactionEnvelope::TxFeeBump(fb) => fb.tx.fee,
639 TransactionEnvelope::Tx(v1) => v1.tx.fee as i64,
640 TransactionEnvelope::TxV0(v0) => v0.tx.fee as i64,
641 }
642}
643
644fn first_failing_op(ops: &[OperationResult]) -> Option<&'static str> {
649 let op = ops.iter().find(|op| match op {
650 OperationResult::OpInner(tr) => match tr {
651 OperationResultTr::InvokeHostFunction(r) => {
652 !matches!(r, InvokeHostFunctionResult::Success(_))
653 }
654 OperationResultTr::ExtendFootprintTtl(r) => r.name() != "Success",
655 OperationResultTr::RestoreFootprint(r) => r.name() != "Success",
656 _ => false,
657 },
658 _ => true,
659 })?;
660 match op {
661 OperationResult::OpInner(tr) => match tr {
662 OperationResultTr::InvokeHostFunction(r) => Some(r.name()),
663 OperationResultTr::ExtendFootprintTtl(r) => Some(r.name()),
664 OperationResultTr::RestoreFootprint(r) => Some(r.name()),
665 _ => Some(tr.name()),
666 },
667 _ => Some(op.name()),
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::models::{NetworkTransactionData, RepositoryError};
675 use crate::repositories::PaginatedResult;
676 use chrono::Duration;
677 use mockall::predicate::eq;
678 use soroban_rs::stellar_rpc_client::GetTransactionResponse;
679
680 use crate::domain::transaction::stellar::test_helpers::*;
681
682 fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
683 GetTransactionResponse {
684 status: status.to_string(),
685 ledger: None,
686 envelope: None,
687 result: None,
688 result_meta: None,
689 events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
690 contract_events: vec![],
691 diagnostic_events: vec![],
692 transaction_events: vec![],
693 },
694 }
695 }
696
697 fn dummy_get_transaction_response_with_result_meta(
698 status: &str,
699 has_return_value: bool,
700 ) -> GetTransactionResponse {
701 use soroban_rs::xdr::{ScVal, SorobanTransactionMeta, TransactionMeta, TransactionMetaV3};
702
703 let result_meta = if has_return_value {
704 let return_value = ScVal::I32(42);
706 Some(TransactionMeta::V3(TransactionMetaV3 {
707 ext: soroban_rs::xdr::ExtensionPoint::V0,
708 tx_changes_before: soroban_rs::xdr::LedgerEntryChanges::default(),
709 operations: soroban_rs::xdr::VecM::default(),
710 tx_changes_after: soroban_rs::xdr::LedgerEntryChanges::default(),
711 soroban_meta: Some(SorobanTransactionMeta {
712 ext: soroban_rs::xdr::SorobanTransactionMetaExt::V0,
713 return_value,
714 events: soroban_rs::xdr::VecM::default(),
715 diagnostic_events: soroban_rs::xdr::VecM::default(),
716 }),
717 }))
718 } else {
719 None
720 };
721
722 GetTransactionResponse {
723 status: status.to_string(),
724 ledger: None,
725 envelope: None,
726 result: None,
727 result_meta,
728 events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
729 contract_events: vec![],
730 diagnostic_events: vec![],
731 transaction_events: vec![],
732 },
733 }
734 }
735
736 mod handle_transaction_status_tests {
737 use crate::services::provider::ProviderError;
738
739 use super::*;
740
741 #[tokio::test]
742 async fn handle_transaction_status_confirmed_triggers_next() {
743 let relayer = create_test_relayer();
744 let mut mocks = default_test_mocks();
745
746 let mut tx_to_handle = create_test_transaction(&relayer.id);
747 tx_to_handle.id = "tx-confirm-this".to_string();
748 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
749 let tx_hash_bytes = [1u8; 32];
750 let tx_hash_hex = hex::encode(tx_hash_bytes);
751 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
752 {
753 stellar_data.hash = Some(tx_hash_hex.clone());
754 } else {
755 panic!("Expected Stellar network data for tx_to_handle");
756 }
757 tx_to_handle.status = TransactionStatus::Submitted;
758
759 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
760
761 mocks
763 .provider
764 .expect_get_transaction()
765 .with(eq(expected_stellar_hash.clone()))
766 .times(1)
767 .returning(move |_| {
768 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
769 });
770
771 mocks
773 .tx_repo
774 .expect_partial_update()
775 .withf(move |id, update| {
776 id == "tx-confirm-this"
777 && update.status == Some(TransactionStatus::Confirmed)
778 && update.confirmed_at.is_some()
779 })
780 .times(1)
781 .returning(move |id, update| {
782 let mut updated_tx = tx_to_handle.clone(); updated_tx.id = id;
784 updated_tx.status = update.status.unwrap();
785 updated_tx.confirmed_at = update.confirmed_at;
786 Ok(updated_tx)
787 });
788
789 mocks
791 .job_producer
792 .expect_produce_send_notification_job()
793 .times(1)
794 .returning(|_, _| Box::pin(async { Ok(()) }));
795
796 let mut oldest_pending_tx = create_test_transaction(&relayer.id);
798 oldest_pending_tx.id = "tx-oldest-pending".to_string();
799 oldest_pending_tx.status = TransactionStatus::Pending;
800 let captured_oldest_pending_tx = oldest_pending_tx.clone();
801 let relayer_id_clone = relayer.id.clone();
802 mocks
803 .tx_repo
804 .expect_find_by_status_paginated()
805 .withf(move |relayer_id, statuses, query, oldest_first| {
806 *relayer_id == relayer_id_clone
807 && statuses == [TransactionStatus::Pending]
808 && query.page == 1
809 && query.per_page == 1
810 && *oldest_first
811 })
812 .times(1)
813 .returning(move |_, _, _, _| {
814 Ok(PaginatedResult {
815 items: vec![captured_oldest_pending_tx.clone()],
816 total: 1,
817 page: 1,
818 per_page: 1,
819 })
820 });
821
822 mocks
824 .job_producer
825 .expect_produce_transaction_request_job()
826 .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
827 .times(1)
828 .returning(|_, _| Box::pin(async { Ok(()) }));
829
830 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
831 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
832 initial_tx_for_handling.id = "tx-confirm-this".to_string();
833 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
834 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
835 initial_tx_for_handling.network_data
836 {
837 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
838 } else {
839 panic!("Expected Stellar network data for initial_tx_for_handling");
840 }
841 initial_tx_for_handling.status = TransactionStatus::Submitted;
842
843 let result = handler
844 .handle_transaction_status_impl(initial_tx_for_handling, None)
845 .await;
846
847 assert!(result.is_ok());
848 let handled_tx = result.unwrap();
849 assert_eq!(handled_tx.id, "tx-confirm-this");
850 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
851 assert!(handled_tx.confirmed_at.is_some());
852 }
853
854 #[tokio::test]
855 async fn handle_transaction_status_still_pending() {
856 let relayer = create_test_relayer();
857 let mut mocks = default_test_mocks();
858
859 let mut tx_to_handle = create_test_transaction(&relayer.id);
860 tx_to_handle.id = "tx-pending-check".to_string();
861 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
862 let tx_hash_bytes = [2u8; 32];
863 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
864 {
865 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
866 } else {
867 panic!("Expected Stellar network data");
868 }
869 tx_to_handle.status = TransactionStatus::Submitted; let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
872
873 mocks
875 .provider
876 .expect_get_transaction()
877 .with(eq(expected_stellar_hash.clone()))
878 .times(1)
879 .returning(move |_| {
880 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
881 });
882
883 mocks.tx_repo.expect_partial_update().never();
885
886 mocks
888 .job_producer
889 .expect_produce_send_notification_job()
890 .never();
891
892 mocks
894 .job_producer
895 .expect_produce_submit_transaction_job()
896 .times(1)
897 .returning(|_, _| Box::pin(async { Ok(()) }));
898
899 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
900 let original_tx_clone = tx_to_handle.clone();
901
902 let result = handler
903 .handle_transaction_status_impl(tx_to_handle, None)
904 .await;
905
906 assert!(result.is_ok());
907 let returned_tx = result.unwrap();
908 assert_eq!(returned_tx.id, original_tx_clone.id);
910 assert_eq!(returned_tx.status, original_tx_clone.status);
911 assert!(returned_tx.confirmed_at.is_none()); }
913
914 #[tokio::test]
915 async fn handle_transaction_status_failed() {
916 let relayer = create_test_relayer();
917 let mut mocks = default_test_mocks();
918
919 let mut tx_to_handle = create_test_transaction(&relayer.id);
920 tx_to_handle.id = "tx-fail-this".to_string();
921 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
922 let tx_hash_bytes = [3u8; 32];
923 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
924 {
925 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
926 } else {
927 panic!("Expected Stellar network data");
928 }
929 tx_to_handle.status = TransactionStatus::Submitted;
930
931 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
932
933 mocks
935 .provider
936 .expect_get_transaction()
937 .with(eq(expected_stellar_hash.clone()))
938 .times(1)
939 .returning(move |_| {
940 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
941 });
942
943 let relayer_id_for_mock = relayer.id.clone();
945 mocks
946 .tx_repo
947 .expect_partial_update()
948 .times(1)
949 .returning(move |id, update| {
950 let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
952 updated_tx.id = id;
953 updated_tx.status = update.status.unwrap();
954 updated_tx.status_reason = update.status_reason.clone();
955 Ok::<_, RepositoryError>(updated_tx)
956 });
957
958 mocks
960 .job_producer
961 .expect_produce_send_notification_job()
962 .times(1)
963 .returning(|_, _| Box::pin(async { Ok(()) }));
964
965 let relayer_id_clone = relayer.id.clone();
967 mocks
968 .tx_repo
969 .expect_find_by_status_paginated()
970 .withf(move |relayer_id, statuses, query, oldest_first| {
971 *relayer_id == relayer_id_clone
972 && statuses == [TransactionStatus::Pending]
973 && query.page == 1
974 && query.per_page == 1
975 && *oldest_first
976 })
977 .times(1)
978 .returning(move |_, _, _, _| {
979 Ok(PaginatedResult {
980 items: vec![],
981 total: 0,
982 page: 1,
983 per_page: 1,
984 })
985 }); mocks
989 .job_producer
990 .expect_produce_transaction_request_job()
991 .never();
992 mocks
994 .job_producer
995 .expect_produce_check_transaction_status_job()
996 .never();
997
998 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
999 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
1000 initial_tx_for_handling.id = "tx-fail-this".to_string();
1001 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1002 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
1003 initial_tx_for_handling.network_data
1004 {
1005 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1006 } else {
1007 panic!("Expected Stellar network data");
1008 }
1009 initial_tx_for_handling.status = TransactionStatus::Submitted;
1010
1011 let result = handler
1012 .handle_transaction_status_impl(initial_tx_for_handling, None)
1013 .await;
1014
1015 assert!(result.is_ok());
1016 let handled_tx = result.unwrap();
1017 assert_eq!(handled_tx.id, "tx-fail-this");
1018 assert_eq!(handled_tx.status, TransactionStatus::Failed);
1019 assert!(handled_tx.status_reason.is_some());
1020 assert_eq!(
1021 handled_tx.status_reason.unwrap(),
1022 "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: unknown."
1023 );
1024 }
1025
1026 #[tokio::test]
1027 async fn handle_transaction_status_provider_error() {
1028 let relayer = create_test_relayer();
1029 let mut mocks = default_test_mocks();
1030
1031 let mut tx_to_handle = create_test_transaction(&relayer.id);
1032 tx_to_handle.id = "tx-provider-error".to_string();
1033 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1034 let tx_hash_bytes = [4u8; 32];
1035 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1036 {
1037 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1038 } else {
1039 panic!("Expected Stellar network data");
1040 }
1041 tx_to_handle.status = TransactionStatus::Submitted;
1042
1043 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1044
1045 mocks
1047 .provider
1048 .expect_get_transaction()
1049 .with(eq(expected_stellar_hash.clone()))
1050 .times(1)
1051 .returning(move |_| {
1052 Box::pin(async { Err(ProviderError::Other("RPC boom".to_string())) })
1053 });
1054
1055 mocks.tx_repo.expect_partial_update().never();
1057
1058 mocks
1060 .job_producer
1061 .expect_produce_send_notification_job()
1062 .never();
1063 mocks
1065 .job_producer
1066 .expect_produce_transaction_request_job()
1067 .never();
1068
1069 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1070
1071 let result = handler
1072 .handle_transaction_status_impl(tx_to_handle, None)
1073 .await;
1074
1075 assert!(result.is_err());
1077 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1078 }
1079
1080 #[tokio::test]
1081 async fn handle_transaction_status_no_hashes() {
1082 let relayer = create_test_relayer();
1083 let mut mocks = default_test_mocks();
1084
1085 let mut tx_to_handle = create_test_transaction(&relayer.id);
1086 tx_to_handle.id = "tx-no-hashes".to_string();
1087 tx_to_handle.status = TransactionStatus::Submitted;
1088 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1089
1090 mocks.provider.expect_get_transaction().never();
1092
1093 mocks
1095 .tx_repo
1096 .expect_partial_update()
1097 .times(1)
1098 .returning(|_, update| {
1099 let mut updated_tx = create_test_transaction("test-relayer");
1100 updated_tx.status = update.status.unwrap_or(updated_tx.status);
1101 updated_tx.status_reason = update.status_reason.clone();
1102 Ok(updated_tx)
1103 });
1104
1105 mocks
1107 .job_producer
1108 .expect_produce_send_notification_job()
1109 .times(1)
1110 .returning(|_, _| Box::pin(async { Ok(()) }));
1111
1112 let relayer_id_clone = relayer.id.clone();
1114 mocks
1115 .tx_repo
1116 .expect_find_by_status_paginated()
1117 .withf(move |relayer_id, statuses, query, oldest_first| {
1118 *relayer_id == relayer_id_clone
1119 && statuses == [TransactionStatus::Pending]
1120 && query.page == 1
1121 && query.per_page == 1
1122 && *oldest_first
1123 })
1124 .times(1)
1125 .returning(move |_, _, _, _| {
1126 Ok(PaginatedResult {
1127 items: vec![],
1128 total: 0,
1129 page: 1,
1130 per_page: 1,
1131 })
1132 }); let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1135 let result = handler
1136 .handle_transaction_status_impl(tx_to_handle, None)
1137 .await;
1138
1139 assert!(result.is_ok(), "Expected Ok result");
1141 let updated_tx = result.unwrap();
1142 assert_eq!(updated_tx.status, TransactionStatus::Failed);
1143 assert!(
1144 updated_tx
1145 .status_reason
1146 .as_ref()
1147 .unwrap()
1148 .contains("Failed to parse and validate hash"),
1149 "Expected hash validation error in status_reason, got: {:?}",
1150 updated_tx.status_reason
1151 );
1152 }
1153
1154 #[tokio::test]
1155 async fn test_on_chain_failure_does_not_decrement_sequence() {
1156 let relayer = create_test_relayer();
1157 let mut mocks = default_test_mocks();
1158
1159 let mut tx_to_handle = create_test_transaction(&relayer.id);
1160 tx_to_handle.id = "tx-on-chain-fail".to_string();
1161 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1162 let tx_hash_bytes = [4u8; 32];
1163 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1164 {
1165 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1166 stellar_data.sequence_number = Some(100); }
1168 tx_to_handle.status = TransactionStatus::Submitted;
1169
1170 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1171
1172 mocks
1174 .provider
1175 .expect_get_transaction()
1176 .with(eq(expected_stellar_hash.clone()))
1177 .times(1)
1178 .returning(move |_| {
1179 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
1180 });
1181
1182 mocks.counter.expect_decrement().never();
1184
1185 mocks
1187 .tx_repo
1188 .expect_partial_update()
1189 .times(1)
1190 .returning(move |id, update| {
1191 let mut updated_tx = create_test_transaction("test");
1192 updated_tx.id = id;
1193 updated_tx.status = update.status.unwrap();
1194 updated_tx.status_reason = update.status_reason.clone();
1195 Ok::<_, RepositoryError>(updated_tx)
1196 });
1197
1198 mocks
1200 .job_producer
1201 .expect_produce_send_notification_job()
1202 .times(1)
1203 .returning(|_, _| Box::pin(async { Ok(()) }));
1204
1205 mocks
1207 .tx_repo
1208 .expect_find_by_status_paginated()
1209 .returning(move |_, _, _, _| {
1210 Ok(PaginatedResult {
1211 items: vec![],
1212 total: 0,
1213 page: 1,
1214 per_page: 1,
1215 })
1216 });
1217
1218 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1219 let initial_tx = tx_to_handle.clone();
1220
1221 let result = handler
1222 .handle_transaction_status_impl(initial_tx, None)
1223 .await;
1224
1225 assert!(result.is_ok());
1226 let handled_tx = result.unwrap();
1227 assert_eq!(handled_tx.id, "tx-on-chain-fail");
1228 assert_eq!(handled_tx.status, TransactionStatus::Failed);
1229 }
1230
1231 #[tokio::test]
1232 async fn test_on_chain_success_does_not_decrement_sequence() {
1233 let relayer = create_test_relayer();
1234 let mut mocks = default_test_mocks();
1235
1236 let mut tx_to_handle = create_test_transaction(&relayer.id);
1237 tx_to_handle.id = "tx-on-chain-success".to_string();
1238 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1239 let tx_hash_bytes = [5u8; 32];
1240 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1241 {
1242 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1243 stellar_data.sequence_number = Some(101); }
1245 tx_to_handle.status = TransactionStatus::Submitted;
1246
1247 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1248
1249 mocks
1251 .provider
1252 .expect_get_transaction()
1253 .with(eq(expected_stellar_hash.clone()))
1254 .times(1)
1255 .returning(move |_| {
1256 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1257 });
1258
1259 mocks.counter.expect_decrement().never();
1261
1262 mocks
1264 .tx_repo
1265 .expect_partial_update()
1266 .withf(move |id, update| {
1267 id == "tx-on-chain-success"
1268 && update.status == Some(TransactionStatus::Confirmed)
1269 && update.confirmed_at.is_some()
1270 })
1271 .times(1)
1272 .returning(move |id, update| {
1273 let mut updated_tx = create_test_transaction("test");
1274 updated_tx.id = id;
1275 updated_tx.status = update.status.unwrap();
1276 updated_tx.confirmed_at = update.confirmed_at;
1277 Ok(updated_tx)
1278 });
1279
1280 mocks
1282 .job_producer
1283 .expect_produce_send_notification_job()
1284 .times(1)
1285 .returning(|_, _| Box::pin(async { Ok(()) }));
1286
1287 mocks
1289 .tx_repo
1290 .expect_find_by_status_paginated()
1291 .returning(move |_, _, _, _| {
1292 Ok(PaginatedResult {
1293 items: vec![],
1294 total: 0,
1295 page: 1,
1296 per_page: 1,
1297 })
1298 });
1299
1300 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1301 let initial_tx = tx_to_handle.clone();
1302
1303 let result = handler
1304 .handle_transaction_status_impl(initial_tx, None)
1305 .await;
1306
1307 assert!(result.is_ok());
1308 let handled_tx = result.unwrap();
1309 assert_eq!(handled_tx.id, "tx-on-chain-success");
1310 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1311 }
1312
1313 #[tokio::test]
1314 async fn test_handle_transaction_status_with_xdr_error_requeues() {
1315 let relayer = create_test_relayer();
1317 let mut mocks = default_test_mocks();
1318
1319 let mut tx_to_handle = create_test_transaction(&relayer.id);
1320 tx_to_handle.id = "tx-xdr-error-requeue".to_string();
1321 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1322 let tx_hash_bytes = [8u8; 32];
1323 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1324 {
1325 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1326 }
1327 tx_to_handle.status = TransactionStatus::Submitted;
1328
1329 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1330
1331 mocks
1333 .provider
1334 .expect_get_transaction()
1335 .with(eq(expected_stellar_hash.clone()))
1336 .times(1)
1337 .returning(move |_| {
1338 Box::pin(async { Err(ProviderError::Other("Network timeout".to_string())) })
1339 });
1340
1341 mocks.tx_repo.expect_partial_update().never();
1343 mocks
1344 .job_producer
1345 .expect_produce_send_notification_job()
1346 .never();
1347
1348 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1349
1350 let result = handler
1351 .handle_transaction_status_impl(tx_to_handle, None)
1352 .await;
1353
1354 assert!(result.is_err());
1356 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1357 }
1358
1359 #[tokio::test]
1360 async fn handle_transaction_status_extracts_transaction_result_xdr() {
1361 let relayer = create_test_relayer();
1362 let mut mocks = default_test_mocks();
1363
1364 let mut tx_to_handle = create_test_transaction(&relayer.id);
1365 tx_to_handle.id = "tx-with-result".to_string();
1366 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1367 let tx_hash_bytes = [9u8; 32];
1368 let tx_hash_hex = hex::encode(tx_hash_bytes);
1369 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1370 {
1371 stellar_data.hash = Some(tx_hash_hex.clone());
1372 } else {
1373 panic!("Expected Stellar network data");
1374 }
1375 tx_to_handle.status = TransactionStatus::Submitted;
1376
1377 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1378
1379 mocks
1381 .provider
1382 .expect_get_transaction()
1383 .with(eq(expected_stellar_hash.clone()))
1384 .times(1)
1385 .returning(move |_| {
1386 Box::pin(async {
1387 Ok(dummy_get_transaction_response_with_result_meta(
1388 "SUCCESS", true,
1389 ))
1390 })
1391 });
1392
1393 let tx_to_handle_clone = tx_to_handle.clone();
1395 mocks
1396 .tx_repo
1397 .expect_partial_update()
1398 .withf(move |id, update| {
1399 id == "tx-with-result"
1400 && update.status == Some(TransactionStatus::Confirmed)
1401 && update.confirmed_at.is_some()
1402 && update.network_data.as_ref().is_some_and(|and| {
1403 if let NetworkTransactionData::Stellar(stellar_data) = and {
1404 stellar_data.transaction_result_xdr.is_some()
1406 } else {
1407 false
1408 }
1409 })
1410 })
1411 .times(1)
1412 .returning(move |id, update| {
1413 let mut updated_tx = tx_to_handle_clone.clone();
1414 updated_tx.id = id;
1415 updated_tx.status = update.status.unwrap();
1416 updated_tx.confirmed_at = update.confirmed_at;
1417 if let Some(network_data) = update.network_data {
1418 updated_tx.network_data = network_data;
1419 }
1420 Ok(updated_tx)
1421 });
1422
1423 mocks
1425 .job_producer
1426 .expect_produce_send_notification_job()
1427 .times(1)
1428 .returning(|_, _| Box::pin(async { Ok(()) }));
1429
1430 mocks
1432 .tx_repo
1433 .expect_find_by_status_paginated()
1434 .returning(move |_, _, _, _| {
1435 Ok(PaginatedResult {
1436 items: vec![],
1437 total: 0,
1438 page: 1,
1439 per_page: 1,
1440 })
1441 });
1442
1443 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1444 let result = handler
1445 .handle_transaction_status_impl(tx_to_handle, None)
1446 .await;
1447
1448 assert!(result.is_ok());
1449 let handled_tx = result.unwrap();
1450 assert_eq!(handled_tx.id, "tx-with-result");
1451 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1452
1453 if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1455 assert!(
1456 stellar_data.transaction_result_xdr.is_some(),
1457 "transaction_result_xdr should be stored when result_meta contains return_value"
1458 );
1459 } else {
1460 panic!("Expected Stellar network data");
1461 }
1462 }
1463
1464 #[tokio::test]
1465 async fn handle_transaction_status_no_result_meta_does_not_store_xdr() {
1466 let relayer = create_test_relayer();
1467 let mut mocks = default_test_mocks();
1468
1469 let mut tx_to_handle = create_test_transaction(&relayer.id);
1470 tx_to_handle.id = "tx-no-result-meta".to_string();
1471 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1472 let tx_hash_bytes = [10u8; 32];
1473 let tx_hash_hex = hex::encode(tx_hash_bytes);
1474 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1475 {
1476 stellar_data.hash = Some(tx_hash_hex.clone());
1477 } else {
1478 panic!("Expected Stellar network data");
1479 }
1480 tx_to_handle.status = TransactionStatus::Submitted;
1481
1482 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1483
1484 mocks
1486 .provider
1487 .expect_get_transaction()
1488 .with(eq(expected_stellar_hash.clone()))
1489 .times(1)
1490 .returning(move |_| {
1491 Box::pin(async {
1492 Ok(dummy_get_transaction_response_with_result_meta(
1493 "SUCCESS", false,
1494 ))
1495 })
1496 });
1497
1498 let tx_to_handle_clone = tx_to_handle.clone();
1500 mocks
1501 .tx_repo
1502 .expect_partial_update()
1503 .times(1)
1504 .returning(move |id, update| {
1505 let mut updated_tx = tx_to_handle_clone.clone();
1506 updated_tx.id = id;
1507 updated_tx.status = update.status.unwrap();
1508 updated_tx.confirmed_at = update.confirmed_at;
1509 if let Some(network_data) = update.network_data {
1510 updated_tx.network_data = network_data;
1511 }
1512 Ok(updated_tx)
1513 });
1514
1515 mocks
1517 .job_producer
1518 .expect_produce_send_notification_job()
1519 .times(1)
1520 .returning(|_, _| Box::pin(async { Ok(()) }));
1521
1522 mocks
1524 .tx_repo
1525 .expect_find_by_status_paginated()
1526 .returning(move |_, _, _, _| {
1527 Ok(PaginatedResult {
1528 items: vec![],
1529 total: 0,
1530 page: 1,
1531 per_page: 1,
1532 })
1533 });
1534
1535 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1536 let result = handler
1537 .handle_transaction_status_impl(tx_to_handle, None)
1538 .await;
1539
1540 assert!(result.is_ok());
1541 let handled_tx = result.unwrap();
1542
1543 if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1545 assert!(
1546 stellar_data.transaction_result_xdr.is_none(),
1547 "transaction_result_xdr should be None when result_meta is missing"
1548 );
1549 } else {
1550 panic!("Expected Stellar network data");
1551 }
1552 }
1553
1554 #[tokio::test]
1555 async fn test_sent_transaction_not_stuck_yet_returns_ok() {
1556 let relayer = create_test_relayer();
1558 let mut mocks = default_test_mocks();
1559
1560 let mut tx = create_test_transaction(&relayer.id);
1561 tx.id = "tx-sent-not-stuck".to_string();
1562 tx.status = TransactionStatus::Sent;
1563 tx.created_at = Utc::now().to_rfc3339();
1565 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1567 stellar_data.hash = None;
1568 }
1569
1570 mocks.provider.expect_get_transaction().never();
1572 mocks.tx_repo.expect_partial_update().never();
1573 mocks
1574 .job_producer
1575 .expect_produce_submit_transaction_job()
1576 .never();
1577
1578 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1579 let result = handler
1580 .handle_transaction_status_impl(tx.clone(), None)
1581 .await;
1582
1583 assert!(result.is_ok());
1584 let returned_tx = result.unwrap();
1585 assert_eq!(returned_tx.id, tx.id);
1587 assert_eq!(returned_tx.status, TransactionStatus::Sent);
1588 }
1589
1590 #[tokio::test]
1591 async fn test_stuck_sent_transaction_reenqueues_submit_job() {
1592 let relayer = create_test_relayer();
1595 let mut mocks = default_test_mocks();
1596
1597 let mut tx = create_test_transaction(&relayer.id);
1598 tx.id = "tx-stuck-with-xdr".to_string();
1599 tx.status = TransactionStatus::Sent;
1600 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1602 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1604 stellar_data.hash = None;
1605 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1606 }
1607
1608 mocks
1610 .job_producer
1611 .expect_produce_submit_transaction_job()
1612 .times(1)
1613 .returning(|_, _| Box::pin(async { Ok(()) }));
1614
1615 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1616 let result = handler
1617 .handle_transaction_status_impl(tx.clone(), None)
1618 .await;
1619
1620 assert!(result.is_ok());
1621 let returned_tx = result.unwrap();
1622 assert_eq!(returned_tx.status, TransactionStatus::Sent);
1624 }
1625
1626 #[tokio::test]
1627 async fn test_stuck_sent_transaction_expired_marks_expired() {
1628 let relayer = create_test_relayer();
1630 let mut mocks = default_test_mocks();
1631
1632 let mut tx = create_test_transaction(&relayer.id);
1633 tx.id = "tx-expired".to_string();
1634 tx.status = TransactionStatus::Sent;
1635 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1637 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
1639 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1640 stellar_data.hash = None;
1641 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1642 }
1643
1644 mocks
1646 .tx_repo
1647 .expect_partial_update()
1648 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
1649 .times(1)
1650 .returning(|id, update| {
1651 let mut updated = create_test_transaction("test");
1652 updated.id = id;
1653 updated.status = update.status.unwrap();
1654 updated.status_reason = update.status_reason.clone();
1655 Ok(updated)
1656 });
1657
1658 mocks
1660 .job_producer
1661 .expect_produce_submit_transaction_job()
1662 .never();
1663
1664 mocks
1666 .job_producer
1667 .expect_produce_send_notification_job()
1668 .times(1)
1669 .returning(|_, _| Box::pin(async { Ok(()) }));
1670
1671 mocks
1673 .tx_repo
1674 .expect_find_by_status_paginated()
1675 .returning(move |_, _, _, _| {
1676 Ok(PaginatedResult {
1677 items: vec![],
1678 total: 0,
1679 page: 1,
1680 per_page: 1,
1681 })
1682 });
1683
1684 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1685 let result = handler.handle_transaction_status_impl(tx, None).await;
1686
1687 assert!(result.is_ok());
1688 let expired_tx = result.unwrap();
1689 assert_eq!(expired_tx.status, TransactionStatus::Expired);
1690 assert!(expired_tx
1691 .status_reason
1692 .as_ref()
1693 .unwrap()
1694 .contains("expired"));
1695 }
1696
1697 #[tokio::test]
1698 async fn test_stuck_sent_transaction_max_lifetime_marks_failed() {
1699 let relayer = create_test_relayer();
1701 let mut mocks = default_test_mocks();
1702
1703 let mut tx = create_test_transaction(&relayer.id);
1704 tx.id = "tx-max-lifetime".to_string();
1705 tx.status = TransactionStatus::Sent;
1706 tx.created_at = (Utc::now() - Duration::minutes(35)).to_rfc3339();
1708 tx.valid_until = None;
1710 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1711 stellar_data.hash = None;
1712 stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1713 }
1714
1715 mocks
1717 .tx_repo
1718 .expect_partial_update()
1719 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1720 .times(1)
1721 .returning(|id, update| {
1722 let mut updated = create_test_transaction("test");
1723 updated.id = id;
1724 updated.status = update.status.unwrap();
1725 updated.status_reason = update.status_reason.clone();
1726 Ok(updated)
1727 });
1728
1729 mocks
1731 .job_producer
1732 .expect_produce_submit_transaction_job()
1733 .never();
1734
1735 mocks
1737 .job_producer
1738 .expect_produce_send_notification_job()
1739 .times(1)
1740 .returning(|_, _| Box::pin(async { Ok(()) }));
1741
1742 mocks
1744 .tx_repo
1745 .expect_find_by_status_paginated()
1746 .returning(|_, _, _, _| {
1747 Ok(PaginatedResult {
1748 items: vec![],
1749 total: 0,
1750 page: 1,
1751 per_page: 1,
1752 })
1753 });
1754
1755 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1756 let result = handler.handle_transaction_status_impl(tx, None).await;
1757
1758 assert!(result.is_ok());
1759 let failed_tx = result.unwrap();
1760 assert_eq!(failed_tx.status, TransactionStatus::Failed);
1761 assert!(failed_tx
1763 .status_reason
1764 .as_ref()
1765 .unwrap()
1766 .contains("stuck in Sent status for too long"));
1767 }
1768 #[tokio::test]
1769 async fn handle_status_concurrent_update_conflict_reloads_latest_state() {
1770 let relayer = create_test_relayer();
1773 let mut mocks = default_test_mocks();
1774
1775 let mut tx = create_test_transaction(&relayer.id);
1776 tx.id = "tx-cas-conflict".to_string();
1777 tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1778 let tx_hash_bytes = [11u8; 32];
1779 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1780 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1781 }
1782 tx.status = TransactionStatus::Submitted;
1783
1784 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1785
1786 mocks
1788 .provider
1789 .expect_get_transaction()
1790 .with(eq(expected_stellar_hash))
1791 .times(1)
1792 .returning(move |_| {
1793 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1794 });
1795
1796 mocks
1798 .tx_repo
1799 .expect_partial_update()
1800 .times(1)
1801 .returning(|_id, _update| {
1802 Err(RepositoryError::ConcurrentUpdateConflict(
1803 "CAS mismatch".to_string(),
1804 ))
1805 });
1806
1807 let reloaded_tx = {
1809 let mut t = create_test_transaction(&relayer.id);
1810 t.id = "tx-cas-conflict".to_string();
1811 t.status = TransactionStatus::Confirmed;
1813 t
1814 };
1815 let reloaded_clone = reloaded_tx.clone();
1816 mocks
1817 .tx_repo
1818 .expect_get_by_id()
1819 .with(eq("tx-cas-conflict".to_string()))
1820 .times(1)
1821 .returning(move |_| Ok(reloaded_clone.clone()));
1822
1823 mocks
1825 .job_producer
1826 .expect_produce_send_notification_job()
1827 .never();
1828 mocks
1829 .job_producer
1830 .expect_produce_transaction_request_job()
1831 .never();
1832
1833 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1834 let result = handler.handle_transaction_status_impl(tx, None).await;
1835
1836 assert!(result.is_ok(), "CAS conflict should return Ok after reload");
1837 let returned_tx = result.unwrap();
1838 assert_eq!(returned_tx.id, "tx-cas-conflict");
1839 assert_eq!(returned_tx.status, TransactionStatus::Confirmed);
1841 }
1842 }
1843
1844 mod handle_pending_state_tests {
1845 use super::*;
1846 use crate::constants::get_stellar_max_stuck_transaction_lifetime;
1847 use crate::constants::STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS;
1848
1849 #[tokio::test]
1850 async fn test_pending_exceeds_max_lifetime_marks_failed() {
1851 let relayer = create_test_relayer();
1852 let mut mocks = default_test_mocks();
1853
1854 let mut tx = create_test_transaction(&relayer.id);
1855 tx.id = "tx-pending-old".to_string();
1856 tx.status = TransactionStatus::Pending;
1857 tx.created_at =
1859 (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
1860 .to_rfc3339();
1861
1862 mocks
1864 .tx_repo
1865 .expect_partial_update()
1866 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1867 .times(1)
1868 .returning(|id, update| {
1869 let mut updated = create_test_transaction("test");
1870 updated.id = id;
1871 updated.status = update.status.unwrap();
1872 updated.status_reason = update.status_reason.clone();
1873 Ok(updated)
1874 });
1875
1876 mocks
1878 .job_producer
1879 .expect_produce_send_notification_job()
1880 .times(1)
1881 .returning(|_, _| Box::pin(async { Ok(()) }));
1882
1883 mocks
1885 .tx_repo
1886 .expect_find_by_status_paginated()
1887 .returning(move |_, _, _, _| {
1888 Ok(PaginatedResult {
1889 items: vec![],
1890 total: 0,
1891 page: 1,
1892 per_page: 1,
1893 })
1894 });
1895
1896 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1897 let result = handler.handle_transaction_status_impl(tx, None).await;
1898
1899 assert!(result.is_ok());
1900 let failed_tx = result.unwrap();
1901 assert_eq!(failed_tx.status, TransactionStatus::Failed);
1902 assert!(failed_tx
1903 .status_reason
1904 .as_ref()
1905 .unwrap()
1906 .contains("stuck in Pending status for too long"));
1907 }
1908
1909 #[tokio::test]
1910 async fn test_pending_triggers_recovery_job_when_old_enough() {
1911 let relayer = create_test_relayer();
1912 let mut mocks = default_test_mocks();
1913
1914 let mut tx = create_test_transaction(&relayer.id);
1915 tx.id = "tx-pending-recovery".to_string();
1916 tx.status = TransactionStatus::Pending;
1917 tx.created_at = (Utc::now()
1919 - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS + 5))
1920 .to_rfc3339();
1921
1922 mocks
1924 .job_producer
1925 .expect_produce_transaction_request_job()
1926 .times(1)
1927 .returning(|_, _| Box::pin(async { Ok(()) }));
1928
1929 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1930 let result = handler.handle_transaction_status_impl(tx, None).await;
1931
1932 assert!(result.is_ok());
1933 let tx_result = result.unwrap();
1934 assert_eq!(tx_result.status, TransactionStatus::Pending);
1935 }
1936
1937 #[tokio::test]
1938 async fn test_pending_too_young_does_not_schedule_recovery() {
1939 let relayer = create_test_relayer();
1940 let mut mocks = default_test_mocks();
1941
1942 let mut tx = create_test_transaction(&relayer.id);
1943 tx.id = "tx-pending-young".to_string();
1944 tx.status = TransactionStatus::Pending;
1945 tx.created_at = (Utc::now()
1947 - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS - 5))
1948 .to_rfc3339();
1949
1950 mocks
1952 .job_producer
1953 .expect_produce_transaction_request_job()
1954 .never();
1955
1956 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1957 let result = handler.handle_transaction_status_impl(tx, None).await;
1958
1959 assert!(result.is_ok());
1960 let tx_result = result.unwrap();
1961 assert_eq!(tx_result.status, TransactionStatus::Pending);
1962 }
1963
1964 #[tokio::test]
1965 async fn test_sent_without_hash_handles_stuck_recovery() {
1966 use crate::constants::STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS;
1967
1968 let relayer = create_test_relayer();
1969 let mut mocks = default_test_mocks();
1970
1971 let mut tx = create_test_transaction(&relayer.id);
1972 tx.id = "tx-sent-no-hash".to_string();
1973 tx.status = TransactionStatus::Sent;
1974 tx.created_at = (Utc::now()
1976 - Duration::seconds(STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS)
1977 - Duration::seconds(1))
1978 .to_rfc3339();
1979 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1980 stellar_data.hash = None; }
1982
1983 mocks
1985 .job_producer
1986 .expect_produce_submit_transaction_job()
1987 .times(1)
1988 .returning(|_, _| Box::pin(async { Ok(()) }));
1989
1990 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1991 let result = handler.handle_transaction_status_impl(tx, None).await;
1992
1993 assert!(result.is_ok());
1994 let tx_result = result.unwrap();
1995 assert_eq!(tx_result.status, TransactionStatus::Sent);
1996 }
1997
1998 #[tokio::test]
1999 async fn test_submitted_without_hash_marks_failed() {
2000 let relayer = create_test_relayer();
2001 let mut mocks = default_test_mocks();
2002
2003 let mut tx = create_test_transaction(&relayer.id);
2004 tx.id = "tx-submitted-no-hash".to_string();
2005 tx.status = TransactionStatus::Submitted;
2006 tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2007 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2008 stellar_data.hash = None; }
2010
2011 mocks
2013 .tx_repo
2014 .expect_partial_update()
2015 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2016 .times(1)
2017 .returning(|id, update| {
2018 let mut updated = create_test_transaction("test");
2019 updated.id = id;
2020 updated.status = update.status.unwrap();
2021 updated.status_reason = update.status_reason.clone();
2022 Ok(updated)
2023 });
2024
2025 mocks
2027 .job_producer
2028 .expect_produce_send_notification_job()
2029 .times(1)
2030 .returning(|_, _| Box::pin(async { Ok(()) }));
2031
2032 mocks
2034 .tx_repo
2035 .expect_find_by_status_paginated()
2036 .returning(move |_, _, _, _| {
2037 Ok(PaginatedResult {
2038 items: vec![],
2039 total: 0,
2040 page: 1,
2041 per_page: 1,
2042 })
2043 });
2044
2045 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2046 let result = handler.handle_transaction_status_impl(tx, None).await;
2047
2048 assert!(result.is_ok());
2049 let failed_tx = result.unwrap();
2050 assert_eq!(failed_tx.status, TransactionStatus::Failed);
2051 assert!(failed_tx
2052 .status_reason
2053 .as_ref()
2054 .unwrap()
2055 .contains("Failed to parse and validate hash"));
2056 }
2057
2058 #[tokio::test]
2059 async fn test_submitted_exceeds_max_lifetime_marks_failed() {
2060 let relayer = create_test_relayer();
2061 let mut mocks = default_test_mocks();
2062
2063 let mut tx = create_test_transaction(&relayer.id);
2064 tx.id = "tx-submitted-old".to_string();
2065 tx.status = TransactionStatus::Submitted;
2066 tx.created_at =
2068 (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
2069 .to_rfc3339();
2070 let tx_hash_bytes = [6u8; 32];
2072 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2073 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2074 }
2075
2076 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2077
2078 mocks
2080 .provider
2081 .expect_get_transaction()
2082 .with(eq(expected_stellar_hash.clone()))
2083 .times(1)
2084 .returning(move |_| {
2085 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2086 });
2087
2088 mocks
2090 .tx_repo
2091 .expect_partial_update()
2092 .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2093 .times(1)
2094 .returning(|id, update| {
2095 let mut updated = create_test_transaction("test");
2096 updated.id = id;
2097 updated.status = update.status.unwrap();
2098 updated.status_reason = update.status_reason.clone();
2099 Ok(updated)
2100 });
2101
2102 mocks
2104 .job_producer
2105 .expect_produce_send_notification_job()
2106 .times(1)
2107 .returning(|_, _| Box::pin(async { Ok(()) }));
2108
2109 mocks
2111 .tx_repo
2112 .expect_find_by_status_paginated()
2113 .returning(move |_, _, _, _| {
2114 Ok(PaginatedResult {
2115 items: vec![],
2116 total: 0,
2117 page: 1,
2118 per_page: 1,
2119 })
2120 });
2121
2122 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2123 let result = handler.handle_transaction_status_impl(tx, None).await;
2124
2125 assert!(result.is_ok());
2126 let failed_tx = result.unwrap();
2127 assert_eq!(failed_tx.status, TransactionStatus::Failed);
2128 assert!(failed_tx
2129 .status_reason
2130 .as_ref()
2131 .unwrap()
2132 .contains("stuck in Submitted status for too long"));
2133 }
2134
2135 #[tokio::test]
2136 async fn test_submitted_expired_marks_expired() {
2137 let relayer = create_test_relayer();
2138 let mut mocks = default_test_mocks();
2139
2140 let mut tx = create_test_transaction(&relayer.id);
2141 tx.id = "tx-submitted-expired".to_string();
2142 tx.status = TransactionStatus::Submitted;
2143 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2144 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2146 let tx_hash_bytes = [7u8; 32];
2148 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2149 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2150 }
2151
2152 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2153
2154 mocks
2156 .provider
2157 .expect_get_transaction()
2158 .with(eq(expected_stellar_hash.clone()))
2159 .times(1)
2160 .returning(move |_| {
2161 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2162 });
2163
2164 mocks
2166 .tx_repo
2167 .expect_partial_update()
2168 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2169 .times(1)
2170 .returning(|id, update| {
2171 let mut updated = create_test_transaction("test");
2172 updated.id = id;
2173 updated.status = update.status.unwrap();
2174 updated.status_reason = update.status_reason.clone();
2175 Ok(updated)
2176 });
2177
2178 mocks
2180 .job_producer
2181 .expect_produce_send_notification_job()
2182 .times(1)
2183 .returning(|_, _| Box::pin(async { Ok(()) }));
2184
2185 mocks
2187 .tx_repo
2188 .expect_find_by_status_paginated()
2189 .returning(move |_, _, _, _| {
2190 Ok(PaginatedResult {
2191 items: vec![],
2192 total: 0,
2193 page: 1,
2194 per_page: 1,
2195 })
2196 });
2197
2198 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2199 let result = handler.handle_transaction_status_impl(tx, None).await;
2200
2201 assert!(result.is_ok());
2202 let expired_tx = result.unwrap();
2203 assert_eq!(expired_tx.status, TransactionStatus::Expired);
2204 assert!(expired_tx
2205 .status_reason
2206 .as_ref()
2207 .unwrap()
2208 .contains("expired"));
2209 }
2210
2211 #[tokio::test]
2212 async fn test_handle_submitted_state_resubmits_after_timeout() {
2213 let relayer = create_test_relayer();
2215 let mut mocks = default_test_mocks();
2216
2217 let mut tx = create_test_transaction(&relayer.id);
2218 tx.id = "tx-submitted-resubmit".to_string();
2219 tx.status = TransactionStatus::Submitted;
2220 let sixteen_seconds_ago = (Utc::now() - Duration::seconds(16)).to_rfc3339();
2221 tx.created_at = sixteen_seconds_ago.clone();
2222 tx.sent_at = Some(sixteen_seconds_ago);
2223 let tx_hash_bytes = [8u8; 32];
2225 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2226 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2227 }
2228
2229 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2230
2231 mocks
2233 .provider
2234 .expect_get_transaction()
2235 .with(eq(expected_stellar_hash.clone()))
2236 .times(1)
2237 .returning(move |_| {
2238 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2239 });
2240
2241 mocks
2243 .job_producer
2244 .expect_produce_submit_transaction_job()
2245 .times(1)
2246 .returning(|_, _| Box::pin(async { Ok(()) }));
2247
2248 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2249 let result = handler.handle_transaction_status_impl(tx, None).await;
2250
2251 assert!(result.is_ok());
2252 let tx_result = result.unwrap();
2253 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2254 }
2255
2256 #[tokio::test]
2257 async fn test_handle_submitted_state_backoff_increases_interval() {
2258 let relayer = create_test_relayer();
2262 let mut mocks = default_test_mocks();
2263
2264 let mut tx = create_test_transaction(&relayer.id);
2265 tx.id = "tx-submitted-backoff".to_string();
2266 tx.status = TransactionStatus::Submitted;
2267 tx.created_at = (Utc::now() - Duration::seconds(30)).to_rfc3339();
2268 tx.sent_at = Some((Utc::now() - Duration::seconds(15)).to_rfc3339());
2269 let tx_hash_bytes = [11u8; 32];
2270 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2271 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2272 }
2273
2274 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2275
2276 mocks
2277 .provider
2278 .expect_get_transaction()
2279 .with(eq(expected_stellar_hash.clone()))
2280 .times(1)
2281 .returning(move |_| {
2282 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2283 });
2284
2285 mocks
2287 .job_producer
2288 .expect_produce_submit_transaction_job()
2289 .never();
2290
2291 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2292 let result = handler.handle_transaction_status_impl(tx, None).await;
2293
2294 assert!(result.is_ok());
2295 let tx_result = result.unwrap();
2296 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2297 }
2298
2299 #[tokio::test]
2300 async fn test_handle_submitted_state_backoff_resubmits_when_interval_exceeded() {
2301 let relayer = create_test_relayer();
2305 let mut mocks = default_test_mocks();
2306
2307 let mut tx = create_test_transaction(&relayer.id);
2308 tx.id = "tx-submitted-backoff-resubmit".to_string();
2309 tx.status = TransactionStatus::Submitted;
2310 tx.created_at = (Utc::now() - Duration::seconds(25)).to_rfc3339();
2311 tx.sent_at = Some((Utc::now() - Duration::seconds(25)).to_rfc3339());
2312 let tx_hash_bytes = [12u8; 32];
2313 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2314 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2315 }
2316
2317 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2318
2319 mocks
2320 .provider
2321 .expect_get_transaction()
2322 .with(eq(expected_stellar_hash.clone()))
2323 .times(1)
2324 .returning(move |_| {
2325 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2326 });
2327
2328 mocks
2330 .job_producer
2331 .expect_produce_submit_transaction_job()
2332 .times(1)
2333 .returning(|_, _| Box::pin(async { Ok(()) }));
2334
2335 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2336 let result = handler.handle_transaction_status_impl(tx, None).await;
2337
2338 assert!(result.is_ok());
2339 let tx_result = result.unwrap();
2340 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2341 }
2342
2343 #[tokio::test]
2344 async fn test_handle_submitted_state_recent_sent_at_prevents_resubmit() {
2345 let relayer = create_test_relayer();
2350 let mut mocks = default_test_mocks();
2351
2352 let mut tx = create_test_transaction(&relayer.id);
2353 tx.id = "tx-submitted-recent-sent".to_string();
2354 tx.status = TransactionStatus::Submitted;
2355 tx.created_at = (Utc::now() - Duration::seconds(60)).to_rfc3339();
2356 tx.sent_at = Some((Utc::now() - Duration::seconds(5)).to_rfc3339());
2357 let tx_hash_bytes = [13u8; 32];
2358 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2359 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2360 }
2361
2362 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2363
2364 mocks
2365 .provider
2366 .expect_get_transaction()
2367 .with(eq(expected_stellar_hash.clone()))
2368 .times(1)
2369 .returning(move |_| {
2370 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2371 });
2372
2373 mocks
2375 .job_producer
2376 .expect_produce_submit_transaction_job()
2377 .never();
2378
2379 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2380 let result = handler.handle_transaction_status_impl(tx, None).await;
2381
2382 assert!(result.is_ok());
2383 let tx_result = result.unwrap();
2384 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2385 }
2386
2387 #[tokio::test]
2388 async fn test_handle_submitted_state_no_resubmit_before_timeout() {
2389 let relayer = create_test_relayer();
2390 let mut mocks = default_test_mocks();
2391
2392 let mut tx = create_test_transaction(&relayer.id);
2393 tx.id = "tx-submitted-young".to_string();
2394 tx.status = TransactionStatus::Submitted;
2395 tx.created_at = Utc::now().to_rfc3339();
2397 let tx_hash_bytes = [9u8; 32];
2399 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2400 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2401 }
2402
2403 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2404
2405 mocks
2407 .provider
2408 .expect_get_transaction()
2409 .with(eq(expected_stellar_hash.clone()))
2410 .times(1)
2411 .returning(move |_| {
2412 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2413 });
2414
2415 mocks
2417 .job_producer
2418 .expect_produce_submit_transaction_job()
2419 .never();
2420
2421 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2422 let result = handler.handle_transaction_status_impl(tx, None).await;
2423
2424 assert!(result.is_ok());
2425 let tx_result = result.unwrap();
2426 assert_eq!(tx_result.status, TransactionStatus::Submitted);
2427 }
2428
2429 #[tokio::test]
2430 async fn test_handle_submitted_state_expired_before_resubmit() {
2431 let relayer = create_test_relayer();
2432 let mut mocks = default_test_mocks();
2433
2434 let mut tx = create_test_transaction(&relayer.id);
2435 tx.id = "tx-submitted-expired-no-resubmit".to_string();
2436 tx.status = TransactionStatus::Submitted;
2437 tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2438 tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2440 let tx_hash_bytes = [10u8; 32];
2442 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2443 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2444 }
2445
2446 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2447
2448 mocks
2450 .provider
2451 .expect_get_transaction()
2452 .with(eq(expected_stellar_hash.clone()))
2453 .times(1)
2454 .returning(move |_| {
2455 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2456 });
2457
2458 mocks
2460 .tx_repo
2461 .expect_partial_update()
2462 .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2463 .times(1)
2464 .returning(|id, update| {
2465 let mut updated = create_test_transaction("test");
2466 updated.id = id;
2467 updated.status = update.status.unwrap();
2468 updated.status_reason = update.status_reason.clone();
2469 Ok(updated)
2470 });
2471
2472 mocks
2474 .job_producer
2475 .expect_produce_submit_transaction_job()
2476 .never();
2477
2478 mocks
2480 .job_producer
2481 .expect_produce_send_notification_job()
2482 .times(1)
2483 .returning(|_, _| Box::pin(async { Ok(()) }));
2484
2485 mocks
2487 .tx_repo
2488 .expect_find_by_status_paginated()
2489 .returning(move |_, _, _, _| {
2490 Ok(PaginatedResult {
2491 items: vec![],
2492 total: 0,
2493 page: 1,
2494 per_page: 1,
2495 })
2496 });
2497
2498 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2499 let result = handler.handle_transaction_status_impl(tx, None).await;
2500
2501 assert!(result.is_ok());
2502 let expired_tx = result.unwrap();
2503 assert_eq!(expired_tx.status, TransactionStatus::Expired);
2504 assert!(expired_tx
2505 .status_reason
2506 .as_ref()
2507 .unwrap()
2508 .contains("expired"));
2509 }
2510 }
2511
2512 mod is_valid_until_expired_tests {
2513 use super::*;
2514 use crate::{
2515 jobs::MockJobProducerTrait,
2516 repositories::{
2517 MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository,
2518 },
2519 services::{
2520 provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait,
2521 },
2522 };
2523 use chrono::{Duration, Utc};
2524
2525 type TestHandler = StellarRelayerTransaction<
2527 MockRelayerRepository,
2528 MockTransactionRepository,
2529 MockJobProducerTrait,
2530 MockStellarCombinedSigner,
2531 MockStellarProviderTrait,
2532 MockTransactionCounterTrait,
2533 MockStellarDexServiceTrait,
2534 >;
2535
2536 #[test]
2537 fn test_rfc3339_expired() {
2538 let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2539 assert!(TestHandler::is_valid_until_string_expired(&past));
2540 }
2541
2542 #[test]
2543 fn test_rfc3339_not_expired() {
2544 let future = (Utc::now() + Duration::hours(1)).to_rfc3339();
2545 assert!(!TestHandler::is_valid_until_string_expired(&future));
2546 }
2547
2548 #[test]
2549 fn test_numeric_timestamp_expired() {
2550 let past_timestamp = (Utc::now() - Duration::hours(1)).timestamp().to_string();
2551 assert!(TestHandler::is_valid_until_string_expired(&past_timestamp));
2552 }
2553
2554 #[test]
2555 fn test_numeric_timestamp_not_expired() {
2556 let future_timestamp = (Utc::now() + Duration::hours(1)).timestamp().to_string();
2557 assert!(!TestHandler::is_valid_until_string_expired(
2558 &future_timestamp
2559 ));
2560 }
2561
2562 #[test]
2563 fn test_zero_timestamp_unbounded() {
2564 assert!(!TestHandler::is_valid_until_string_expired("0"));
2566 }
2567
2568 #[test]
2569 fn test_invalid_format_not_expired() {
2570 assert!(!TestHandler::is_valid_until_string_expired("not-a-date"));
2572 }
2573 }
2574
2575 mod circuit_breaker_tests {
2577 use super::*;
2578 use crate::jobs::StatusCheckContext;
2579 use crate::models::NetworkType;
2580
2581 fn create_triggered_context() -> StatusCheckContext {
2583 StatusCheckContext::new(
2584 110, 150, 160, 100, 300, NetworkType::Stellar,
2590 )
2591 }
2592
2593 fn create_safe_context() -> StatusCheckContext {
2595 StatusCheckContext::new(
2596 10, 20, 25, 100, 300, NetworkType::Stellar,
2602 )
2603 }
2604
2605 fn create_total_triggered_context() -> StatusCheckContext {
2607 StatusCheckContext::new(
2608 20, 310, 350, 100, 300, NetworkType::Stellar,
2614 )
2615 }
2616
2617 #[tokio::test]
2618 async fn test_circuit_breaker_submitted_marks_as_failed() {
2619 let relayer = create_test_relayer();
2620 let mut mocks = default_test_mocks();
2621
2622 let mut tx_to_handle = create_test_transaction(&relayer.id);
2623 tx_to_handle.status = TransactionStatus::Submitted;
2624 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2625
2626 mocks
2628 .tx_repo
2629 .expect_partial_update()
2630 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2631 .times(1)
2632 .returning(|_, update| {
2633 let mut updated_tx = create_test_transaction("test-relayer");
2634 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2635 updated_tx.status_reason = update.status_reason.clone();
2636 Ok(updated_tx)
2637 });
2638
2639 mocks
2641 .job_producer
2642 .expect_produce_send_notification_job()
2643 .returning(|_, _| Box::pin(async { Ok(()) }));
2644
2645 mocks
2647 .tx_repo
2648 .expect_find_by_status_paginated()
2649 .returning(|_, _, _, _| {
2650 Ok(PaginatedResult {
2651 items: vec![],
2652 total: 0,
2653 page: 1,
2654 per_page: 1,
2655 })
2656 });
2657
2658 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2659 let ctx = create_triggered_context();
2660
2661 let result = handler
2662 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2663 .await;
2664
2665 assert!(result.is_ok());
2666 let tx = result.unwrap();
2667 assert_eq!(tx.status, TransactionStatus::Failed);
2668 assert!(tx.status_reason.is_some());
2669 assert!(tx.status_reason.unwrap().contains("consecutive errors"));
2670 }
2671
2672 #[tokio::test]
2673 async fn test_circuit_breaker_pending_marks_as_failed() {
2674 let relayer = create_test_relayer();
2675 let mut mocks = default_test_mocks();
2676
2677 let mut tx_to_handle = create_test_transaction(&relayer.id);
2678 tx_to_handle.status = TransactionStatus::Pending;
2679 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2680
2681 mocks
2683 .tx_repo
2684 .expect_partial_update()
2685 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2686 .times(1)
2687 .returning(|_, update| {
2688 let mut updated_tx = create_test_transaction("test-relayer");
2689 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2690 updated_tx.status_reason = update.status_reason.clone();
2691 Ok(updated_tx)
2692 });
2693
2694 mocks
2695 .job_producer
2696 .expect_produce_send_notification_job()
2697 .returning(|_, _| Box::pin(async { Ok(()) }));
2698
2699 mocks
2700 .tx_repo
2701 .expect_find_by_status_paginated()
2702 .returning(|_, _, _, _| {
2703 Ok(PaginatedResult {
2704 items: vec![],
2705 total: 0,
2706 page: 1,
2707 per_page: 1,
2708 })
2709 });
2710
2711 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2712 let ctx = create_triggered_context();
2713
2714 let result = handler
2715 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2716 .await;
2717
2718 assert!(result.is_ok());
2719 let tx = result.unwrap();
2720 assert_eq!(tx.status, TransactionStatus::Failed);
2721 }
2722
2723 #[tokio::test]
2724 async fn test_circuit_breaker_total_failures_triggers() {
2725 let relayer = create_test_relayer();
2726 let mut mocks = default_test_mocks();
2727
2728 let mut tx_to_handle = create_test_transaction(&relayer.id);
2729 tx_to_handle.status = TransactionStatus::Submitted;
2730 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2731
2732 mocks
2733 .tx_repo
2734 .expect_partial_update()
2735 .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2736 .times(1)
2737 .returning(|_, update| {
2738 let mut updated_tx = create_test_transaction("test-relayer");
2739 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2740 updated_tx.status_reason = update.status_reason.clone();
2741 Ok(updated_tx)
2742 });
2743
2744 mocks
2745 .job_producer
2746 .expect_produce_send_notification_job()
2747 .returning(|_, _| Box::pin(async { Ok(()) }));
2748
2749 mocks
2750 .tx_repo
2751 .expect_find_by_status_paginated()
2752 .returning(|_, _, _, _| {
2753 Ok(PaginatedResult {
2754 items: vec![],
2755 total: 0,
2756 page: 1,
2757 per_page: 1,
2758 })
2759 });
2760
2761 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2762 let ctx = create_total_triggered_context();
2764
2765 let result = handler
2766 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2767 .await;
2768
2769 assert!(result.is_ok());
2770 let tx = result.unwrap();
2771 assert_eq!(tx.status, TransactionStatus::Failed);
2772 }
2773
2774 #[tokio::test]
2775 async fn test_circuit_breaker_below_threshold_continues() {
2776 let relayer = create_test_relayer();
2777 let mut mocks = default_test_mocks();
2778
2779 let mut tx_to_handle = create_test_transaction(&relayer.id);
2780 tx_to_handle.status = TransactionStatus::Submitted;
2781 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2782 let tx_hash_bytes = [1u8; 32];
2783 let tx_hash_hex = hex::encode(tx_hash_bytes);
2784 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2785 {
2786 stellar_data.hash = Some(tx_hash_hex.clone());
2787 }
2788
2789 mocks
2791 .provider
2792 .expect_get_transaction()
2793 .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2794
2795 mocks
2796 .tx_repo
2797 .expect_partial_update()
2798 .returning(|_, update| {
2799 let mut updated_tx = create_test_transaction("test-relayer");
2800 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2801 Ok(updated_tx)
2802 });
2803
2804 mocks
2805 .job_producer
2806 .expect_produce_send_notification_job()
2807 .returning(|_, _| Box::pin(async { Ok(()) }));
2808
2809 mocks
2810 .tx_repo
2811 .expect_find_by_status_paginated()
2812 .returning(|_, _, _, _| {
2813 Ok(PaginatedResult {
2814 items: vec![],
2815 total: 0,
2816 page: 1,
2817 per_page: 1,
2818 })
2819 });
2820
2821 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2822 let ctx = create_safe_context();
2823
2824 let result = handler
2825 .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2826 .await;
2827
2828 assert!(result.is_ok());
2829 let tx = result.unwrap();
2830 assert_eq!(tx.status, TransactionStatus::Confirmed);
2832 }
2833
2834 #[tokio::test]
2835 async fn test_circuit_breaker_final_state_early_return() {
2836 let relayer = create_test_relayer();
2837 let mocks = default_test_mocks();
2838
2839 let mut tx_to_handle = create_test_transaction(&relayer.id);
2841 tx_to_handle.status = TransactionStatus::Confirmed;
2842
2843 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2844 let ctx = create_triggered_context();
2845
2846 let result = handler
2848 .handle_transaction_status_impl(tx_to_handle.clone(), Some(ctx))
2849 .await;
2850
2851 assert!(result.is_ok());
2852 assert_eq!(result.unwrap().id, tx_to_handle.id);
2853 }
2854
2855 #[tokio::test]
2856 async fn test_circuit_breaker_no_context_continues() {
2857 let relayer = create_test_relayer();
2858 let mut mocks = default_test_mocks();
2859
2860 let mut tx_to_handle = create_test_transaction(&relayer.id);
2861 tx_to_handle.status = TransactionStatus::Submitted;
2862 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2863 let tx_hash_bytes = [1u8; 32];
2864 let tx_hash_hex = hex::encode(tx_hash_bytes);
2865 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2866 {
2867 stellar_data.hash = Some(tx_hash_hex.clone());
2868 }
2869
2870 mocks
2872 .provider
2873 .expect_get_transaction()
2874 .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2875
2876 mocks
2877 .tx_repo
2878 .expect_partial_update()
2879 .returning(|_, update| {
2880 let mut updated_tx = create_test_transaction("test-relayer");
2881 updated_tx.status = update.status.unwrap_or(updated_tx.status);
2882 Ok(updated_tx)
2883 });
2884
2885 mocks
2886 .job_producer
2887 .expect_produce_send_notification_job()
2888 .returning(|_, _| Box::pin(async { Ok(()) }));
2889
2890 mocks
2891 .tx_repo
2892 .expect_find_by_status_paginated()
2893 .returning(|_, _, _, _| {
2894 Ok(PaginatedResult {
2895 items: vec![],
2896 total: 0,
2897 page: 1,
2898 per_page: 1,
2899 })
2900 });
2901
2902 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2903
2904 let result = handler
2906 .handle_transaction_status_impl(tx_to_handle, None)
2907 .await;
2908
2909 assert!(result.is_ok());
2910 let tx = result.unwrap();
2911 assert_eq!(tx.status, TransactionStatus::Confirmed);
2912 }
2913 }
2914
2915 mod failure_detail_helper_tests {
2916 use super::*;
2917 use soroban_rs::xdr::{InvokeHostFunctionResult, OperationResult, OperationResultTr, VecM};
2918
2919 #[test]
2920 fn first_failing_op_finds_trapped() {
2921 let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2922 OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Trapped),
2923 )]
2924 .try_into()
2925 .unwrap();
2926 assert_eq!(first_failing_op(ops.as_slice()), Some("Trapped"));
2927 }
2928
2929 #[test]
2930 fn first_failing_op_skips_success() {
2931 let ops: VecM<OperationResult> = vec![
2932 OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2933 InvokeHostFunctionResult::Success(soroban_rs::xdr::Hash([0u8; 32])),
2934 )),
2935 OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2936 InvokeHostFunctionResult::ResourceLimitExceeded,
2937 )),
2938 ]
2939 .try_into()
2940 .unwrap();
2941 assert_eq!(
2942 first_failing_op(ops.as_slice()),
2943 Some("ResourceLimitExceeded")
2944 );
2945 }
2946
2947 #[test]
2948 fn first_failing_op_all_success_returns_none() {
2949 let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2950 OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Success(
2951 soroban_rs::xdr::Hash([0u8; 32]),
2952 )),
2953 )]
2954 .try_into()
2955 .unwrap();
2956 assert_eq!(first_failing_op(ops.as_slice()), None);
2957 }
2958
2959 #[test]
2960 fn first_failing_op_empty_returns_none() {
2961 assert_eq!(first_failing_op(&[]), None);
2962 }
2963
2964 #[test]
2965 fn first_failing_op_op_bad_auth() {
2966 let ops: VecM<OperationResult> = vec![OperationResult::OpBadAuth].try_into().unwrap();
2967 assert_eq!(first_failing_op(ops.as_slice()), Some("OpBadAuth"));
2968 }
2969 }
2970}