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