1use chrono::Utc;
6use log::{info, warn};
7use serde_json::{json, Value};
8use soroban_rs::xdr::{Error, Hash};
9
10use super::StellarRelayerTransaction;
11use crate::{
12 constants::STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS,
13 jobs::{JobProducerTrait, TransactionStatusCheck},
14 models::{
15 NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
16 TransactionStatus, TransactionUpdateRequest,
17 },
18 repositories::{Repository, TransactionCounterTrait, TransactionRepository},
19 services::{Signer, StellarProviderTrait},
20};
21
22impl<R, T, J, S, P, C> StellarRelayerTransaction<R, T, J, S, P, C>
23where
24 R: Repository<RelayerRepoModel, String> + Send + Sync,
25 T: TransactionRepository + Send + Sync,
26 J: JobProducerTrait + Send + Sync,
27 S: Signer + Send + Sync,
28 P: StellarProviderTrait + Send + Sync,
29 C: TransactionCounterTrait + Send + Sync,
30{
31 pub async fn handle_transaction_status_impl(
34 &self,
35 tx: TransactionRepoModel,
36 ) -> Result<TransactionRepoModel, TransactionError> {
37 info!("Handling transaction status for: {:?}", tx.id);
38
39 match self.status_core(tx.clone()).await {
41 Ok(updated_tx) => Ok(updated_tx),
42 Err(error) => {
43 match error {
45 TransactionError::ValidationError(_) => {
46 Err(error)
48 }
49 _ => {
50 self.handle_status_failure(tx, error).await
52 }
53 }
54 }
55 }
56 }
57
58 async fn status_core(
60 &self,
61 tx: TransactionRepoModel,
62 ) -> Result<TransactionRepoModel, TransactionError> {
63 let stellar_hash = self.parse_and_validate_hash(&tx)?;
64
65 let provider_response = match self.provider().get_transaction(&stellar_hash).await {
66 Ok(response) => response,
67 Err(e) => {
68 let error_str = format!("{:?}", e);
69
70 if error_str.contains("Xdr(Invalid)") || error_str.contains("xdr processing error")
72 {
73 warn!(
74 "XDR parsing error for transaction {}, using raw RPC fallback",
75 tx.id
76 );
77
78 match self.get_transaction_status_raw(&stellar_hash).await {
82 Ok(status) => {
83 soroban_rs::stellar_rpc_client::GetTransactionResponse {
85 status,
86 envelope: None,
87 result: None,
88 result_meta: None,
89 }
90 }
91 Err(raw_err) => {
92 warn!("Raw RPC fallback also failed for {}: {:?}", tx.id, raw_err);
93 return Err(TransactionError::from(e));
94 }
95 }
96 } else {
97 warn!("Provider get_transaction failed for {}: {:?}", tx.id, e);
98 return Err(TransactionError::from(e));
99 }
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 async fn handle_status_failure(
116 &self,
117 tx: TransactionRepoModel,
118 error: TransactionError,
119 ) -> Result<TransactionRepoModel, TransactionError> {
120 warn!(
121 "Failed to get Stellar transaction status for {}: {}. Re-queueing check.",
122 tx.id, error
123 );
124
125 if let Err(requeue_error) = self.requeue_status_check(&tx).await {
127 warn!(
128 "Failed to requeue status check for transaction {}: {}",
129 tx.id, requeue_error
130 );
131 }
133
134 info!(
136 "Transaction {} status check failure handled. Will retry later. Error: {}",
137 tx.id, error
138 );
139
140 Ok(tx)
142 }
143
144 pub async fn requeue_status_check(
146 &self,
147 tx: &TransactionRepoModel,
148 ) -> Result<(), TransactionError> {
149 self.job_producer()
150 .produce_check_transaction_status_job(
151 TransactionStatusCheck::new(tx.id.clone(), tx.relayer_id.clone()),
152 Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS),
153 )
154 .await?;
155 Ok(())
156 }
157
158 pub fn parse_and_validate_hash(
161 &self,
162 tx: &TransactionRepoModel,
163 ) -> Result<Hash, TransactionError> {
164 let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
165
166 let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
167 TransactionError::ValidationError(format!(
168 "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
169 tx.id
170 ))
171 })?;
172
173 let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
174 TransactionError::UnexpectedError(format!(
175 "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
176 tx_hash_str, tx.id, e
177 ))
178 })?;
179
180 Ok(stellar_hash)
181 }
182
183 pub async fn handle_stellar_success(
185 &self,
186 tx: TransactionRepoModel,
187 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
188 ) -> Result<TransactionRepoModel, TransactionError> {
189 let updated_network_data = provider_response.result.as_ref().and_then(|tx_result| {
191 tx.network_data
192 .get_stellar_transaction_data()
193 .ok()
194 .map(|stellar_data| {
195 NetworkTransactionData::Stellar(
196 stellar_data.with_fee(tx_result.fee_charged as u32),
197 )
198 })
199 });
200
201 let update_request = TransactionUpdateRequest {
202 status: Some(TransactionStatus::Confirmed),
203 confirmed_at: Some(Utc::now().to_rfc3339()),
204 network_data: updated_network_data,
205 ..Default::default()
206 };
207
208 let confirmed_tx = self
209 .finalize_transaction_state(tx.id.clone(), update_request)
210 .await?;
211
212 self.enqueue_next_pending_transaction(&tx.id).await?;
213
214 Ok(confirmed_tx)
215 }
216
217 pub async fn handle_stellar_failed(
219 &self,
220 tx: TransactionRepoModel,
221 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
222 ) -> Result<TransactionRepoModel, TransactionError> {
223 let base_reason = "Transaction failed on-chain. Provider status: FAILED.".to_string();
224 let detailed_reason = if let Some(ref tx_result_xdr) = provider_response.result {
225 format!(
226 "{} Specific XDR reason: {}.",
227 base_reason,
228 tx_result_xdr.result.name()
229 )
230 } else {
231 format!("{} No detailed XDR result available.", base_reason)
232 };
233
234 warn!("Stellar transaction {} failed: {}", tx.id, detailed_reason);
235
236 let update_request = TransactionUpdateRequest {
237 status: Some(TransactionStatus::Failed),
238 status_reason: Some(detailed_reason),
239 ..Default::default()
240 };
241
242 let updated_tx = self
243 .finalize_transaction_state(tx.id.clone(), update_request)
244 .await?;
245
246 self.enqueue_next_pending_transaction(&tx.id).await?;
247
248 Ok(updated_tx)
249 }
250
251 pub async fn handle_stellar_pending(
253 &self,
254 tx: TransactionRepoModel,
255 original_status_str: String,
256 ) -> Result<TransactionRepoModel, TransactionError> {
257 info!(
258 "Stellar transaction {} status is still '{}'. Re-queueing check.",
259 tx.id, original_status_str
260 );
261 self.requeue_status_check(&tx).await?;
262 Ok(tx)
263 }
264
265 async fn get_transaction_status_raw(
267 &self,
268 tx_hash: &soroban_rs::xdr::Hash,
269 ) -> Result<String, TransactionError> {
270 let hash_hex: String = tx_hash
272 .0
273 .iter()
274 .map(|byte| format!("{:02x}", byte))
275 .collect();
276
277 let request_body = json!({
279 "jsonrpc": "2.0",
280 "id": 1,
281 "method": "getTransaction",
282 "params": {
283 "hash": hash_hex
284 }
285 });
286
287 let rpc_url = self.provider().rpc_url();
289
290 let client = reqwest::Client::new();
292 let response = client
293 .post(rpc_url)
294 .json(&request_body)
295 .send()
296 .await
297 .map_err(|e| {
298 TransactionError::UnexpectedError(format!("Raw RPC request failed: {}", e))
299 })?;
300
301 let json_response: Value = response.json().await.map_err(|e| {
303 TransactionError::UnexpectedError(format!("Failed to parse JSON response: {}", e))
304 })?;
305
306 if let Some(error) = json_response.get("error") {
308 if let Some(code) = error.get("code").and_then(|c| c.as_i64()) {
309 if code == -32602 || code == -32600 {
310 return Ok("NOT_FOUND".to_string());
311 }
312 }
313 return Err(TransactionError::UnexpectedError(format!(
314 "RPC error: {:?}",
315 error
316 )));
317 }
318
319 json_response
321 .get("result")
322 .and_then(|result| result.get("status"))
323 .and_then(|status| status.as_str())
324 .map(|s| s.to_string())
325 .ok_or_else(|| {
326 TransactionError::UnexpectedError("Missing status in response".to_string())
327 })
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::models::{NetworkTransactionData, RepositoryError};
335 use mockall::predicate::eq;
336 use soroban_rs::stellar_rpc_client::GetTransactionResponse;
337
338 use crate::domain::transaction::stellar::test_helpers::*;
339
340 fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
341 GetTransactionResponse {
342 status: status.to_string(),
343 envelope: None,
344 result: None,
345 result_meta: None,
346 }
347 }
348
349 mod handle_transaction_status_tests {
350 use super::*;
351
352 #[tokio::test]
353 async fn handle_transaction_status_confirmed_triggers_next() {
354 let relayer = create_test_relayer();
355 let mut mocks = default_test_mocks();
356
357 let mut tx_to_handle = create_test_transaction(&relayer.id);
358 tx_to_handle.id = "tx-confirm-this".to_string();
359 let tx_hash_bytes = [1u8; 32];
360 let tx_hash_hex = hex::encode(tx_hash_bytes);
361 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
362 {
363 stellar_data.hash = Some(tx_hash_hex.clone());
364 } else {
365 panic!("Expected Stellar network data for tx_to_handle");
366 }
367 tx_to_handle.status = TransactionStatus::Submitted;
368
369 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
370
371 mocks
373 .provider
374 .expect_get_transaction()
375 .with(eq(expected_stellar_hash.clone()))
376 .times(1)
377 .returning(move |_| {
378 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
379 });
380
381 mocks
383 .tx_repo
384 .expect_partial_update()
385 .withf(move |id, update| {
386 id == "tx-confirm-this"
387 && update.status == Some(TransactionStatus::Confirmed)
388 && update.confirmed_at.is_some()
389 })
390 .times(1)
391 .returning(move |id, update| {
392 let mut updated_tx = tx_to_handle.clone(); updated_tx.id = id;
394 updated_tx.status = update.status.unwrap();
395 updated_tx.confirmed_at = update.confirmed_at;
396 Ok(updated_tx)
397 });
398
399 mocks
401 .job_producer
402 .expect_produce_send_notification_job()
403 .times(1)
404 .returning(|_, _| Box::pin(async { Ok(()) }));
405
406 let mut oldest_pending_tx = create_test_transaction(&relayer.id);
408 oldest_pending_tx.id = "tx-oldest-pending".to_string();
409 oldest_pending_tx.status = TransactionStatus::Pending;
410 let captured_oldest_pending_tx = oldest_pending_tx.clone();
411 mocks
412 .tx_repo
413 .expect_find_by_status()
414 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
415 .times(1)
416 .returning(move |_, _| Ok(vec![captured_oldest_pending_tx.clone()]));
417
418 mocks
420 .job_producer
421 .expect_produce_transaction_request_job()
422 .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
423 .times(1)
424 .returning(|_, _| Box::pin(async { Ok(()) }));
425
426 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
427 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
428 initial_tx_for_handling.id = "tx-confirm-this".to_string();
429 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
430 initial_tx_for_handling.network_data
431 {
432 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
433 } else {
434 panic!("Expected Stellar network data for initial_tx_for_handling");
435 }
436 initial_tx_for_handling.status = TransactionStatus::Submitted;
437
438 let result = handler
439 .handle_transaction_status_impl(initial_tx_for_handling)
440 .await;
441
442 assert!(result.is_ok());
443 let handled_tx = result.unwrap();
444 assert_eq!(handled_tx.id, "tx-confirm-this");
445 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
446 assert!(handled_tx.confirmed_at.is_some());
447 }
448
449 #[tokio::test]
450 async fn handle_transaction_status_still_pending() {
451 let relayer = create_test_relayer();
452 let mut mocks = default_test_mocks();
453
454 let mut tx_to_handle = create_test_transaction(&relayer.id);
455 tx_to_handle.id = "tx-pending-check".to_string();
456 let tx_hash_bytes = [2u8; 32];
457 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
458 {
459 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
460 } else {
461 panic!("Expected Stellar network data");
462 }
463 tx_to_handle.status = TransactionStatus::Submitted; let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
466
467 mocks
469 .provider
470 .expect_get_transaction()
471 .with(eq(expected_stellar_hash.clone()))
472 .times(1)
473 .returning(move |_| {
474 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
475 });
476
477 mocks.tx_repo.expect_partial_update().never();
479
480 mocks
482 .job_producer
483 .expect_produce_check_transaction_status_job()
484 .withf(move |job, delay| {
485 job.transaction_id == "tx-pending-check"
486 && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
487 })
488 .times(1)
489 .returning(|_, _| Box::pin(async { Ok(()) }));
490
491 mocks
493 .job_producer
494 .expect_produce_send_notification_job()
495 .never();
496
497 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
498 let original_tx_clone = tx_to_handle.clone();
499
500 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
501
502 assert!(result.is_ok());
503 let returned_tx = result.unwrap();
504 assert_eq!(returned_tx.id, original_tx_clone.id);
506 assert_eq!(returned_tx.status, original_tx_clone.status);
507 assert!(returned_tx.confirmed_at.is_none()); }
509
510 #[tokio::test]
511 async fn handle_transaction_status_failed() {
512 let relayer = create_test_relayer();
513 let mut mocks = default_test_mocks();
514
515 let mut tx_to_handle = create_test_transaction(&relayer.id);
516 tx_to_handle.id = "tx-fail-this".to_string();
517 let tx_hash_bytes = [3u8; 32];
518 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
519 {
520 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
521 } else {
522 panic!("Expected Stellar network data");
523 }
524 tx_to_handle.status = TransactionStatus::Submitted;
525
526 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
527
528 mocks
530 .provider
531 .expect_get_transaction()
532 .with(eq(expected_stellar_hash.clone()))
533 .times(1)
534 .returning(move |_| {
535 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
536 });
537
538 let relayer_id_for_mock = relayer.id.clone();
540 mocks
541 .tx_repo
542 .expect_partial_update()
543 .times(1)
544 .returning(move |id, update| {
545 let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
547 updated_tx.id = id;
548 updated_tx.status = update.status.unwrap();
549 updated_tx.status_reason = update.status_reason.clone();
550 Ok::<_, RepositoryError>(updated_tx)
551 });
552
553 mocks
555 .job_producer
556 .expect_produce_send_notification_job()
557 .times(1)
558 .returning(|_, _| Box::pin(async { Ok(()) }));
559
560 mocks
562 .tx_repo
563 .expect_find_by_status()
564 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
565 .times(1)
566 .returning(move |_, _| Ok(vec![])); mocks
570 .job_producer
571 .expect_produce_transaction_request_job()
572 .never();
573 mocks
575 .job_producer
576 .expect_produce_check_transaction_status_job()
577 .never();
578
579 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
580 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
581 initial_tx_for_handling.id = "tx-fail-this".to_string();
582 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
583 initial_tx_for_handling.network_data
584 {
585 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
586 } else {
587 panic!("Expected Stellar network data");
588 }
589 initial_tx_for_handling.status = TransactionStatus::Submitted;
590
591 let result = handler
592 .handle_transaction_status_impl(initial_tx_for_handling)
593 .await;
594
595 assert!(result.is_ok());
596 let handled_tx = result.unwrap();
597 assert_eq!(handled_tx.id, "tx-fail-this");
598 assert_eq!(handled_tx.status, TransactionStatus::Failed);
599 assert!(handled_tx.status_reason.is_some());
600 assert_eq!(
601 handled_tx.status_reason.unwrap(),
602 "Transaction failed on-chain. Provider status: FAILED. No detailed XDR result available."
603 );
604 }
605
606 #[tokio::test]
607 async fn handle_transaction_status_provider_error() {
608 let relayer = create_test_relayer();
609 let mut mocks = default_test_mocks();
610
611 let mut tx_to_handle = create_test_transaction(&relayer.id);
612 tx_to_handle.id = "tx-provider-error".to_string();
613 let tx_hash_bytes = [4u8; 32];
614 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
615 {
616 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
617 } else {
618 panic!("Expected Stellar network data");
619 }
620 tx_to_handle.status = TransactionStatus::Submitted;
621
622 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
623
624 mocks
626 .provider
627 .expect_get_transaction()
628 .with(eq(expected_stellar_hash.clone()))
629 .times(1)
630 .returning(move |_| Box::pin(async { Err(eyre::eyre!("RPC boom")) }));
631
632 mocks.tx_repo.expect_partial_update().never();
634
635 mocks
637 .job_producer
638 .expect_produce_check_transaction_status_job()
639 .withf(move |job, delay| {
640 job.transaction_id == "tx-provider-error"
641 && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
642 })
643 .times(1)
644 .returning(|_, _| Box::pin(async { Ok(()) }));
645
646 mocks
648 .job_producer
649 .expect_produce_send_notification_job()
650 .never();
651 mocks
653 .job_producer
654 .expect_produce_transaction_request_job()
655 .never();
656
657 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
658 let original_tx_clone = tx_to_handle.clone();
659
660 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
661
662 assert!(result.is_ok()); let returned_tx = result.unwrap();
664 assert_eq!(returned_tx.id, original_tx_clone.id);
666 assert_eq!(returned_tx.status, original_tx_clone.status);
667 }
668
669 #[tokio::test]
670 async fn handle_transaction_status_no_hashes() {
671 let relayer = create_test_relayer();
672 let mut mocks = default_test_mocks(); let mut tx_to_handle = create_test_transaction(&relayer.id);
675 tx_to_handle.id = "tx-no-hashes".to_string();
676 tx_to_handle.status = TransactionStatus::Submitted;
677
678 mocks.provider.expect_get_transaction().never();
679 mocks.tx_repo.expect_partial_update().never();
680 mocks
681 .job_producer
682 .expect_produce_check_transaction_status_job()
683 .never();
684 mocks
685 .job_producer
686 .expect_produce_send_notification_job()
687 .never();
688
689 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
690 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
691
692 assert!(
693 result.is_err(),
694 "Expected an error when hash is missing, but got Ok"
695 );
696 match result.unwrap_err() {
697 TransactionError::ValidationError(msg) => {
698 assert!(
699 msg.contains("Stellar transaction tx-no-hashes is missing or has an empty on-chain hash in network_data"),
700 "Unexpected error message: {}",
701 msg
702 );
703 }
704 other => panic!("Expected ValidationError, got {:?}", other),
705 }
706 }
707
708 #[tokio::test]
709 async fn test_on_chain_failure_does_not_decrement_sequence() {
710 let relayer = create_test_relayer();
711 let mut mocks = default_test_mocks();
712
713 let mut tx_to_handle = create_test_transaction(&relayer.id);
714 tx_to_handle.id = "tx-on-chain-fail".to_string();
715 let tx_hash_bytes = [4u8; 32];
716 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
717 {
718 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
719 stellar_data.sequence_number = Some(100); }
721 tx_to_handle.status = TransactionStatus::Submitted;
722
723 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
724
725 mocks
727 .provider
728 .expect_get_transaction()
729 .with(eq(expected_stellar_hash.clone()))
730 .times(1)
731 .returning(move |_| {
732 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
733 });
734
735 mocks.counter.expect_decrement().never();
737
738 mocks
740 .tx_repo
741 .expect_partial_update()
742 .times(1)
743 .returning(move |id, update| {
744 let mut updated_tx = create_test_transaction("test");
745 updated_tx.id = id;
746 updated_tx.status = update.status.unwrap();
747 updated_tx.status_reason = update.status_reason.clone();
748 Ok::<_, RepositoryError>(updated_tx)
749 });
750
751 mocks
753 .job_producer
754 .expect_produce_send_notification_job()
755 .times(1)
756 .returning(|_, _| Box::pin(async { Ok(()) }));
757
758 mocks
760 .tx_repo
761 .expect_find_by_status()
762 .returning(move |_, _| Ok(vec![]));
763
764 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
765 let initial_tx = tx_to_handle.clone();
766
767 let result = handler.handle_transaction_status_impl(initial_tx).await;
768
769 assert!(result.is_ok());
770 let handled_tx = result.unwrap();
771 assert_eq!(handled_tx.id, "tx-on-chain-fail");
772 assert_eq!(handled_tx.status, TransactionStatus::Failed);
773 }
774
775 #[tokio::test]
776 async fn test_on_chain_success_does_not_decrement_sequence() {
777 let relayer = create_test_relayer();
778 let mut mocks = default_test_mocks();
779
780 let mut tx_to_handle = create_test_transaction(&relayer.id);
781 tx_to_handle.id = "tx-on-chain-success".to_string();
782 let tx_hash_bytes = [5u8; 32];
783 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
784 {
785 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
786 stellar_data.sequence_number = Some(101); }
788 tx_to_handle.status = TransactionStatus::Submitted;
789
790 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
791
792 mocks
794 .provider
795 .expect_get_transaction()
796 .with(eq(expected_stellar_hash.clone()))
797 .times(1)
798 .returning(move |_| {
799 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
800 });
801
802 mocks.counter.expect_decrement().never();
804
805 mocks
807 .tx_repo
808 .expect_partial_update()
809 .withf(move |id, update| {
810 id == "tx-on-chain-success"
811 && update.status == Some(TransactionStatus::Confirmed)
812 && update.confirmed_at.is_some()
813 })
814 .times(1)
815 .returning(move |id, update| {
816 let mut updated_tx = create_test_transaction("test");
817 updated_tx.id = id;
818 updated_tx.status = update.status.unwrap();
819 updated_tx.confirmed_at = update.confirmed_at;
820 Ok(updated_tx)
821 });
822
823 mocks
825 .job_producer
826 .expect_produce_send_notification_job()
827 .times(1)
828 .returning(|_, _| Box::pin(async { Ok(()) }));
829
830 mocks
832 .tx_repo
833 .expect_find_by_status()
834 .returning(move |_, _| Ok(vec![]));
835
836 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
837 let initial_tx = tx_to_handle.clone();
838
839 let result = handler.handle_transaction_status_impl(initial_tx).await;
840
841 assert!(result.is_ok());
842 let handled_tx = result.unwrap();
843 assert_eq!(handled_tx.id, "tx-on-chain-success");
844 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
845 }
846
847 #[tokio::test]
848 async fn test_xdr_parsing_error_detection() {
849 let error_str1 = format!("{:?}", eyre::eyre!("Xdr(Invalid)"));
854 assert!(error_str1.contains("Xdr(Invalid)"));
855
856 let error_str2 = format!("{:?}", eyre::eyre!("xdr processing error"));
858 assert!(error_str2.contains("xdr processing error"));
859
860 let test_errors = vec![
862 "Xdr(Invalid) - some additional context",
863 "Failed with xdr processing error: malformed",
864 "Error: Xdr(Invalid)",
865 ];
866
867 for error_msg in test_errors {
868 let error_str = format!("{:?}", eyre::eyre!(error_msg));
869 let should_use_fallback = error_str.contains("Xdr(Invalid)")
870 || error_str.contains("xdr processing error");
871 assert!(
872 should_use_fallback,
873 "Error '{}' should trigger fallback",
874 error_msg
875 );
876 }
877 }
878
879 #[tokio::test]
880 async fn test_get_transaction_status_raw() {
881 use mockito::Server;
882 use serde_json::json;
883
884 let mut server = Server::new_async().await;
886 let url = server.url();
887
888 let relayer = create_test_relayer();
889 let mut mocks = default_test_mocks();
890
891 mocks.provider.expect_rpc_url().return_const(url.clone());
893
894 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
895
896 let tx_hash = soroban_rs::xdr::Hash([1u8; 32]);
898
899 let mock = server
900 .mock("POST", "/")
901 .with_status(200)
902 .with_header("content-type", "application/json")
903 .with_body(
904 json!({
905 "jsonrpc": "2.0",
906 "id": 1,
907 "result": {
908 "status": "SUCCESS"
909 }
910 })
911 .to_string(),
912 )
913 .expect(1)
914 .create_async()
915 .await;
916
917 let status = handler.get_transaction_status_raw(&tx_hash).await;
918 assert!(status.is_ok());
919 assert_eq!(status.unwrap(), "SUCCESS");
920 mock.assert_async().await;
921
922 let mock = server
924 .mock("POST", "/")
925 .with_status(200)
926 .with_header("content-type", "application/json")
927 .with_body(
928 json!({
929 "jsonrpc": "2.0",
930 "id": 1,
931 "result": {
932 "status": "FAILED"
933 }
934 })
935 .to_string(),
936 )
937 .expect(1)
938 .create_async()
939 .await;
940
941 let status = handler.get_transaction_status_raw(&tx_hash).await;
942 assert!(status.is_ok());
943 assert_eq!(status.unwrap(), "FAILED");
944 mock.assert_async().await;
945
946 let mock = server
948 .mock("POST", "/")
949 .with_status(200)
950 .with_header("content-type", "application/json")
951 .with_body(
952 json!({
953 "jsonrpc": "2.0",
954 "id": 1,
955 "error": {
956 "code": -32602,
957 "message": "Invalid params"
958 }
959 })
960 .to_string(),
961 )
962 .expect(1)
963 .create_async()
964 .await;
965
966 let status = handler.get_transaction_status_raw(&tx_hash).await;
967 assert!(status.is_ok());
968 assert_eq!(status.unwrap(), "NOT_FOUND");
969 mock.assert_async().await;
970
971 let mock = server
973 .mock("POST", "/")
974 .with_status(200)
975 .with_header("content-type", "application/json")
976 .with_body(
977 json!({
978 "jsonrpc": "2.0",
979 "id": 1,
980 "error": {
981 "code": -32600,
982 "message": "Invalid request"
983 }
984 })
985 .to_string(),
986 )
987 .expect(1)
988 .create_async()
989 .await;
990
991 let status = handler.get_transaction_status_raw(&tx_hash).await;
992 assert!(status.is_ok());
993 assert_eq!(status.unwrap(), "NOT_FOUND");
994 mock.assert_async().await;
995
996 let mock = server
998 .mock("POST", "/")
999 .with_status(200)
1000 .with_header("content-type", "application/json")
1001 .with_body(
1002 json!({
1003 "jsonrpc": "2.0",
1004 "id": 1,
1005 "error": {
1006 "code": -32000,
1007 "message": "Server error"
1008 }
1009 })
1010 .to_string(),
1011 )
1012 .expect(1)
1013 .create_async()
1014 .await;
1015
1016 let status = handler.get_transaction_status_raw(&tx_hash).await;
1017 assert!(status.is_err());
1018 match status.unwrap_err() {
1019 TransactionError::UnexpectedError(msg) => {
1020 assert!(msg.contains("RPC error"));
1021 }
1022 _ => panic!("Expected UnexpectedError"),
1023 }
1024 mock.assert_async().await;
1025
1026 let mock = server
1028 .mock("POST", "/")
1029 .with_status(200)
1030 .with_header("content-type", "application/json")
1031 .with_body(
1032 json!({
1033 "jsonrpc": "2.0",
1034 "id": 1,
1035 "result": {
1036 "other_field": "value"
1037 }
1038 })
1039 .to_string(),
1040 )
1041 .expect(1)
1042 .create_async()
1043 .await;
1044
1045 let status = handler.get_transaction_status_raw(&tx_hash).await;
1046 assert!(status.is_err());
1047 match status.unwrap_err() {
1048 TransactionError::UnexpectedError(msg) => {
1049 assert!(msg.contains("Missing status in response"));
1050 }
1051 _ => panic!("Expected UnexpectedError"),
1052 }
1053 mock.assert_async().await;
1054
1055 let mut mocks = default_test_mocks();
1057 mocks
1058 .provider
1059 .expect_rpc_url()
1060 .return_const("http://localhost:1".to_string()); let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1063 let status = handler.get_transaction_status_raw(&tx_hash).await;
1064 assert!(status.is_err());
1065 match status.unwrap_err() {
1066 TransactionError::UnexpectedError(msg) => {
1067 assert!(msg.contains("Raw RPC request failed"));
1068 }
1069 _ => panic!("Expected UnexpectedError"),
1070 }
1071
1072 let mock = server
1074 .mock("POST", "/")
1075 .with_status(200)
1076 .with_header("content-type", "application/json")
1077 .with_body("not valid json")
1078 .expect(1)
1079 .create_async()
1080 .await;
1081
1082 let mut mocks = default_test_mocks();
1083 mocks.provider.expect_rpc_url().return_const(url.clone());
1084
1085 let handler = make_stellar_tx_handler(relayer, mocks);
1086 let status = handler.get_transaction_status_raw(&tx_hash).await;
1087 assert!(status.is_err());
1088 match status.unwrap_err() {
1089 TransactionError::UnexpectedError(msg) => {
1090 assert!(msg.contains("Failed to parse JSON response"));
1091 }
1092 _ => panic!("Expected UnexpectedError"),
1093 }
1094 mock.assert_async().await;
1095 }
1096
1097 #[tokio::test]
1098 async fn test_handle_transaction_status_with_xdr_error_requeues() {
1099 let relayer = create_test_relayer();
1102 let mut mocks = default_test_mocks();
1103
1104 let mut tx_to_handle = create_test_transaction(&relayer.id);
1105 tx_to_handle.id = "tx-xdr-error-requeue".to_string();
1106 let tx_hash_bytes = [8u8; 32];
1107 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1108 {
1109 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1110 }
1111 tx_to_handle.status = TransactionStatus::Submitted;
1112
1113 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1114
1115 mocks
1117 .provider
1118 .expect_get_transaction()
1119 .with(eq(expected_stellar_hash.clone()))
1120 .times(1)
1121 .returning(move |_| Box::pin(async { Err(eyre::eyre!("Network timeout")) }));
1122
1123 mocks
1125 .job_producer
1126 .expect_produce_check_transaction_status_job()
1127 .withf(move |job, delay| {
1128 job.transaction_id == "tx-xdr-error-requeue"
1129 && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
1130 })
1131 .times(1)
1132 .returning(|_, _| Box::pin(async { Ok(()) }));
1133
1134 mocks.tx_repo.expect_partial_update().never();
1136 mocks
1137 .job_producer
1138 .expect_produce_send_notification_job()
1139 .never();
1140
1141 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1142 let original_tx_clone = tx_to_handle.clone();
1143
1144 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
1145
1146 assert!(result.is_ok()); let returned_tx = result.unwrap();
1148 assert_eq!(returned_tx.id, original_tx_clone.id);
1150 assert_eq!(returned_tx.status, original_tx_clone.status);
1151 }
1152 }
1153}