openzeppelin_relayer/domain/transaction/stellar/
status.rs

1//! This module contains the status handling functionality for Stellar transactions.
2//! It includes methods for checking transaction status with robust error handling,
3//! ensuring proper transaction state management and lane cleanup.
4
5use chrono::Utc;
6use soroban_rs::xdr::{Error, Hash};
7use tracing::{info, warn};
8
9use super::StellarRelayerTransaction;
10use crate::{
11    constants::STELLAR_STATUS_CHECK_JOB_DELAY_SECONDS,
12    jobs::{JobProducerTrait, TransactionStatusCheck},
13    models::{
14        NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
15        TransactionStatus, TransactionUpdateRequest,
16    },
17    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
18    services::{Signer, StellarProviderTrait},
19    utils::calculate_scheduled_timestamp,
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    /// Main status handling method with robust error handling.
32    /// This method checks transaction status and handles lane cleanup for finalized transactions.
33    pub async fn handle_transaction_status_impl(
34        &self,
35        tx: TransactionRepoModel,
36    ) -> Result<TransactionRepoModel, TransactionError> {
37        info!("handling transaction status");
38
39        // Call core status checking logic with error handling
40        match self.status_core(tx.clone()).await {
41            Ok(updated_tx) => Ok(updated_tx),
42            Err(error) => {
43                // Only retry for provider errors, not validation errors
44                match error {
45                    TransactionError::ValidationError(_) => {
46                        // Don't retry validation errors (like missing hash)
47                        Err(error)
48                    }
49                    _ => {
50                        // Handle status check failure - requeue for retry
51                        self.handle_status_failure(tx, error).await
52                    }
53                }
54            }
55        }
56    }
57
58    /// Core status checking logic - pure business logic without error handling concerns.
59    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                warn!(error = ?e, "provider get_transaction failed");
69                return Err(TransactionError::from(e));
70            }
71        };
72
73        match provider_response.status.as_str().to_uppercase().as_str() {
74            "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
75            "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
76            _ => {
77                self.handle_stellar_pending(tx, provider_response.status)
78                    .await
79            }
80        }
81    }
82
83    /// Handles status check failures with retry logic.
84    /// This method ensures failed status checks are retried appropriately.
85    async fn handle_status_failure(
86        &self,
87        tx: TransactionRepoModel,
88        error: TransactionError,
89    ) -> Result<TransactionRepoModel, TransactionError> {
90        warn!(error = %error, "failed to get stellar transaction status, re-queueing check");
91
92        // Step 1: Re-queue status check for retry
93        if let Err(requeue_error) = self.requeue_status_check(&tx).await {
94            warn!(error = %requeue_error, "failed to requeue status check for transaction");
95            // Continue with original error even if requeue fails
96        }
97
98        // Step 2: Log failure for monitoring (status_check_fail_total metric would go here)
99        info!(error = %error, "transaction status check failure handled, will retry later");
100
101        // Step 3: Return original transaction unchanged (will be retried)
102        Ok(tx)
103    }
104
105    /// Helper function to re-queue a transaction status check job.
106    pub async fn requeue_status_check(
107        &self,
108        tx: &TransactionRepoModel,
109    ) -> Result<(), TransactionError> {
110        self.job_producer()
111            .produce_check_transaction_status_job(
112                TransactionStatusCheck::new(tx.id.clone(), tx.relayer_id.clone()),
113                Some(calculate_scheduled_timestamp(
114                    STELLAR_STATUS_CHECK_JOB_DELAY_SECONDS,
115                )),
116            )
117            .await?;
118        Ok(())
119    }
120
121    /// Parses the transaction hash from the network data and validates it.
122    /// Returns a `TransactionError::ValidationError` if the hash is missing, empty, or invalid.
123    pub fn parse_and_validate_hash(
124        &self,
125        tx: &TransactionRepoModel,
126    ) -> Result<Hash, TransactionError> {
127        let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
128
129        let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
130            TransactionError::ValidationError(format!(
131                "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
132                tx.id
133            ))
134        })?;
135
136        let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
137            TransactionError::UnexpectedError(format!(
138                "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
139                tx_hash_str, tx.id, e
140            ))
141        })?;
142
143        Ok(stellar_hash)
144    }
145
146    /// Handles the logic when a Stellar transaction is confirmed successfully.
147    pub async fn handle_stellar_success(
148        &self,
149        tx: TransactionRepoModel,
150        provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
151    ) -> Result<TransactionRepoModel, TransactionError> {
152        // Extract the actual fee charged from the transaction result and update network data
153        let updated_network_data = provider_response.result.as_ref().and_then(|tx_result| {
154            tx.network_data
155                .get_stellar_transaction_data()
156                .ok()
157                .map(|stellar_data| {
158                    NetworkTransactionData::Stellar(
159                        stellar_data.with_fee(tx_result.fee_charged as u32),
160                    )
161                })
162        });
163
164        let update_request = TransactionUpdateRequest {
165            status: Some(TransactionStatus::Confirmed),
166            confirmed_at: Some(Utc::now().to_rfc3339()),
167            network_data: updated_network_data,
168            ..Default::default()
169        };
170
171        let confirmed_tx = self
172            .finalize_transaction_state(tx.id.clone(), update_request)
173            .await?;
174
175        self.enqueue_next_pending_transaction(&tx.id).await?;
176
177        Ok(confirmed_tx)
178    }
179
180    /// Handles the logic when a Stellar transaction has failed.
181    pub async fn handle_stellar_failed(
182        &self,
183        tx: TransactionRepoModel,
184        provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
185    ) -> Result<TransactionRepoModel, TransactionError> {
186        let base_reason = "Transaction failed on-chain. Provider status: FAILED.".to_string();
187        let detailed_reason = if let Some(ref tx_result_xdr) = provider_response.result {
188            format!(
189                "{} Specific XDR reason: {}.",
190                base_reason,
191                tx_result_xdr.result.name()
192            )
193        } else {
194            format!("{} No detailed XDR result available.", base_reason)
195        };
196
197        warn!(reason = %detailed_reason, "stellar transaction failed");
198
199        let update_request = TransactionUpdateRequest {
200            status: Some(TransactionStatus::Failed),
201            status_reason: Some(detailed_reason),
202            ..Default::default()
203        };
204
205        let updated_tx = self
206            .finalize_transaction_state(tx.id.clone(), update_request)
207            .await?;
208
209        self.enqueue_next_pending_transaction(&tx.id).await?;
210
211        Ok(updated_tx)
212    }
213
214    /// Handles the logic when a Stellar transaction is still pending or in an unknown state.
215    pub async fn handle_stellar_pending(
216        &self,
217        tx: TransactionRepoModel,
218        original_status_str: String,
219    ) -> Result<TransactionRepoModel, TransactionError> {
220        info!(status = %original_status_str, "stellar transaction status is still pending, re-queueing check");
221        self.requeue_status_check(&tx).await?;
222        Ok(tx)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::models::{NetworkTransactionData, RepositoryError};
230    use mockall::predicate::eq;
231    use soroban_rs::stellar_rpc_client::GetTransactionResponse;
232
233    use crate::domain::transaction::stellar::test_helpers::*;
234
235    fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
236        GetTransactionResponse {
237            status: status.to_string(),
238            ledger: None,
239            envelope: None,
240            result: None,
241            result_meta: None,
242            events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
243                contract_events: vec![],
244                diagnostic_events: vec![],
245                transaction_events: vec![],
246            },
247        }
248    }
249
250    mod handle_transaction_status_tests {
251        use super::*;
252
253        #[tokio::test]
254        async fn handle_transaction_status_confirmed_triggers_next() {
255            let relayer = create_test_relayer();
256            let mut mocks = default_test_mocks();
257
258            let mut tx_to_handle = create_test_transaction(&relayer.id);
259            tx_to_handle.id = "tx-confirm-this".to_string();
260            let tx_hash_bytes = [1u8; 32];
261            let tx_hash_hex = hex::encode(tx_hash_bytes);
262            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
263            {
264                stellar_data.hash = Some(tx_hash_hex.clone());
265            } else {
266                panic!("Expected Stellar network data for tx_to_handle");
267            }
268            tx_to_handle.status = TransactionStatus::Submitted;
269
270            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
271
272            // 1. Mock provider to return SUCCESS
273            mocks
274                .provider
275                .expect_get_transaction()
276                .with(eq(expected_stellar_hash.clone()))
277                .times(1)
278                .returning(move |_| {
279                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
280                });
281
282            // 2. Mock partial_update for confirmation
283            mocks
284                .tx_repo
285                .expect_partial_update()
286                .withf(move |id, update| {
287                    id == "tx-confirm-this"
288                        && update.status == Some(TransactionStatus::Confirmed)
289                        && update.confirmed_at.is_some()
290                })
291                .times(1)
292                .returning(move |id, update| {
293                    let mut updated_tx = tx_to_handle.clone(); // Use the original tx_to_handle as base
294                    updated_tx.id = id;
295                    updated_tx.status = update.status.unwrap();
296                    updated_tx.confirmed_at = update.confirmed_at;
297                    Ok(updated_tx)
298                });
299
300            // Send notification for confirmed tx
301            mocks
302                .job_producer
303                .expect_produce_send_notification_job()
304                .times(1)
305                .returning(|_, _| Box::pin(async { Ok(()) }));
306
307            // 3. Mock find_by_status for pending transactions
308            let mut oldest_pending_tx = create_test_transaction(&relayer.id);
309            oldest_pending_tx.id = "tx-oldest-pending".to_string();
310            oldest_pending_tx.status = TransactionStatus::Pending;
311            let captured_oldest_pending_tx = oldest_pending_tx.clone();
312            mocks
313                .tx_repo
314                .expect_find_by_status()
315                .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
316                .times(1)
317                .returning(move |_, _| Ok(vec![captured_oldest_pending_tx.clone()]));
318
319            // 4. Mock produce_transaction_request_job for the next pending transaction
320            mocks
321                .job_producer
322                .expect_produce_transaction_request_job()
323                .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
324                .times(1)
325                .returning(|_, _| Box::pin(async { Ok(()) }));
326
327            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
328            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
329            initial_tx_for_handling.id = "tx-confirm-this".to_string();
330            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
331                initial_tx_for_handling.network_data
332            {
333                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
334            } else {
335                panic!("Expected Stellar network data for initial_tx_for_handling");
336            }
337            initial_tx_for_handling.status = TransactionStatus::Submitted;
338
339            let result = handler
340                .handle_transaction_status_impl(initial_tx_for_handling)
341                .await;
342
343            assert!(result.is_ok());
344            let handled_tx = result.unwrap();
345            assert_eq!(handled_tx.id, "tx-confirm-this");
346            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
347            assert!(handled_tx.confirmed_at.is_some());
348        }
349
350        #[tokio::test]
351        async fn handle_transaction_status_still_pending() {
352            let relayer = create_test_relayer();
353            let mut mocks = default_test_mocks();
354
355            let mut tx_to_handle = create_test_transaction(&relayer.id);
356            tx_to_handle.id = "tx-pending-check".to_string();
357            let tx_hash_bytes = [2u8; 32];
358            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
359            {
360                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
361            } else {
362                panic!("Expected Stellar network data");
363            }
364            tx_to_handle.status = TransactionStatus::Submitted; // Or any status that implies it's being watched
365
366            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
367
368            // 1. Mock provider to return PENDING
369            mocks
370                .provider
371                .expect_get_transaction()
372                .with(eq(expected_stellar_hash.clone()))
373                .times(1)
374                .returning(move |_| {
375                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
376                });
377
378            // 2. Mock partial_update: should NOT be called
379            mocks.tx_repo.expect_partial_update().never();
380
381            // 3. Mock job_producer to expect a re-enqueue of status check
382            mocks
383                .job_producer
384                .expect_produce_check_transaction_status_job()
385                .withf(move |job, delay| {
386                    job.transaction_id == "tx-pending-check" && delay.is_some()
387                })
388                .times(1)
389                .returning(|_, _| Box::pin(async { Ok(()) }));
390
391            // Notifications should NOT be sent for pending
392            mocks
393                .job_producer
394                .expect_produce_send_notification_job()
395                .never();
396
397            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
398            let original_tx_clone = tx_to_handle.clone();
399
400            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
401
402            assert!(result.is_ok());
403            let returned_tx = result.unwrap();
404            // Transaction should be returned unchanged as it's still pending
405            assert_eq!(returned_tx.id, original_tx_clone.id);
406            assert_eq!(returned_tx.status, original_tx_clone.status);
407            assert!(returned_tx.confirmed_at.is_none()); // Ensure it wasn't accidentally confirmed
408        }
409
410        #[tokio::test]
411        async fn handle_transaction_status_failed() {
412            let relayer = create_test_relayer();
413            let mut mocks = default_test_mocks();
414
415            let mut tx_to_handle = create_test_transaction(&relayer.id);
416            tx_to_handle.id = "tx-fail-this".to_string();
417            let tx_hash_bytes = [3u8; 32];
418            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
419            {
420                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
421            } else {
422                panic!("Expected Stellar network data");
423            }
424            tx_to_handle.status = TransactionStatus::Submitted;
425
426            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
427
428            // 1. Mock provider to return FAILED
429            mocks
430                .provider
431                .expect_get_transaction()
432                .with(eq(expected_stellar_hash.clone()))
433                .times(1)
434                .returning(move |_| {
435                    Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
436                });
437
438            // 2. Mock partial_update for failure - use actual update values
439            let relayer_id_for_mock = relayer.id.clone();
440            mocks
441                .tx_repo
442                .expect_partial_update()
443                .times(1)
444                .returning(move |id, update| {
445                    // Use the actual update values instead of hardcoding
446                    let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
447                    updated_tx.id = id;
448                    updated_tx.status = update.status.unwrap();
449                    updated_tx.status_reason = update.status_reason.clone();
450                    Ok::<_, RepositoryError>(updated_tx)
451                });
452
453            // Send notification for failed tx
454            mocks
455                .job_producer
456                .expect_produce_send_notification_job()
457                .times(1)
458                .returning(|_, _| Box::pin(async { Ok(()) }));
459
460            // 3. Mock find_by_status for pending transactions (should be called by enqueue_next_pending_transaction)
461            mocks
462                .tx_repo
463                .expect_find_by_status()
464                .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
465                .times(1)
466                .returning(move |_, _| Ok(vec![])); // No pending transactions
467
468            // Should NOT try to enqueue next transaction since there are no pending ones
469            mocks
470                .job_producer
471                .expect_produce_transaction_request_job()
472                .never();
473            // Should NOT re-queue status check
474            mocks
475                .job_producer
476                .expect_produce_check_transaction_status_job()
477                .never();
478
479            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
480            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
481            initial_tx_for_handling.id = "tx-fail-this".to_string();
482            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
483                initial_tx_for_handling.network_data
484            {
485                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
486            } else {
487                panic!("Expected Stellar network data");
488            }
489            initial_tx_for_handling.status = TransactionStatus::Submitted;
490
491            let result = handler
492                .handle_transaction_status_impl(initial_tx_for_handling)
493                .await;
494
495            assert!(result.is_ok());
496            let handled_tx = result.unwrap();
497            assert_eq!(handled_tx.id, "tx-fail-this");
498            assert_eq!(handled_tx.status, TransactionStatus::Failed);
499            assert!(handled_tx.status_reason.is_some());
500            assert_eq!(
501                handled_tx.status_reason.unwrap(),
502                "Transaction failed on-chain. Provider status: FAILED. No detailed XDR result available."
503            );
504        }
505
506        #[tokio::test]
507        async fn handle_transaction_status_provider_error() {
508            let relayer = create_test_relayer();
509            let mut mocks = default_test_mocks();
510
511            let mut tx_to_handle = create_test_transaction(&relayer.id);
512            tx_to_handle.id = "tx-provider-error".to_string();
513            let tx_hash_bytes = [4u8; 32];
514            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
515            {
516                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
517            } else {
518                panic!("Expected Stellar network data");
519            }
520            tx_to_handle.status = TransactionStatus::Submitted;
521
522            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
523
524            // 1. Mock provider to return an error
525            mocks
526                .provider
527                .expect_get_transaction()
528                .with(eq(expected_stellar_hash.clone()))
529                .times(1)
530                .returning(move |_| Box::pin(async { Err(eyre::eyre!("RPC boom")) }));
531
532            // 2. Mock partial_update: should NOT be called
533            mocks.tx_repo.expect_partial_update().never();
534
535            // 3. Mock job_producer to expect a re-enqueue of status check
536            mocks
537                .job_producer
538                .expect_produce_check_transaction_status_job()
539                .withf(move |job, delay| {
540                    job.transaction_id == "tx-provider-error" && delay.is_some()
541                })
542                .times(1)
543                .returning(|_, _| Box::pin(async { Ok(()) }));
544
545            // Notifications should NOT be sent
546            mocks
547                .job_producer
548                .expect_produce_send_notification_job()
549                .never();
550            // Should NOT try to enqueue next transaction
551            mocks
552                .job_producer
553                .expect_produce_transaction_request_job()
554                .never();
555
556            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
557            let original_tx_clone = tx_to_handle.clone();
558
559            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
560
561            assert!(result.is_ok()); // The handler itself should return Ok(original_tx)
562            let returned_tx = result.unwrap();
563            // Transaction should be returned unchanged
564            assert_eq!(returned_tx.id, original_tx_clone.id);
565            assert_eq!(returned_tx.status, original_tx_clone.status);
566        }
567
568        #[tokio::test]
569        async fn handle_transaction_status_no_hashes() {
570            let relayer = create_test_relayer();
571            let mut mocks = default_test_mocks(); // No mocks should be called, but make mutable for consistency
572
573            let mut tx_to_handle = create_test_transaction(&relayer.id);
574            tx_to_handle.id = "tx-no-hashes".to_string();
575            tx_to_handle.status = TransactionStatus::Submitted;
576
577            mocks.provider.expect_get_transaction().never();
578            mocks.tx_repo.expect_partial_update().never();
579            mocks
580                .job_producer
581                .expect_produce_check_transaction_status_job()
582                .never();
583            mocks
584                .job_producer
585                .expect_produce_send_notification_job()
586                .never();
587
588            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
589            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
590
591            assert!(
592                result.is_err(),
593                "Expected an error when hash is missing, but got Ok"
594            );
595            match result.unwrap_err() {
596                TransactionError::ValidationError(msg) => {
597                    assert!(
598                        msg.contains("Stellar transaction tx-no-hashes is missing or has an empty on-chain hash in network_data"),
599                        "Unexpected error message: {}",
600                        msg
601                    );
602                }
603                other => panic!("Expected ValidationError, got {:?}", other),
604            }
605        }
606
607        #[tokio::test]
608        async fn test_on_chain_failure_does_not_decrement_sequence() {
609            let relayer = create_test_relayer();
610            let mut mocks = default_test_mocks();
611
612            let mut tx_to_handle = create_test_transaction(&relayer.id);
613            tx_to_handle.id = "tx-on-chain-fail".to_string();
614            let tx_hash_bytes = [4u8; 32];
615            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
616            {
617                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
618                stellar_data.sequence_number = Some(100); // Has a sequence
619            }
620            tx_to_handle.status = TransactionStatus::Submitted;
621
622            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
623
624            // Mock provider to return FAILED (on-chain failure)
625            mocks
626                .provider
627                .expect_get_transaction()
628                .with(eq(expected_stellar_hash.clone()))
629                .times(1)
630                .returning(move |_| {
631                    Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
632                });
633
634            // Decrement should NEVER be called for on-chain failures
635            mocks.counter.expect_decrement().never();
636
637            // Mock partial_update for failure
638            mocks
639                .tx_repo
640                .expect_partial_update()
641                .times(1)
642                .returning(move |id, update| {
643                    let mut updated_tx = create_test_transaction("test");
644                    updated_tx.id = id;
645                    updated_tx.status = update.status.unwrap();
646                    updated_tx.status_reason = update.status_reason.clone();
647                    Ok::<_, RepositoryError>(updated_tx)
648                });
649
650            // Mock notification
651            mocks
652                .job_producer
653                .expect_produce_send_notification_job()
654                .times(1)
655                .returning(|_, _| Box::pin(async { Ok(()) }));
656
657            // Mock find_by_status
658            mocks
659                .tx_repo
660                .expect_find_by_status()
661                .returning(move |_, _| Ok(vec![]));
662
663            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
664            let initial_tx = tx_to_handle.clone();
665
666            let result = handler.handle_transaction_status_impl(initial_tx).await;
667
668            assert!(result.is_ok());
669            let handled_tx = result.unwrap();
670            assert_eq!(handled_tx.id, "tx-on-chain-fail");
671            assert_eq!(handled_tx.status, TransactionStatus::Failed);
672        }
673
674        #[tokio::test]
675        async fn test_on_chain_success_does_not_decrement_sequence() {
676            let relayer = create_test_relayer();
677            let mut mocks = default_test_mocks();
678
679            let mut tx_to_handle = create_test_transaction(&relayer.id);
680            tx_to_handle.id = "tx-on-chain-success".to_string();
681            let tx_hash_bytes = [5u8; 32];
682            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
683            {
684                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
685                stellar_data.sequence_number = Some(101); // Has a sequence
686            }
687            tx_to_handle.status = TransactionStatus::Submitted;
688
689            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
690
691            // Mock provider to return SUCCESS
692            mocks
693                .provider
694                .expect_get_transaction()
695                .with(eq(expected_stellar_hash.clone()))
696                .times(1)
697                .returning(move |_| {
698                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
699                });
700
701            // Decrement should NEVER be called for on-chain success
702            mocks.counter.expect_decrement().never();
703
704            // Mock partial_update for confirmation
705            mocks
706                .tx_repo
707                .expect_partial_update()
708                .withf(move |id, update| {
709                    id == "tx-on-chain-success"
710                        && update.status == Some(TransactionStatus::Confirmed)
711                        && update.confirmed_at.is_some()
712                })
713                .times(1)
714                .returning(move |id, update| {
715                    let mut updated_tx = create_test_transaction("test");
716                    updated_tx.id = id;
717                    updated_tx.status = update.status.unwrap();
718                    updated_tx.confirmed_at = update.confirmed_at;
719                    Ok(updated_tx)
720                });
721
722            // Mock notification
723            mocks
724                .job_producer
725                .expect_produce_send_notification_job()
726                .times(1)
727                .returning(|_, _| Box::pin(async { Ok(()) }));
728
729            // Mock find_by_status for next transaction
730            mocks
731                .tx_repo
732                .expect_find_by_status()
733                .returning(move |_, _| Ok(vec![]));
734
735            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
736            let initial_tx = tx_to_handle.clone();
737
738            let result = handler.handle_transaction_status_impl(initial_tx).await;
739
740            assert!(result.is_ok());
741            let handled_tx = result.unwrap();
742            assert_eq!(handled_tx.id, "tx-on-chain-success");
743            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
744        }
745
746        #[tokio::test]
747        async fn test_handle_transaction_status_with_xdr_error_requeues() {
748            // This test verifies that when get_transaction fails we re-queue for retry
749            let relayer = create_test_relayer();
750            let mut mocks = default_test_mocks();
751
752            let mut tx_to_handle = create_test_transaction(&relayer.id);
753            tx_to_handle.id = "tx-xdr-error-requeue".to_string();
754            let tx_hash_bytes = [8u8; 32];
755            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
756            {
757                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
758            }
759            tx_to_handle.status = TransactionStatus::Submitted;
760
761            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
762
763            // Mock provider to return a non-XDR error (won't trigger fallback)
764            mocks
765                .provider
766                .expect_get_transaction()
767                .with(eq(expected_stellar_hash.clone()))
768                .times(1)
769                .returning(move |_| Box::pin(async { Err(eyre::eyre!("Network timeout")) }));
770
771            // Mock job_producer to expect a re-enqueue of status check
772            mocks
773                .job_producer
774                .expect_produce_check_transaction_status_job()
775                .withf(move |job, delay| {
776                    job.transaction_id == "tx-xdr-error-requeue" && delay.is_some()
777                })
778                .times(1)
779                .returning(|_, _| Box::pin(async { Ok(()) }));
780
781            // No partial update should occur
782            mocks.tx_repo.expect_partial_update().never();
783            mocks
784                .job_producer
785                .expect_produce_send_notification_job()
786                .never();
787
788            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
789            let original_tx_clone = tx_to_handle.clone();
790
791            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
792
793            assert!(result.is_ok()); // The handler returns Ok with the original transaction
794            let returned_tx = result.unwrap();
795            // Transaction should be returned unchanged
796            assert_eq!(returned_tx.id, original_tx_clone.id);
797            assert_eq!(returned_tx.status, original_tx_clone.status);
798        }
799    }
800}