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 log::{info, warn};
7use soroban_rs::xdr::{Error, Hash};
8
9use super::StellarRelayerTransaction;
10use crate::{
11    constants::STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS,
12    jobs::{JobProducerTrait, TransactionStatusCheck},
13    models::{RelayerRepoModel, TransactionError, TransactionRepoModel, TransactionStatus},
14    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
15    services::{Signer, StellarProviderTrait},
16};
17
18impl<R, T, J, S, P, C> StellarRelayerTransaction<R, T, J, S, P, C>
19where
20    R: Repository<RelayerRepoModel, String> + Send + Sync,
21    T: TransactionRepository + Send + Sync,
22    J: JobProducerTrait + Send + Sync,
23    S: Signer + Send + Sync,
24    P: StellarProviderTrait + Send + Sync,
25    C: TransactionCounterTrait + Send + Sync,
26{
27    /// Main status handling method with robust error handling.
28    /// This method checks transaction status and handles lane cleanup for finalized transactions.
29    pub async fn handle_transaction_status_impl(
30        &self,
31        tx: TransactionRepoModel,
32    ) -> Result<TransactionRepoModel, TransactionError> {
33        info!("Handling transaction status for: {:?}", tx.id);
34
35        // Call core status checking logic with error handling
36        match self.status_core(tx.clone()).await {
37            Ok(updated_tx) => Ok(updated_tx),
38            Err(error) => {
39                // Only retry for provider errors, not validation errors
40                match error {
41                    TransactionError::ValidationError(_) => {
42                        // Don't retry validation errors (like missing hash)
43                        Err(error)
44                    }
45                    _ => {
46                        // Handle status check failure - requeue for retry
47                        self.handle_status_failure(tx, error).await
48                    }
49                }
50            }
51        }
52    }
53
54    /// Core status checking logic - pure business logic without error handling concerns.
55    async fn status_core(
56        &self,
57        tx: TransactionRepoModel,
58    ) -> Result<TransactionRepoModel, TransactionError> {
59        let stellar_hash = self.parse_and_validate_hash(&tx)?;
60
61        let provider_response = self
62            .provider()
63            .get_transaction(&stellar_hash)
64            .await
65            .map_err(TransactionError::from)?;
66
67        match provider_response.status.as_str().to_uppercase().as_str() {
68            "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
69            "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
70            _ => {
71                self.handle_stellar_pending(tx, provider_response.status)
72                    .await
73            }
74        }
75    }
76
77    /// Handles status check failures with retry logic.
78    /// This method ensures failed status checks are retried appropriately.
79    async fn handle_status_failure(
80        &self,
81        tx: TransactionRepoModel,
82        error: TransactionError,
83    ) -> Result<TransactionRepoModel, TransactionError> {
84        warn!(
85            "Failed to get Stellar transaction status for {}: {}. Re-queueing check.",
86            tx.id, error
87        );
88
89        // Step 1: Re-queue status check for retry
90        if let Err(requeue_error) = self.requeue_status_check(&tx).await {
91            warn!(
92                "Failed to requeue status check for transaction {}: {}",
93                tx.id, requeue_error
94            );
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!(
100            "Transaction {} status check failure handled. Will retry later. Error: {}",
101            tx.id, error
102        );
103
104        // Step 3: Return original transaction unchanged (will be retried)
105        Ok(tx)
106    }
107
108    /// Helper function to re-queue a transaction status check job.
109    pub async fn requeue_status_check(
110        &self,
111        tx: &TransactionRepoModel,
112    ) -> Result<(), TransactionError> {
113        self.job_producer()
114            .produce_check_transaction_status_job(
115                TransactionStatusCheck::new(tx.id.clone(), tx.relayer_id.clone()),
116                Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS),
117            )
118            .await?;
119        Ok(())
120    }
121
122    /// Parses the transaction hash from the network data and validates it.
123    /// Returns a `TransactionError::ValidationError` if the hash is missing, empty, or invalid.
124    pub fn parse_and_validate_hash(
125        &self,
126        tx: &TransactionRepoModel,
127    ) -> Result<Hash, TransactionError> {
128        let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
129
130        let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
131            TransactionError::ValidationError(format!(
132                "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
133                tx.id
134            ))
135        })?;
136
137        let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
138            TransactionError::UnexpectedError(format!(
139                "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
140                tx_hash_str, tx.id, e
141            ))
142        })?;
143
144        Ok(stellar_hash)
145    }
146
147    /// Handles the logic when a Stellar transaction is confirmed successfully.
148    pub async fn handle_stellar_success(
149        &self,
150        tx: TransactionRepoModel,
151        _provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse, // May be needed later for more details
152    ) -> Result<TransactionRepoModel, TransactionError> {
153        let confirmed_tx = self
154            .finalize_transaction_state(
155                tx.id.clone(),
156                TransactionStatus::Confirmed,
157                None,
158                Some(Utc::now().to_rfc3339()),
159            )
160            .await?;
161
162        self.enqueue_next_pending_transaction(&tx.id).await?;
163
164        Ok(confirmed_tx)
165    }
166
167    /// Handles the logic when a Stellar transaction has failed.
168    pub async fn handle_stellar_failed(
169        &self,
170        tx: TransactionRepoModel,
171        provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
172    ) -> Result<TransactionRepoModel, TransactionError> {
173        let base_reason = "Transaction failed on-chain. Provider status: FAILED.".to_string();
174        let detailed_reason = if let Some(ref tx_result_xdr) = provider_response.result {
175            format!(
176                "{} Specific XDR reason: {}.",
177                base_reason,
178                tx_result_xdr.result.name()
179            )
180        } else {
181            format!("{} No detailed XDR result available.", base_reason)
182        };
183
184        warn!("Stellar transaction {} failed: {}", tx.id, detailed_reason);
185        let updated_tx = self
186            .finalize_transaction_state(
187                tx.id.clone(),
188                TransactionStatus::Failed,
189                Some(detailed_reason),
190                None,
191            )
192            .await?;
193
194        self.enqueue_next_pending_transaction(&tx.id).await?;
195
196        Ok(updated_tx)
197    }
198
199    /// Handles the logic when a Stellar transaction is still pending or in an unknown state.
200    pub async fn handle_stellar_pending(
201        &self,
202        tx: TransactionRepoModel,
203        original_status_str: String,
204    ) -> Result<TransactionRepoModel, TransactionError> {
205        info!(
206            "Stellar transaction {} status is still '{}'. Re-queueing check.",
207            tx.id, original_status_str
208        );
209        self.requeue_status_check(&tx).await?;
210        Ok(tx)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::models::{NetworkTransactionData, RepositoryError};
218    use mockall::predicate::eq;
219    use soroban_rs::stellar_rpc_client::GetTransactionResponse;
220
221    use crate::domain::transaction::stellar::test_helpers::*;
222
223    fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
224        GetTransactionResponse {
225            status: status.to_string(),
226            envelope: None,
227            result: None,
228            result_meta: None,
229        }
230    }
231
232    mod handle_transaction_status_tests {
233        use super::*;
234
235        #[tokio::test]
236        async fn handle_transaction_status_confirmed_triggers_next() {
237            let relayer = create_test_relayer();
238            let mut mocks = default_test_mocks();
239
240            let mut tx_to_handle = create_test_transaction(&relayer.id);
241            tx_to_handle.id = "tx-confirm-this".to_string();
242            let tx_hash_bytes = [1u8; 32];
243            let tx_hash_hex = hex::encode(tx_hash_bytes);
244            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
245            {
246                stellar_data.hash = Some(tx_hash_hex.clone());
247            } else {
248                panic!("Expected Stellar network data for tx_to_handle");
249            }
250            tx_to_handle.status = TransactionStatus::Submitted;
251
252            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
253
254            // 1. Mock provider to return SUCCESS
255            mocks
256                .provider
257                .expect_get_transaction()
258                .with(eq(expected_stellar_hash.clone()))
259                .times(1)
260                .returning(move |_| {
261                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
262                });
263
264            // 2. Mock partial_update for confirmation
265            mocks
266                .tx_repo
267                .expect_partial_update()
268                .withf(move |id, update| {
269                    id == "tx-confirm-this"
270                        && update.status == Some(TransactionStatus::Confirmed)
271                        && update.confirmed_at.is_some()
272                })
273                .times(1)
274                .returning(move |id, update| {
275                    let mut updated_tx = tx_to_handle.clone(); // Use the original tx_to_handle as base
276                    updated_tx.id = id;
277                    updated_tx.status = update.status.unwrap();
278                    updated_tx.confirmed_at = update.confirmed_at;
279                    Ok(updated_tx)
280                });
281
282            // Send notification for confirmed tx
283            mocks
284                .job_producer
285                .expect_produce_send_notification_job()
286                .times(1)
287                .returning(|_, _| Box::pin(async { Ok(()) }));
288
289            // 3. Mock find_by_status for pending transactions
290            let mut oldest_pending_tx = create_test_transaction(&relayer.id);
291            oldest_pending_tx.id = "tx-oldest-pending".to_string();
292            oldest_pending_tx.status = TransactionStatus::Pending;
293            let captured_oldest_pending_tx = oldest_pending_tx.clone();
294            mocks
295                .tx_repo
296                .expect_find_by_status()
297                .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
298                .times(1)
299                .returning(move |_, _| Ok(vec![captured_oldest_pending_tx.clone()]));
300
301            // 4. Mock produce_transaction_request_job for the next pending transaction
302            mocks
303                .job_producer
304                .expect_produce_transaction_request_job()
305                .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
306                .times(1)
307                .returning(|_, _| Box::pin(async { Ok(()) }));
308
309            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
310            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
311            initial_tx_for_handling.id = "tx-confirm-this".to_string();
312            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
313                initial_tx_for_handling.network_data
314            {
315                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
316            } else {
317                panic!("Expected Stellar network data for initial_tx_for_handling");
318            }
319            initial_tx_for_handling.status = TransactionStatus::Submitted;
320
321            let result = handler
322                .handle_transaction_status_impl(initial_tx_for_handling)
323                .await;
324
325            assert!(result.is_ok());
326            let handled_tx = result.unwrap();
327            assert_eq!(handled_tx.id, "tx-confirm-this");
328            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
329            assert!(handled_tx.confirmed_at.is_some());
330        }
331
332        #[tokio::test]
333        async fn handle_transaction_status_still_pending() {
334            let relayer = create_test_relayer();
335            let mut mocks = default_test_mocks();
336
337            let mut tx_to_handle = create_test_transaction(&relayer.id);
338            tx_to_handle.id = "tx-pending-check".to_string();
339            let tx_hash_bytes = [2u8; 32];
340            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
341            {
342                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
343            } else {
344                panic!("Expected Stellar network data");
345            }
346            tx_to_handle.status = TransactionStatus::Submitted; // Or any status that implies it's being watched
347
348            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
349
350            // 1. Mock provider to return PENDING
351            mocks
352                .provider
353                .expect_get_transaction()
354                .with(eq(expected_stellar_hash.clone()))
355                .times(1)
356                .returning(move |_| {
357                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
358                });
359
360            // 2. Mock partial_update: should NOT be called
361            mocks.tx_repo.expect_partial_update().never();
362
363            // 3. Mock job_producer to expect a re-enqueue of status check
364            mocks
365                .job_producer
366                .expect_produce_check_transaction_status_job()
367                .withf(move |job, delay| {
368                    job.transaction_id == "tx-pending-check"
369                        && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
370                })
371                .times(1)
372                .returning(|_, _| Box::pin(async { Ok(()) }));
373
374            // Notifications should NOT be sent for pending
375            mocks
376                .job_producer
377                .expect_produce_send_notification_job()
378                .never();
379
380            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
381            let original_tx_clone = tx_to_handle.clone();
382
383            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
384
385            assert!(result.is_ok());
386            let returned_tx = result.unwrap();
387            // Transaction should be returned unchanged as it's still pending
388            assert_eq!(returned_tx.id, original_tx_clone.id);
389            assert_eq!(returned_tx.status, original_tx_clone.status);
390            assert!(returned_tx.confirmed_at.is_none()); // Ensure it wasn't accidentally confirmed
391        }
392
393        #[tokio::test]
394        async fn handle_transaction_status_failed() {
395            let relayer = create_test_relayer();
396            let mut mocks = default_test_mocks();
397
398            let mut tx_to_handle = create_test_transaction(&relayer.id);
399            tx_to_handle.id = "tx-fail-this".to_string();
400            let tx_hash_bytes = [3u8; 32];
401            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
402            {
403                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
404            } else {
405                panic!("Expected Stellar network data");
406            }
407            tx_to_handle.status = TransactionStatus::Submitted;
408
409            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
410
411            // 1. Mock provider to return FAILED
412            mocks
413                .provider
414                .expect_get_transaction()
415                .with(eq(expected_stellar_hash.clone()))
416                .times(1)
417                .returning(move |_| {
418                    Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
419                });
420
421            // 2. Mock partial_update for failure - use actual update values
422            let relayer_id_for_mock = relayer.id.clone();
423            mocks
424                .tx_repo
425                .expect_partial_update()
426                .times(1)
427                .returning(move |id, update| {
428                    // Use the actual update values instead of hardcoding
429                    let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
430                    updated_tx.id = id;
431                    updated_tx.status = update.status.unwrap();
432                    updated_tx.status_reason = update.status_reason.clone();
433                    Ok::<_, RepositoryError>(updated_tx)
434                });
435
436            // Send notification for failed tx
437            mocks
438                .job_producer
439                .expect_produce_send_notification_job()
440                .times(1)
441                .returning(|_, _| Box::pin(async { Ok(()) }));
442
443            // 3. Mock find_by_status for pending transactions (should be called by enqueue_next_pending_transaction)
444            mocks
445                .tx_repo
446                .expect_find_by_status()
447                .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
448                .times(1)
449                .returning(move |_, _| Ok(vec![])); // No pending transactions
450
451            // Should NOT try to enqueue next transaction since there are no pending ones
452            mocks
453                .job_producer
454                .expect_produce_transaction_request_job()
455                .never();
456            // Should NOT re-queue status check
457            mocks
458                .job_producer
459                .expect_produce_check_transaction_status_job()
460                .never();
461
462            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
463            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
464            initial_tx_for_handling.id = "tx-fail-this".to_string();
465            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
466                initial_tx_for_handling.network_data
467            {
468                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
469            } else {
470                panic!("Expected Stellar network data");
471            }
472            initial_tx_for_handling.status = TransactionStatus::Submitted;
473
474            let result = handler
475                .handle_transaction_status_impl(initial_tx_for_handling)
476                .await;
477
478            assert!(result.is_ok());
479            let handled_tx = result.unwrap();
480            assert_eq!(handled_tx.id, "tx-fail-this");
481            assert_eq!(handled_tx.status, TransactionStatus::Failed);
482            assert!(handled_tx.status_reason.is_some());
483            assert_eq!(
484                handled_tx.status_reason.unwrap(),
485                "Transaction failed on-chain. Provider status: FAILED. No detailed XDR result available."
486            );
487        }
488
489        #[tokio::test]
490        async fn handle_transaction_status_provider_error() {
491            let relayer = create_test_relayer();
492            let mut mocks = default_test_mocks();
493
494            let mut tx_to_handle = create_test_transaction(&relayer.id);
495            tx_to_handle.id = "tx-provider-error".to_string();
496            let tx_hash_bytes = [4u8; 32];
497            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
498            {
499                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
500            } else {
501                panic!("Expected Stellar network data");
502            }
503            tx_to_handle.status = TransactionStatus::Submitted;
504
505            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
506
507            // 1. Mock provider to return an error
508            mocks
509                .provider
510                .expect_get_transaction()
511                .with(eq(expected_stellar_hash.clone()))
512                .times(1)
513                .returning(move |_| Box::pin(async { Err(eyre::eyre!("RPC boom")) }));
514
515            // 2. Mock partial_update: should NOT be called
516            mocks.tx_repo.expect_partial_update().never();
517
518            // 3. Mock job_producer to expect a re-enqueue of status check
519            mocks
520                .job_producer
521                .expect_produce_check_transaction_status_job()
522                .withf(move |job, delay| {
523                    job.transaction_id == "tx-provider-error"
524                        && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
525                })
526                .times(1)
527                .returning(|_, _| Box::pin(async { Ok(()) }));
528
529            // Notifications should NOT be sent
530            mocks
531                .job_producer
532                .expect_produce_send_notification_job()
533                .never();
534            // Should NOT try to enqueue next transaction
535            mocks
536                .job_producer
537                .expect_produce_transaction_request_job()
538                .never();
539
540            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
541            let original_tx_clone = tx_to_handle.clone();
542
543            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
544
545            assert!(result.is_ok()); // The handler itself should return Ok(original_tx)
546            let returned_tx = result.unwrap();
547            // Transaction should be returned unchanged
548            assert_eq!(returned_tx.id, original_tx_clone.id);
549            assert_eq!(returned_tx.status, original_tx_clone.status);
550        }
551
552        #[tokio::test]
553        async fn handle_transaction_status_no_hashes() {
554            let relayer = create_test_relayer();
555            let mut mocks = default_test_mocks(); // No mocks should be called, but make mutable for consistency
556
557            let mut tx_to_handle = create_test_transaction(&relayer.id);
558            tx_to_handle.id = "tx-no-hashes".to_string();
559            tx_to_handle.status = TransactionStatus::Submitted;
560
561            mocks.provider.expect_get_transaction().never();
562            mocks.tx_repo.expect_partial_update().never();
563            mocks
564                .job_producer
565                .expect_produce_check_transaction_status_job()
566                .never();
567            mocks
568                .job_producer
569                .expect_produce_send_notification_job()
570                .never();
571
572            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
573            let result = handler.handle_transaction_status_impl(tx_to_handle).await;
574
575            assert!(
576                result.is_err(),
577                "Expected an error when hash is missing, but got Ok"
578            );
579            match result.unwrap_err() {
580                TransactionError::ValidationError(msg) => {
581                    assert!(
582                        msg.contains("Stellar transaction tx-no-hashes is missing or has an empty on-chain hash in network_data"),
583                        "Unexpected error message: {}",
584                        msg
585                    );
586                }
587                other => panic!("Expected ValidationError, got {:?}", other),
588            }
589        }
590    }
591}