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