1use chrono::Utc;
6use soroban_rs::xdr::{Error, Hash};
7use tracing::{debug, info, warn};
8
9use super::{is_final_state, StellarRelayerTransaction};
10use crate::{
11 jobs::JobProducerTrait,
12 models::{
13 NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
14 TransactionStatus, TransactionUpdateRequest,
15 },
16 repositories::{Repository, TransactionCounterTrait, TransactionRepository},
17 services::{provider::StellarProviderTrait, signer::Signer},
18};
19
20impl<R, T, J, S, P, C> StellarRelayerTransaction<R, T, J, S, P, C>
21where
22 R: Repository<RelayerRepoModel, String> + Send + Sync,
23 T: TransactionRepository + Send + Sync,
24 J: JobProducerTrait + Send + Sync,
25 S: Signer + Send + Sync,
26 P: StellarProviderTrait + Send + Sync,
27 C: TransactionCounterTrait + Send + Sync,
28{
29 pub async fn handle_transaction_status_impl(
32 &self,
33 tx: TransactionRepoModel,
34 ) -> Result<TransactionRepoModel, TransactionError> {
35 info!(tx_id = %tx.id, status = ?tx.status, "handling transaction status");
36
37 if is_final_state(&tx.status) {
39 info!(tx_id = %tx.id, status = ?tx.status, "transaction in final state, skipping status check");
40 return Ok(tx);
41 }
42
43 match self.status_core(tx.clone()).await {
44 Ok(updated_tx) => {
45 debug!(
46 tx_id = %updated_tx.id,
47 status = ?updated_tx.status,
48 "status check completed successfully"
49 );
50 Ok(updated_tx)
51 }
52 Err(error) => {
53 debug!(
54 tx_id = %tx.id,
55 error = ?error,
56 "status check encountered error"
57 );
58
59 match error {
61 TransactionError::ValidationError(ref msg) => {
62 warn!(
65 tx_id = %tx.id,
66 error = %msg,
67 "validation error detected - marking transaction as failed"
68 );
69
70 self.mark_as_failed(tx, format!("Validation error: {msg}"))
71 .await
72 }
73 _ => {
74 warn!(
77 tx_id = %tx.id,
78 error = ?error,
79 "status check failed with retriable error, will retry"
80 );
81 Err(error)
82 }
83 }
84 }
85 }
86 }
87
88 async fn status_core(
90 &self,
91 tx: TransactionRepoModel,
92 ) -> Result<TransactionRepoModel, TransactionError> {
93 let stellar_hash = self.parse_and_validate_hash(&tx)?;
94
95 let provider_response = match self.provider().get_transaction(&stellar_hash).await {
96 Ok(response) => response,
97 Err(e) => {
98 warn!(error = ?e, "provider get_transaction failed");
99 return Err(TransactionError::from(e));
100 }
101 };
102
103 match provider_response.status.as_str().to_uppercase().as_str() {
104 "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
105 "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
106 _ => {
107 self.handle_stellar_pending(tx, provider_response.status)
108 .await
109 }
110 }
111 }
112
113 pub fn parse_and_validate_hash(
116 &self,
117 tx: &TransactionRepoModel,
118 ) -> Result<Hash, TransactionError> {
119 let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
120
121 let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
122 TransactionError::ValidationError(format!(
123 "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
124 tx.id
125 ))
126 })?;
127
128 let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
129 TransactionError::UnexpectedError(format!(
130 "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
131 tx_hash_str, tx.id, e
132 ))
133 })?;
134
135 Ok(stellar_hash)
136 }
137
138 async fn mark_as_failed(
140 &self,
141 tx: TransactionRepoModel,
142 reason: String,
143 ) -> Result<TransactionRepoModel, TransactionError> {
144 warn!(tx_id = %tx.id, reason = %reason, "marking transaction as failed");
145
146 let update_request = TransactionUpdateRequest {
147 status: Some(TransactionStatus::Failed),
148 status_reason: Some(reason),
149 ..Default::default()
150 };
151
152 let failed_tx = self
153 .finalize_transaction_state(tx.id.clone(), update_request)
154 .await?;
155
156 if let Err(e) = self.enqueue_next_pending_transaction(&tx.id).await {
158 warn!(error = %e, "failed to enqueue next pending transaction after failure");
159 }
160
161 Ok(failed_tx)
162 }
163
164 pub async fn handle_stellar_success(
166 &self,
167 tx: TransactionRepoModel,
168 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
169 ) -> Result<TransactionRepoModel, TransactionError> {
170 let updated_network_data = provider_response.result.as_ref().and_then(|tx_result| {
172 tx.network_data
173 .get_stellar_transaction_data()
174 .ok()
175 .map(|stellar_data| {
176 NetworkTransactionData::Stellar(
177 stellar_data.with_fee(tx_result.fee_charged as u32),
178 )
179 })
180 });
181
182 let update_request = TransactionUpdateRequest {
183 status: Some(TransactionStatus::Confirmed),
184 confirmed_at: Some(Utc::now().to_rfc3339()),
185 network_data: updated_network_data,
186 ..Default::default()
187 };
188
189 let confirmed_tx = self
190 .finalize_transaction_state(tx.id.clone(), update_request)
191 .await?;
192
193 self.enqueue_next_pending_transaction(&tx.id).await?;
194
195 Ok(confirmed_tx)
196 }
197
198 pub async fn handle_stellar_failed(
200 &self,
201 tx: TransactionRepoModel,
202 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
203 ) -> Result<TransactionRepoModel, TransactionError> {
204 let base_reason = "Transaction failed on-chain. Provider status: FAILED.".to_string();
205 let detailed_reason = if let Some(ref tx_result_xdr) = provider_response.result {
206 format!(
207 "{} Specific XDR reason: {}.",
208 base_reason,
209 tx_result_xdr.result.name()
210 )
211 } else {
212 format!("{base_reason} No detailed XDR result available.")
213 };
214
215 warn!(reason = %detailed_reason, "stellar transaction failed");
216
217 let update_request = TransactionUpdateRequest {
218 status: Some(TransactionStatus::Failed),
219 status_reason: Some(detailed_reason),
220 ..Default::default()
221 };
222
223 let updated_tx = self
224 .finalize_transaction_state(tx.id.clone(), update_request)
225 .await?;
226
227 self.enqueue_next_pending_transaction(&tx.id).await?;
228
229 Ok(updated_tx)
230 }
231
232 pub async fn handle_stellar_pending(
234 &self,
235 tx: TransactionRepoModel,
236 original_status_str: String,
237 ) -> Result<TransactionRepoModel, TransactionError> {
238 debug!(status = %original_status_str, "stellar transaction status is still pending, will retry check later");
239 Ok(tx)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::models::{NetworkTransactionData, RepositoryError};
247 use chrono::Duration;
248 use mockall::predicate::eq;
249 use soroban_rs::stellar_rpc_client::GetTransactionResponse;
250
251 use crate::domain::transaction::stellar::test_helpers::*;
252
253 fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
254 GetTransactionResponse {
255 status: status.to_string(),
256 ledger: None,
257 envelope: None,
258 result: None,
259 result_meta: None,
260 events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
261 contract_events: vec![],
262 diagnostic_events: vec![],
263 transaction_events: vec![],
264 },
265 }
266 }
267
268 mod handle_transaction_status_tests {
269 use crate::services::provider::ProviderError;
270
271 use super::*;
272
273 #[tokio::test]
274 async fn handle_transaction_status_confirmed_triggers_next() {
275 let relayer = create_test_relayer();
276 let mut mocks = default_test_mocks();
277
278 let mut tx_to_handle = create_test_transaction(&relayer.id);
279 tx_to_handle.id = "tx-confirm-this".to_string();
280 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
281 let tx_hash_bytes = [1u8; 32];
282 let tx_hash_hex = hex::encode(tx_hash_bytes);
283 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
284 {
285 stellar_data.hash = Some(tx_hash_hex.clone());
286 } else {
287 panic!("Expected Stellar network data for tx_to_handle");
288 }
289 tx_to_handle.status = TransactionStatus::Submitted;
290
291 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
292
293 mocks
295 .provider
296 .expect_get_transaction()
297 .with(eq(expected_stellar_hash.clone()))
298 .times(1)
299 .returning(move |_| {
300 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
301 });
302
303 mocks
305 .tx_repo
306 .expect_partial_update()
307 .withf(move |id, update| {
308 id == "tx-confirm-this"
309 && update.status == Some(TransactionStatus::Confirmed)
310 && update.confirmed_at.is_some()
311 })
312 .times(1)
313 .returning(move |id, update| {
314 let mut updated_tx = tx_to_handle.clone(); updated_tx.id = id;
316 updated_tx.status = update.status.unwrap();
317 updated_tx.confirmed_at = update.confirmed_at;
318 Ok(updated_tx)
319 });
320
321 mocks
323 .job_producer
324 .expect_produce_send_notification_job()
325 .times(1)
326 .returning(|_, _| Box::pin(async { Ok(()) }));
327
328 let mut oldest_pending_tx = create_test_transaction(&relayer.id);
330 oldest_pending_tx.id = "tx-oldest-pending".to_string();
331 oldest_pending_tx.status = TransactionStatus::Pending;
332 let captured_oldest_pending_tx = oldest_pending_tx.clone();
333 mocks
334 .tx_repo
335 .expect_find_by_status()
336 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
337 .times(1)
338 .returning(move |_, _| Ok(vec![captured_oldest_pending_tx.clone()]));
339
340 mocks
342 .job_producer
343 .expect_produce_transaction_request_job()
344 .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
345 .times(1)
346 .returning(|_, _| Box::pin(async { Ok(()) }));
347
348 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
349 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
350 initial_tx_for_handling.id = "tx-confirm-this".to_string();
351 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
352 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
353 initial_tx_for_handling.network_data
354 {
355 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
356 } else {
357 panic!("Expected Stellar network data for initial_tx_for_handling");
358 }
359 initial_tx_for_handling.status = TransactionStatus::Submitted;
360
361 let result = handler
362 .handle_transaction_status_impl(initial_tx_for_handling)
363 .await;
364
365 assert!(result.is_ok());
366 let handled_tx = result.unwrap();
367 assert_eq!(handled_tx.id, "tx-confirm-this");
368 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
369 assert!(handled_tx.confirmed_at.is_some());
370 }
371
372 #[tokio::test]
373 async fn handle_transaction_status_still_pending() {
374 let relayer = create_test_relayer();
375 let mut mocks = default_test_mocks();
376
377 let mut tx_to_handle = create_test_transaction(&relayer.id);
378 tx_to_handle.id = "tx-pending-check".to_string();
379 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
380 let tx_hash_bytes = [2u8; 32];
381 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
382 {
383 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
384 } else {
385 panic!("Expected Stellar network data");
386 }
387 tx_to_handle.status = TransactionStatus::Submitted; let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
390
391 mocks
393 .provider
394 .expect_get_transaction()
395 .with(eq(expected_stellar_hash.clone()))
396 .times(1)
397 .returning(move |_| {
398 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
399 });
400
401 mocks.tx_repo.expect_partial_update().never();
403
404 mocks
406 .job_producer
407 .expect_produce_send_notification_job()
408 .never();
409
410 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
411 let original_tx_clone = tx_to_handle.clone();
412
413 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
414
415 assert!(result.is_ok());
416 let returned_tx = result.unwrap();
417 assert_eq!(returned_tx.id, original_tx_clone.id);
419 assert_eq!(returned_tx.status, original_tx_clone.status);
420 assert!(returned_tx.confirmed_at.is_none()); }
422
423 #[tokio::test]
424 async fn handle_transaction_status_failed() {
425 let relayer = create_test_relayer();
426 let mut mocks = default_test_mocks();
427
428 let mut tx_to_handle = create_test_transaction(&relayer.id);
429 tx_to_handle.id = "tx-fail-this".to_string();
430 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
431 let tx_hash_bytes = [3u8; 32];
432 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
433 {
434 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
435 } else {
436 panic!("Expected Stellar network data");
437 }
438 tx_to_handle.status = TransactionStatus::Submitted;
439
440 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
441
442 mocks
444 .provider
445 .expect_get_transaction()
446 .with(eq(expected_stellar_hash.clone()))
447 .times(1)
448 .returning(move |_| {
449 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
450 });
451
452 let relayer_id_for_mock = relayer.id.clone();
454 mocks
455 .tx_repo
456 .expect_partial_update()
457 .times(1)
458 .returning(move |id, update| {
459 let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
461 updated_tx.id = id;
462 updated_tx.status = update.status.unwrap();
463 updated_tx.status_reason = update.status_reason.clone();
464 Ok::<_, RepositoryError>(updated_tx)
465 });
466
467 mocks
469 .job_producer
470 .expect_produce_send_notification_job()
471 .times(1)
472 .returning(|_, _| Box::pin(async { Ok(()) }));
473
474 mocks
476 .tx_repo
477 .expect_find_by_status()
478 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
479 .times(1)
480 .returning(move |_, _| Ok(vec![])); mocks
484 .job_producer
485 .expect_produce_transaction_request_job()
486 .never();
487 mocks
489 .job_producer
490 .expect_produce_check_transaction_status_job()
491 .never();
492
493 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
494 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
495 initial_tx_for_handling.id = "tx-fail-this".to_string();
496 initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
497 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
498 initial_tx_for_handling.network_data
499 {
500 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
501 } else {
502 panic!("Expected Stellar network data");
503 }
504 initial_tx_for_handling.status = TransactionStatus::Submitted;
505
506 let result = handler
507 .handle_transaction_status_impl(initial_tx_for_handling)
508 .await;
509
510 assert!(result.is_ok());
511 let handled_tx = result.unwrap();
512 assert_eq!(handled_tx.id, "tx-fail-this");
513 assert_eq!(handled_tx.status, TransactionStatus::Failed);
514 assert!(handled_tx.status_reason.is_some());
515 assert_eq!(
516 handled_tx.status_reason.unwrap(),
517 "Transaction failed on-chain. Provider status: FAILED. No detailed XDR result available."
518 );
519 }
520
521 #[tokio::test]
522 async fn handle_transaction_status_provider_error() {
523 let relayer = create_test_relayer();
524 let mut mocks = default_test_mocks();
525
526 let mut tx_to_handle = create_test_transaction(&relayer.id);
527 tx_to_handle.id = "tx-provider-error".to_string();
528 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
529 let tx_hash_bytes = [4u8; 32];
530 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
531 {
532 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
533 } else {
534 panic!("Expected Stellar network data");
535 }
536 tx_to_handle.status = TransactionStatus::Submitted;
537
538 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
539
540 mocks
542 .provider
543 .expect_get_transaction()
544 .with(eq(expected_stellar_hash.clone()))
545 .times(1)
546 .returning(move |_| {
547 Box::pin(async { Err(ProviderError::Other("RPC boom".to_string())) })
548 });
549
550 mocks.tx_repo.expect_partial_update().never();
552
553 mocks
555 .job_producer
556 .expect_produce_send_notification_job()
557 .never();
558 mocks
560 .job_producer
561 .expect_produce_transaction_request_job()
562 .never();
563
564 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
565
566 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
567
568 assert!(result.is_err());
570 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
571 }
572
573 #[tokio::test]
574 async fn handle_transaction_status_no_hashes() {
575 let relayer = create_test_relayer();
576 let mut mocks = default_test_mocks();
577
578 let mut tx_to_handle = create_test_transaction(&relayer.id);
579 tx_to_handle.id = "tx-no-hashes".to_string();
580 tx_to_handle.status = TransactionStatus::Submitted;
581 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
582
583 mocks.provider.expect_get_transaction().never();
585
586 mocks
588 .tx_repo
589 .expect_partial_update()
590 .times(1)
591 .returning(|_, update| {
592 let mut updated_tx = create_test_transaction("test-relayer");
593 updated_tx.status = update.status.unwrap_or(updated_tx.status);
594 updated_tx.status_reason = update.status_reason.clone();
595 Ok(updated_tx)
596 });
597
598 mocks
600 .job_producer
601 .expect_produce_send_notification_job()
602 .times(1)
603 .returning(|_, _| Box::pin(async { Ok(()) }));
604
605 mocks
607 .tx_repo
608 .expect_find_by_status()
609 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
610 .times(1)
611 .returning(move |_, _| Ok(vec![])); let handler = make_stellar_tx_handler(relayer.clone(), mocks);
614 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
615
616 assert!(result.is_ok(), "Expected Ok result");
618 let updated_tx = result.unwrap();
619 assert_eq!(updated_tx.status, TransactionStatus::Failed);
620 assert!(
621 updated_tx
622 .status_reason
623 .as_ref()
624 .unwrap()
625 .contains("Validation error"),
626 "Expected validation error in status_reason, got: {:?}",
627 updated_tx.status_reason
628 );
629 }
630
631 #[tokio::test]
632 async fn test_on_chain_failure_does_not_decrement_sequence() {
633 let relayer = create_test_relayer();
634 let mut mocks = default_test_mocks();
635
636 let mut tx_to_handle = create_test_transaction(&relayer.id);
637 tx_to_handle.id = "tx-on-chain-fail".to_string();
638 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
639 let tx_hash_bytes = [4u8; 32];
640 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
641 {
642 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
643 stellar_data.sequence_number = Some(100); }
645 tx_to_handle.status = TransactionStatus::Submitted;
646
647 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
648
649 mocks
651 .provider
652 .expect_get_transaction()
653 .with(eq(expected_stellar_hash.clone()))
654 .times(1)
655 .returning(move |_| {
656 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
657 });
658
659 mocks.counter.expect_decrement().never();
661
662 mocks
664 .tx_repo
665 .expect_partial_update()
666 .times(1)
667 .returning(move |id, update| {
668 let mut updated_tx = create_test_transaction("test");
669 updated_tx.id = id;
670 updated_tx.status = update.status.unwrap();
671 updated_tx.status_reason = update.status_reason.clone();
672 Ok::<_, RepositoryError>(updated_tx)
673 });
674
675 mocks
677 .job_producer
678 .expect_produce_send_notification_job()
679 .times(1)
680 .returning(|_, _| Box::pin(async { Ok(()) }));
681
682 mocks
684 .tx_repo
685 .expect_find_by_status()
686 .returning(move |_, _| Ok(vec![]));
687
688 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
689 let initial_tx = tx_to_handle.clone();
690
691 let result = handler.handle_transaction_status_impl(initial_tx).await;
692
693 assert!(result.is_ok());
694 let handled_tx = result.unwrap();
695 assert_eq!(handled_tx.id, "tx-on-chain-fail");
696 assert_eq!(handled_tx.status, TransactionStatus::Failed);
697 }
698
699 #[tokio::test]
700 async fn test_on_chain_success_does_not_decrement_sequence() {
701 let relayer = create_test_relayer();
702 let mut mocks = default_test_mocks();
703
704 let mut tx_to_handle = create_test_transaction(&relayer.id);
705 tx_to_handle.id = "tx-on-chain-success".to_string();
706 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
707 let tx_hash_bytes = [5u8; 32];
708 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
709 {
710 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
711 stellar_data.sequence_number = Some(101); }
713 tx_to_handle.status = TransactionStatus::Submitted;
714
715 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
716
717 mocks
719 .provider
720 .expect_get_transaction()
721 .with(eq(expected_stellar_hash.clone()))
722 .times(1)
723 .returning(move |_| {
724 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
725 });
726
727 mocks.counter.expect_decrement().never();
729
730 mocks
732 .tx_repo
733 .expect_partial_update()
734 .withf(move |id, update| {
735 id == "tx-on-chain-success"
736 && update.status == Some(TransactionStatus::Confirmed)
737 && update.confirmed_at.is_some()
738 })
739 .times(1)
740 .returning(move |id, update| {
741 let mut updated_tx = create_test_transaction("test");
742 updated_tx.id = id;
743 updated_tx.status = update.status.unwrap();
744 updated_tx.confirmed_at = update.confirmed_at;
745 Ok(updated_tx)
746 });
747
748 mocks
750 .job_producer
751 .expect_produce_send_notification_job()
752 .times(1)
753 .returning(|_, _| Box::pin(async { Ok(()) }));
754
755 mocks
757 .tx_repo
758 .expect_find_by_status()
759 .returning(move |_, _| Ok(vec![]));
760
761 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
762 let initial_tx = tx_to_handle.clone();
763
764 let result = handler.handle_transaction_status_impl(initial_tx).await;
765
766 assert!(result.is_ok());
767 let handled_tx = result.unwrap();
768 assert_eq!(handled_tx.id, "tx-on-chain-success");
769 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
770 }
771
772 #[tokio::test]
773 async fn test_handle_transaction_status_with_xdr_error_requeues() {
774 let relayer = create_test_relayer();
776 let mut mocks = default_test_mocks();
777
778 let mut tx_to_handle = create_test_transaction(&relayer.id);
779 tx_to_handle.id = "tx-xdr-error-requeue".to_string();
780 tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
781 let tx_hash_bytes = [8u8; 32];
782 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
783 {
784 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
785 }
786 tx_to_handle.status = TransactionStatus::Submitted;
787
788 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
789
790 mocks
792 .provider
793 .expect_get_transaction()
794 .with(eq(expected_stellar_hash.clone()))
795 .times(1)
796 .returning(move |_| {
797 Box::pin(async { Err(ProviderError::Other("Network timeout".to_string())) })
798 });
799
800 mocks.tx_repo.expect_partial_update().never();
802 mocks
803 .job_producer
804 .expect_produce_send_notification_job()
805 .never();
806
807 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
808
809 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
810
811 assert!(result.is_err());
813 matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
814 }
815 }
816}