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