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::{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    /// Main status handling method with robust error handling.
30    /// This method checks transaction status and handles lane cleanup for finalized transactions.
31    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        // Early exit for final states - no need to check
38        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                // Handle different error types appropriately
60                match error {
61                    TransactionError::ValidationError(ref msg) => {
62                        // Validation errors (like missing hash) indicate a fundamental problem
63                        // that won't be fixed by retrying. Mark the transaction as Failed.
64                        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                        // For other errors (like provider errors), log and propagate
75                        // The job system will retry based on the job configuration
76                        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    /// Core status checking logic - pure business logic without error handling concerns.
89    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    /// Parses the transaction hash from the network data and validates it.
114    /// Returns a `TransactionError::ValidationError` if the hash is missing, empty, or invalid.
115    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    /// Mark a transaction as failed with a reason
139    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        // Try to enqueue next transaction
157        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    /// Handles the logic when a Stellar transaction is confirmed successfully.
165    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        // Extract the actual fee charged from the transaction result and update network data
171        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    /// Handles the logic when a Stellar transaction has failed.
199    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    /// Handles the logic when a Stellar transaction is still pending or in an unknown state.
233    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            // 1. Mock provider to return SUCCESS
294            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            // 2. Mock partial_update for confirmation
304            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(); // Use the original tx_to_handle as base
315                    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            // Send notification for confirmed tx
322            mocks
323                .job_producer
324                .expect_produce_send_notification_job()
325                .times(1)
326                .returning(|_, _| Box::pin(async { Ok(()) }));
327
328            // 3. Mock find_by_status for pending transactions
329            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            // 4. Mock produce_transaction_request_job for the next pending transaction
341            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; // Or any status that implies it's being watched
388
389            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
390
391            // 1. Mock provider to return PENDING
392            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            // 2. Mock partial_update: should NOT be called
402            mocks.tx_repo.expect_partial_update().never();
403
404            // Notifications should NOT be sent for pending
405            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            // Transaction should be returned unchanged as it's still pending
418            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()); // Ensure it wasn't accidentally confirmed
421        }
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            // 1. Mock provider to return FAILED
443            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            // 2. Mock partial_update for failure - use actual update values
453            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                    // Use the actual update values instead of hardcoding
460                    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            // Send notification for failed tx
468            mocks
469                .job_producer
470                .expect_produce_send_notification_job()
471                .times(1)
472                .returning(|_, _| Box::pin(async { Ok(()) }));
473
474            // 3. Mock find_by_status for pending transactions (should be called by enqueue_next_pending_transaction)
475            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![])); // No pending transactions
481
482            // Should NOT try to enqueue next transaction since there are no pending ones
483            mocks
484                .job_producer
485                .expect_produce_transaction_request_job()
486                .never();
487            // Should NOT re-queue status check
488            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            // 1. Mock provider to return an error
541            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            // 2. Mock partial_update: should NOT be called
551            mocks.tx_repo.expect_partial_update().never();
552
553            // Notifications should NOT be sent
554            mocks
555                .job_producer
556                .expect_produce_send_notification_job()
557                .never();
558            // Should NOT try to enqueue next transaction
559            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            // Provider errors are now propagated as errors (retriable)
569            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            // With our new error handling, validation errors mark the transaction as failed
584            mocks.provider.expect_get_transaction().never();
585
586            // Expect partial_update to be called to mark as failed
587            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            // Expect notification to be sent after marking as failed
599            mocks
600                .job_producer
601                .expect_produce_send_notification_job()
602                .times(1)
603                .returning(|_, _| Box::pin(async { Ok(()) }));
604
605            // Expect find_by_status to be called when enqueuing next transaction
606            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![])); // No pending transactions
612
613            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
614            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
615
616            // Should succeed but mark transaction as Failed
617            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); // Has a sequence
644            }
645            tx_to_handle.status = TransactionStatus::Submitted;
646
647            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
648
649            // Mock provider to return FAILED (on-chain failure)
650            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            // Decrement should NEVER be called for on-chain failures
660            mocks.counter.expect_decrement().never();
661
662            // Mock partial_update for failure
663            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            // Mock notification
676            mocks
677                .job_producer
678                .expect_produce_send_notification_job()
679                .times(1)
680                .returning(|_, _| Box::pin(async { Ok(()) }));
681
682            // Mock find_by_status
683            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); // Has a sequence
712            }
713            tx_to_handle.status = TransactionStatus::Submitted;
714
715            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
716
717            // Mock provider to return SUCCESS
718            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            // Decrement should NEVER be called for on-chain success
728            mocks.counter.expect_decrement().never();
729
730            // Mock partial_update for confirmation
731            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            // Mock notification
749            mocks
750                .job_producer
751                .expect_produce_send_notification_job()
752                .times(1)
753                .returning(|_, _| Box::pin(async { Ok(()) }));
754
755            // Mock find_by_status for next transaction
756            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            // This test verifies that when get_transaction fails we re-queue for retry
775            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            // Mock provider to return a non-XDR error (won't trigger fallback)
791            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            // No partial update should occur
801            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            // Provider errors are now propagated as errors (retriable)
812            assert!(result.is_err());
813            matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
814        }
815    }
816}