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