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::{DateTime, Utc};
6use soroban_rs::xdr::{
7    Error, Hash, InnerTransactionResultResult, InvokeHostFunctionResult, Limits, OperationResult,
8    OperationResultTr, TransactionEnvelope, TransactionResultResult, WriteXdr,
9};
10use tracing::{debug, info, warn};
11
12use super::{is_final_state, StellarRelayerTransaction};
13use crate::constants::{
14    get_stellar_max_stuck_transaction_lifetime, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
15    STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
16};
17use crate::domain::transaction::stellar::prepare::common::send_submit_transaction_job;
18use crate::domain::transaction::stellar::utils::{
19    compute_resubmit_backoff_interval, extract_return_value_from_meta, extract_time_bounds,
20};
21use crate::domain::transaction::util::{get_age_since_created, get_age_since_sent_or_created};
22use crate::domain::xdr_utils::parse_transaction_xdr;
23use crate::{
24    constants::STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS,
25    jobs::{JobProducerTrait, StatusCheckContext, TransactionRequest},
26    models::{
27        NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
28        TransactionStatus, TransactionUpdateRequest,
29    },
30    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
31    services::{
32        provider::StellarProviderTrait,
33        signer::{Signer, StellarSignTrait},
34    },
35};
36
37impl<R, T, J, S, P, C, D> StellarRelayerTransaction<R, T, J, S, P, C, D>
38where
39    R: Repository<RelayerRepoModel, String> + Send + Sync,
40    T: TransactionRepository + Send + Sync,
41    J: JobProducerTrait + Send + Sync,
42    S: Signer + StellarSignTrait + Send + Sync,
43    P: StellarProviderTrait + Send + Sync,
44    C: TransactionCounterTrait + Send + Sync,
45    D: crate::services::stellar_dex::StellarDexServiceTrait + Send + Sync + 'static,
46{
47    /// Main status handling method with robust error handling.
48    /// This method checks transaction status and handles lane cleanup for finalized transactions.
49    ///
50    /// # Arguments
51    ///
52    /// * `tx` - The transaction to check status for
53    /// * `context` - Optional circuit breaker context with failure tracking information
54    pub async fn handle_transaction_status_impl(
55        &self,
56        tx: TransactionRepoModel,
57        context: Option<StatusCheckContext>,
58    ) -> Result<TransactionRepoModel, TransactionError> {
59        debug!(
60            tx_id = %tx.id,
61            relayer_id = %tx.relayer_id,
62            status = ?tx.status,
63            "handling transaction status"
64        );
65
66        // Early exit for final states - no need to check
67        if is_final_state(&tx.status) {
68            debug!(
69                tx_id = %tx.id,
70                relayer_id = %tx.relayer_id,
71                status = ?tx.status,
72                "transaction in final state, skipping status check"
73            );
74            return Ok(tx);
75        }
76
77        // Check if circuit breaker should force finalization
78        if let Some(ref ctx) = context {
79            if ctx.should_force_finalize() {
80                let reason = format!(
81                    "Transaction status monitoring failed after {} consecutive errors (total: {}). \
82                     Last status: {:?}. Unable to determine final on-chain state.",
83                    ctx.consecutive_failures, ctx.total_failures, tx.status
84                );
85                warn!(
86                    tx_id = %tx.id,
87                    consecutive_failures = ctx.consecutive_failures,
88                    total_failures = ctx.total_failures,
89                    max_consecutive = ctx.max_consecutive_failures,
90                    "circuit breaker triggered, forcing transaction to failed state"
91                );
92                // Note: Expiry checks are already performed in the normal flow for Pending/Sent
93                // states (before any RPC calls). If we've hit consecutive failures, it's a strong
94                // signal that status monitoring is fundamentally broken for this transaction.
95                return self.mark_as_failed(tx, reason).await;
96            }
97        }
98
99        match self.status_core(tx.clone()).await {
100            Ok(updated_tx) => {
101                debug!(
102                    tx_id = %updated_tx.id,
103                    status = ?updated_tx.status,
104                    "status check completed successfully"
105                );
106                Ok(updated_tx)
107            }
108            Err(error) => {
109                debug!(
110                    tx_id = %tx.id,
111                    error = ?error,
112                    "status check encountered error"
113                );
114
115                // CAS conflict means another writer already mutated this tx.
116                // Reload the latest state and return Ok so the status handler
117                // sees a non-final status and schedules the next poll cycle via
118                // HandlerError::Retry — no work is lost, just deferred.
119                if error.is_concurrent_update_conflict() {
120                    info!(
121                        tx_id = %tx.id,
122                        relayer_id = %tx.relayer_id,
123                        "concurrent transaction update detected during status handling, reloading latest state"
124                    );
125                    return self
126                        .transaction_repository()
127                        .get_by_id(tx.id.clone())
128                        .await
129                        .map_err(TransactionError::from);
130                }
131
132                // Handle different error types appropriately
133                match error {
134                    TransactionError::ValidationError(ref msg) => {
135                        // Validation errors (like missing hash) indicate a fundamental problem
136                        // that won't be fixed by retrying. Mark the transaction as Failed.
137                        warn!(
138                            tx_id = %tx.id,
139                            error = %msg,
140                            "validation error detected - marking transaction as failed"
141                        );
142
143                        self.mark_as_failed(tx, format!("Validation error: {msg}"))
144                            .await
145                    }
146                    _ => {
147                        // For other errors (like provider errors), log and propagate
148                        // The job system will retry based on the job configuration
149                        warn!(
150                            tx_id = %tx.id,
151                            error = ?error,
152                            "status check failed with retriable error, will retry"
153                        );
154                        Err(error)
155                    }
156                }
157            }
158        }
159    }
160
161    /// Core status checking logic - pure business logic without error handling concerns.
162    /// Dispatches to the appropriate handler based on internal transaction status.
163    async fn status_core(
164        &self,
165        tx: TransactionRepoModel,
166    ) -> Result<TransactionRepoModel, TransactionError> {
167        match tx.status {
168            TransactionStatus::Pending => self.handle_pending_state(tx).await,
169            TransactionStatus::Sent => self.handle_sent_state(tx).await,
170            _ => self.handle_submitted_state(tx).await,
171        }
172    }
173
174    /// Parses the transaction hash from the network data and validates it.
175    /// Returns a `TransactionError::ValidationError` if the hash is missing, empty, or invalid.
176    pub fn parse_and_validate_hash(
177        &self,
178        tx: &TransactionRepoModel,
179    ) -> Result<Hash, TransactionError> {
180        let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
181
182        let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
183            TransactionError::ValidationError(format!(
184                "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
185                tx.id
186            ))
187        })?;
188
189        let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
190            TransactionError::UnexpectedError(format!(
191                "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
192                tx_hash_str, tx.id, e
193            ))
194        })?;
195
196        Ok(stellar_hash)
197    }
198
199    /// Mark a transaction as failed with a reason
200    pub(super) async fn mark_as_failed(
201        &self,
202        tx: TransactionRepoModel,
203        reason: String,
204    ) -> Result<TransactionRepoModel, TransactionError> {
205        warn!(tx_id = %tx.id, reason = %reason, "marking transaction as failed");
206
207        let update_request = TransactionUpdateRequest {
208            status: Some(TransactionStatus::Failed),
209            status_reason: Some(reason),
210            ..Default::default()
211        };
212
213        let failed_tx = self
214            .finalize_transaction_state(tx.id.clone(), update_request)
215            .await?;
216
217        // Try to enqueue next transaction
218        if let Err(e) = self.enqueue_next_pending_transaction(&tx.id).await {
219            warn!(error = %e, "failed to enqueue next pending transaction after failure");
220        }
221
222        Ok(failed_tx)
223    }
224
225    /// Mark a transaction as expired with a reason
226    pub(super) async fn mark_as_expired(
227        &self,
228        tx: TransactionRepoModel,
229        reason: String,
230    ) -> Result<TransactionRepoModel, TransactionError> {
231        info!(tx_id = %tx.id, reason = %reason, "marking transaction as expired");
232
233        let update_request = TransactionUpdateRequest {
234            status: Some(TransactionStatus::Expired),
235            status_reason: Some(reason),
236            ..Default::default()
237        };
238
239        let expired_tx = self
240            .finalize_transaction_state(tx.id.clone(), update_request)
241            .await?;
242
243        // Try to enqueue next transaction
244        if let Err(e) = self.enqueue_next_pending_transaction(&tx.id).await {
245            warn!(tx_id = %tx.id, relayer_id = %tx.relayer_id, error = %e, "failed to enqueue next pending transaction after expiration");
246        }
247
248        Ok(expired_tx)
249    }
250
251    /// Check if expired: valid_until > XDR time_bounds > false
252    pub(super) fn is_transaction_expired(
253        &self,
254        tx: &TransactionRepoModel,
255    ) -> Result<bool, TransactionError> {
256        if let Some(valid_until_str) = &tx.valid_until {
257            return Ok(Self::is_valid_until_string_expired(valid_until_str));
258        }
259
260        // Fallback: parse signed_envelope_xdr for legacy rows
261        let stellar_data = tx.network_data.get_stellar_transaction_data()?;
262        if let Some(signed_xdr) = &stellar_data.signed_envelope_xdr {
263            if let Ok(envelope) = parse_transaction_xdr(signed_xdr, true) {
264                if let Some(tb) = extract_time_bounds(&envelope) {
265                    if tb.max_time.0 == 0 {
266                        return Ok(false); // unbounded
267                    }
268                    return Ok(Utc::now().timestamp() as u64 > tb.max_time.0);
269                }
270            }
271        }
272
273        Ok(false)
274    }
275
276    /// Check if a valid_until string has expired (RFC3339 or numeric timestamp).
277    fn is_valid_until_string_expired(valid_until: &str) -> bool {
278        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(valid_until) {
279            return Utc::now() > dt.with_timezone(&Utc);
280        }
281        match valid_until.parse::<i64>() {
282            Ok(0) => false,
283            Ok(ts) => Utc::now().timestamp() > ts,
284            Err(_) => false,
285        }
286    }
287
288    /// Handles the logic when a Stellar transaction is confirmed successfully.
289    pub async fn handle_stellar_success(
290        &self,
291        tx: TransactionRepoModel,
292        provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
293    ) -> Result<TransactionRepoModel, TransactionError> {
294        // Extract the actual fee charged and transaction result from the transaction response
295        let updated_network_data =
296            tx.network_data
297                .get_stellar_transaction_data()
298                .ok()
299                .map(|mut stellar_data| {
300                    // Update fee if available
301                    if let Some(tx_result) = provider_response.result.as_ref() {
302                        stellar_data = stellar_data.with_fee(tx_result.fee_charged as u32);
303                    }
304
305                    // Extract transaction result XDR from result_meta if available
306                    if let Some(result_meta) = provider_response.result_meta.as_ref() {
307                        if let Some(return_value) = extract_return_value_from_meta(result_meta) {
308                            let xdr_base64 = return_value.to_xdr_base64(Limits::none());
309                            if let Ok(xdr_base64) = xdr_base64 {
310                                stellar_data = stellar_data.with_transaction_result_xdr(xdr_base64);
311                            } else {
312                                warn!("Failed to serialize return value to XDR base64");
313                            }
314                        }
315                    }
316
317                    NetworkTransactionData::Stellar(stellar_data)
318                });
319
320        let update_request = TransactionUpdateRequest {
321            status: Some(TransactionStatus::Confirmed),
322            confirmed_at: Some(Utc::now().to_rfc3339()),
323            network_data: updated_network_data,
324            ..Default::default()
325        };
326
327        let confirmed_tx = self
328            .finalize_transaction_state(tx.id.clone(), update_request)
329            .await?;
330
331        self.enqueue_next_pending_transaction(&tx.id).await?;
332
333        Ok(confirmed_tx)
334    }
335
336    /// Handles the logic when a Stellar transaction has failed.
337    pub async fn handle_stellar_failed(
338        &self,
339        tx: TransactionRepoModel,
340        provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
341    ) -> Result<TransactionRepoModel, TransactionError> {
342        let result_code = provider_response
343            .result
344            .as_ref()
345            .map(|r| r.result.name())
346            .unwrap_or("unknown");
347
348        // Extract inner failure fields for fee-bump and op-level detail
349        let (inner_result_code, op_result_code, inner_tx_hash, inner_fee_charged) =
350            match provider_response.result.as_ref().map(|r| &r.result) {
351                Some(TransactionResultResult::TxFeeBumpInnerFailed(pair)) => {
352                    let inner = &pair.result.result;
353                    let op = match inner {
354                        InnerTransactionResultResult::TxFailed(ops) => {
355                            first_failing_op(ops.as_slice())
356                        }
357                        _ => None,
358                    };
359                    (
360                        Some(inner.name()),
361                        op,
362                        Some(hex::encode(pair.transaction_hash.0)),
363                        pair.result.fee_charged,
364                    )
365                }
366                Some(TransactionResultResult::TxFailed(ops)) => {
367                    (None, first_failing_op(ops.as_slice()), None, 0)
368                }
369                _ => (None, None, None, 0),
370            };
371
372        let fee_charged = provider_response.result.as_ref().map(|r| r.fee_charged);
373        let fee_bid = provider_response.envelope.as_ref().map(extract_fee_bid);
374
375        warn!(
376            tx_id = %tx.id,
377            result_code,
378            inner_result_code = inner_result_code.unwrap_or("n/a"),
379            op_result_code = op_result_code.unwrap_or("n/a"),
380            inner_tx_hash = inner_tx_hash.as_deref().unwrap_or("n/a"),
381            inner_fee_charged,
382            fee_charged = ?fee_charged,
383            fee_bid = ?fee_bid,
384            "stellar transaction failed"
385        );
386
387        let status_reason = format!(
388            "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: {result_code}."
389        );
390
391        let update_request = TransactionUpdateRequest {
392            status: Some(TransactionStatus::Failed),
393            status_reason: Some(status_reason),
394            ..Default::default()
395        };
396
397        let updated_tx = self
398            .finalize_transaction_state(tx.id.clone(), update_request)
399            .await?;
400
401        self.enqueue_next_pending_transaction(&tx.id).await?;
402
403        Ok(updated_tx)
404    }
405
406    /// Checks if transaction has expired or exceeded max lifetime.
407    /// Returns Some(Result) if transaction was handled (expired or failed), None if checks passed.
408    async fn check_expiration_and_max_lifetime(
409        &self,
410        tx: TransactionRepoModel,
411        failed_reason: String,
412    ) -> Option<Result<TransactionRepoModel, TransactionError>> {
413        let age = match get_age_since_created(&tx) {
414            Ok(age) => age,
415            Err(e) => return Some(Err(e)),
416        };
417
418        // Check if transaction has expired
419        if let Ok(true) = self.is_transaction_expired(&tx) {
420            info!(tx_id = %tx.id, valid_until = ?tx.valid_until, "Transaction has expired");
421            return Some(
422                self.mark_as_expired(tx, "Transaction time_bounds expired".to_string())
423                    .await,
424            );
425        }
426
427        // Check if transaction exceeded max lifetime
428        if age > get_stellar_max_stuck_transaction_lifetime() {
429            warn!(tx_id = %tx.id, age_minutes = age.num_minutes(),
430                "Transaction exceeded max lifetime, marking as Failed");
431            return Some(self.mark_as_failed(tx, failed_reason).await);
432        }
433
434        None
435    }
436
437    /// Handles Sent transactions that failed hash parsing.
438    /// Checks for expiration, max lifetime, and re-enqueues submit job if needed.
439    async fn handle_sent_state(
440        &self,
441        tx: TransactionRepoModel,
442    ) -> Result<TransactionRepoModel, TransactionError> {
443        // Check expiration and max lifetime
444        if let Some(result) = self
445            .check_expiration_and_max_lifetime(
446                tx.clone(),
447                "Transaction stuck in Sent status for too long".to_string(),
448            )
449            .await
450        {
451            return result;
452        }
453
454        // Resubmit with exponential backoff based on total transaction age.
455        // Uses the same backoff logic as the Submitted state handler:
456        // 15s → 30s → 60s → 120s → 180s (capped).
457        let total_age = get_age_since_created(&tx)?;
458        if let Some(backoff_interval) = compute_resubmit_backoff_interval(
459            total_age,
460            STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
461            STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
462        ) {
463            let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
464            if age_since_last_submit > backoff_interval {
465                info!(
466                    tx_id = %tx.id,
467                    total_age_seconds = total_age.num_seconds(),
468                    since_last_submit_seconds = age_since_last_submit.num_seconds(),
469                    backoff_interval_seconds = backoff_interval.num_seconds(),
470                    "re-enqueueing submit job for stuck Sent transaction"
471                );
472                send_submit_transaction_job(self.job_producer(), &tx, None).await?;
473            }
474        }
475
476        Ok(tx)
477    }
478
479    /// Handles pending transactions without a hash (e.g., reset after bad sequence error).
480    /// Schedules a recovery job if the transaction is old enough to prevent it from being stuck.
481    async fn handle_pending_state(
482        &self,
483        tx: TransactionRepoModel,
484    ) -> Result<TransactionRepoModel, TransactionError> {
485        // Check expiration and max lifetime
486        if let Some(result) = self
487            .check_expiration_and_max_lifetime(
488                tx.clone(),
489                "Transaction stuck in Pending status for too long".to_string(),
490            )
491            .await
492        {
493            return result;
494        }
495
496        // Check transaction age to determine if recovery is needed
497        let age = self.get_time_since_created_at(&tx)?;
498
499        // Only schedule recovery job if transaction exceeds recovery trigger timeout
500        // This prevents scheduling a job on every status check
501        if age.num_seconds() >= STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS {
502            info!(
503                tx_id = %tx.id,
504                age_seconds = age.num_seconds(),
505                "pending transaction without hash may be stuck, scheduling recovery job"
506            );
507
508            let transaction_request = TransactionRequest::new(tx.id.clone(), tx.relayer_id.clone());
509            if let Err(e) = self
510                .job_producer()
511                .produce_transaction_request_job(transaction_request, None)
512                .await
513            {
514                warn!(
515                    tx_id = %tx.id,
516                    error = %e,
517                    "failed to schedule recovery job for pending transaction"
518                );
519            }
520        } else {
521            debug!(
522                tx_id = %tx.id,
523                age_seconds = age.num_seconds(),
524                "pending transaction without hash too young for recovery check"
525            );
526        }
527
528        Ok(tx)
529    }
530
531    /// Get time since transaction was created.
532    /// Returns an error if created_at is missing or invalid.
533    fn get_time_since_created_at(
534        &self,
535        tx: &TransactionRepoModel,
536    ) -> Result<chrono::Duration, TransactionError> {
537        match DateTime::parse_from_rfc3339(&tx.created_at) {
538            Ok(dt) => Ok(Utc::now().signed_duration_since(dt.with_timezone(&Utc))),
539            Err(e) => {
540                warn!(tx_id = %tx.id, ts = %tx.created_at, error = %e, "failed to parse created_at timestamp");
541                Err(TransactionError::UnexpectedError(format!(
542                    "Invalid created_at timestamp for transaction {}: {}",
543                    tx.id, e
544                )))
545            }
546        }
547    }
548
549    /// Handles status checking for Submitted transactions (and any other state with a hash).
550    /// Parses the hash, queries the provider, and dispatches to success/failed/pending handlers.
551    /// For non-final on-chain status, checks expiration/max-lifetime and resubmits if needed.
552    async fn handle_submitted_state(
553        &self,
554        tx: TransactionRepoModel,
555    ) -> Result<TransactionRepoModel, TransactionError> {
556        let stellar_hash = match self.parse_and_validate_hash(&tx) {
557            Ok(hash) => hash,
558            Err(e) => {
559                // If hash is missing, this is a database inconsistency that won't fix itself
560                warn!(
561                    tx_id = %tx.id,
562                    status = ?tx.status,
563                    error = ?e,
564                    "failed to parse and validate hash for submitted transaction"
565                );
566                return self
567                    .mark_as_failed(tx, format!("Failed to parse and validate hash: {e}"))
568                    .await;
569            }
570        };
571
572        let provider_response = match self.provider().get_transaction(&stellar_hash).await {
573            Ok(response) => response,
574            Err(e) => {
575                warn!(error = ?e, "provider get_transaction failed");
576                return Err(TransactionError::from(e));
577            }
578        };
579
580        match provider_response.status.as_str().to_uppercase().as_str() {
581            "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
582            "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
583            _ => {
584                debug!(
585                    tx_id = %tx.id,
586                    relayer_id = %tx.relayer_id,
587                    status = %provider_response.status,
588                    "submitted transaction not yet final on-chain, will retry check later"
589                );
590
591                // Check for expiration and max lifetime
592                if let Some(result) = self
593                    .check_expiration_and_max_lifetime(
594                        tx.clone(),
595                        "Transaction stuck in Submitted status for too long".to_string(),
596                    )
597                    .await
598                {
599                    return result;
600                }
601
602                // Resubmit with exponential backoff based on total transaction age.
603                // The backoff interval grows: 15s → 30s → 60s → 120s → 180s (capped).
604                let total_age = get_age_since_created(&tx)?;
605                if let Some(backoff_interval) = compute_resubmit_backoff_interval(
606                    total_age,
607                    STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
608                    STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
609                ) {
610                    let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
611                    if age_since_last_submit > backoff_interval {
612                        info!(
613                            tx_id = %tx.id,
614                            relayer_id = %tx.relayer_id,
615                            total_age_seconds = total_age.num_seconds(),
616                            since_last_submit_seconds = age_since_last_submit.num_seconds(),
617                            backoff_interval_seconds = backoff_interval.num_seconds(),
618                            "resubmitting Submitted transaction to ensure mempool inclusion"
619                        );
620                        send_submit_transaction_job(self.job_producer(), &tx, None).await?;
621                    }
622                }
623
624                Ok(tx)
625            }
626        }
627    }
628}
629
630/// Extracts the fee bid from a transaction envelope.
631///
632/// For fee-bump transactions, returns the outer bump fee (the max the submitter was
633/// willing to pay). For regular V1 transactions, returns the `fee` field.
634fn extract_fee_bid(envelope: &TransactionEnvelope) -> i64 {
635    match envelope {
636        TransactionEnvelope::TxFeeBump(fb) => fb.tx.fee,
637        TransactionEnvelope::Tx(v1) => v1.tx.fee as i64,
638        TransactionEnvelope::TxV0(v0) => v0.tx.fee as i64,
639    }
640}
641
642/// Returns the `.name()` of the first failing operation in the results.
643///
644/// Scans left-to-right since earlier operations may show success while a later
645/// one carries the actual failure code. Returns `None` if no failure is found.
646fn first_failing_op(ops: &[OperationResult]) -> Option<&'static str> {
647    let op = ops.iter().find(|op| match op {
648        OperationResult::OpInner(tr) => match tr {
649            OperationResultTr::InvokeHostFunction(r) => {
650                !matches!(r, InvokeHostFunctionResult::Success(_))
651            }
652            OperationResultTr::ExtendFootprintTtl(r) => r.name() != "Success",
653            OperationResultTr::RestoreFootprint(r) => r.name() != "Success",
654            _ => false,
655        },
656        _ => true,
657    })?;
658    match op {
659        OperationResult::OpInner(tr) => match tr {
660            OperationResultTr::InvokeHostFunction(r) => Some(r.name()),
661            OperationResultTr::ExtendFootprintTtl(r) => Some(r.name()),
662            OperationResultTr::RestoreFootprint(r) => Some(r.name()),
663            _ => Some(tr.name()),
664        },
665        _ => Some(op.name()),
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::models::{NetworkTransactionData, RepositoryError};
673    use crate::repositories::PaginatedResult;
674    use chrono::Duration;
675    use mockall::predicate::eq;
676    use soroban_rs::stellar_rpc_client::GetTransactionResponse;
677
678    use crate::domain::transaction::stellar::test_helpers::*;
679
680    fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
681        GetTransactionResponse {
682            status: status.to_string(),
683            ledger: None,
684            envelope: None,
685            result: None,
686            result_meta: None,
687            events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
688                contract_events: vec![],
689                diagnostic_events: vec![],
690                transaction_events: vec![],
691            },
692        }
693    }
694
695    fn dummy_get_transaction_response_with_result_meta(
696        status: &str,
697        has_return_value: bool,
698    ) -> GetTransactionResponse {
699        use soroban_rs::xdr::{ScVal, SorobanTransactionMeta, TransactionMeta, TransactionMetaV3};
700
701        let result_meta = if has_return_value {
702            // Create a dummy ScVal for testing (using I32(42) as a simple test value)
703            let return_value = ScVal::I32(42);
704            Some(TransactionMeta::V3(TransactionMetaV3 {
705                ext: soroban_rs::xdr::ExtensionPoint::V0,
706                tx_changes_before: soroban_rs::xdr::LedgerEntryChanges::default(),
707                operations: soroban_rs::xdr::VecM::default(),
708                tx_changes_after: soroban_rs::xdr::LedgerEntryChanges::default(),
709                soroban_meta: Some(SorobanTransactionMeta {
710                    ext: soroban_rs::xdr::SorobanTransactionMetaExt::V0,
711                    return_value,
712                    events: soroban_rs::xdr::VecM::default(),
713                    diagnostic_events: soroban_rs::xdr::VecM::default(),
714                }),
715            }))
716        } else {
717            None
718        };
719
720        GetTransactionResponse {
721            status: status.to_string(),
722            ledger: None,
723            envelope: None,
724            result: None,
725            result_meta,
726            events: soroban_rs::stellar_rpc_client::GetTransactionEvents {
727                contract_events: vec![],
728                diagnostic_events: vec![],
729                transaction_events: vec![],
730            },
731        }
732    }
733
734    mod handle_transaction_status_tests {
735        use crate::services::provider::ProviderError;
736
737        use super::*;
738
739        #[tokio::test]
740        async fn handle_transaction_status_confirmed_triggers_next() {
741            let relayer = create_test_relayer();
742            let mut mocks = default_test_mocks();
743
744            let mut tx_to_handle = create_test_transaction(&relayer.id);
745            tx_to_handle.id = "tx-confirm-this".to_string();
746            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
747            let tx_hash_bytes = [1u8; 32];
748            let tx_hash_hex = hex::encode(tx_hash_bytes);
749            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
750            {
751                stellar_data.hash = Some(tx_hash_hex.clone());
752            } else {
753                panic!("Expected Stellar network data for tx_to_handle");
754            }
755            tx_to_handle.status = TransactionStatus::Submitted;
756
757            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
758
759            // 1. Mock provider to return SUCCESS
760            mocks
761                .provider
762                .expect_get_transaction()
763                .with(eq(expected_stellar_hash.clone()))
764                .times(1)
765                .returning(move |_| {
766                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
767                });
768
769            // 2. Mock partial_update for confirmation
770            mocks
771                .tx_repo
772                .expect_partial_update()
773                .withf(move |id, update| {
774                    id == "tx-confirm-this"
775                        && update.status == Some(TransactionStatus::Confirmed)
776                        && update.confirmed_at.is_some()
777                })
778                .times(1)
779                .returning(move |id, update| {
780                    let mut updated_tx = tx_to_handle.clone(); // Use the original tx_to_handle as base
781                    updated_tx.id = id;
782                    updated_tx.status = update.status.unwrap();
783                    updated_tx.confirmed_at = update.confirmed_at;
784                    Ok(updated_tx)
785                });
786
787            // Send notification for confirmed tx
788            mocks
789                .job_producer
790                .expect_produce_send_notification_job()
791                .times(1)
792                .returning(|_, _| Box::pin(async { Ok(()) }));
793
794            // 3. Mock find_by_status_paginated for pending transactions
795            let mut oldest_pending_tx = create_test_transaction(&relayer.id);
796            oldest_pending_tx.id = "tx-oldest-pending".to_string();
797            oldest_pending_tx.status = TransactionStatus::Pending;
798            let captured_oldest_pending_tx = oldest_pending_tx.clone();
799            let relayer_id_clone = relayer.id.clone();
800            mocks
801                .tx_repo
802                .expect_find_by_status_paginated()
803                .withf(move |relayer_id, statuses, query, oldest_first| {
804                    *relayer_id == relayer_id_clone
805                        && statuses == [TransactionStatus::Pending]
806                        && query.page == 1
807                        && query.per_page == 1
808                        && *oldest_first
809                })
810                .times(1)
811                .returning(move |_, _, _, _| {
812                    Ok(PaginatedResult {
813                        items: vec![captured_oldest_pending_tx.clone()],
814                        total: 1,
815                        page: 1,
816                        per_page: 1,
817                    })
818                });
819
820            // 4. Mock produce_transaction_request_job for the next pending transaction
821            mocks
822                .job_producer
823                .expect_produce_transaction_request_job()
824                .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
825                .times(1)
826                .returning(|_, _| Box::pin(async { Ok(()) }));
827
828            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
829            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
830            initial_tx_for_handling.id = "tx-confirm-this".to_string();
831            initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
832            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
833                initial_tx_for_handling.network_data
834            {
835                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
836            } else {
837                panic!("Expected Stellar network data for initial_tx_for_handling");
838            }
839            initial_tx_for_handling.status = TransactionStatus::Submitted;
840
841            let result = handler
842                .handle_transaction_status_impl(initial_tx_for_handling, None)
843                .await;
844
845            assert!(result.is_ok());
846            let handled_tx = result.unwrap();
847            assert_eq!(handled_tx.id, "tx-confirm-this");
848            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
849            assert!(handled_tx.confirmed_at.is_some());
850        }
851
852        #[tokio::test]
853        async fn handle_transaction_status_still_pending() {
854            let relayer = create_test_relayer();
855            let mut mocks = default_test_mocks();
856
857            let mut tx_to_handle = create_test_transaction(&relayer.id);
858            tx_to_handle.id = "tx-pending-check".to_string();
859            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
860            let tx_hash_bytes = [2u8; 32];
861            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
862            {
863                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
864            } else {
865                panic!("Expected Stellar network data");
866            }
867            tx_to_handle.status = TransactionStatus::Submitted; // Or any status that implies it's being watched
868
869            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
870
871            // 1. Mock provider to return PENDING
872            mocks
873                .provider
874                .expect_get_transaction()
875                .with(eq(expected_stellar_hash.clone()))
876                .times(1)
877                .returning(move |_| {
878                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
879                });
880
881            // 2. Mock partial_update: should NOT be called
882            mocks.tx_repo.expect_partial_update().never();
883
884            // Notifications should NOT be sent for pending
885            mocks
886                .job_producer
887                .expect_produce_send_notification_job()
888                .never();
889
890            // Submitted tx older than resubmit timeout triggers resubmission
891            mocks
892                .job_producer
893                .expect_produce_submit_transaction_job()
894                .times(1)
895                .returning(|_, _| Box::pin(async { Ok(()) }));
896
897            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
898            let original_tx_clone = tx_to_handle.clone();
899
900            let result = handler
901                .handle_transaction_status_impl(tx_to_handle, None)
902                .await;
903
904            assert!(result.is_ok());
905            let returned_tx = result.unwrap();
906            // Transaction should be returned unchanged as it's still pending
907            assert_eq!(returned_tx.id, original_tx_clone.id);
908            assert_eq!(returned_tx.status, original_tx_clone.status);
909            assert!(returned_tx.confirmed_at.is_none()); // Ensure it wasn't accidentally confirmed
910        }
911
912        #[tokio::test]
913        async fn handle_transaction_status_failed() {
914            let relayer = create_test_relayer();
915            let mut mocks = default_test_mocks();
916
917            let mut tx_to_handle = create_test_transaction(&relayer.id);
918            tx_to_handle.id = "tx-fail-this".to_string();
919            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
920            let tx_hash_bytes = [3u8; 32];
921            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
922            {
923                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
924            } else {
925                panic!("Expected Stellar network data");
926            }
927            tx_to_handle.status = TransactionStatus::Submitted;
928
929            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
930
931            // 1. Mock provider to return FAILED
932            mocks
933                .provider
934                .expect_get_transaction()
935                .with(eq(expected_stellar_hash.clone()))
936                .times(1)
937                .returning(move |_| {
938                    Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
939                });
940
941            // 2. Mock partial_update for failure - use actual update values
942            let relayer_id_for_mock = relayer.id.clone();
943            mocks
944                .tx_repo
945                .expect_partial_update()
946                .times(1)
947                .returning(move |id, update| {
948                    // Use the actual update values instead of hardcoding
949                    let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
950                    updated_tx.id = id;
951                    updated_tx.status = update.status.unwrap();
952                    updated_tx.status_reason = update.status_reason.clone();
953                    Ok::<_, RepositoryError>(updated_tx)
954                });
955
956            // Send notification for failed tx
957            mocks
958                .job_producer
959                .expect_produce_send_notification_job()
960                .times(1)
961                .returning(|_, _| Box::pin(async { Ok(()) }));
962
963            // 3. Mock find_by_status_paginated for pending transactions (should be called by enqueue_next_pending_transaction)
964            let relayer_id_clone = relayer.id.clone();
965            mocks
966                .tx_repo
967                .expect_find_by_status_paginated()
968                .withf(move |relayer_id, statuses, query, oldest_first| {
969                    *relayer_id == relayer_id_clone
970                        && statuses == [TransactionStatus::Pending]
971                        && query.page == 1
972                        && query.per_page == 1
973                        && *oldest_first
974                })
975                .times(1)
976                .returning(move |_, _, _, _| {
977                    Ok(PaginatedResult {
978                        items: vec![],
979                        total: 0,
980                        page: 1,
981                        per_page: 1,
982                    })
983                }); // No pending transactions
984
985            // Should NOT try to enqueue next transaction since there are no pending ones
986            mocks
987                .job_producer
988                .expect_produce_transaction_request_job()
989                .never();
990            // Should NOT re-queue status check
991            mocks
992                .job_producer
993                .expect_produce_check_transaction_status_job()
994                .never();
995
996            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
997            let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
998            initial_tx_for_handling.id = "tx-fail-this".to_string();
999            initial_tx_for_handling.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1000            if let NetworkTransactionData::Stellar(ref mut stellar_data) =
1001                initial_tx_for_handling.network_data
1002            {
1003                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1004            } else {
1005                panic!("Expected Stellar network data");
1006            }
1007            initial_tx_for_handling.status = TransactionStatus::Submitted;
1008
1009            let result = handler
1010                .handle_transaction_status_impl(initial_tx_for_handling, None)
1011                .await;
1012
1013            assert!(result.is_ok());
1014            let handled_tx = result.unwrap();
1015            assert_eq!(handled_tx.id, "tx-fail-this");
1016            assert_eq!(handled_tx.status, TransactionStatus::Failed);
1017            assert!(handled_tx.status_reason.is_some());
1018            assert_eq!(
1019                handled_tx.status_reason.unwrap(),
1020                "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: unknown."
1021            );
1022        }
1023
1024        #[tokio::test]
1025        async fn handle_transaction_status_provider_error() {
1026            let relayer = create_test_relayer();
1027            let mut mocks = default_test_mocks();
1028
1029            let mut tx_to_handle = create_test_transaction(&relayer.id);
1030            tx_to_handle.id = "tx-provider-error".to_string();
1031            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1032            let tx_hash_bytes = [4u8; 32];
1033            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1034            {
1035                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1036            } else {
1037                panic!("Expected Stellar network data");
1038            }
1039            tx_to_handle.status = TransactionStatus::Submitted;
1040
1041            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1042
1043            // 1. Mock provider to return an error
1044            mocks
1045                .provider
1046                .expect_get_transaction()
1047                .with(eq(expected_stellar_hash.clone()))
1048                .times(1)
1049                .returning(move |_| {
1050                    Box::pin(async { Err(ProviderError::Other("RPC boom".to_string())) })
1051                });
1052
1053            // 2. Mock partial_update: should NOT be called
1054            mocks.tx_repo.expect_partial_update().never();
1055
1056            // Notifications should NOT be sent
1057            mocks
1058                .job_producer
1059                .expect_produce_send_notification_job()
1060                .never();
1061            // Should NOT try to enqueue next transaction
1062            mocks
1063                .job_producer
1064                .expect_produce_transaction_request_job()
1065                .never();
1066
1067            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1068
1069            let result = handler
1070                .handle_transaction_status_impl(tx_to_handle, None)
1071                .await;
1072
1073            // Provider errors are now propagated as errors (retriable)
1074            assert!(result.is_err());
1075            matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1076        }
1077
1078        #[tokio::test]
1079        async fn handle_transaction_status_no_hashes() {
1080            let relayer = create_test_relayer();
1081            let mut mocks = default_test_mocks();
1082
1083            let mut tx_to_handle = create_test_transaction(&relayer.id);
1084            tx_to_handle.id = "tx-no-hashes".to_string();
1085            tx_to_handle.status = TransactionStatus::Submitted;
1086            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1087
1088            // With our new error handling, validation errors mark the transaction as failed
1089            mocks.provider.expect_get_transaction().never();
1090
1091            // Expect partial_update to be called to mark as failed
1092            mocks
1093                .tx_repo
1094                .expect_partial_update()
1095                .times(1)
1096                .returning(|_, update| {
1097                    let mut updated_tx = create_test_transaction("test-relayer");
1098                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1099                    updated_tx.status_reason = update.status_reason.clone();
1100                    Ok(updated_tx)
1101                });
1102
1103            // Expect notification to be sent after marking as failed
1104            mocks
1105                .job_producer
1106                .expect_produce_send_notification_job()
1107                .times(1)
1108                .returning(|_, _| Box::pin(async { Ok(()) }));
1109
1110            // Expect find_by_status_paginated to be called when enqueuing next transaction
1111            let relayer_id_clone = relayer.id.clone();
1112            mocks
1113                .tx_repo
1114                .expect_find_by_status_paginated()
1115                .withf(move |relayer_id, statuses, query, oldest_first| {
1116                    *relayer_id == relayer_id_clone
1117                        && statuses == [TransactionStatus::Pending]
1118                        && query.page == 1
1119                        && query.per_page == 1
1120                        && *oldest_first
1121                })
1122                .times(1)
1123                .returning(move |_, _, _, _| {
1124                    Ok(PaginatedResult {
1125                        items: vec![],
1126                        total: 0,
1127                        page: 1,
1128                        per_page: 1,
1129                    })
1130                }); // No pending transactions
1131
1132            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1133            let result = handler
1134                .handle_transaction_status_impl(tx_to_handle, None)
1135                .await;
1136
1137            // Should succeed but mark transaction as Failed
1138            assert!(result.is_ok(), "Expected Ok result");
1139            let updated_tx = result.unwrap();
1140            assert_eq!(updated_tx.status, TransactionStatus::Failed);
1141            assert!(
1142                updated_tx
1143                    .status_reason
1144                    .as_ref()
1145                    .unwrap()
1146                    .contains("Failed to parse and validate hash"),
1147                "Expected hash validation error in status_reason, got: {:?}",
1148                updated_tx.status_reason
1149            );
1150        }
1151
1152        #[tokio::test]
1153        async fn test_on_chain_failure_does_not_decrement_sequence() {
1154            let relayer = create_test_relayer();
1155            let mut mocks = default_test_mocks();
1156
1157            let mut tx_to_handle = create_test_transaction(&relayer.id);
1158            tx_to_handle.id = "tx-on-chain-fail".to_string();
1159            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1160            let tx_hash_bytes = [4u8; 32];
1161            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1162            {
1163                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1164                stellar_data.sequence_number = Some(100); // Has a sequence
1165            }
1166            tx_to_handle.status = TransactionStatus::Submitted;
1167
1168            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1169
1170            // Mock provider to return FAILED (on-chain failure)
1171            mocks
1172                .provider
1173                .expect_get_transaction()
1174                .with(eq(expected_stellar_hash.clone()))
1175                .times(1)
1176                .returning(move |_| {
1177                    Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
1178                });
1179
1180            // Decrement should NEVER be called for on-chain failures
1181            mocks.counter.expect_decrement().never();
1182
1183            // Mock partial_update for failure
1184            mocks
1185                .tx_repo
1186                .expect_partial_update()
1187                .times(1)
1188                .returning(move |id, update| {
1189                    let mut updated_tx = create_test_transaction("test");
1190                    updated_tx.id = id;
1191                    updated_tx.status = update.status.unwrap();
1192                    updated_tx.status_reason = update.status_reason.clone();
1193                    Ok::<_, RepositoryError>(updated_tx)
1194                });
1195
1196            // Mock notification
1197            mocks
1198                .job_producer
1199                .expect_produce_send_notification_job()
1200                .times(1)
1201                .returning(|_, _| Box::pin(async { Ok(()) }));
1202
1203            // Mock find_by_status_paginated
1204            mocks
1205                .tx_repo
1206                .expect_find_by_status_paginated()
1207                .returning(move |_, _, _, _| {
1208                    Ok(PaginatedResult {
1209                        items: vec![],
1210                        total: 0,
1211                        page: 1,
1212                        per_page: 1,
1213                    })
1214                });
1215
1216            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1217            let initial_tx = tx_to_handle.clone();
1218
1219            let result = handler
1220                .handle_transaction_status_impl(initial_tx, None)
1221                .await;
1222
1223            assert!(result.is_ok());
1224            let handled_tx = result.unwrap();
1225            assert_eq!(handled_tx.id, "tx-on-chain-fail");
1226            assert_eq!(handled_tx.status, TransactionStatus::Failed);
1227        }
1228
1229        #[tokio::test]
1230        async fn test_on_chain_success_does_not_decrement_sequence() {
1231            let relayer = create_test_relayer();
1232            let mut mocks = default_test_mocks();
1233
1234            let mut tx_to_handle = create_test_transaction(&relayer.id);
1235            tx_to_handle.id = "tx-on-chain-success".to_string();
1236            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1237            let tx_hash_bytes = [5u8; 32];
1238            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1239            {
1240                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1241                stellar_data.sequence_number = Some(101); // Has a sequence
1242            }
1243            tx_to_handle.status = TransactionStatus::Submitted;
1244
1245            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1246
1247            // Mock provider to return SUCCESS
1248            mocks
1249                .provider
1250                .expect_get_transaction()
1251                .with(eq(expected_stellar_hash.clone()))
1252                .times(1)
1253                .returning(move |_| {
1254                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1255                });
1256
1257            // Decrement should NEVER be called for on-chain success
1258            mocks.counter.expect_decrement().never();
1259
1260            // Mock partial_update for confirmation
1261            mocks
1262                .tx_repo
1263                .expect_partial_update()
1264                .withf(move |id, update| {
1265                    id == "tx-on-chain-success"
1266                        && update.status == Some(TransactionStatus::Confirmed)
1267                        && update.confirmed_at.is_some()
1268                })
1269                .times(1)
1270                .returning(move |id, update| {
1271                    let mut updated_tx = create_test_transaction("test");
1272                    updated_tx.id = id;
1273                    updated_tx.status = update.status.unwrap();
1274                    updated_tx.confirmed_at = update.confirmed_at;
1275                    Ok(updated_tx)
1276                });
1277
1278            // Mock notification
1279            mocks
1280                .job_producer
1281                .expect_produce_send_notification_job()
1282                .times(1)
1283                .returning(|_, _| Box::pin(async { Ok(()) }));
1284
1285            // Mock find_by_status_paginated for next transaction
1286            mocks
1287                .tx_repo
1288                .expect_find_by_status_paginated()
1289                .returning(move |_, _, _, _| {
1290                    Ok(PaginatedResult {
1291                        items: vec![],
1292                        total: 0,
1293                        page: 1,
1294                        per_page: 1,
1295                    })
1296                });
1297
1298            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1299            let initial_tx = tx_to_handle.clone();
1300
1301            let result = handler
1302                .handle_transaction_status_impl(initial_tx, None)
1303                .await;
1304
1305            assert!(result.is_ok());
1306            let handled_tx = result.unwrap();
1307            assert_eq!(handled_tx.id, "tx-on-chain-success");
1308            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1309        }
1310
1311        #[tokio::test]
1312        async fn test_handle_transaction_status_with_xdr_error_requeues() {
1313            // This test verifies that when get_transaction fails we re-queue for retry
1314            let relayer = create_test_relayer();
1315            let mut mocks = default_test_mocks();
1316
1317            let mut tx_to_handle = create_test_transaction(&relayer.id);
1318            tx_to_handle.id = "tx-xdr-error-requeue".to_string();
1319            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1320            let tx_hash_bytes = [8u8; 32];
1321            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1322            {
1323                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1324            }
1325            tx_to_handle.status = TransactionStatus::Submitted;
1326
1327            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1328
1329            // Mock provider to return a non-XDR error (won't trigger fallback)
1330            mocks
1331                .provider
1332                .expect_get_transaction()
1333                .with(eq(expected_stellar_hash.clone()))
1334                .times(1)
1335                .returning(move |_| {
1336                    Box::pin(async { Err(ProviderError::Other("Network timeout".to_string())) })
1337                });
1338
1339            // No partial update should occur
1340            mocks.tx_repo.expect_partial_update().never();
1341            mocks
1342                .job_producer
1343                .expect_produce_send_notification_job()
1344                .never();
1345
1346            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1347
1348            let result = handler
1349                .handle_transaction_status_impl(tx_to_handle, None)
1350                .await;
1351
1352            // Provider errors are now propagated as errors (retriable)
1353            assert!(result.is_err());
1354            matches!(result.unwrap_err(), TransactionError::UnderlyingProvider(_));
1355        }
1356
1357        #[tokio::test]
1358        async fn handle_transaction_status_extracts_transaction_result_xdr() {
1359            let relayer = create_test_relayer();
1360            let mut mocks = default_test_mocks();
1361
1362            let mut tx_to_handle = create_test_transaction(&relayer.id);
1363            tx_to_handle.id = "tx-with-result".to_string();
1364            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1365            let tx_hash_bytes = [9u8; 32];
1366            let tx_hash_hex = hex::encode(tx_hash_bytes);
1367            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1368            {
1369                stellar_data.hash = Some(tx_hash_hex.clone());
1370            } else {
1371                panic!("Expected Stellar network data");
1372            }
1373            tx_to_handle.status = TransactionStatus::Submitted;
1374
1375            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1376
1377            // Mock provider to return SUCCESS with result_meta containing return_value
1378            mocks
1379                .provider
1380                .expect_get_transaction()
1381                .with(eq(expected_stellar_hash.clone()))
1382                .times(1)
1383                .returning(move |_| {
1384                    Box::pin(async {
1385                        Ok(dummy_get_transaction_response_with_result_meta(
1386                            "SUCCESS", true,
1387                        ))
1388                    })
1389                });
1390
1391            // Mock partial_update - verify that transaction_result_xdr is stored
1392            let tx_to_handle_clone = tx_to_handle.clone();
1393            mocks
1394                .tx_repo
1395                .expect_partial_update()
1396                .withf(move |id, update| {
1397                    id == "tx-with-result"
1398                        && update.status == Some(TransactionStatus::Confirmed)
1399                        && update.confirmed_at.is_some()
1400                        && update.network_data.as_ref().is_some_and(|and| {
1401                            if let NetworkTransactionData::Stellar(stellar_data) = and {
1402                                // Verify transaction_result_xdr is present
1403                                stellar_data.transaction_result_xdr.is_some()
1404                            } else {
1405                                false
1406                            }
1407                        })
1408                })
1409                .times(1)
1410                .returning(move |id, update| {
1411                    let mut updated_tx = tx_to_handle_clone.clone();
1412                    updated_tx.id = id;
1413                    updated_tx.status = update.status.unwrap();
1414                    updated_tx.confirmed_at = update.confirmed_at;
1415                    if let Some(network_data) = update.network_data {
1416                        updated_tx.network_data = network_data;
1417                    }
1418                    Ok(updated_tx)
1419                });
1420
1421            // Mock notification
1422            mocks
1423                .job_producer
1424                .expect_produce_send_notification_job()
1425                .times(1)
1426                .returning(|_, _| Box::pin(async { Ok(()) }));
1427
1428            // Mock find_by_status_paginated
1429            mocks
1430                .tx_repo
1431                .expect_find_by_status_paginated()
1432                .returning(move |_, _, _, _| {
1433                    Ok(PaginatedResult {
1434                        items: vec![],
1435                        total: 0,
1436                        page: 1,
1437                        per_page: 1,
1438                    })
1439                });
1440
1441            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1442            let result = handler
1443                .handle_transaction_status_impl(tx_to_handle, None)
1444                .await;
1445
1446            assert!(result.is_ok());
1447            let handled_tx = result.unwrap();
1448            assert_eq!(handled_tx.id, "tx-with-result");
1449            assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
1450
1451            // Verify transaction_result_xdr is stored
1452            if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1453                assert!(
1454                    stellar_data.transaction_result_xdr.is_some(),
1455                    "transaction_result_xdr should be stored when result_meta contains return_value"
1456                );
1457            } else {
1458                panic!("Expected Stellar network data");
1459            }
1460        }
1461
1462        #[tokio::test]
1463        async fn handle_transaction_status_no_result_meta_does_not_store_xdr() {
1464            let relayer = create_test_relayer();
1465            let mut mocks = default_test_mocks();
1466
1467            let mut tx_to_handle = create_test_transaction(&relayer.id);
1468            tx_to_handle.id = "tx-no-result-meta".to_string();
1469            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1470            let tx_hash_bytes = [10u8; 32];
1471            let tx_hash_hex = hex::encode(tx_hash_bytes);
1472            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
1473            {
1474                stellar_data.hash = Some(tx_hash_hex.clone());
1475            } else {
1476                panic!("Expected Stellar network data");
1477            }
1478            tx_to_handle.status = TransactionStatus::Submitted;
1479
1480            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1481
1482            // Mock provider to return SUCCESS without result_meta
1483            mocks
1484                .provider
1485                .expect_get_transaction()
1486                .with(eq(expected_stellar_hash.clone()))
1487                .times(1)
1488                .returning(move |_| {
1489                    Box::pin(async {
1490                        Ok(dummy_get_transaction_response_with_result_meta(
1491                            "SUCCESS", false,
1492                        ))
1493                    })
1494                });
1495
1496            // Mock partial_update
1497            let tx_to_handle_clone = tx_to_handle.clone();
1498            mocks
1499                .tx_repo
1500                .expect_partial_update()
1501                .times(1)
1502                .returning(move |id, update| {
1503                    let mut updated_tx = tx_to_handle_clone.clone();
1504                    updated_tx.id = id;
1505                    updated_tx.status = update.status.unwrap();
1506                    updated_tx.confirmed_at = update.confirmed_at;
1507                    if let Some(network_data) = update.network_data {
1508                        updated_tx.network_data = network_data;
1509                    }
1510                    Ok(updated_tx)
1511                });
1512
1513            // Mock notification
1514            mocks
1515                .job_producer
1516                .expect_produce_send_notification_job()
1517                .times(1)
1518                .returning(|_, _| Box::pin(async { Ok(()) }));
1519
1520            // Mock find_by_status_paginated
1521            mocks
1522                .tx_repo
1523                .expect_find_by_status_paginated()
1524                .returning(move |_, _, _, _| {
1525                    Ok(PaginatedResult {
1526                        items: vec![],
1527                        total: 0,
1528                        page: 1,
1529                        per_page: 1,
1530                    })
1531                });
1532
1533            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1534            let result = handler
1535                .handle_transaction_status_impl(tx_to_handle, None)
1536                .await;
1537
1538            assert!(result.is_ok());
1539            let handled_tx = result.unwrap();
1540
1541            // Verify transaction_result_xdr is None when result_meta is missing
1542            if let NetworkTransactionData::Stellar(stellar_data) = handled_tx.network_data {
1543                assert!(
1544                    stellar_data.transaction_result_xdr.is_none(),
1545                    "transaction_result_xdr should be None when result_meta is missing"
1546                );
1547            } else {
1548                panic!("Expected Stellar network data");
1549            }
1550        }
1551
1552        #[tokio::test]
1553        async fn test_sent_transaction_not_stuck_yet_returns_ok() {
1554            // Transaction in Sent status for < 5 minutes should NOT trigger recovery
1555            let relayer = create_test_relayer();
1556            let mut mocks = default_test_mocks();
1557
1558            let mut tx = create_test_transaction(&relayer.id);
1559            tx.id = "tx-sent-not-stuck".to_string();
1560            tx.status = TransactionStatus::Sent;
1561            // Created just now - not stuck yet
1562            tx.created_at = Utc::now().to_rfc3339();
1563            // No hash (simulating stuck state)
1564            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1565                stellar_data.hash = None;
1566            }
1567
1568            // Should NOT call any provider methods or update transaction
1569            mocks.provider.expect_get_transaction().never();
1570            mocks.tx_repo.expect_partial_update().never();
1571            mocks
1572                .job_producer
1573                .expect_produce_submit_transaction_job()
1574                .never();
1575
1576            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1577            let result = handler
1578                .handle_transaction_status_impl(tx.clone(), None)
1579                .await;
1580
1581            assert!(result.is_ok());
1582            let returned_tx = result.unwrap();
1583            // Transaction should be returned unchanged
1584            assert_eq!(returned_tx.id, tx.id);
1585            assert_eq!(returned_tx.status, TransactionStatus::Sent);
1586        }
1587
1588        #[tokio::test]
1589        async fn test_stuck_sent_transaction_reenqueues_submit_job() {
1590            // Transaction in Sent status for > 5 minutes should re-enqueue submit job
1591            // The submit handler (not status checker) will handle signed XDR validation
1592            let relayer = create_test_relayer();
1593            let mut mocks = default_test_mocks();
1594
1595            let mut tx = create_test_transaction(&relayer.id);
1596            tx.id = "tx-stuck-with-xdr".to_string();
1597            tx.status = TransactionStatus::Sent;
1598            // Created 10 minutes ago - definitely stuck
1599            tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1600            // No hash (simulating stuck state)
1601            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1602                stellar_data.hash = None;
1603                stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1604            }
1605
1606            // Should re-enqueue submit job (idempotent - submit handler will validate)
1607            mocks
1608                .job_producer
1609                .expect_produce_submit_transaction_job()
1610                .times(1)
1611                .returning(|_, _| Box::pin(async { Ok(()) }));
1612
1613            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1614            let result = handler
1615                .handle_transaction_status_impl(tx.clone(), None)
1616                .await;
1617
1618            assert!(result.is_ok());
1619            let returned_tx = result.unwrap();
1620            // Transaction status unchanged - submit job will handle the actual submission
1621            assert_eq!(returned_tx.status, TransactionStatus::Sent);
1622        }
1623
1624        #[tokio::test]
1625        async fn test_stuck_sent_transaction_expired_marks_expired() {
1626            // Expired transaction should be marked as Expired
1627            let relayer = create_test_relayer();
1628            let mut mocks = default_test_mocks();
1629
1630            let mut tx = create_test_transaction(&relayer.id);
1631            tx.id = "tx-expired".to_string();
1632            tx.status = TransactionStatus::Sent;
1633            // Created 10 minutes ago - definitely stuck
1634            tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
1635            // Set valid_until to a past time (expired)
1636            tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
1637            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1638                stellar_data.hash = None;
1639                stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1640            }
1641
1642            // Should mark as Expired
1643            mocks
1644                .tx_repo
1645                .expect_partial_update()
1646                .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
1647                .times(1)
1648                .returning(|id, update| {
1649                    let mut updated = create_test_transaction("test");
1650                    updated.id = id;
1651                    updated.status = update.status.unwrap();
1652                    updated.status_reason = update.status_reason.clone();
1653                    Ok(updated)
1654                });
1655
1656            // Should NOT try to re-enqueue submit job (expired)
1657            mocks
1658                .job_producer
1659                .expect_produce_submit_transaction_job()
1660                .never();
1661
1662            // Notification for expiration
1663            mocks
1664                .job_producer
1665                .expect_produce_send_notification_job()
1666                .times(1)
1667                .returning(|_, _| Box::pin(async { Ok(()) }));
1668
1669            // Try to enqueue next pending
1670            mocks
1671                .tx_repo
1672                .expect_find_by_status_paginated()
1673                .returning(move |_, _, _, _| {
1674                    Ok(PaginatedResult {
1675                        items: vec![],
1676                        total: 0,
1677                        page: 1,
1678                        per_page: 1,
1679                    })
1680                });
1681
1682            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1683            let result = handler.handle_transaction_status_impl(tx, None).await;
1684
1685            assert!(result.is_ok());
1686            let expired_tx = result.unwrap();
1687            assert_eq!(expired_tx.status, TransactionStatus::Expired);
1688            assert!(expired_tx
1689                .status_reason
1690                .as_ref()
1691                .unwrap()
1692                .contains("expired"));
1693        }
1694
1695        #[tokio::test]
1696        async fn test_stuck_sent_transaction_max_lifetime_marks_failed() {
1697            // Transaction stuck beyond max lifetime should be marked as Failed
1698            let relayer = create_test_relayer();
1699            let mut mocks = default_test_mocks();
1700
1701            let mut tx = create_test_transaction(&relayer.id);
1702            tx.id = "tx-max-lifetime".to_string();
1703            tx.status = TransactionStatus::Sent;
1704            // Created 35 minutes ago - beyond 30 min max lifetime
1705            tx.created_at = (Utc::now() - Duration::minutes(35)).to_rfc3339();
1706            // No valid_until (unbounded transaction)
1707            tx.valid_until = None;
1708            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1709                stellar_data.hash = None;
1710                stellar_data.signed_envelope_xdr = Some("AAAA...signed...".to_string());
1711            }
1712
1713            // Should mark as Failed (not Expired, since no time bounds)
1714            mocks
1715                .tx_repo
1716                .expect_partial_update()
1717                .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1718                .times(1)
1719                .returning(|id, update| {
1720                    let mut updated = create_test_transaction("test");
1721                    updated.id = id;
1722                    updated.status = update.status.unwrap();
1723                    updated.status_reason = update.status_reason.clone();
1724                    Ok(updated)
1725                });
1726
1727            // Should NOT try to re-enqueue submit job
1728            mocks
1729                .job_producer
1730                .expect_produce_submit_transaction_job()
1731                .never();
1732
1733            // Notification for failure
1734            mocks
1735                .job_producer
1736                .expect_produce_send_notification_job()
1737                .times(1)
1738                .returning(|_, _| Box::pin(async { Ok(()) }));
1739
1740            // Try to enqueue next pending
1741            mocks
1742                .tx_repo
1743                .expect_find_by_status_paginated()
1744                .returning(|_, _, _, _| {
1745                    Ok(PaginatedResult {
1746                        items: vec![],
1747                        total: 0,
1748                        page: 1,
1749                        per_page: 1,
1750                    })
1751                });
1752
1753            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1754            let result = handler.handle_transaction_status_impl(tx, None).await;
1755
1756            assert!(result.is_ok());
1757            let failed_tx = result.unwrap();
1758            assert_eq!(failed_tx.status, TransactionStatus::Failed);
1759            // assert_eq!(failed_tx.status_reason.as_ref().unwrap(), "Transaction stuck in Sent status for too long");
1760            assert!(failed_tx
1761                .status_reason
1762                .as_ref()
1763                .unwrap()
1764                .contains("stuck in Sent status for too long"));
1765        }
1766        #[tokio::test]
1767        async fn handle_status_concurrent_update_conflict_reloads_latest_state() {
1768            // When status_core returns ConcurrentUpdateConflict, the handler
1769            // should reload the latest state via get_by_id and return Ok.
1770            let relayer = create_test_relayer();
1771            let mut mocks = default_test_mocks();
1772
1773            let mut tx = create_test_transaction(&relayer.id);
1774            tx.id = "tx-cas-conflict".to_string();
1775            tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1776            let tx_hash_bytes = [11u8; 32];
1777            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1778                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
1779            }
1780            tx.status = TransactionStatus::Submitted;
1781
1782            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
1783
1784            // Provider returns SUCCESS — triggers a partial_update for confirmation
1785            mocks
1786                .provider
1787                .expect_get_transaction()
1788                .with(eq(expected_stellar_hash))
1789                .times(1)
1790                .returning(move |_| {
1791                    Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
1792                });
1793
1794            // partial_update fails with ConcurrentUpdateConflict
1795            mocks
1796                .tx_repo
1797                .expect_partial_update()
1798                .times(1)
1799                .returning(|_id, _update| {
1800                    Err(RepositoryError::ConcurrentUpdateConflict(
1801                        "CAS mismatch".to_string(),
1802                    ))
1803                });
1804
1805            // After conflict, handler reloads via get_by_id
1806            let reloaded_tx = {
1807                let mut t = create_test_transaction(&relayer.id);
1808                t.id = "tx-cas-conflict".to_string();
1809                // Simulate another writer already confirmed it
1810                t.status = TransactionStatus::Confirmed;
1811                t
1812            };
1813            let reloaded_clone = reloaded_tx.clone();
1814            mocks
1815                .tx_repo
1816                .expect_get_by_id()
1817                .with(eq("tx-cas-conflict".to_string()))
1818                .times(1)
1819                .returning(move |_| Ok(reloaded_clone.clone()));
1820
1821            // No notifications or job enqueuing should happen on CAS path
1822            mocks
1823                .job_producer
1824                .expect_produce_send_notification_job()
1825                .never();
1826            mocks
1827                .job_producer
1828                .expect_produce_transaction_request_job()
1829                .never();
1830
1831            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1832            let result = handler.handle_transaction_status_impl(tx, None).await;
1833
1834            assert!(result.is_ok(), "CAS conflict should return Ok after reload");
1835            let returned_tx = result.unwrap();
1836            assert_eq!(returned_tx.id, "tx-cas-conflict");
1837            // The reloaded tx reflects what the other writer persisted
1838            assert_eq!(returned_tx.status, TransactionStatus::Confirmed);
1839        }
1840    }
1841
1842    mod handle_pending_state_tests {
1843        use super::*;
1844        use crate::constants::get_stellar_max_stuck_transaction_lifetime;
1845        use crate::constants::STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS;
1846
1847        #[tokio::test]
1848        async fn test_pending_exceeds_max_lifetime_marks_failed() {
1849            let relayer = create_test_relayer();
1850            let mut mocks = default_test_mocks();
1851
1852            let mut tx = create_test_transaction(&relayer.id);
1853            tx.id = "tx-pending-old".to_string();
1854            tx.status = TransactionStatus::Pending;
1855            // Created more than max lifetime ago (16 minutes > 15 minutes)
1856            tx.created_at =
1857                (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
1858                    .to_rfc3339();
1859
1860            // Should mark as Failed
1861            mocks
1862                .tx_repo
1863                .expect_partial_update()
1864                .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
1865                .times(1)
1866                .returning(|id, update| {
1867                    let mut updated = create_test_transaction("test");
1868                    updated.id = id;
1869                    updated.status = update.status.unwrap();
1870                    updated.status_reason = update.status_reason.clone();
1871                    Ok(updated)
1872                });
1873
1874            // Notification for failure
1875            mocks
1876                .job_producer
1877                .expect_produce_send_notification_job()
1878                .times(1)
1879                .returning(|_, _| Box::pin(async { Ok(()) }));
1880
1881            // Try to enqueue next pending
1882            mocks
1883                .tx_repo
1884                .expect_find_by_status_paginated()
1885                .returning(move |_, _, _, _| {
1886                    Ok(PaginatedResult {
1887                        items: vec![],
1888                        total: 0,
1889                        page: 1,
1890                        per_page: 1,
1891                    })
1892                });
1893
1894            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1895            let result = handler.handle_transaction_status_impl(tx, None).await;
1896
1897            assert!(result.is_ok());
1898            let failed_tx = result.unwrap();
1899            assert_eq!(failed_tx.status, TransactionStatus::Failed);
1900            assert!(failed_tx
1901                .status_reason
1902                .as_ref()
1903                .unwrap()
1904                .contains("stuck in Pending status for too long"));
1905        }
1906
1907        #[tokio::test]
1908        async fn test_pending_triggers_recovery_job_when_old_enough() {
1909            let relayer = create_test_relayer();
1910            let mut mocks = default_test_mocks();
1911
1912            let mut tx = create_test_transaction(&relayer.id);
1913            tx.id = "tx-pending-recovery".to_string();
1914            tx.status = TransactionStatus::Pending;
1915            // Created more than recovery trigger seconds ago
1916            tx.created_at = (Utc::now()
1917                - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS + 5))
1918            .to_rfc3339();
1919
1920            // Should schedule recovery job
1921            mocks
1922                .job_producer
1923                .expect_produce_transaction_request_job()
1924                .times(1)
1925                .returning(|_, _| Box::pin(async { Ok(()) }));
1926
1927            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1928            let result = handler.handle_transaction_status_impl(tx, None).await;
1929
1930            assert!(result.is_ok());
1931            let tx_result = result.unwrap();
1932            assert_eq!(tx_result.status, TransactionStatus::Pending);
1933        }
1934
1935        #[tokio::test]
1936        async fn test_pending_too_young_does_not_schedule_recovery() {
1937            let relayer = create_test_relayer();
1938            let mut mocks = default_test_mocks();
1939
1940            let mut tx = create_test_transaction(&relayer.id);
1941            tx.id = "tx-pending-young".to_string();
1942            tx.status = TransactionStatus::Pending;
1943            // Created less than recovery trigger seconds ago
1944            tx.created_at = (Utc::now()
1945                - Duration::seconds(STELLAR_PENDING_RECOVERY_TRIGGER_SECONDS - 5))
1946            .to_rfc3339();
1947
1948            // Should NOT schedule recovery job
1949            mocks
1950                .job_producer
1951                .expect_produce_transaction_request_job()
1952                .never();
1953
1954            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1955            let result = handler.handle_transaction_status_impl(tx, None).await;
1956
1957            assert!(result.is_ok());
1958            let tx_result = result.unwrap();
1959            assert_eq!(tx_result.status, TransactionStatus::Pending);
1960        }
1961
1962        #[tokio::test]
1963        async fn test_sent_without_hash_handles_stuck_recovery() {
1964            use crate::constants::STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS;
1965
1966            let relayer = create_test_relayer();
1967            let mut mocks = default_test_mocks();
1968
1969            let mut tx = create_test_transaction(&relayer.id);
1970            tx.id = "tx-sent-no-hash".to_string();
1971            tx.status = TransactionStatus::Sent;
1972            // Created more than base resubmit interval ago (16 seconds > 15 seconds)
1973            tx.created_at = (Utc::now()
1974                - Duration::seconds(STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS)
1975                - Duration::seconds(1))
1976            .to_rfc3339();
1977            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
1978                stellar_data.hash = None; // No hash
1979            }
1980
1981            // Should handle stuck Sent transaction and re-enqueue submit job
1982            mocks
1983                .job_producer
1984                .expect_produce_submit_transaction_job()
1985                .times(1)
1986                .returning(|_, _| Box::pin(async { Ok(()) }));
1987
1988            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
1989            let result = handler.handle_transaction_status_impl(tx, None).await;
1990
1991            assert!(result.is_ok());
1992            let tx_result = result.unwrap();
1993            assert_eq!(tx_result.status, TransactionStatus::Sent);
1994        }
1995
1996        #[tokio::test]
1997        async fn test_submitted_without_hash_marks_failed() {
1998            let relayer = create_test_relayer();
1999            let mut mocks = default_test_mocks();
2000
2001            let mut tx = create_test_transaction(&relayer.id);
2002            tx.id = "tx-submitted-no-hash".to_string();
2003            tx.status = TransactionStatus::Submitted;
2004            tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2005            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2006                stellar_data.hash = None; // No hash
2007            }
2008
2009            // Should mark as Failed
2010            mocks
2011                .tx_repo
2012                .expect_partial_update()
2013                .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2014                .times(1)
2015                .returning(|id, update| {
2016                    let mut updated = create_test_transaction("test");
2017                    updated.id = id;
2018                    updated.status = update.status.unwrap();
2019                    updated.status_reason = update.status_reason.clone();
2020                    Ok(updated)
2021                });
2022
2023            // Notification for failure
2024            mocks
2025                .job_producer
2026                .expect_produce_send_notification_job()
2027                .times(1)
2028                .returning(|_, _| Box::pin(async { Ok(()) }));
2029
2030            // Try to enqueue next pending
2031            mocks
2032                .tx_repo
2033                .expect_find_by_status_paginated()
2034                .returning(move |_, _, _, _| {
2035                    Ok(PaginatedResult {
2036                        items: vec![],
2037                        total: 0,
2038                        page: 1,
2039                        per_page: 1,
2040                    })
2041                });
2042
2043            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2044            let result = handler.handle_transaction_status_impl(tx, None).await;
2045
2046            assert!(result.is_ok());
2047            let failed_tx = result.unwrap();
2048            assert_eq!(failed_tx.status, TransactionStatus::Failed);
2049            assert!(failed_tx
2050                .status_reason
2051                .as_ref()
2052                .unwrap()
2053                .contains("Failed to parse and validate hash"));
2054        }
2055
2056        #[tokio::test]
2057        async fn test_submitted_exceeds_max_lifetime_marks_failed() {
2058            let relayer = create_test_relayer();
2059            let mut mocks = default_test_mocks();
2060
2061            let mut tx = create_test_transaction(&relayer.id);
2062            tx.id = "tx-submitted-old".to_string();
2063            tx.status = TransactionStatus::Submitted;
2064            // Created more than max lifetime ago (16 minutes > 15 minutes)
2065            tx.created_at =
2066                (Utc::now() - get_stellar_max_stuck_transaction_lifetime() - Duration::minutes(1))
2067                    .to_rfc3339();
2068            // Set a hash so it can query provider
2069            let tx_hash_bytes = [6u8; 32];
2070            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2071                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2072            }
2073
2074            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2075
2076            // Mock provider to return PENDING status (not SUCCESS or FAILED)
2077            mocks
2078                .provider
2079                .expect_get_transaction()
2080                .with(eq(expected_stellar_hash.clone()))
2081                .times(1)
2082                .returning(move |_| {
2083                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2084                });
2085
2086            // Should mark as Failed
2087            mocks
2088                .tx_repo
2089                .expect_partial_update()
2090                .withf(|_id, update| update.status == Some(TransactionStatus::Failed))
2091                .times(1)
2092                .returning(|id, update| {
2093                    let mut updated = create_test_transaction("test");
2094                    updated.id = id;
2095                    updated.status = update.status.unwrap();
2096                    updated.status_reason = update.status_reason.clone();
2097                    Ok(updated)
2098                });
2099
2100            // Notification for failure
2101            mocks
2102                .job_producer
2103                .expect_produce_send_notification_job()
2104                .times(1)
2105                .returning(|_, _| Box::pin(async { Ok(()) }));
2106
2107            // Try to enqueue next pending
2108            mocks
2109                .tx_repo
2110                .expect_find_by_status_paginated()
2111                .returning(move |_, _, _, _| {
2112                    Ok(PaginatedResult {
2113                        items: vec![],
2114                        total: 0,
2115                        page: 1,
2116                        per_page: 1,
2117                    })
2118                });
2119
2120            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2121            let result = handler.handle_transaction_status_impl(tx, None).await;
2122
2123            assert!(result.is_ok());
2124            let failed_tx = result.unwrap();
2125            assert_eq!(failed_tx.status, TransactionStatus::Failed);
2126            assert!(failed_tx
2127                .status_reason
2128                .as_ref()
2129                .unwrap()
2130                .contains("stuck in Submitted status for too long"));
2131        }
2132
2133        #[tokio::test]
2134        async fn test_submitted_expired_marks_expired() {
2135            let relayer = create_test_relayer();
2136            let mut mocks = default_test_mocks();
2137
2138            let mut tx = create_test_transaction(&relayer.id);
2139            tx.id = "tx-submitted-expired".to_string();
2140            tx.status = TransactionStatus::Submitted;
2141            tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2142            // Set valid_until to a past time (expired)
2143            tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2144            // Set a hash so it can query provider
2145            let tx_hash_bytes = [7u8; 32];
2146            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2147                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2148            }
2149
2150            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2151
2152            // Mock provider to return PENDING status (not SUCCESS or FAILED)
2153            mocks
2154                .provider
2155                .expect_get_transaction()
2156                .with(eq(expected_stellar_hash.clone()))
2157                .times(1)
2158                .returning(move |_| {
2159                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2160                });
2161
2162            // Should mark as Expired
2163            mocks
2164                .tx_repo
2165                .expect_partial_update()
2166                .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2167                .times(1)
2168                .returning(|id, update| {
2169                    let mut updated = create_test_transaction("test");
2170                    updated.id = id;
2171                    updated.status = update.status.unwrap();
2172                    updated.status_reason = update.status_reason.clone();
2173                    Ok(updated)
2174                });
2175
2176            // Notification for expiration
2177            mocks
2178                .job_producer
2179                .expect_produce_send_notification_job()
2180                .times(1)
2181                .returning(|_, _| Box::pin(async { Ok(()) }));
2182
2183            // Try to enqueue next pending
2184            mocks
2185                .tx_repo
2186                .expect_find_by_status_paginated()
2187                .returning(move |_, _, _, _| {
2188                    Ok(PaginatedResult {
2189                        items: vec![],
2190                        total: 0,
2191                        page: 1,
2192                        per_page: 1,
2193                    })
2194                });
2195
2196            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2197            let result = handler.handle_transaction_status_impl(tx, None).await;
2198
2199            assert!(result.is_ok());
2200            let expired_tx = result.unwrap();
2201            assert_eq!(expired_tx.status, TransactionStatus::Expired);
2202            assert!(expired_tx
2203                .status_reason
2204                .as_ref()
2205                .unwrap()
2206                .contains("expired"));
2207        }
2208
2209        #[tokio::test]
2210        async fn test_handle_submitted_state_resubmits_after_timeout() {
2211            // Transaction created 16s ago, sent_at also 16s ago → exceeds base interval (15s)
2212            let relayer = create_test_relayer();
2213            let mut mocks = default_test_mocks();
2214
2215            let mut tx = create_test_transaction(&relayer.id);
2216            tx.id = "tx-submitted-resubmit".to_string();
2217            tx.status = TransactionStatus::Submitted;
2218            let sixteen_seconds_ago = (Utc::now() - Duration::seconds(16)).to_rfc3339();
2219            tx.created_at = sixteen_seconds_ago.clone();
2220            tx.sent_at = Some(sixteen_seconds_ago);
2221            // Set a hash so it can query provider
2222            let tx_hash_bytes = [8u8; 32];
2223            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2224                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2225            }
2226
2227            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2228
2229            // Mock provider to return PENDING status (not SUCCESS or FAILED)
2230            mocks
2231                .provider
2232                .expect_get_transaction()
2233                .with(eq(expected_stellar_hash.clone()))
2234                .times(1)
2235                .returning(move |_| {
2236                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2237                });
2238
2239            // Should resubmit the transaction
2240            mocks
2241                .job_producer
2242                .expect_produce_submit_transaction_job()
2243                .times(1)
2244                .returning(|_, _| Box::pin(async { Ok(()) }));
2245
2246            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2247            let result = handler.handle_transaction_status_impl(tx, None).await;
2248
2249            assert!(result.is_ok());
2250            let tx_result = result.unwrap();
2251            assert_eq!(tx_result.status, TransactionStatus::Submitted);
2252        }
2253
2254        #[tokio::test]
2255        async fn test_handle_submitted_state_backoff_increases_interval() {
2256            // Transaction created 30s ago but sent_at only 15s ago.
2257            // At total_age=30s, backoff interval = 30s (base*2^1, since 30/15=2, log2(2)=1).
2258            // age_since_last_submit=15s < 30s → should NOT resubmit yet.
2259            let relayer = create_test_relayer();
2260            let mut mocks = default_test_mocks();
2261
2262            let mut tx = create_test_transaction(&relayer.id);
2263            tx.id = "tx-submitted-backoff".to_string();
2264            tx.status = TransactionStatus::Submitted;
2265            tx.created_at = (Utc::now() - Duration::seconds(30)).to_rfc3339();
2266            tx.sent_at = Some((Utc::now() - Duration::seconds(15)).to_rfc3339());
2267            let tx_hash_bytes = [11u8; 32];
2268            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2269                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2270            }
2271
2272            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2273
2274            mocks
2275                .provider
2276                .expect_get_transaction()
2277                .with(eq(expected_stellar_hash.clone()))
2278                .times(1)
2279                .returning(move |_| {
2280                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2281                });
2282
2283            // Should NOT resubmit (15s < 30s backoff interval)
2284            mocks
2285                .job_producer
2286                .expect_produce_submit_transaction_job()
2287                .never();
2288
2289            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2290            let result = handler.handle_transaction_status_impl(tx, None).await;
2291
2292            assert!(result.is_ok());
2293            let tx_result = result.unwrap();
2294            assert_eq!(tx_result.status, TransactionStatus::Submitted);
2295        }
2296
2297        #[tokio::test]
2298        async fn test_handle_submitted_state_backoff_resubmits_when_interval_exceeded() {
2299            // Transaction created 25s ago, sent_at 21s ago.
2300            // At total_age=25s, backoff interval = 15s (base*2^0, since 25/15=1, log2(1)=0).
2301            // age_since_last_submit=21s > 15s → should resubmit.
2302            let relayer = create_test_relayer();
2303            let mut mocks = default_test_mocks();
2304
2305            let mut tx = create_test_transaction(&relayer.id);
2306            tx.id = "tx-submitted-backoff-resubmit".to_string();
2307            tx.status = TransactionStatus::Submitted;
2308            tx.created_at = (Utc::now() - Duration::seconds(25)).to_rfc3339();
2309            tx.sent_at = Some((Utc::now() - Duration::seconds(21)).to_rfc3339());
2310            let tx_hash_bytes = [12u8; 32];
2311            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2312                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2313            }
2314
2315            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2316
2317            mocks
2318                .provider
2319                .expect_get_transaction()
2320                .with(eq(expected_stellar_hash.clone()))
2321                .times(1)
2322                .returning(move |_| {
2323                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2324                });
2325
2326            // Should resubmit (21s > 15s backoff interval)
2327            mocks
2328                .job_producer
2329                .expect_produce_submit_transaction_job()
2330                .times(1)
2331                .returning(|_, _| Box::pin(async { Ok(()) }));
2332
2333            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2334            let result = handler.handle_transaction_status_impl(tx, None).await;
2335
2336            assert!(result.is_ok());
2337            let tx_result = result.unwrap();
2338            assert_eq!(tx_result.status, TransactionStatus::Submitted);
2339        }
2340
2341        #[tokio::test]
2342        async fn test_handle_submitted_state_recent_sent_at_prevents_resubmit() {
2343            // Transaction created 60s ago (old), but sent_at only 5s ago (recent resubmission).
2344            // At total_age=60s, backoff interval = 60s (base*2^2, since 60/15=4, log2(4)=2).
2345            // age_since_last_submit=5s < 60s → should NOT resubmit.
2346            // This verifies that sent_at being updated on resubmission correctly resets the clock.
2347            let relayer = create_test_relayer();
2348            let mut mocks = default_test_mocks();
2349
2350            let mut tx = create_test_transaction(&relayer.id);
2351            tx.id = "tx-submitted-recent-sent".to_string();
2352            tx.status = TransactionStatus::Submitted;
2353            tx.created_at = (Utc::now() - Duration::seconds(60)).to_rfc3339();
2354            tx.sent_at = Some((Utc::now() - Duration::seconds(5)).to_rfc3339());
2355            let tx_hash_bytes = [13u8; 32];
2356            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2357                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2358            }
2359
2360            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2361
2362            mocks
2363                .provider
2364                .expect_get_transaction()
2365                .with(eq(expected_stellar_hash.clone()))
2366                .times(1)
2367                .returning(move |_| {
2368                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2369                });
2370
2371            // Should NOT resubmit (sent_at is recent despite old created_at)
2372            mocks
2373                .job_producer
2374                .expect_produce_submit_transaction_job()
2375                .never();
2376
2377            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2378            let result = handler.handle_transaction_status_impl(tx, None).await;
2379
2380            assert!(result.is_ok());
2381            let tx_result = result.unwrap();
2382            assert_eq!(tx_result.status, TransactionStatus::Submitted);
2383        }
2384
2385        #[tokio::test]
2386        async fn test_handle_submitted_state_no_resubmit_before_timeout() {
2387            let relayer = create_test_relayer();
2388            let mut mocks = default_test_mocks();
2389
2390            let mut tx = create_test_transaction(&relayer.id);
2391            tx.id = "tx-submitted-young".to_string();
2392            tx.status = TransactionStatus::Submitted;
2393            // Created just now - below resubmit timeout
2394            tx.created_at = Utc::now().to_rfc3339();
2395            // Set a hash so it can query provider
2396            let tx_hash_bytes = [9u8; 32];
2397            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2398                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2399            }
2400
2401            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2402
2403            // Mock provider to return PENDING status (not SUCCESS or FAILED)
2404            mocks
2405                .provider
2406                .expect_get_transaction()
2407                .with(eq(expected_stellar_hash.clone()))
2408                .times(1)
2409                .returning(move |_| {
2410                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2411                });
2412
2413            // Should NOT resubmit
2414            mocks
2415                .job_producer
2416                .expect_produce_submit_transaction_job()
2417                .never();
2418
2419            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2420            let result = handler.handle_transaction_status_impl(tx, None).await;
2421
2422            assert!(result.is_ok());
2423            let tx_result = result.unwrap();
2424            assert_eq!(tx_result.status, TransactionStatus::Submitted);
2425        }
2426
2427        #[tokio::test]
2428        async fn test_handle_submitted_state_expired_before_resubmit() {
2429            let relayer = create_test_relayer();
2430            let mut mocks = default_test_mocks();
2431
2432            let mut tx = create_test_transaction(&relayer.id);
2433            tx.id = "tx-submitted-expired-no-resubmit".to_string();
2434            tx.status = TransactionStatus::Submitted;
2435            tx.created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
2436            // Set valid_until to a past time (expired)
2437            tx.valid_until = Some((Utc::now() - Duration::minutes(5)).to_rfc3339());
2438            // Set a hash so it can query provider
2439            let tx_hash_bytes = [10u8; 32];
2440            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
2441                stellar_data.hash = Some(hex::encode(tx_hash_bytes));
2442            }
2443
2444            let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
2445
2446            // Mock provider to return PENDING status
2447            mocks
2448                .provider
2449                .expect_get_transaction()
2450                .with(eq(expected_stellar_hash.clone()))
2451                .times(1)
2452                .returning(move |_| {
2453                    Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
2454                });
2455
2456            // Should mark as Expired, NOT resubmit
2457            mocks
2458                .tx_repo
2459                .expect_partial_update()
2460                .withf(|_id, update| update.status == Some(TransactionStatus::Expired))
2461                .times(1)
2462                .returning(|id, update| {
2463                    let mut updated = create_test_transaction("test");
2464                    updated.id = id;
2465                    updated.status = update.status.unwrap();
2466                    updated.status_reason = update.status_reason.clone();
2467                    Ok(updated)
2468                });
2469
2470            // Should NOT resubmit
2471            mocks
2472                .job_producer
2473                .expect_produce_submit_transaction_job()
2474                .never();
2475
2476            // Notification for expiration
2477            mocks
2478                .job_producer
2479                .expect_produce_send_notification_job()
2480                .times(1)
2481                .returning(|_, _| Box::pin(async { Ok(()) }));
2482
2483            // Try to enqueue next pending
2484            mocks
2485                .tx_repo
2486                .expect_find_by_status_paginated()
2487                .returning(move |_, _, _, _| {
2488                    Ok(PaginatedResult {
2489                        items: vec![],
2490                        total: 0,
2491                        page: 1,
2492                        per_page: 1,
2493                    })
2494                });
2495
2496            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2497            let result = handler.handle_transaction_status_impl(tx, None).await;
2498
2499            assert!(result.is_ok());
2500            let expired_tx = result.unwrap();
2501            assert_eq!(expired_tx.status, TransactionStatus::Expired);
2502            assert!(expired_tx
2503                .status_reason
2504                .as_ref()
2505                .unwrap()
2506                .contains("expired"));
2507        }
2508    }
2509
2510    mod is_valid_until_expired_tests {
2511        use super::*;
2512        use crate::{
2513            jobs::MockJobProducerTrait,
2514            repositories::{
2515                MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository,
2516            },
2517            services::{
2518                provider::MockStellarProviderTrait, stellar_dex::MockStellarDexServiceTrait,
2519            },
2520        };
2521        use chrono::{Duration, Utc};
2522
2523        // Type alias for testing static methods
2524        type TestHandler = StellarRelayerTransaction<
2525            MockRelayerRepository,
2526            MockTransactionRepository,
2527            MockJobProducerTrait,
2528            MockStellarCombinedSigner,
2529            MockStellarProviderTrait,
2530            MockTransactionCounterTrait,
2531            MockStellarDexServiceTrait,
2532        >;
2533
2534        #[test]
2535        fn test_rfc3339_expired() {
2536            let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2537            assert!(TestHandler::is_valid_until_string_expired(&past));
2538        }
2539
2540        #[test]
2541        fn test_rfc3339_not_expired() {
2542            let future = (Utc::now() + Duration::hours(1)).to_rfc3339();
2543            assert!(!TestHandler::is_valid_until_string_expired(&future));
2544        }
2545
2546        #[test]
2547        fn test_numeric_timestamp_expired() {
2548            let past_timestamp = (Utc::now() - Duration::hours(1)).timestamp().to_string();
2549            assert!(TestHandler::is_valid_until_string_expired(&past_timestamp));
2550        }
2551
2552        #[test]
2553        fn test_numeric_timestamp_not_expired() {
2554            let future_timestamp = (Utc::now() + Duration::hours(1)).timestamp().to_string();
2555            assert!(!TestHandler::is_valid_until_string_expired(
2556                &future_timestamp
2557            ));
2558        }
2559
2560        #[test]
2561        fn test_zero_timestamp_unbounded() {
2562            // Zero means unbounded in Stellar
2563            assert!(!TestHandler::is_valid_until_string_expired("0"));
2564        }
2565
2566        #[test]
2567        fn test_invalid_format_not_expired() {
2568            // Invalid format should be treated as not expired (conservative)
2569            assert!(!TestHandler::is_valid_until_string_expired("not-a-date"));
2570        }
2571    }
2572
2573    // Tests for circuit breaker functionality
2574    mod circuit_breaker_tests {
2575        use super::*;
2576        use crate::jobs::StatusCheckContext;
2577        use crate::models::NetworkType;
2578
2579        /// Helper to create a context that should trigger the circuit breaker
2580        fn create_triggered_context() -> StatusCheckContext {
2581            StatusCheckContext::new(
2582                110, // consecutive_failures: exceeds Stellar threshold of 100
2583                150, // total_failures
2584                160, // total_retries
2585                100, // max_consecutive_failures (Stellar default)
2586                300, // max_total_failures (Stellar default)
2587                NetworkType::Stellar,
2588            )
2589        }
2590
2591        /// Helper to create a context that should NOT trigger the circuit breaker
2592        fn create_safe_context() -> StatusCheckContext {
2593            StatusCheckContext::new(
2594                10,  // consecutive_failures: below threshold
2595                20,  // total_failures
2596                25,  // total_retries
2597                100, // max_consecutive_failures
2598                300, // max_total_failures
2599                NetworkType::Stellar,
2600            )
2601        }
2602
2603        /// Helper to create a context that triggers via total failures (safety net)
2604        fn create_total_triggered_context() -> StatusCheckContext {
2605            StatusCheckContext::new(
2606                20,  // consecutive_failures: below threshold
2607                310, // total_failures: exceeds Stellar threshold of 300
2608                350, // total_retries
2609                100, // max_consecutive_failures
2610                300, // max_total_failures
2611                NetworkType::Stellar,
2612            )
2613        }
2614
2615        #[tokio::test]
2616        async fn test_circuit_breaker_submitted_marks_as_failed() {
2617            let relayer = create_test_relayer();
2618            let mut mocks = default_test_mocks();
2619
2620            let mut tx_to_handle = create_test_transaction(&relayer.id);
2621            tx_to_handle.status = TransactionStatus::Submitted;
2622            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2623
2624            // Expect partial_update to be called with Failed status
2625            mocks
2626                .tx_repo
2627                .expect_partial_update()
2628                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2629                .times(1)
2630                .returning(|_, update| {
2631                    let mut updated_tx = create_test_transaction("test-relayer");
2632                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2633                    updated_tx.status_reason = update.status_reason.clone();
2634                    Ok(updated_tx)
2635                });
2636
2637            // Mock notification
2638            mocks
2639                .job_producer
2640                .expect_produce_send_notification_job()
2641                .returning(|_, _| Box::pin(async { Ok(()) }));
2642
2643            // Try to enqueue next pending (called after lane cleanup)
2644            mocks
2645                .tx_repo
2646                .expect_find_by_status_paginated()
2647                .returning(|_, _, _, _| {
2648                    Ok(PaginatedResult {
2649                        items: vec![],
2650                        total: 0,
2651                        page: 1,
2652                        per_page: 1,
2653                    })
2654                });
2655
2656            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2657            let ctx = create_triggered_context();
2658
2659            let result = handler
2660                .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2661                .await;
2662
2663            assert!(result.is_ok());
2664            let tx = result.unwrap();
2665            assert_eq!(tx.status, TransactionStatus::Failed);
2666            assert!(tx.status_reason.is_some());
2667            assert!(tx.status_reason.unwrap().contains("consecutive errors"));
2668        }
2669
2670        #[tokio::test]
2671        async fn test_circuit_breaker_pending_marks_as_failed() {
2672            let relayer = create_test_relayer();
2673            let mut mocks = default_test_mocks();
2674
2675            let mut tx_to_handle = create_test_transaction(&relayer.id);
2676            tx_to_handle.status = TransactionStatus::Pending;
2677            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2678
2679            // Expect partial_update to be called with Failed status
2680            mocks
2681                .tx_repo
2682                .expect_partial_update()
2683                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2684                .times(1)
2685                .returning(|_, update| {
2686                    let mut updated_tx = create_test_transaction("test-relayer");
2687                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2688                    updated_tx.status_reason = update.status_reason.clone();
2689                    Ok(updated_tx)
2690                });
2691
2692            mocks
2693                .job_producer
2694                .expect_produce_send_notification_job()
2695                .returning(|_, _| Box::pin(async { Ok(()) }));
2696
2697            mocks
2698                .tx_repo
2699                .expect_find_by_status_paginated()
2700                .returning(|_, _, _, _| {
2701                    Ok(PaginatedResult {
2702                        items: vec![],
2703                        total: 0,
2704                        page: 1,
2705                        per_page: 1,
2706                    })
2707                });
2708
2709            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2710            let ctx = create_triggered_context();
2711
2712            let result = handler
2713                .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2714                .await;
2715
2716            assert!(result.is_ok());
2717            let tx = result.unwrap();
2718            assert_eq!(tx.status, TransactionStatus::Failed);
2719        }
2720
2721        #[tokio::test]
2722        async fn test_circuit_breaker_total_failures_triggers() {
2723            let relayer = create_test_relayer();
2724            let mut mocks = default_test_mocks();
2725
2726            let mut tx_to_handle = create_test_transaction(&relayer.id);
2727            tx_to_handle.status = TransactionStatus::Submitted;
2728            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2729
2730            mocks
2731                .tx_repo
2732                .expect_partial_update()
2733                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2734                .times(1)
2735                .returning(|_, update| {
2736                    let mut updated_tx = create_test_transaction("test-relayer");
2737                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2738                    updated_tx.status_reason = update.status_reason.clone();
2739                    Ok(updated_tx)
2740                });
2741
2742            mocks
2743                .job_producer
2744                .expect_produce_send_notification_job()
2745                .returning(|_, _| Box::pin(async { Ok(()) }));
2746
2747            mocks
2748                .tx_repo
2749                .expect_find_by_status_paginated()
2750                .returning(|_, _, _, _| {
2751                    Ok(PaginatedResult {
2752                        items: vec![],
2753                        total: 0,
2754                        page: 1,
2755                        per_page: 1,
2756                    })
2757                });
2758
2759            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2760            // Use context that triggers via total failures (safety net)
2761            let ctx = create_total_triggered_context();
2762
2763            let result = handler
2764                .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2765                .await;
2766
2767            assert!(result.is_ok());
2768            let tx = result.unwrap();
2769            assert_eq!(tx.status, TransactionStatus::Failed);
2770        }
2771
2772        #[tokio::test]
2773        async fn test_circuit_breaker_below_threshold_continues() {
2774            let relayer = create_test_relayer();
2775            let mut mocks = default_test_mocks();
2776
2777            let mut tx_to_handle = create_test_transaction(&relayer.id);
2778            tx_to_handle.status = TransactionStatus::Submitted;
2779            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2780            let tx_hash_bytes = [1u8; 32];
2781            let tx_hash_hex = hex::encode(tx_hash_bytes);
2782            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2783            {
2784                stellar_data.hash = Some(tx_hash_hex.clone());
2785            }
2786
2787            // Below threshold, should continue with normal status checking
2788            mocks
2789                .provider
2790                .expect_get_transaction()
2791                .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2792
2793            mocks
2794                .tx_repo
2795                .expect_partial_update()
2796                .returning(|_, update| {
2797                    let mut updated_tx = create_test_transaction("test-relayer");
2798                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2799                    Ok(updated_tx)
2800                });
2801
2802            mocks
2803                .job_producer
2804                .expect_produce_send_notification_job()
2805                .returning(|_, _| Box::pin(async { Ok(()) }));
2806
2807            mocks
2808                .tx_repo
2809                .expect_find_by_status_paginated()
2810                .returning(|_, _, _, _| {
2811                    Ok(PaginatedResult {
2812                        items: vec![],
2813                        total: 0,
2814                        page: 1,
2815                        per_page: 1,
2816                    })
2817                });
2818
2819            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2820            let ctx = create_safe_context();
2821
2822            let result = handler
2823                .handle_transaction_status_impl(tx_to_handle, Some(ctx))
2824                .await;
2825
2826            assert!(result.is_ok());
2827            let tx = result.unwrap();
2828            // Should become Confirmed (normal flow), not Failed (circuit breaker)
2829            assert_eq!(tx.status, TransactionStatus::Confirmed);
2830        }
2831
2832        #[tokio::test]
2833        async fn test_circuit_breaker_final_state_early_return() {
2834            let relayer = create_test_relayer();
2835            let mocks = default_test_mocks();
2836
2837            // Transaction is already in final state
2838            let mut tx_to_handle = create_test_transaction(&relayer.id);
2839            tx_to_handle.status = TransactionStatus::Confirmed;
2840
2841            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2842            let ctx = create_triggered_context();
2843
2844            // Even with triggered context, final states should return early
2845            let result = handler
2846                .handle_transaction_status_impl(tx_to_handle.clone(), Some(ctx))
2847                .await;
2848
2849            assert!(result.is_ok());
2850            assert_eq!(result.unwrap().id, tx_to_handle.id);
2851        }
2852
2853        #[tokio::test]
2854        async fn test_circuit_breaker_no_context_continues() {
2855            let relayer = create_test_relayer();
2856            let mut mocks = default_test_mocks();
2857
2858            let mut tx_to_handle = create_test_transaction(&relayer.id);
2859            tx_to_handle.status = TransactionStatus::Submitted;
2860            tx_to_handle.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2861            let tx_hash_bytes = [1u8; 32];
2862            let tx_hash_hex = hex::encode(tx_hash_bytes);
2863            if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
2864            {
2865                stellar_data.hash = Some(tx_hash_hex.clone());
2866            }
2867
2868            // No context means no circuit breaker
2869            mocks
2870                .provider
2871                .expect_get_transaction()
2872                .returning(|_| Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) }));
2873
2874            mocks
2875                .tx_repo
2876                .expect_partial_update()
2877                .returning(|_, update| {
2878                    let mut updated_tx = create_test_transaction("test-relayer");
2879                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2880                    Ok(updated_tx)
2881                });
2882
2883            mocks
2884                .job_producer
2885                .expect_produce_send_notification_job()
2886                .returning(|_, _| Box::pin(async { Ok(()) }));
2887
2888            mocks
2889                .tx_repo
2890                .expect_find_by_status_paginated()
2891                .returning(|_, _, _, _| {
2892                    Ok(PaginatedResult {
2893                        items: vec![],
2894                        total: 0,
2895                        page: 1,
2896                        per_page: 1,
2897                    })
2898                });
2899
2900            let handler = make_stellar_tx_handler(relayer.clone(), mocks);
2901
2902            // Pass None for context - should continue normally
2903            let result = handler
2904                .handle_transaction_status_impl(tx_to_handle, None)
2905                .await;
2906
2907            assert!(result.is_ok());
2908            let tx = result.unwrap();
2909            assert_eq!(tx.status, TransactionStatus::Confirmed);
2910        }
2911    }
2912
2913    mod failure_detail_helper_tests {
2914        use super::*;
2915        use soroban_rs::xdr::{InvokeHostFunctionResult, OperationResult, OperationResultTr, VecM};
2916
2917        #[test]
2918        fn first_failing_op_finds_trapped() {
2919            let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2920                OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Trapped),
2921            )]
2922            .try_into()
2923            .unwrap();
2924            assert_eq!(first_failing_op(ops.as_slice()), Some("Trapped"));
2925        }
2926
2927        #[test]
2928        fn first_failing_op_skips_success() {
2929            let ops: VecM<OperationResult> = vec![
2930                OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2931                    InvokeHostFunctionResult::Success(soroban_rs::xdr::Hash([0u8; 32])),
2932                )),
2933                OperationResult::OpInner(OperationResultTr::InvokeHostFunction(
2934                    InvokeHostFunctionResult::ResourceLimitExceeded,
2935                )),
2936            ]
2937            .try_into()
2938            .unwrap();
2939            assert_eq!(
2940                first_failing_op(ops.as_slice()),
2941                Some("ResourceLimitExceeded")
2942            );
2943        }
2944
2945        #[test]
2946        fn first_failing_op_all_success_returns_none() {
2947            let ops: VecM<OperationResult> = vec![OperationResult::OpInner(
2948                OperationResultTr::InvokeHostFunction(InvokeHostFunctionResult::Success(
2949                    soroban_rs::xdr::Hash([0u8; 32]),
2950                )),
2951            )]
2952            .try_into()
2953            .unwrap();
2954            assert_eq!(first_failing_op(ops.as_slice()), None);
2955        }
2956
2957        #[test]
2958        fn first_failing_op_empty_returns_none() {
2959            assert_eq!(first_failing_op(&[]), None);
2960        }
2961
2962        #[test]
2963        fn first_failing_op_op_bad_auth() {
2964            let ops: VecM<OperationResult> = vec![OperationResult::OpBadAuth].try_into().unwrap();
2965            assert_eq!(first_failing_op(ops.as_slice()), Some("OpBadAuth"));
2966        }
2967    }
2968}