openzeppelin_relayer/domain/transaction/evm/
status.rs

1//! This module contains the status-related functionality for EVM transactions.
2//! It includes methods for checking transaction status, determining when to resubmit
3//! or replace transactions with NOOPs, and updating transaction status in the repository.
4
5use alloy::network::ReceiptResponse;
6use chrono::{DateTime, Duration, Utc};
7use eyre::Result;
8use tracing::{debug, error, warn};
9
10use super::EvmRelayerTransaction;
11use super::{
12    ensure_status, get_age_since_status_change, has_enough_confirmations, is_noop,
13    is_too_early_to_resubmit, is_transaction_valid, make_noop, too_many_attempts,
14    too_many_noop_attempts,
15};
16use crate::constants::{
17    get_evm_min_age_for_hash_recovery, get_evm_pending_recovery_trigger_timeout,
18    get_evm_prepare_timeout, get_evm_resend_timeout, ARBITRUM_TIME_TO_RESUBMIT,
19    EVM_MIN_HASHES_FOR_RECOVERY,
20};
21use crate::domain::transaction::common::{
22    get_age_of_sent_at, is_final_state, is_pending_transaction,
23};
24use crate::domain::transaction::util::get_age_since_created;
25use crate::models::{EvmNetwork, NetworkRepoModel, NetworkType};
26use crate::repositories::{NetworkRepository, RelayerRepository};
27use crate::{
28    domain::transaction::evm::price_calculator::PriceCalculatorTrait,
29    jobs::{JobProducerTrait, StatusCheckContext},
30    models::{
31        NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel,
32        TransactionStatus, TransactionUpdateRequest,
33    },
34    repositories::{Repository, TransactionCounterTrait, TransactionRepository},
35    services::{provider::EvmProviderTrait, signer::Signer},
36    utils::{get_resubmit_timeout_for_speed, get_resubmit_timeout_with_backoff},
37};
38
39impl<P, RR, NR, TR, J, S, TCR, PC> EvmRelayerTransaction<P, RR, NR, TR, J, S, TCR, PC>
40where
41    P: EvmProviderTrait + Send + Sync,
42    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
43    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
44    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
45    J: JobProducerTrait + Send + Sync + 'static,
46    S: Signer + Send + Sync + 'static,
47    TCR: TransactionCounterTrait + Send + Sync + 'static,
48    PC: PriceCalculatorTrait + Send + Sync,
49{
50    pub(super) async fn check_transaction_status(
51        &self,
52        tx: &TransactionRepoModel,
53    ) -> Result<TransactionStatus, TransactionError> {
54        // Early return if transaction is already in a final state
55        if is_final_state(&tx.status) {
56            return Ok(tx.status.clone());
57        }
58
59        // Early return for Pending/Sent states - these are DB-only states
60        // that don't require on-chain queries and may not have a hash yet
61        match tx.status {
62            TransactionStatus::Pending | TransactionStatus::Sent => {
63                return Ok(tx.status.clone());
64            }
65            _ => {}
66        }
67
68        let evm_data = tx.network_data.get_evm_transaction_data()?;
69        let tx_hash = evm_data
70            .hash
71            .as_ref()
72            .ok_or(TransactionError::UnexpectedError(
73                "Transaction hash is missing".to_string(),
74            ))?;
75
76        let receipt_result = self.provider().get_transaction_receipt(tx_hash).await?;
77
78        if let Some(receipt) = receipt_result {
79            if !receipt.inner.status() {
80                return Ok(TransactionStatus::Failed);
81            }
82            let last_block_number = self.provider().get_block_number().await?;
83            let tx_block_number = receipt
84                .block_number
85                .ok_or(TransactionError::UnexpectedError(
86                    "Transaction receipt missing block number".to_string(),
87                ))?;
88
89            let network_model = self
90                .network_repository()
91                .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
92                .await?
93                .ok_or(TransactionError::UnexpectedError(format!(
94                    "Network with chain id {} not found",
95                    evm_data.chain_id
96                )))?;
97
98            let network = EvmNetwork::try_from(network_model).map_err(|e| {
99                TransactionError::UnexpectedError(format!(
100                    "Error converting network model to EvmNetwork: {e}"
101                ))
102            })?;
103
104            if !has_enough_confirmations(
105                tx_block_number,
106                last_block_number,
107                network.required_confirmations,
108            ) {
109                debug!(
110                    tx_id = %tx.id,
111                    relayer_id = %tx.relayer_id,
112                    tx_hash = %tx_hash,
113                    "transaction mined but not confirmed"
114                );
115                return Ok(TransactionStatus::Mined);
116            }
117            Ok(TransactionStatus::Confirmed)
118        } else {
119            debug!(
120                tx_id = %tx.id,
121                relayer_id = %tx.relayer_id,
122                tx_hash = %tx_hash,
123                "transaction not yet mined"
124            );
125
126            // FALLBACK: Try to find transaction by checking all historical hashes
127            // Only do this for transactions that have multiple resubmission attempts
128            // and have been stuck in Submitted for a while
129            if tx.hashes.len() > 1 && self.should_try_hash_recovery(tx)? {
130                if let Some(recovered_tx) = self
131                    .try_recover_with_historical_hashes(tx, &evm_data)
132                    .await?
133                {
134                    // Return the status from the recovered (updated) transaction
135                    return Ok(recovered_tx.status);
136                }
137            }
138
139            Ok(TransactionStatus::Submitted)
140        }
141    }
142
143    /// Determines if a transaction should be resubmitted.
144    pub(super) async fn should_resubmit(
145        &self,
146        tx: &TransactionRepoModel,
147    ) -> Result<bool, TransactionError> {
148        // Validate transaction is in correct state for resubmission
149        ensure_status(tx, TransactionStatus::Submitted, Some("should_resubmit"))?;
150
151        let evm_data = tx.network_data.get_evm_transaction_data()?;
152        let age = get_age_of_sent_at(tx)?;
153
154        // Check if network lacks mempool and determine appropriate timeout
155        let network_model = self
156            .network_repository()
157            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
158            .await?
159            .ok_or(TransactionError::UnexpectedError(format!(
160                "Network with chain id {} not found",
161                evm_data.chain_id
162            )))?;
163
164        let network = EvmNetwork::try_from(network_model).map_err(|e| {
165            TransactionError::UnexpectedError(format!(
166                "Error converting network model to EvmNetwork: {e}"
167            ))
168        })?;
169
170        let timeout = match network.is_arbitrum() {
171            true => ARBITRUM_TIME_TO_RESUBMIT,
172            false => get_resubmit_timeout_for_speed(&evm_data.speed),
173        };
174
175        let timeout_with_backoff = match network.is_arbitrum() {
176            true => timeout, // Use base timeout without backoff for Arbitrum
177            false => get_resubmit_timeout_with_backoff(timeout, tx.hashes.len()),
178        };
179
180        if age > Duration::milliseconds(timeout_with_backoff) {
181            debug!(
182                tx_id = %tx.id,
183                relayer_id = %tx.relayer_id,
184                age_ms = %age.num_milliseconds(),
185                "transaction has been pending for too long, resubmitting"
186            );
187            return Ok(true);
188        }
189        Ok(false)
190    }
191
192    /// Determines if a transaction should be replaced with a NOOP transaction.
193    ///
194    /// Returns a tuple `(should_noop, reason)` where:
195    /// - `should_noop`: `true` if transaction should be replaced with NOOP
196    /// - `reason`: Optional reason string explaining why NOOP is needed (only set when `should_noop` is `true`)
197    ///
198    /// # Arguments
199    ///
200    /// * `tx` - The transaction to check
201    pub(super) async fn should_noop(
202        &self,
203        tx: &TransactionRepoModel,
204    ) -> Result<(bool, Option<String>), TransactionError> {
205        if too_many_noop_attempts(tx) {
206            debug!("Transaction has too many NOOP attempts already");
207            return Ok((false, None));
208        }
209
210        let evm_data = tx.network_data.get_evm_transaction_data()?;
211        if is_noop(&evm_data) {
212            return Ok((false, None));
213        }
214
215        let network_model = self
216            .network_repository()
217            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
218            .await?
219            .ok_or(TransactionError::UnexpectedError(format!(
220                "Network with chain id {} not found",
221                evm_data.chain_id
222            )))?;
223
224        let network = EvmNetwork::try_from(network_model).map_err(|e| {
225            TransactionError::UnexpectedError(format!(
226                "Error converting network model to EvmNetwork: {e}"
227            ))
228        })?;
229
230        if network.is_rollup() && too_many_attempts(tx) {
231            let reason =
232                "Rollup transaction has too many attempts. Replacing with NOOP.".to_string();
233            debug!(
234                tx_id = %tx.id,
235                relayer_id = %tx.relayer_id,
236                reason = %reason,
237                "replacing transaction with NOOP"
238            );
239            return Ok((true, Some(reason)));
240        }
241
242        if !is_transaction_valid(&tx.created_at, &tx.valid_until) {
243            let reason = "Transaction is expired. Replacing with NOOP.".to_string();
244            debug!(
245                tx_id = %tx.id,
246                relayer_id = %tx.relayer_id,
247                reason = %reason,
248                "replacing transaction with NOOP"
249            );
250            return Ok((true, Some(reason)));
251        }
252
253        if tx.status == TransactionStatus::Pending {
254            let created_at = &tx.created_at;
255            let created_time = DateTime::parse_from_rfc3339(created_at)
256                .map_err(|e| {
257                    TransactionError::UnexpectedError(format!("Invalid created_at timestamp: {e}"))
258                })?
259                .with_timezone(&Utc);
260            let age = Utc::now().signed_duration_since(created_time);
261            if age > get_evm_prepare_timeout() {
262                let reason = format!(
263                    "Transaction in Pending state for over {} minutes. Replacing with NOOP.",
264                    get_evm_prepare_timeout().num_minutes()
265                );
266                debug!(
267                    tx_id = %tx.id,
268                    relayer_id = %tx.relayer_id,
269                    reason = %reason,
270                    "replacing transaction with NOOP"
271                );
272                return Ok((true, Some(reason)));
273            }
274        }
275
276        let latest_block = self.provider().get_block_by_number().await;
277        if let Ok(block) = latest_block {
278            let block_gas_limit = block.header.gas_limit;
279            if let Some(gas_limit) = evm_data.gas_limit {
280                if gas_limit > block_gas_limit {
281                    let reason = format!(
282                                "Transaction gas limit ({gas_limit}) exceeds block gas limit ({block_gas_limit}). Replacing with NOOP.",
283                            );
284                    warn!(
285                        tx_id = %tx.id,
286                        tx_gas_limit = %gas_limit,
287                        block_gas_limit = %block_gas_limit,
288                        "transaction gas limit exceeds block gas limit, replacing with NOOP"
289                    );
290                    return Ok((true, Some(reason)));
291                }
292            }
293        }
294
295        Ok((false, None))
296    }
297
298    /// Helper method that updates transaction status only if it's different from the current status.
299    pub(super) async fn update_transaction_status_if_needed(
300        &self,
301        tx: TransactionRepoModel,
302        new_status: TransactionStatus,
303        status_reason: Option<String>,
304    ) -> Result<TransactionRepoModel, TransactionError> {
305        if tx.status != new_status {
306            return self
307                .update_transaction_status(tx, new_status, status_reason)
308                .await;
309        }
310        Ok(tx)
311    }
312
313    /// Prepares a NOOP transaction update request.
314    pub(super) async fn prepare_noop_update_request(
315        &self,
316        tx: &TransactionRepoModel,
317        is_cancellation: bool,
318        reason: Option<String>,
319    ) -> Result<TransactionUpdateRequest, TransactionError> {
320        let mut evm_data = tx.network_data.get_evm_transaction_data()?;
321        let network_model = self
322            .network_repository()
323            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
324            .await?
325            .ok_or(TransactionError::UnexpectedError(format!(
326                "Network with chain id {} not found",
327                evm_data.chain_id
328            )))?;
329
330        let network = EvmNetwork::try_from(network_model).map_err(|e| {
331            TransactionError::UnexpectedError(format!(
332                "Error converting network model to EvmNetwork: {e}"
333            ))
334        })?;
335
336        make_noop(&mut evm_data, &network, Some(self.provider())).await?;
337
338        let noop_count = tx.noop_count.unwrap_or(0) + 1;
339        let update_request = TransactionUpdateRequest {
340            network_data: Some(NetworkTransactionData::Evm(evm_data)),
341            noop_count: Some(noop_count),
342            status_reason: reason,
343            is_canceled: if is_cancellation {
344                Some(true)
345            } else {
346                tx.is_canceled
347            },
348            ..Default::default()
349        };
350        Ok(update_request)
351    }
352
353    /// Handles transactions in the Submitted state.
354    async fn handle_submitted_state(
355        &self,
356        tx: TransactionRepoModel,
357    ) -> Result<TransactionRepoModel, TransactionError> {
358        if self.should_resubmit(&tx).await? {
359            let resubmitted_tx = self.handle_resubmission(tx).await?;
360            return Ok(resubmitted_tx);
361        }
362
363        self.update_transaction_status_if_needed(tx, TransactionStatus::Submitted, None)
364            .await
365    }
366
367    /// Processes transaction resubmission logic
368    async fn handle_resubmission(
369        &self,
370        tx: TransactionRepoModel,
371    ) -> Result<TransactionRepoModel, TransactionError> {
372        debug!(
373            tx_id = %tx.id,
374            relayer_id = %tx.relayer_id,
375            status = ?tx.status,
376            "scheduling resubmit job for transaction"
377        );
378
379        // Check if transaction gas limit exceeds block gas limit before resubmitting
380        let (should_noop, reason) = self.should_noop(&tx).await?;
381        let tx_to_process = if should_noop {
382            self.process_noop_transaction(&tx, reason).await?
383        } else {
384            tx
385        };
386
387        self.send_transaction_resubmit_job(&tx_to_process).await?;
388        Ok(tx_to_process)
389    }
390
391    /// Handles NOOP transaction processing before resubmission
392    async fn process_noop_transaction(
393        &self,
394        tx: &TransactionRepoModel,
395        reason: Option<String>,
396    ) -> Result<TransactionRepoModel, TransactionError> {
397        debug!(
398            tx_id = %tx.id,
399            relayer_id = %tx.relayer_id,
400            status = ?tx.status,
401            "preparing transaction NOOP before resubmission"
402        );
403        let update = self.prepare_noop_update_request(tx, false, reason).await?;
404        let updated_tx = self
405            .transaction_repository()
406            .partial_update(tx.id.clone(), update)
407            .await?;
408
409        let res = self.send_transaction_update_notification(&updated_tx).await;
410        if let Err(e) = res {
411            error!(
412                tx_id = %updated_tx.id,
413                relayer_id = %updated_tx.relayer_id,
414                status = ?updated_tx.status,
415                error = %e,
416                "sending transaction update notification failed for NOOP transaction"
417            );
418        }
419        Ok(updated_tx)
420    }
421
422    /// Handles transactions in the Pending state.
423    async fn handle_pending_state(
424        &self,
425        tx: TransactionRepoModel,
426    ) -> Result<TransactionRepoModel, TransactionError> {
427        let (should_noop, reason) = self.should_noop(&tx).await?;
428        if should_noop {
429            // For Pending state transactions, nonces are not yet assigned, so we mark as Failed
430            // instead of NOOP. This matches prepare_transaction behavior.
431            debug!(
432                tx_id = %tx.id,
433                relayer_id = %tx.relayer_id,
434                reason = %reason.as_ref().unwrap_or(&"unknown".to_string()),
435                "marking pending transaction as Failed (nonce not assigned, no NOOP needed)"
436            );
437            let update = TransactionUpdateRequest {
438                status: Some(TransactionStatus::Failed),
439                status_reason: reason,
440                ..Default::default()
441            };
442            let updated_tx = self
443                .transaction_repository()
444                .partial_update(tx.id.clone(), update)
445                .await?;
446
447            let res = self.send_transaction_update_notification(&updated_tx).await;
448            if let Err(e) = res {
449                error!(
450                    tx_id = %updated_tx.id,
451                    relayer_id = %updated_tx.relayer_id,
452                    status = ?updated_tx.status,
453                    error = %e,
454                    "sending transaction update notification failed for Pending state NOOP"
455                );
456            }
457            return Ok(updated_tx);
458        }
459
460        // Check if transaction is stuck in Pending (prepare job may have failed)
461        let age = get_age_since_created(&tx)?;
462        if age > get_evm_pending_recovery_trigger_timeout() {
463            warn!(
464                tx_id = %tx.id,
465                relayer_id = %tx.relayer_id,
466                age_seconds = age.num_seconds(),
467                "transaction stuck in Pending, queuing prepare job"
468            );
469
470            // Re-queue prepare job
471            self.send_transaction_request_job(&tx).await?;
472        }
473
474        Ok(tx)
475    }
476
477    /// Handles transactions in the Mined state.
478    async fn handle_mined_state(
479        &self,
480        tx: TransactionRepoModel,
481    ) -> Result<TransactionRepoModel, TransactionError> {
482        self.update_transaction_status_if_needed(tx, TransactionStatus::Mined, None)
483            .await
484    }
485
486    /// Handles transactions in final states (Confirmed, Failed, Expired).
487    async fn handle_final_state(
488        &self,
489        tx: TransactionRepoModel,
490        status: TransactionStatus,
491        status_reason: Option<String>,
492    ) -> Result<TransactionRepoModel, TransactionError> {
493        self.update_transaction_status_if_needed(tx, status, status_reason)
494            .await
495    }
496
497    /// Marks a transaction as Failed with a given reason.
498    async fn mark_as_failed(
499        &self,
500        tx: TransactionRepoModel,
501        reason: String,
502    ) -> Result<TransactionRepoModel, TransactionError> {
503        warn!(
504            tx_id = %tx.id,
505            relayer_id = %tx.relayer_id,
506            reason = %reason,
507            "force-failing transaction due to circuit breaker"
508        );
509
510        let update = TransactionUpdateRequest {
511            status: Some(TransactionStatus::Failed),
512            status_reason: Some(reason),
513            ..Default::default()
514        };
515
516        let updated_tx = self
517            .transaction_repository()
518            .partial_update(tx.id.clone(), update)
519            .await?;
520
521        // Send notification (best effort)
522        if let Err(e) = self.send_transaction_update_notification(&updated_tx).await {
523            error!(
524                tx_id = %updated_tx.id,
525                relayer_id = %updated_tx.relayer_id,
526                error = %e,
527                "failed to send notification for force-failed transaction"
528            );
529        }
530
531        Ok(updated_tx)
532    }
533
534    /// Handles circuit breaker safely based on transaction status.
535    ///
536    /// This method implements the safe circuit breaker logic:
537    /// - **Pending/Sent**: Safe to mark as Failed (never broadcast to network)
538    /// - **Submitted**: Must trigger NOOP to clear nonce slot (regardless of expiry)
539    ///
540    /// For Submitted transactions, we always issue a NOOP because the nonce slot is
541    /// occupied and the original transaction could still execute. Simply marking as
542    /// Failed/Expired would leave the nonce blocked and risk the relayer stopping.
543    ///
544    /// Note: NOOP transactions are filtered out before entering this function.
545    async fn handle_circuit_breaker_safely(
546        &self,
547        tx: TransactionRepoModel,
548        ctx: &StatusCheckContext,
549    ) -> Result<TransactionRepoModel, TransactionError> {
550        let reason = format!(
551            "Transaction status monitoring failed after {} consecutive errors (total: {}). \
552             Last status: {:?}.",
553            ctx.consecutive_failures, ctx.total_failures, tx.status
554        );
555
556        match tx.status {
557            TransactionStatus::Pending | TransactionStatus::Sent => {
558                // Pending: no nonce assigned yet
559                // Sent: nonce assigned but never broadcast to network
560                // Both are safe to mark as Failed - transaction can't execute on-chain
561                debug!(
562                    tx_id = %tx.id,
563                    relayer_id = %tx.relayer_id,
564                    status = ?tx.status,
565                    "circuit breaker: transaction never broadcast - safe to mark as Failed"
566                );
567                self.mark_as_failed(tx, reason).await
568            }
569            TransactionStatus::Submitted => {
570                // Submitted transactions occupy a nonce slot and could still execute.
571                // Regardless of expiry status, we MUST issue a NOOP to:
572                // 1. Clear the nonce slot so subsequent transactions can proceed
573                // 2. Prevent the original transaction from executing later
574                // Note: NOOP transactions are filtered out before entering this function.
575                warn!(
576                    tx_id = %tx.id,
577                    relayer_id = %tx.relayer_id,
578                    "circuit breaker: Submitted transaction - triggering NOOP to safely clear nonce"
579                );
580                let noop_reason = Some(format!(
581                    "{reason}. Replacing with NOOP to clear nonce slot."
582                ));
583                let updated_tx = self.process_noop_transaction(&tx, noop_reason).await?;
584                self.send_transaction_resubmit_job(&updated_tx).await?;
585                Ok(updated_tx)
586            }
587            _ => {
588                // Final states shouldn't reach here, but handle gracefully
589                debug!(
590                    tx_id = %tx.id,
591                    relayer_id = %tx.relayer_id,
592                    status = ?tx.status,
593                    "circuit breaker: unexpected status, returning transaction unchanged"
594                );
595                Ok(tx)
596            }
597        }
598    }
599
600    /// Inherent status-handling method.
601    ///
602    /// This method encapsulates the full logic for handling transaction status,
603    /// including resubmission, NOOP replacement, timeout detection, and updating status.
604    pub async fn handle_status_impl(
605        &self,
606        tx: TransactionRepoModel,
607        context: Option<StatusCheckContext>,
608    ) -> Result<TransactionRepoModel, TransactionError> {
609        debug!(
610            tx_id = %tx.id,
611            relayer_id = %tx.relayer_id,
612            status = ?tx.status,
613            "checking transaction status"
614        );
615
616        // 1. Early return if final state
617        if is_final_state(&tx.status) {
618            debug!(
619                tx_id = %tx.id,
620                relayer_id = %tx.relayer_id,
621                status = ?tx.status,
622                "transaction already in final state"
623            );
624            return Ok(tx);
625        }
626
627        // 1.1. Check if circuit breaker should force finalization
628        // Skip circuit breaker for NOOP transactions - they're already safe (just clearing nonce)
629        // and should be handled by normal status logic which will eventually resolve them.
630        if let Some(ref ctx) = context {
631            let is_noop_tx = tx
632                .network_data
633                .get_evm_transaction_data()
634                .map(|data| is_noop(&data))
635                .unwrap_or(false);
636
637            if ctx.should_force_finalize() && !is_noop_tx {
638                warn!(
639                    tx_id = %tx.id,
640                    consecutive_failures = ctx.consecutive_failures,
641                    total_failures = ctx.total_failures,
642                    max_consecutive = ctx.max_consecutive_failures,
643                    status = ?tx.status,
644                    "circuit breaker triggered - handling safely based on transaction state"
645                );
646                return self.handle_circuit_breaker_safely(tx, ctx).await;
647            }
648
649            if ctx.should_force_finalize() && is_noop_tx {
650                debug!(
651                    tx_id = %tx.id,
652                    consecutive_failures = ctx.consecutive_failures,
653                    relayer_id = %tx.relayer_id,
654                    "circuit breaker would trigger but transaction is NOOP - continuing with normal status logic"
655                );
656            }
657        }
658
659        // 2. Check transaction status first
660        // This allows fast transactions to update their status immediately,
661        // even if they're young (<20s). For Pending/Sent states, this returns
662        // early without querying the blockchain.
663        let status = self.check_transaction_status(&tx).await?;
664
665        debug!(
666            tx_id = %tx.id,
667            previous_status = ?tx.status,
668            new_status = ?status,
669            relayer_id = %tx.relayer_id,
670            "transaction status check completed"
671        );
672
673        // 2.1. Reload transaction from DB if status changed
674        // This ensures we have fresh data if check_transaction_status triggered a recovery
675        // or any other update that modified the transaction in the database.
676        let tx = if status != tx.status {
677            debug!(
678                tx_id = %tx.id,
679                old_status = ?tx.status,
680                new_status = ?status,
681                relayer_id = %tx.relayer_id,
682                "status changed during check, reloading transaction from DB to ensure fresh data"
683            );
684            self.transaction_repository()
685                .get_by_id(tx.id.clone())
686                .await?
687        } else {
688            tx
689        };
690
691        // 3. Check if too early for resubmission on in-progress transactions
692        // For Pending/Sent/Submitted states, defer resubmission logic and timeout checks
693        // if the transaction is too young. Just update status and return.
694        // For other states (Mined/Confirmed/Failed/etc), process immediately regardless of age.
695        if is_too_early_to_resubmit(&tx)? && is_pending_transaction(&status) {
696            // Update status if it changed, then return
697            return self
698                .update_transaction_status_if_needed(tx, status, None)
699                .await;
700        }
701
702        // 4. Handle based on status (including complex operations like resubmission)
703        match status {
704            TransactionStatus::Pending => self.handle_pending_state(tx).await,
705            TransactionStatus::Sent => self.handle_sent_state(tx).await,
706            TransactionStatus::Submitted => self.handle_submitted_state(tx).await,
707            TransactionStatus::Mined => self.handle_mined_state(tx).await,
708            TransactionStatus::Failed => {
709                // Provide a descriptive status_reason when transitioning to Failed
710                // from an on-chain receipt check (i.e., receipt status was false).
711                let status_reason = if tx.status != TransactionStatus::Failed {
712                    Some("Transaction reverted on-chain (receipt status: failed)".to_string())
713                } else {
714                    None
715                };
716                self.handle_final_state(tx, status, status_reason).await
717            }
718            TransactionStatus::Confirmed
719            | TransactionStatus::Expired
720            | TransactionStatus::Canceled => self.handle_final_state(tx, status, None).await,
721        }
722    }
723
724    /// Handle transactions stuck in Sent (prepared but not submitted)
725    async fn handle_sent_state(
726        &self,
727        tx: TransactionRepoModel,
728    ) -> Result<TransactionRepoModel, TransactionError> {
729        debug!(
730            tx_id = %tx.id,
731            relayer_id = %tx.relayer_id,
732            "handling Sent state"
733        );
734
735        // Check if transaction should be replaced with NOOP (expired, too many attempts on rollup, etc.)
736        let (should_noop, reason) = self.should_noop(&tx).await?;
737        if should_noop {
738            debug!(
739                tx_id = %tx.id,
740                relayer_id = %tx.relayer_id,
741                "preparing NOOP for sent transaction"
742            );
743            let update = self.prepare_noop_update_request(&tx, false, reason).await?;
744            let updated_tx = self
745                .transaction_repository()
746                .partial_update(tx.id.clone(), update)
747                .await?;
748
749            self.send_transaction_submit_job(&updated_tx).await?;
750            let res = self.send_transaction_update_notification(&updated_tx).await;
751            if let Err(e) = res {
752                error!(
753                    tx_id = %updated_tx.id,
754                    relayer_id = %updated_tx.relayer_id,
755                    status = ?updated_tx.status,
756                    error = %e,
757                    "sending transaction update notification failed for Sent state NOOP"
758                );
759            }
760            return Ok(updated_tx);
761        }
762
763        // Transaction was prepared but submission job may have failed
764        // Re-queue a resend job if it's been stuck for a while
765        let age_since_sent = get_age_since_status_change(&tx)?;
766
767        if age_since_sent > get_evm_resend_timeout() {
768            warn!(
769                tx_id = %tx.id,
770                relayer_id = %tx.relayer_id,
771                age_seconds = age_since_sent.num_seconds(),
772                "transaction stuck in Sent, queuing resubmit job with repricing"
773            );
774
775            // Queue resubmit job to reprice the transaction for better acceptance
776            self.send_transaction_resubmit_job(&tx).await?;
777        }
778
779        self.update_transaction_status_if_needed(tx, TransactionStatus::Sent, None)
780            .await
781    }
782
783    /// Determines if we should attempt hash recovery for a stuck transaction.
784    ///
785    /// This is an expensive operation, so we only do it when:
786    /// - Transaction has been in Submitted status for a while (> 2 minutes)
787    /// - Transaction has had at least 2 resubmission attempts (hashes.len() > 1)
788    /// - Haven't tried recovery too recently (to avoid repeated attempts)
789    fn should_try_hash_recovery(
790        &self,
791        tx: &TransactionRepoModel,
792    ) -> Result<bool, TransactionError> {
793        // Only try recovery for transactions stuck in Submitted
794        if tx.status != TransactionStatus::Submitted {
795            return Ok(false);
796        }
797
798        // Must have multiple hashes (indicating resubmissions happened)
799        if tx.hashes.len() <= 1 {
800            return Ok(false);
801        }
802
803        // Only try if transaction has been stuck for a while
804        let age = get_age_of_sent_at(tx)?;
805        let min_age_for_recovery = get_evm_min_age_for_hash_recovery();
806
807        if age < min_age_for_recovery {
808            return Ok(false);
809        }
810
811        // Check if we've had enough resubmission attempts (more attempts = more likely to have wrong hash)
812        // Only try recovery if we have at least 3 hashes (2 resubmissions)
813        if tx.hashes.len() < EVM_MIN_HASHES_FOR_RECOVERY {
814            return Ok(false);
815        }
816
817        Ok(true)
818    }
819
820    /// Attempts to recover transaction status by checking all historical hashes.
821    ///
822    /// When a transaction is resubmitted multiple times due to timeouts, the database
823    /// may contain multiple hashes. The "current" hash (network_data.hash) might not
824    /// be the one that actually got mined. This method checks all historical hashes
825    /// to find if any were mined, and updates the database with the correct one.
826    ///
827    /// Returns the updated transaction model if recovery was successful, None otherwise.
828    async fn try_recover_with_historical_hashes(
829        &self,
830        tx: &TransactionRepoModel,
831        evm_data: &crate::models::EvmTransactionData,
832    ) -> Result<Option<TransactionRepoModel>, TransactionError> {
833        warn!(
834            tx_id = %tx.id,
835            relayer_id = %tx.relayer_id,
836            current_hash = ?evm_data.hash,
837            total_hashes = %tx.hashes.len(),
838            "attempting hash recovery - checking historical hashes"
839        );
840
841        // Check each historical hash (most recent first, since it's more likely)
842        for (idx, historical_hash) in tx.hashes.iter().rev().enumerate() {
843            // Skip if this is the current hash (already checked)
844            if Some(historical_hash) == evm_data.hash.as_ref() {
845                continue;
846            }
847
848            debug!(
849                tx_id = %tx.id,
850                relayer_id = %tx.relayer_id,
851                hash = %historical_hash,
852                index = %idx,
853                "checking historical hash"
854            );
855
856            // Try to get receipt for this hash
857            match self
858                .provider()
859                .get_transaction_receipt(historical_hash)
860                .await
861            {
862                Ok(Some(receipt)) => {
863                    warn!(
864                        tx_id = %tx.id,
865                        relayer_id = %tx.relayer_id,
866                        mined_hash = %historical_hash,
867                        wrong_hash = ?evm_data.hash,
868                        block_number = ?receipt.block_number,
869                        "RECOVERED: found mined transaction with historical hash - correcting database"
870                    );
871
872                    // Update with correct hash and Mined status
873                    // Let the normal status check flow handle confirmation checking
874                    let updated_tx = self
875                        .update_transaction_with_corrected_hash(
876                            tx,
877                            evm_data,
878                            historical_hash,
879                            TransactionStatus::Mined,
880                        )
881                        .await?;
882
883                    return Ok(Some(updated_tx));
884                }
885                Ok(None) => {
886                    // This hash not found either, continue to next
887                    continue;
888                }
889                Err(e) => {
890                    // Network error, log but continue checking other hashes
891                    warn!(
892                        tx_id = %tx.id,
893                        relayer_id = %tx.relayer_id,
894                        hash = %historical_hash,
895                        error = %e,
896                        "error checking historical hash, continuing to next"
897                    );
898                    continue;
899                }
900            }
901        }
902
903        // None of the historical hashes found on-chain
904        debug!(
905            tx_id = %tx.id,
906            relayer_id = %tx.relayer_id,
907            "hash recovery completed - no historical hashes found on-chain"
908        );
909        Ok(None)
910    }
911
912    /// Updates transaction with the corrected hash and status
913    ///
914    /// Returns the updated transaction model and sends a notification about the status change.
915    async fn update_transaction_with_corrected_hash(
916        &self,
917        tx: &TransactionRepoModel,
918        evm_data: &crate::models::EvmTransactionData,
919        correct_hash: &str,
920        status: TransactionStatus,
921    ) -> Result<TransactionRepoModel, TransactionError> {
922        let mut corrected_data = evm_data.clone();
923        corrected_data.hash = Some(correct_hash.to_string());
924
925        let updated_tx = self
926            .transaction_repository()
927            .partial_update(
928                tx.id.clone(),
929                TransactionUpdateRequest {
930                    network_data: Some(NetworkTransactionData::Evm(corrected_data)),
931                    status: Some(status),
932                    ..Default::default()
933                },
934            )
935            .await?;
936
937        // Send notification about the recovered transaction
938        if let Err(e) = self.send_transaction_update_notification(&updated_tx).await {
939            error!(
940                tx_id = %updated_tx.id,
941                relayer_id = %updated_tx.relayer_id,
942                error = %e,
943                "failed to send notification for hash recovery"
944            );
945        }
946
947        Ok(updated_tx)
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use crate::{
954        config::{EvmNetworkConfig, NetworkConfigCommon},
955        domain::transaction::evm::{EvmRelayerTransaction, MockPriceCalculatorTrait},
956        jobs::MockJobProducerTrait,
957        models::{
958            evm::Speed, EvmTransactionData, NetworkConfigData, NetworkRepoModel,
959            NetworkTransactionData, NetworkType, RelayerEvmPolicy, RelayerNetworkPolicy,
960            RelayerRepoModel, RpcConfig, TransactionReceipt, TransactionRepoModel,
961            TransactionStatus, U256,
962        },
963        repositories::{
964            MockNetworkRepository, MockRelayerRepository, MockTransactionCounterTrait,
965            MockTransactionRepository,
966        },
967        services::{provider::MockEvmProviderTrait, signer::MockSigner},
968    };
969    use alloy::{
970        consensus::{Eip658Value, Receipt, ReceiptWithBloom},
971        network::AnyReceiptEnvelope,
972        primitives::{b256, Address, BlockHash, Bloom, TxHash},
973    };
974    use chrono::{Duration, Utc};
975    use std::sync::Arc;
976
977    /// Helper struct holding all the mocks we often need
978    pub struct TestMocks {
979        pub provider: MockEvmProviderTrait,
980        pub relayer_repo: MockRelayerRepository,
981        pub network_repo: MockNetworkRepository,
982        pub tx_repo: MockTransactionRepository,
983        pub job_producer: MockJobProducerTrait,
984        pub signer: MockSigner,
985        pub counter: MockTransactionCounterTrait,
986        pub price_calc: MockPriceCalculatorTrait,
987    }
988
989    /// Returns a default `TestMocks` with zero-configuration stubs.
990    /// You can override expectations in each test as needed.
991    pub fn default_test_mocks() -> TestMocks {
992        TestMocks {
993            provider: MockEvmProviderTrait::new(),
994            relayer_repo: MockRelayerRepository::new(),
995            network_repo: MockNetworkRepository::new(),
996            tx_repo: MockTransactionRepository::new(),
997            job_producer: MockJobProducerTrait::new(),
998            signer: MockSigner::new(),
999            counter: MockTransactionCounterTrait::new(),
1000            price_calc: MockPriceCalculatorTrait::new(),
1001        }
1002    }
1003
1004    /// Returns a `TestMocks` with network repository configured for prepare_noop_update_request tests.
1005    pub fn default_test_mocks_with_network() -> TestMocks {
1006        let mut mocks = default_test_mocks();
1007        // Set up default expectation for get_by_chain_id that prepare_noop_update_request tests need
1008        mocks
1009            .network_repo
1010            .expect_get_by_chain_id()
1011            .returning(|network_type, chain_id| {
1012                if network_type == NetworkType::Evm && chain_id == 1 {
1013                    Ok(Some(create_test_network_model()))
1014                } else {
1015                    Ok(None)
1016                }
1017            });
1018        mocks
1019    }
1020
1021    /// Creates a test NetworkRepoModel for chain_id 1 (mainnet)
1022    pub fn create_test_network_model() -> NetworkRepoModel {
1023        let evm_config = EvmNetworkConfig {
1024            common: NetworkConfigCommon {
1025                network: "mainnet".to_string(),
1026                from: None,
1027                rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]),
1028                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
1029                average_blocktime_ms: Some(12000),
1030                is_testnet: Some(false),
1031                tags: Some(vec!["mainnet".to_string()]),
1032            },
1033            chain_id: Some(1),
1034            required_confirmations: Some(12),
1035            features: Some(vec!["eip1559".to_string()]),
1036            symbol: Some("ETH".to_string()),
1037            gas_price_cache: None,
1038        };
1039        NetworkRepoModel {
1040            id: "evm:mainnet".to_string(),
1041            name: "mainnet".to_string(),
1042            network_type: NetworkType::Evm,
1043            config: NetworkConfigData::Evm(evm_config),
1044        }
1045    }
1046
1047    /// Creates a test NetworkRepoModel for chain_id 42161 (Arbitrum-like) with no-mempool tag
1048    pub fn create_test_no_mempool_network_model() -> NetworkRepoModel {
1049        let evm_config = EvmNetworkConfig {
1050            common: NetworkConfigCommon {
1051                network: "arbitrum".to_string(),
1052                from: None,
1053                rpc_urls: Some(vec![crate::models::RpcConfig::new(
1054                    "https://arb-rpc.example.com".to_string(),
1055                )]),
1056                explorer_urls: Some(vec!["https://arb-explorer.example.com".to_string()]),
1057                average_blocktime_ms: Some(1000),
1058                is_testnet: Some(false),
1059                tags: Some(vec![
1060                    "arbitrum".to_string(),
1061                    "rollup".to_string(),
1062                    "no-mempool".to_string(),
1063                ]),
1064            },
1065            chain_id: Some(42161),
1066            required_confirmations: Some(12),
1067            features: Some(vec!["eip1559".to_string()]),
1068            symbol: Some("ETH".to_string()),
1069            gas_price_cache: None,
1070        };
1071        NetworkRepoModel {
1072            id: "evm:arbitrum".to_string(),
1073            name: "arbitrum".to_string(),
1074            network_type: NetworkType::Evm,
1075            config: NetworkConfigData::Evm(evm_config),
1076        }
1077    }
1078
1079    /// Minimal "builder" for TransactionRepoModel.
1080    /// Allows quick creation of a test transaction with default fields,
1081    /// then updates them based on the provided status or overrides.
1082    pub fn make_test_transaction(status: TransactionStatus) -> TransactionRepoModel {
1083        TransactionRepoModel {
1084            id: "test-tx-id".to_string(),
1085            relayer_id: "test-relayer-id".to_string(),
1086            status,
1087            status_reason: None,
1088            created_at: Utc::now().to_rfc3339(),
1089            sent_at: None,
1090            confirmed_at: None,
1091            valid_until: None,
1092            delete_at: None,
1093            network_type: NetworkType::Evm,
1094            network_data: NetworkTransactionData::Evm(EvmTransactionData {
1095                chain_id: 1,
1096                from: "0xSender".to_string(),
1097                to: Some("0xRecipient".to_string()),
1098                value: U256::from(0),
1099                data: Some("0xData".to_string()),
1100                gas_limit: Some(21000),
1101                gas_price: Some(20000000000),
1102                max_fee_per_gas: None,
1103                max_priority_fee_per_gas: None,
1104                nonce: None,
1105                signature: None,
1106                hash: None,
1107                speed: Some(Speed::Fast),
1108                raw: None,
1109            }),
1110            priced_at: None,
1111            hashes: Vec::new(),
1112            noop_count: None,
1113            is_canceled: Some(false),
1114            metadata: None,
1115        }
1116    }
1117
1118    /// Minimal "builder" for EvmRelayerTransaction.
1119    /// Takes mock dependencies as arguments.
1120    pub fn make_test_evm_relayer_transaction(
1121        relayer: RelayerRepoModel,
1122        mocks: TestMocks,
1123    ) -> EvmRelayerTransaction<
1124        MockEvmProviderTrait,
1125        MockRelayerRepository,
1126        MockNetworkRepository,
1127        MockTransactionRepository,
1128        MockJobProducerTrait,
1129        MockSigner,
1130        MockTransactionCounterTrait,
1131        MockPriceCalculatorTrait,
1132    > {
1133        EvmRelayerTransaction::new(
1134            relayer,
1135            mocks.provider,
1136            Arc::new(mocks.relayer_repo),
1137            Arc::new(mocks.network_repo),
1138            Arc::new(mocks.tx_repo),
1139            Arc::new(mocks.counter),
1140            Arc::new(mocks.job_producer),
1141            mocks.price_calc,
1142            mocks.signer,
1143        )
1144        .unwrap()
1145    }
1146
1147    fn create_test_relayer() -> RelayerRepoModel {
1148        RelayerRepoModel {
1149            id: "test-relayer-id".to_string(),
1150            name: "Test Relayer".to_string(),
1151            paused: false,
1152            system_disabled: false,
1153            network: "test_network".to_string(),
1154            network_type: NetworkType::Evm,
1155            policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
1156            signer_id: "test_signer".to_string(),
1157            address: "0x".to_string(),
1158            notification_id: None,
1159            custom_rpc_urls: None,
1160            ..Default::default()
1161        }
1162    }
1163
1164    fn make_mock_receipt(status: bool, block_number: Option<u64>) -> TransactionReceipt {
1165        // Use some placeholder values for minimal completeness
1166        let tx_hash = TxHash::from(b256!(
1167            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1168        ));
1169        let block_hash = BlockHash::from(b256!(
1170            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1171        ));
1172        let from_address = Address::from([0x11; 20]);
1173
1174        TransactionReceipt {
1175            inner: alloy::rpc::types::TransactionReceipt {
1176                inner: AnyReceiptEnvelope {
1177                    inner: ReceiptWithBloom {
1178                        receipt: Receipt {
1179                            status: Eip658Value::Eip658(status), // determines success/fail
1180                            cumulative_gas_used: 0,
1181                            logs: vec![],
1182                        },
1183                        logs_bloom: Bloom::ZERO,
1184                    },
1185                    r#type: 0, // Legacy transaction type
1186                },
1187                transaction_hash: tx_hash,
1188                transaction_index: Some(0),
1189                block_hash: block_number.map(|_| block_hash), // only set if mined
1190                block_number,
1191                gas_used: 21000,
1192                effective_gas_price: 1000,
1193                blob_gas_used: None,
1194                blob_gas_price: None,
1195                from: from_address,
1196                to: None,
1197                contract_address: None,
1198            },
1199            other: Default::default(),
1200        }
1201    }
1202
1203    // Tests for `check_transaction_status`
1204    mod check_transaction_status_tests {
1205        use super::*;
1206
1207        #[tokio::test]
1208        async fn test_not_mined() {
1209            let mut mocks = default_test_mocks();
1210            let relayer = create_test_relayer();
1211            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1212
1213            // Provide a hash so we can check for receipt
1214            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1215                evm_data.hash = Some("0xFakeHash".to_string());
1216            }
1217
1218            // Mock that get_transaction_receipt returns None (not mined)
1219            mocks
1220                .provider
1221                .expect_get_transaction_receipt()
1222                .returning(|_| Box::pin(async { Ok(None) }));
1223
1224            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1225
1226            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1227            assert_eq!(status, TransactionStatus::Submitted);
1228        }
1229
1230        #[tokio::test]
1231        async fn test_mined_but_not_confirmed() {
1232            let mut mocks = default_test_mocks();
1233            let relayer = create_test_relayer();
1234            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1235
1236            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1237                evm_data.hash = Some("0xFakeHash".to_string());
1238            }
1239
1240            // Mock a mined receipt with block_number = 100
1241            mocks
1242                .provider
1243                .expect_get_transaction_receipt()
1244                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
1245
1246            // Mock block_number that hasn't reached the confirmation threshold
1247            mocks
1248                .provider
1249                .expect_get_block_number()
1250                .return_once(|| Box::pin(async { Ok(100) }));
1251
1252            // Mock network repository to return a test network model
1253            mocks
1254                .network_repo
1255                .expect_get_by_chain_id()
1256                .returning(|_, _| Ok(Some(create_test_network_model())));
1257
1258            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1259
1260            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1261            assert_eq!(status, TransactionStatus::Mined);
1262        }
1263
1264        #[tokio::test]
1265        async fn test_confirmed() {
1266            let mut mocks = default_test_mocks();
1267            let relayer = create_test_relayer();
1268            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1269
1270            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1271                evm_data.hash = Some("0xFakeHash".to_string());
1272            }
1273
1274            // Mock a mined receipt with block_number = 100
1275            mocks
1276                .provider
1277                .expect_get_transaction_receipt()
1278                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
1279
1280            // Mock block_number that meets the confirmation threshold
1281            mocks
1282                .provider
1283                .expect_get_block_number()
1284                .return_once(|| Box::pin(async { Ok(113) }));
1285
1286            // Mock network repository to return a test network model
1287            mocks
1288                .network_repo
1289                .expect_get_by_chain_id()
1290                .returning(|_, _| Ok(Some(create_test_network_model())));
1291
1292            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1293
1294            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1295            assert_eq!(status, TransactionStatus::Confirmed);
1296        }
1297
1298        #[tokio::test]
1299        async fn test_failed() {
1300            let mut mocks = default_test_mocks();
1301            let relayer = create_test_relayer();
1302            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1303
1304            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1305                evm_data.hash = Some("0xFakeHash".to_string());
1306            }
1307
1308            // Mock a mined receipt with failure
1309            mocks
1310                .provider
1311                .expect_get_transaction_receipt()
1312                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(false, Some(100)))) }));
1313
1314            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1315
1316            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1317            assert_eq!(status, TransactionStatus::Failed);
1318        }
1319    }
1320
1321    // Tests for `should_resubmit`
1322    mod should_resubmit_tests {
1323        use super::*;
1324        use crate::models::TransactionError;
1325
1326        #[tokio::test]
1327        async fn test_should_resubmit_true() {
1328            let mut mocks = default_test_mocks();
1329            let relayer = create_test_relayer();
1330
1331            // Set sent_at to 600 seconds ago to force resubmission
1332            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1333            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1334
1335            // Mock network repository to return a regular network model
1336            mocks
1337                .network_repo
1338                .expect_get_by_chain_id()
1339                .returning(|_, _| Ok(Some(create_test_network_model())));
1340
1341            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1342            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1343            assert!(res, "Transaction should be resubmitted after timeout.");
1344        }
1345
1346        #[tokio::test]
1347        async fn test_should_resubmit_false() {
1348            let mut mocks = default_test_mocks();
1349            let relayer = create_test_relayer();
1350
1351            // Make a transaction with status Submitted but recently sent
1352            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1353            tx.sent_at = Some(Utc::now().to_rfc3339());
1354
1355            // Mock network repository to return a regular network model
1356            mocks
1357                .network_repo
1358                .expect_get_by_chain_id()
1359                .returning(|_, _| Ok(Some(create_test_network_model())));
1360
1361            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1362            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1363            assert!(!res, "Transaction should not be resubmitted immediately.");
1364        }
1365
1366        #[tokio::test]
1367        async fn test_should_resubmit_true_for_no_mempool_network() {
1368            let mut mocks = default_test_mocks();
1369            let relayer = create_test_relayer();
1370
1371            // Set up a transaction that would normally be resubmitted (sent_at long ago)
1372            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1373            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1374
1375            // Set chain_id to match the no-mempool network
1376            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1377                evm_data.chain_id = 42161; // Arbitrum chain ID
1378            }
1379
1380            // Mock network repository to return a no-mempool network model
1381            mocks
1382                .network_repo
1383                .expect_get_by_chain_id()
1384                .returning(|_, _| Ok(Some(create_test_no_mempool_network_model())));
1385
1386            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1387            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1388            assert!(
1389                res,
1390                "Transaction should be resubmitted for no-mempool networks."
1391            );
1392        }
1393
1394        #[tokio::test]
1395        async fn test_should_resubmit_network_not_found() {
1396            let mut mocks = default_test_mocks();
1397            let relayer = create_test_relayer();
1398
1399            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1400            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1401
1402            // Mock network repository to return None (network not found)
1403            mocks
1404                .network_repo
1405                .expect_get_by_chain_id()
1406                .returning(|_, _| Ok(None));
1407
1408            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1409            let result = evm_transaction.should_resubmit(&tx).await;
1410
1411            assert!(
1412                result.is_err(),
1413                "should_resubmit should return error when network not found"
1414            );
1415            let error = result.unwrap_err();
1416            match error {
1417                TransactionError::UnexpectedError(msg) => {
1418                    assert!(msg.contains("Network with chain id 1 not found"));
1419                }
1420                _ => panic!("Expected UnexpectedError for network not found"),
1421            }
1422        }
1423
1424        #[tokio::test]
1425        async fn test_should_resubmit_network_conversion_error() {
1426            let mut mocks = default_test_mocks();
1427            let relayer = create_test_relayer();
1428
1429            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1430            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1431
1432            // Create a network model with invalid EVM config (missing chain_id)
1433            let invalid_evm_config = EvmNetworkConfig {
1434                common: NetworkConfigCommon {
1435                    network: "invalid-network".to_string(),
1436                    from: None,
1437                    rpc_urls: Some(vec![crate::models::RpcConfig::new(
1438                        "https://rpc.example.com".to_string(),
1439                    )]),
1440                    explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
1441                    average_blocktime_ms: Some(12000),
1442                    is_testnet: Some(false),
1443                    tags: Some(vec!["testnet".to_string()]),
1444                },
1445                chain_id: None, // This will cause the conversion to fail
1446                required_confirmations: Some(12),
1447                features: Some(vec!["eip1559".to_string()]),
1448                symbol: Some("ETH".to_string()),
1449                gas_price_cache: None,
1450            };
1451            let invalid_network = NetworkRepoModel {
1452                id: "evm:invalid".to_string(),
1453                name: "invalid-network".to_string(),
1454                network_type: NetworkType::Evm,
1455                config: NetworkConfigData::Evm(invalid_evm_config),
1456            };
1457
1458            // Mock network repository to return the invalid network model
1459            mocks
1460                .network_repo
1461                .expect_get_by_chain_id()
1462                .returning(move |_, _| Ok(Some(invalid_network.clone())));
1463
1464            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1465            let result = evm_transaction.should_resubmit(&tx).await;
1466
1467            assert!(
1468                result.is_err(),
1469                "should_resubmit should return error when network conversion fails"
1470            );
1471            let error = result.unwrap_err();
1472            match error {
1473                TransactionError::UnexpectedError(msg) => {
1474                    assert!(msg.contains("Error converting network model to EvmNetwork"));
1475                }
1476                _ => panic!("Expected UnexpectedError for network conversion failure"),
1477            }
1478        }
1479    }
1480
1481    // Tests for `should_noop`
1482    mod should_noop_tests {
1483        use super::*;
1484
1485        #[tokio::test]
1486        async fn test_expired_transaction_triggers_noop() {
1487            let mut mocks = default_test_mocks();
1488            let relayer = create_test_relayer();
1489
1490            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1491            // Force the transaction to be "expired" by setting valid_until in the past
1492            tx.valid_until = Some((Utc::now() - Duration::seconds(10)).to_rfc3339());
1493
1494            // Mock network repository to return a test network model
1495            mocks
1496                .network_repo
1497                .expect_get_by_chain_id()
1498                .returning(|_, _| Ok(Some(create_test_network_model())));
1499
1500            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1501            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1502            assert!(res, "Expired transaction should be replaced with a NOOP.");
1503            assert!(
1504                reason.is_some(),
1505                "Reason should be provided for expired transaction"
1506            );
1507            assert!(
1508                reason.unwrap().contains("expired"),
1509                "Reason should mention expiration"
1510            );
1511        }
1512
1513        #[tokio::test]
1514        async fn test_too_many_noop_attempts_returns_false() {
1515            let mocks = default_test_mocks();
1516            let relayer = create_test_relayer();
1517
1518            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1519            tx.noop_count = Some(51); // Max is 50, so this should return false
1520
1521            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1522            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1523            assert!(
1524                !res,
1525                "Transaction with too many NOOP attempts should not be replaced."
1526            );
1527            assert!(
1528                reason.is_none(),
1529                "Reason should not be provided when should_noop is false"
1530            );
1531        }
1532
1533        #[tokio::test]
1534        async fn test_already_noop_returns_false() {
1535            let mut mocks = default_test_mocks();
1536            let relayer = create_test_relayer();
1537
1538            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1539            // Make it a NOOP by setting to=None and value=0
1540            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1541                evm_data.to = None;
1542                evm_data.value = U256::from(0);
1543            }
1544
1545            mocks
1546                .network_repo
1547                .expect_get_by_chain_id()
1548                .returning(|_, _| Ok(Some(create_test_network_model())));
1549
1550            // Mock get_block_by_number for gas limit validation (won't be called since is_noop returns early, but needed for compilation)
1551            mocks.provider.expect_get_block_by_number().returning(|| {
1552                Box::pin(async {
1553                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1554                    let mut block: Block = Block::default();
1555                    block.header.gas_limit = 30_000_000u64;
1556                    Ok(AnyRpcBlock::from(block))
1557                })
1558            });
1559
1560            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1561            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1562            assert!(
1563                !res,
1564                "Transaction that is already a NOOP should not be replaced."
1565            );
1566            assert!(
1567                reason.is_none(),
1568                "Reason should not be provided when should_noop is false"
1569            );
1570        }
1571
1572        #[tokio::test]
1573        async fn test_rollup_with_too_many_attempts_triggers_noop() {
1574            let mut mocks = default_test_mocks();
1575            let relayer = create_test_relayer();
1576
1577            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1578            // Set chain_id to Arbitrum (rollup network)
1579            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1580                evm_data.chain_id = 42161; // Arbitrum
1581            }
1582            // Set enough hashes to trigger too_many_attempts (> 50)
1583            tx.hashes = vec!["0xHash1".to_string(); 51];
1584
1585            // Mock network repository to return Arbitrum network
1586            mocks
1587                .network_repo
1588                .expect_get_by_chain_id()
1589                .returning(|_, _| Ok(Some(create_test_no_mempool_network_model())));
1590
1591            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1592            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1593            assert!(
1594                res,
1595                "Rollup transaction with too many attempts should be replaced with NOOP."
1596            );
1597            assert!(
1598                reason.is_some(),
1599                "Reason should be provided for rollup transaction"
1600            );
1601            assert!(
1602                reason.unwrap().contains("too many attempts"),
1603                "Reason should mention too many attempts"
1604            );
1605        }
1606
1607        #[tokio::test]
1608        async fn test_pending_state_timeout_triggers_noop() {
1609            let mut mocks = default_test_mocks();
1610            let relayer = create_test_relayer();
1611
1612            let mut tx = make_test_transaction(TransactionStatus::Pending);
1613            // Set created_at to 3 minutes ago (> 2 minute timeout)
1614            tx.created_at = (Utc::now() - Duration::minutes(3)).to_rfc3339();
1615
1616            mocks
1617                .network_repo
1618                .expect_get_by_chain_id()
1619                .returning(|_, _| Ok(Some(create_test_network_model())));
1620
1621            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1622            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1623            assert!(
1624                res,
1625                "Pending transaction stuck for >2 minutes should be replaced with NOOP."
1626            );
1627            assert!(
1628                reason.is_some(),
1629                "Reason should be provided for pending timeout"
1630            );
1631            assert!(
1632                reason.unwrap().contains("Pending state"),
1633                "Reason should mention Pending state"
1634            );
1635        }
1636
1637        #[tokio::test]
1638        async fn test_valid_transaction_returns_false() {
1639            let mut mocks = default_test_mocks();
1640            let relayer = create_test_relayer();
1641
1642            let tx = make_test_transaction(TransactionStatus::Submitted);
1643            // Transaction is recent, not expired, not on rollup, no issues
1644
1645            mocks
1646                .network_repo
1647                .expect_get_by_chain_id()
1648                .returning(|_, _| Ok(Some(create_test_network_model())));
1649
1650            // Mock get_block_by_number for gas limit validation
1651            mocks.provider.expect_get_block_by_number().returning(|| {
1652                Box::pin(async {
1653                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1654                    let mut block: Block = Block::default();
1655                    block.header.gas_limit = 30_000_000u64;
1656                    Ok(AnyRpcBlock::from(block))
1657                })
1658            });
1659
1660            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1661            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1662            assert!(!res, "Valid transaction should not be replaced with NOOP.");
1663            assert!(
1664                reason.is_none(),
1665                "Reason should not be provided when should_noop is false"
1666            );
1667        }
1668    }
1669
1670    // Tests for `update_transaction_status_if_needed`
1671    mod update_transaction_status_tests {
1672        use super::*;
1673
1674        #[tokio::test]
1675        async fn test_no_update_when_status_is_same() {
1676            // Create mocks, relayer, and a transaction with status Submitted.
1677            let mocks = default_test_mocks();
1678            let relayer = create_test_relayer();
1679            let tx = make_test_transaction(TransactionStatus::Submitted);
1680            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1681
1682            // When new status is the same as current, update_transaction_status_if_needed
1683            // should simply return the original transaction.
1684            let updated_tx = evm_transaction
1685                .update_transaction_status_if_needed(tx.clone(), TransactionStatus::Submitted, None)
1686                .await
1687                .unwrap();
1688            assert_eq!(updated_tx.status, TransactionStatus::Submitted);
1689            assert_eq!(updated_tx.id, tx.id);
1690        }
1691
1692        #[tokio::test]
1693        async fn test_updates_when_status_differs() {
1694            let mut mocks = default_test_mocks();
1695            let relayer = create_test_relayer();
1696            let tx = make_test_transaction(TransactionStatus::Submitted);
1697
1698            // Mock partial_update to return a transaction with new status
1699            mocks
1700                .tx_repo
1701                .expect_partial_update()
1702                .returning(|_, update| {
1703                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1704                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1705                    Ok(updated_tx)
1706                });
1707
1708            // Mock notification job
1709            mocks
1710                .job_producer
1711                .expect_produce_send_notification_job()
1712                .returning(|_, _| Box::pin(async { Ok(()) }));
1713
1714            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1715            let updated_tx = evm_transaction
1716                .update_transaction_status_if_needed(tx.clone(), TransactionStatus::Mined, None)
1717                .await
1718                .unwrap();
1719
1720            assert_eq!(updated_tx.status, TransactionStatus::Mined);
1721        }
1722
1723        #[tokio::test]
1724        async fn test_updates_with_status_reason() {
1725            let mut mocks = default_test_mocks();
1726            let relayer = create_test_relayer();
1727            let tx = make_test_transaction(TransactionStatus::Submitted);
1728
1729            mocks
1730                .tx_repo
1731                .expect_partial_update()
1732                .withf(|_, update| {
1733                    update.status == Some(TransactionStatus::Failed)
1734                        && update.status_reason == Some("Transaction reverted on-chain".to_string())
1735                })
1736                .returning(|_, update| {
1737                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1738                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1739                    updated_tx.status_reason = update.status_reason.clone();
1740                    Ok(updated_tx)
1741                });
1742
1743            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1744            let updated_tx = evm_transaction
1745                .update_transaction_status_if_needed(
1746                    tx.clone(),
1747                    TransactionStatus::Failed,
1748                    Some("Transaction reverted on-chain".to_string()),
1749                )
1750                .await
1751                .unwrap();
1752
1753            assert_eq!(updated_tx.status, TransactionStatus::Failed);
1754            assert_eq!(
1755                updated_tx.status_reason.as_deref(),
1756                Some("Transaction reverted on-chain")
1757            );
1758        }
1759    }
1760
1761    // Tests for `handle_sent_state`
1762    mod handle_sent_state_tests {
1763        use super::*;
1764
1765        #[tokio::test]
1766        async fn test_sent_state_recent_no_resend() {
1767            let mut mocks = default_test_mocks();
1768            let relayer = create_test_relayer();
1769
1770            let mut tx = make_test_transaction(TransactionStatus::Sent);
1771            // Set sent_at to recent (e.g., 10 seconds ago)
1772            tx.sent_at = Some((Utc::now() - Duration::seconds(10)).to_rfc3339());
1773
1774            // Mock network repository to return a test network model for should_noop check
1775            mocks
1776                .network_repo
1777                .expect_get_by_chain_id()
1778                .returning(|_, _| Ok(Some(create_test_network_model())));
1779
1780            // Mock get_block_by_number for gas limit validation in handle_sent_state
1781            mocks.provider.expect_get_block_by_number().returning(|| {
1782                Box::pin(async {
1783                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1784                    let mut block: Block = Block::default();
1785                    block.header.gas_limit = 30_000_000u64;
1786                    Ok(AnyRpcBlock::from(block))
1787                })
1788            });
1789
1790            // Mock status check job scheduling
1791            mocks
1792                .job_producer
1793                .expect_produce_check_transaction_status_job()
1794                .returning(|_, _| Box::pin(async { Ok(()) }));
1795
1796            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1797            let result = evm_transaction.handle_sent_state(tx.clone()).await.unwrap();
1798
1799            assert_eq!(result.status, TransactionStatus::Sent);
1800        }
1801
1802        #[tokio::test]
1803        async fn test_sent_state_stuck_schedules_resubmit() {
1804            let mut mocks = default_test_mocks();
1805            let relayer = create_test_relayer();
1806
1807            let mut tx = make_test_transaction(TransactionStatus::Sent);
1808            // Set sent_at to long ago (> 30 seconds for resend timeout)
1809            tx.sent_at = Some((Utc::now() - Duration::seconds(60)).to_rfc3339());
1810
1811            // Mock network repository to return a test network model for should_noop check
1812            mocks
1813                .network_repo
1814                .expect_get_by_chain_id()
1815                .returning(|_, _| Ok(Some(create_test_network_model())));
1816
1817            // Mock get_block_by_number for gas limit validation in handle_sent_state
1818            mocks.provider.expect_get_block_by_number().returning(|| {
1819                Box::pin(async {
1820                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1821                    let mut block: Block = Block::default();
1822                    block.header.gas_limit = 30_000_000u64;
1823                    Ok(AnyRpcBlock::from(block))
1824                })
1825            });
1826
1827            // Mock resubmit job scheduling
1828            mocks
1829                .job_producer
1830                .expect_produce_submit_transaction_job()
1831                .returning(|_, _| Box::pin(async { Ok(()) }));
1832
1833            // Mock status check job scheduling
1834            mocks
1835                .job_producer
1836                .expect_produce_check_transaction_status_job()
1837                .returning(|_, _| Box::pin(async { Ok(()) }));
1838
1839            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1840            let result = evm_transaction.handle_sent_state(tx.clone()).await.unwrap();
1841
1842            assert_eq!(result.status, TransactionStatus::Sent);
1843        }
1844    }
1845
1846    // Tests for `prepare_noop_update_request`
1847    mod prepare_noop_update_request_tests {
1848        use super::*;
1849
1850        #[tokio::test]
1851        async fn test_noop_request_without_cancellation() {
1852            // Create a transaction with an initial noop_count of 2 and is_canceled set to false.
1853            let mocks = default_test_mocks_with_network();
1854            let relayer = create_test_relayer();
1855            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1856            tx.noop_count = Some(2);
1857            tx.is_canceled = Some(false);
1858
1859            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1860            let update_req = evm_transaction
1861                .prepare_noop_update_request(&tx, false, None)
1862                .await
1863                .unwrap();
1864
1865            // NOOP count should be incremented: 2 becomes 3.
1866            assert_eq!(update_req.noop_count, Some(3));
1867            // When not cancelling, the is_canceled flag should remain as in the original transaction.
1868            assert_eq!(update_req.is_canceled, Some(false));
1869        }
1870
1871        #[tokio::test]
1872        async fn test_noop_request_with_cancellation() {
1873            // Create a transaction with no initial noop_count (None) and is_canceled false.
1874            let mocks = default_test_mocks_with_network();
1875            let relayer = create_test_relayer();
1876            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1877            tx.noop_count = None;
1878            tx.is_canceled = Some(false);
1879
1880            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1881            let update_req = evm_transaction
1882                .prepare_noop_update_request(&tx, true, None)
1883                .await
1884                .unwrap();
1885
1886            // NOOP count should default to 1.
1887            assert_eq!(update_req.noop_count, Some(1));
1888            // When cancelling, the is_canceled flag should be forced to true.
1889            assert_eq!(update_req.is_canceled, Some(true));
1890        }
1891    }
1892
1893    // Tests for `handle_submitted_state`
1894    mod handle_submitted_state_tests {
1895        use super::*;
1896
1897        #[tokio::test]
1898        async fn test_schedules_resubmit_job() {
1899            let mut mocks = default_test_mocks();
1900            let relayer = create_test_relayer();
1901
1902            // Set sent_at far in the past to force resubmission
1903            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1904            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1905
1906            // Mock network repository to return a test network model for should_noop check
1907            mocks
1908                .network_repo
1909                .expect_get_by_chain_id()
1910                .returning(|_, _| Ok(Some(create_test_network_model())));
1911
1912            // Mock get_block_by_number for gas limit validation
1913            mocks.provider.expect_get_block_by_number().returning(|| {
1914                Box::pin(async {
1915                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1916                    let mut block: Block = Block::default();
1917                    block.header.gas_limit = 30_000_000u64;
1918                    Ok(AnyRpcBlock::from(block))
1919                })
1920            });
1921
1922            // Expect the resubmit job to be produced
1923            mocks
1924                .job_producer
1925                .expect_produce_submit_transaction_job()
1926                .returning(|_, _| Box::pin(async { Ok(()) }));
1927
1928            // Expect status check to be scheduled
1929            mocks
1930                .job_producer
1931                .expect_produce_check_transaction_status_job()
1932                .returning(|_, _| Box::pin(async { Ok(()) }));
1933
1934            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1935            let updated_tx = evm_transaction.handle_submitted_state(tx).await.unwrap();
1936
1937            // We remain in "Submitted" after scheduling the resubmit
1938            assert_eq!(updated_tx.status, TransactionStatus::Submitted);
1939        }
1940    }
1941
1942    // Tests for `handle_pending_state`
1943    mod handle_pending_state_tests {
1944        use super::*;
1945
1946        #[tokio::test]
1947        async fn test_pending_state_no_noop() {
1948            // Create a pending transaction that is fresh (created now).
1949            let mut mocks = default_test_mocks();
1950            let relayer = create_test_relayer();
1951            let mut tx = make_test_transaction(TransactionStatus::Pending);
1952            tx.created_at = Utc::now().to_rfc3339(); // less than one minute old
1953
1954            // Mock network repository to return a test network model
1955            mocks
1956                .network_repo
1957                .expect_get_by_chain_id()
1958                .returning(|_, _| Ok(Some(create_test_network_model())));
1959
1960            // Mock get_block_by_number for gas limit validation
1961            mocks.provider.expect_get_block_by_number().returning(|| {
1962                Box::pin(async {
1963                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1964                    let mut block: Block = Block::default();
1965                    block.header.gas_limit = 30_000_000u64;
1966                    Ok(AnyRpcBlock::from(block))
1967                })
1968            });
1969
1970            // Expect status check to be scheduled when not doing NOOP
1971            mocks
1972                .job_producer
1973                .expect_produce_check_transaction_status_job()
1974                .returning(|_, _| Box::pin(async { Ok(()) }));
1975
1976            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1977            let result = evm_transaction
1978                .handle_pending_state(tx.clone())
1979                .await
1980                .unwrap();
1981
1982            // When should_noop returns false the original transaction is returned unchanged.
1983            assert_eq!(result.id, tx.id);
1984            assert_eq!(result.status, tx.status);
1985            assert_eq!(result.noop_count, tx.noop_count);
1986        }
1987
1988        #[tokio::test]
1989        async fn test_pending_state_with_noop() {
1990            // Create a pending transaction that is old (created 2 minutes ago)
1991            let mut mocks = default_test_mocks();
1992            let relayer = create_test_relayer();
1993            let mut tx = make_test_transaction(TransactionStatus::Pending);
1994            tx.created_at = (Utc::now() - Duration::minutes(2)).to_rfc3339();
1995
1996            // Mock network repository to return a test network model
1997            mocks
1998                .network_repo
1999                .expect_get_by_chain_id()
2000                .returning(|_, _| Ok(Some(create_test_network_model())));
2001
2002            // Mock get_block_by_number for gas limit validation
2003            mocks.provider.expect_get_block_by_number().returning(|| {
2004                Box::pin(async {
2005                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
2006                    let mut block: Block = Block::default();
2007                    block.header.gas_limit = 30_000_000u64;
2008                    Ok(AnyRpcBlock::from(block))
2009                })
2010            });
2011
2012            // Expect partial_update to be called and simulate a Failed update
2013            // (Pending state transactions are marked as Failed, not NOOP, since nonces aren't assigned)
2014            let tx_clone = tx.clone();
2015            mocks
2016                .tx_repo
2017                .expect_partial_update()
2018                .withf(move |id, update| {
2019                    id == "test-tx-id"
2020                        && update.status == Some(TransactionStatus::Failed)
2021                        && update.status_reason.is_some()
2022                })
2023                .returning(move |_, update| {
2024                    let mut updated_tx = tx_clone.clone();
2025                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2026                    updated_tx.status_reason = update.status_reason.clone();
2027                    Ok(updated_tx)
2028                });
2029            // Expect that a notification is produced (no submit job needed for Failed status)
2030            mocks
2031                .job_producer
2032                .expect_produce_send_notification_job()
2033                .returning(|_, _| Box::pin(async { Ok(()) }));
2034
2035            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2036            let result = evm_transaction
2037                .handle_pending_state(tx.clone())
2038                .await
2039                .unwrap();
2040
2041            // Since should_noop returns true for pending timeout, transaction should be marked as Failed
2042            assert_eq!(result.status, TransactionStatus::Failed);
2043            assert!(result.status_reason.is_some());
2044            assert!(result.status_reason.unwrap().contains("Pending state"));
2045        }
2046    }
2047
2048    // Tests for `handle_mined_state`
2049    mod handle_mined_state_tests {
2050        use super::*;
2051
2052        #[tokio::test]
2053        async fn test_updates_status_and_schedules_check() {
2054            let mut mocks = default_test_mocks();
2055            let relayer = create_test_relayer();
2056            // Create a transaction in Submitted state (the mined branch is reached via status check).
2057            let tx = make_test_transaction(TransactionStatus::Submitted);
2058
2059            // Expect schedule_status_check to be called with delay 5.
2060            mocks
2061                .job_producer
2062                .expect_produce_check_transaction_status_job()
2063                .returning(|_, _| Box::pin(async { Ok(()) }));
2064            // Expect partial_update to update the transaction status to Mined.
2065            mocks
2066                .tx_repo
2067                .expect_partial_update()
2068                .returning(|_, update| {
2069                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2070                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2071                    Ok(updated_tx)
2072                });
2073
2074            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2075            let result = evm_transaction
2076                .handle_mined_state(tx.clone())
2077                .await
2078                .unwrap();
2079            assert_eq!(result.status, TransactionStatus::Mined);
2080        }
2081    }
2082
2083    // Tests for `handle_final_state`
2084    mod handle_final_state_tests {
2085        use super::*;
2086
2087        #[tokio::test]
2088        async fn test_final_state_confirmed() {
2089            let mut mocks = default_test_mocks();
2090            let relayer = create_test_relayer();
2091            let tx = make_test_transaction(TransactionStatus::Submitted);
2092
2093            // Expect partial_update to update status to Confirmed.
2094            mocks
2095                .tx_repo
2096                .expect_partial_update()
2097                .returning(|_, update| {
2098                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2099                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2100                    Ok(updated_tx)
2101                });
2102
2103            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2104            let result = evm_transaction
2105                .handle_final_state(tx.clone(), TransactionStatus::Confirmed, None)
2106                .await
2107                .unwrap();
2108            assert_eq!(result.status, TransactionStatus::Confirmed);
2109        }
2110
2111        #[tokio::test]
2112        async fn test_final_state_failed() {
2113            let mut mocks = default_test_mocks();
2114            let relayer = create_test_relayer();
2115            let tx = make_test_transaction(TransactionStatus::Submitted);
2116
2117            // Expect partial_update to update status to Failed with status_reason.
2118            mocks
2119                .tx_repo
2120                .expect_partial_update()
2121                .returning(|_, update| {
2122                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2123                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2124                    updated_tx.status_reason = update.status_reason.clone();
2125                    Ok(updated_tx)
2126                });
2127
2128            let reason = "Transaction reverted on-chain (receipt status: failed)".to_string();
2129            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2130            let result = evm_transaction
2131                .handle_final_state(tx.clone(), TransactionStatus::Failed, Some(reason.clone()))
2132                .await
2133                .unwrap();
2134            assert_eq!(result.status, TransactionStatus::Failed);
2135            assert_eq!(result.status_reason.as_deref(), Some(reason.as_str()));
2136        }
2137
2138        #[tokio::test]
2139        async fn test_final_state_expired() {
2140            let mut mocks = default_test_mocks();
2141            let relayer = create_test_relayer();
2142            let tx = make_test_transaction(TransactionStatus::Submitted);
2143
2144            // Expect partial_update to update status to Expired.
2145            mocks
2146                .tx_repo
2147                .expect_partial_update()
2148                .returning(|_, update| {
2149                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2150                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2151                    Ok(updated_tx)
2152                });
2153
2154            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2155            let result = evm_transaction
2156                .handle_final_state(tx.clone(), TransactionStatus::Expired, None)
2157                .await
2158                .unwrap();
2159            assert_eq!(result.status, TransactionStatus::Expired);
2160        }
2161    }
2162
2163    // Integration tests for `handle_status_impl`
2164    mod handle_status_impl_tests {
2165        use super::*;
2166
2167        #[tokio::test]
2168        async fn test_impl_submitted_branch() {
2169            let mut mocks = default_test_mocks();
2170            let relayer = create_test_relayer();
2171            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2172            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
2173            // Set a dummy hash so check_transaction_status can proceed.
2174            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2175                evm_data.hash = Some("0xFakeHash".to_string());
2176            }
2177            // Simulate no receipt found.
2178            mocks
2179                .provider
2180                .expect_get_transaction_receipt()
2181                .returning(|_| Box::pin(async { Ok(None) }));
2182            // Mock network repository for should_resubmit check
2183            mocks
2184                .network_repo
2185                .expect_get_by_chain_id()
2186                .returning(|_, _| Ok(Some(create_test_network_model())));
2187            // Expect that a status check job is scheduled.
2188            mocks
2189                .job_producer
2190                .expect_produce_check_transaction_status_job()
2191                .returning(|_, _| Box::pin(async { Ok(()) }));
2192            // Expect update_transaction_status_if_needed to update status to Submitted.
2193            mocks
2194                .tx_repo
2195                .expect_partial_update()
2196                .returning(|_, update| {
2197                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2198                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2199                    Ok(updated_tx)
2200                });
2201
2202            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2203            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2204            assert_eq!(result.status, TransactionStatus::Submitted);
2205        }
2206
2207        #[tokio::test]
2208        async fn test_impl_mined_branch() {
2209            let mut mocks = default_test_mocks();
2210            let relayer = create_test_relayer();
2211            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2212            // Set created_at to be old enough to pass is_too_early_to_resubmit
2213            tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2214            // Set a dummy hash.
2215            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2216                evm_data.hash = Some("0xFakeHash".to_string());
2217            }
2218            // Simulate a receipt with a block number of 100 and a successful receipt.
2219            mocks
2220                .provider
2221                .expect_get_transaction_receipt()
2222                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
2223            // Simulate that the current block number is 100 (so confirmations are insufficient).
2224            mocks
2225                .provider
2226                .expect_get_block_number()
2227                .return_once(|| Box::pin(async { Ok(100) }));
2228            // Mock network repository to return a test network model
2229            mocks
2230                .network_repo
2231                .expect_get_by_chain_id()
2232                .returning(|_, _| Ok(Some(create_test_network_model())));
2233            // Mock the notification job that gets sent after status update
2234            mocks
2235                .job_producer
2236                .expect_produce_send_notification_job()
2237                .returning(|_, _| Box::pin(async { Ok(()) }));
2238            // Expect get_by_id to reload the transaction after status change
2239            mocks.tx_repo.expect_get_by_id().returning(|_| {
2240                let updated_tx = make_test_transaction(TransactionStatus::Mined);
2241                Ok(updated_tx)
2242            });
2243            // Expect update_transaction_status_if_needed to update status to Mined.
2244            mocks
2245                .tx_repo
2246                .expect_partial_update()
2247                .returning(|_, update| {
2248                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2249                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2250                    Ok(updated_tx)
2251                });
2252
2253            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2254            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2255            assert_eq!(result.status, TransactionStatus::Mined);
2256        }
2257
2258        #[tokio::test]
2259        async fn test_impl_final_confirmed_branch() {
2260            let mut mocks = default_test_mocks();
2261            let relayer = create_test_relayer();
2262            // Create a transaction with status Confirmed.
2263            let tx = make_test_transaction(TransactionStatus::Confirmed);
2264
2265            // In this branch, check_transaction_status returns the final status immediately,
2266            // so we expect partial_update to update the transaction status to Confirmed.
2267            mocks
2268                .tx_repo
2269                .expect_partial_update()
2270                .returning(|_, update| {
2271                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2272                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2273                    Ok(updated_tx)
2274                });
2275
2276            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2277            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2278            assert_eq!(result.status, TransactionStatus::Confirmed);
2279        }
2280
2281        #[tokio::test]
2282        async fn test_impl_final_failed_branch() {
2283            let mut mocks = default_test_mocks();
2284            let relayer = create_test_relayer();
2285            // Create a transaction with status Failed.
2286            let tx = make_test_transaction(TransactionStatus::Failed);
2287
2288            mocks
2289                .tx_repo
2290                .expect_partial_update()
2291                .returning(|_, update| {
2292                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2293                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2294                    Ok(updated_tx)
2295                });
2296
2297            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2298            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2299            assert_eq!(result.status, TransactionStatus::Failed);
2300        }
2301
2302        /// Verifies that a Submitted transaction with a failed on-chain receipt
2303        /// transitions to Failed status with a descriptive status_reason.
2304        #[tokio::test]
2305        async fn test_impl_submitted_to_failed_sets_status_reason() {
2306            let mut mocks = default_test_mocks();
2307            let relayer = create_test_relayer();
2308            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2309            tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
2310            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2311                evm_data.hash = Some("0xFakeHash".to_string());
2312            }
2313
2314            // Simulate a receipt with status=false (reverted on-chain).
2315            mocks
2316                .provider
2317                .expect_get_transaction_receipt()
2318                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(false, Some(100)))) }));
2319
2320            // Mock get_by_id for the DB reload after status change.
2321            let tx_clone = tx.clone();
2322            mocks.tx_repo.expect_get_by_id().returning(move |_| {
2323                let mut reloaded = tx_clone.clone();
2324                reloaded.status = TransactionStatus::Submitted;
2325                Ok(reloaded)
2326            });
2327
2328            // Expect partial_update with status=Failed and a status_reason.
2329            mocks
2330                .tx_repo
2331                .expect_partial_update()
2332                .withf(|_, update| {
2333                    update.status == Some(TransactionStatus::Failed)
2334                        && update.status_reason.is_some()
2335                })
2336                .returning(|_, update| {
2337                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2338                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2339                    updated_tx.status_reason = update.status_reason.clone();
2340                    Ok(updated_tx)
2341                });
2342
2343            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2344            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2345            assert_eq!(result.status, TransactionStatus::Failed);
2346            assert!(result.status_reason.is_some());
2347            assert!(
2348                result
2349                    .status_reason
2350                    .as_ref()
2351                    .unwrap()
2352                    .contains("reverted on-chain"),
2353                "Expected on-chain revert reason, got: {:?}",
2354                result.status_reason
2355            );
2356        }
2357
2358        #[tokio::test]
2359        async fn test_impl_final_expired_branch() {
2360            let mut mocks = default_test_mocks();
2361            let relayer = create_test_relayer();
2362            // Create a transaction with status Expired.
2363            let tx = make_test_transaction(TransactionStatus::Expired);
2364
2365            mocks
2366                .tx_repo
2367                .expect_partial_update()
2368                .returning(|_, update| {
2369                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2370                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2371                    Ok(updated_tx)
2372                });
2373
2374            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2375            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2376            assert_eq!(result.status, TransactionStatus::Expired);
2377        }
2378    }
2379
2380    // Tests for circuit breaker functionality
2381    mod circuit_breaker_tests {
2382        use super::*;
2383        use crate::jobs::StatusCheckContext;
2384
2385        /// Helper to create a context that should trigger the circuit breaker
2386        fn create_triggered_context() -> StatusCheckContext {
2387            StatusCheckContext::new(
2388                30, // consecutive_failures: exceeds EVM threshold of 25
2389                50, // total_failures
2390                60, // total_retries
2391                25, // max_consecutive_failures (EVM default)
2392                75, // max_total_failures (EVM default)
2393                NetworkType::Evm,
2394            )
2395        }
2396
2397        /// Helper to create a context that should NOT trigger the circuit breaker
2398        fn create_safe_context() -> StatusCheckContext {
2399            StatusCheckContext::new(
2400                5,  // consecutive_failures: below threshold
2401                10, // total_failures
2402                15, // total_retries
2403                25, // max_consecutive_failures
2404                75, // max_total_failures
2405                NetworkType::Evm,
2406            )
2407        }
2408
2409        /// Helper to create a context that triggers via total failures (safety net)
2410        fn create_total_triggered_context() -> StatusCheckContext {
2411            StatusCheckContext::new(
2412                5,   // consecutive_failures: below threshold
2413                80,  // total_failures: exceeds EVM threshold of 75
2414                100, // total_retries
2415                25,  // max_consecutive_failures
2416                75,  // max_total_failures
2417                NetworkType::Evm,
2418            )
2419        }
2420
2421        #[tokio::test]
2422        async fn test_circuit_breaker_pending_marks_as_failed() {
2423            let mut mocks = default_test_mocks();
2424            let relayer = create_test_relayer();
2425            let tx = make_test_transaction(TransactionStatus::Pending);
2426
2427            // Expect partial_update to be called with Failed status
2428            mocks
2429                .tx_repo
2430                .expect_partial_update()
2431                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2432                .returning(|_, update| {
2433                    let mut updated_tx = make_test_transaction(TransactionStatus::Pending);
2434                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2435                    updated_tx.status_reason = update.status_reason.clone();
2436                    Ok(updated_tx)
2437                });
2438
2439            // Mock notification (best effort, may or may not be called)
2440            mocks
2441                .job_producer
2442                .expect_produce_send_notification_job()
2443                .returning(|_, _| Box::pin(async { Ok(()) }));
2444
2445            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2446            let ctx = create_triggered_context();
2447
2448            let result = evm_transaction
2449                .handle_status_impl(tx, Some(ctx))
2450                .await
2451                .unwrap();
2452
2453            assert_eq!(result.status, TransactionStatus::Failed);
2454            assert!(result.status_reason.is_some());
2455            assert!(result.status_reason.unwrap().contains("consecutive errors"));
2456        }
2457
2458        #[tokio::test]
2459        async fn test_circuit_breaker_sent_marks_as_failed() {
2460            let mut mocks = default_test_mocks();
2461            let relayer = create_test_relayer();
2462            let tx = make_test_transaction(TransactionStatus::Sent);
2463
2464            // Expect partial_update to be called with Failed status
2465            mocks
2466                .tx_repo
2467                .expect_partial_update()
2468                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2469                .returning(|_, update| {
2470                    let mut updated_tx = make_test_transaction(TransactionStatus::Sent);
2471                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2472                    updated_tx.status_reason = update.status_reason.clone();
2473                    Ok(updated_tx)
2474                });
2475
2476            // Mock notification
2477            mocks
2478                .job_producer
2479                .expect_produce_send_notification_job()
2480                .returning(|_, _| Box::pin(async { Ok(()) }));
2481
2482            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2483            let ctx = create_triggered_context();
2484
2485            let result = evm_transaction
2486                .handle_status_impl(tx, Some(ctx))
2487                .await
2488                .unwrap();
2489
2490            assert_eq!(result.status, TransactionStatus::Failed);
2491        }
2492
2493        #[tokio::test]
2494        async fn test_circuit_breaker_submitted_triggers_noop() {
2495            let mut mocks = default_test_mocks();
2496            let relayer = create_test_relayer();
2497            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2498            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
2499
2500            // Mock network repository for NOOP processing
2501            mocks
2502                .network_repo
2503                .expect_get_by_chain_id()
2504                .returning(|_, _| Ok(Some(create_test_network_model())));
2505
2506            // Expect partial_update to be called with NOOP indicator
2507            mocks
2508                .tx_repo
2509                .expect_partial_update()
2510                .returning(|_, update| {
2511                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2512                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2513                    updated_tx.status_reason = update.status_reason.clone();
2514                    updated_tx.noop_count = update.noop_count;
2515                    Ok(updated_tx)
2516                });
2517
2518            // Mock resubmit job (NOOP triggers resubmit)
2519            mocks
2520                .job_producer
2521                .expect_produce_submit_transaction_job()
2522                .returning(|_, _| Box::pin(async { Ok(()) }));
2523
2524            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2525            let ctx = create_triggered_context();
2526
2527            let result = evm_transaction
2528                .handle_status_impl(tx, Some(ctx))
2529                .await
2530                .unwrap();
2531
2532            // NOOP processing should succeed
2533            assert!(result.noop_count.is_some());
2534        }
2535
2536        #[tokio::test]
2537        async fn test_circuit_breaker_noop_tx_excluded() {
2538            let mut mocks = default_test_mocks();
2539            let relayer = create_test_relayer();
2540
2541            // Create a NOOP transaction (to: self, value: 0, data: "0x")
2542            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2543            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
2544            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2545                evm_data.to = Some(evm_data.from.clone()); // to == from (NOOP indicator)
2546                evm_data.value = U256::from(0);
2547                evm_data.data = Some("0x".to_string());
2548                evm_data.hash = Some("0xFakeHash".to_string());
2549            }
2550
2551            // NOOP transactions should NOT trigger circuit breaker
2552            // Instead, they should go through normal status checking
2553            mocks
2554                .provider
2555                .expect_get_transaction_receipt()
2556                .returning(|_| Box::pin(async { Ok(None) }));
2557
2558            mocks
2559                .network_repo
2560                .expect_get_by_chain_id()
2561                .returning(|_, _| Ok(Some(create_test_network_model())));
2562
2563            mocks
2564                .job_producer
2565                .expect_produce_check_transaction_status_job()
2566                .returning(|_, _| Box::pin(async { Ok(()) }));
2567
2568            // Mock resubmit job (may be triggered by normal status flow for stuck transactions)
2569            mocks
2570                .job_producer
2571                .expect_produce_submit_transaction_job()
2572                .returning(|_, _| Box::pin(async { Ok(()) }));
2573
2574            mocks
2575                .tx_repo
2576                .expect_partial_update()
2577                .returning(|_, update| {
2578                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2579                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2580                    Ok(updated_tx)
2581                });
2582
2583            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2584            let ctx = create_triggered_context();
2585
2586            let result = evm_transaction
2587                .handle_status_impl(tx, Some(ctx))
2588                .await
2589                .unwrap();
2590
2591            // NOOP tx should continue normal processing, not be force-failed
2592            assert_eq!(result.status, TransactionStatus::Submitted);
2593        }
2594
2595        #[tokio::test]
2596        async fn test_circuit_breaker_total_failures_triggers() {
2597            let mut mocks = default_test_mocks();
2598            let relayer = create_test_relayer();
2599            let tx = make_test_transaction(TransactionStatus::Pending);
2600
2601            // Expect partial_update to be called with Failed status
2602            mocks
2603                .tx_repo
2604                .expect_partial_update()
2605                .withf(|_, update| update.status == Some(TransactionStatus::Failed))
2606                .returning(|_, update| {
2607                    let mut updated_tx = make_test_transaction(TransactionStatus::Pending);
2608                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2609                    updated_tx.status_reason = update.status_reason.clone();
2610                    Ok(updated_tx)
2611                });
2612
2613            mocks
2614                .job_producer
2615                .expect_produce_send_notification_job()
2616                .returning(|_, _| Box::pin(async { Ok(()) }));
2617
2618            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2619            // Use context that triggers via total failures (safety net)
2620            let ctx = create_total_triggered_context();
2621
2622            let result = evm_transaction
2623                .handle_status_impl(tx, Some(ctx))
2624                .await
2625                .unwrap();
2626
2627            assert_eq!(result.status, TransactionStatus::Failed);
2628            assert!(result.status_reason.is_some());
2629        }
2630
2631        #[tokio::test]
2632        async fn test_circuit_breaker_below_threshold_continues_normally() {
2633            let mut mocks = default_test_mocks();
2634            let relayer = create_test_relayer();
2635            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2636            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
2637
2638            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2639                evm_data.hash = Some("0xFakeHash".to_string());
2640            }
2641
2642            // Below threshold, should continue with normal status checking
2643            mocks
2644                .provider
2645                .expect_get_transaction_receipt()
2646                .returning(|_| Box::pin(async { Ok(None) }));
2647
2648            mocks
2649                .network_repo
2650                .expect_get_by_chain_id()
2651                .returning(|_, _| Ok(Some(create_test_network_model())));
2652
2653            mocks
2654                .job_producer
2655                .expect_produce_check_transaction_status_job()
2656                .returning(|_, _| Box::pin(async { Ok(()) }));
2657
2658            mocks
2659                .tx_repo
2660                .expect_partial_update()
2661                .returning(|_, update| {
2662                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2663                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2664                    Ok(updated_tx)
2665                });
2666
2667            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2668            let ctx = create_safe_context();
2669
2670            let result = evm_transaction
2671                .handle_status_impl(tx, Some(ctx))
2672                .await
2673                .unwrap();
2674
2675            // Should continue normal processing, not trigger circuit breaker
2676            assert_eq!(result.status, TransactionStatus::Submitted);
2677        }
2678
2679        #[tokio::test]
2680        async fn test_circuit_breaker_no_context_continues_normally() {
2681            let mut mocks = default_test_mocks();
2682            let relayer = create_test_relayer();
2683            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2684            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
2685
2686            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2687                evm_data.hash = Some("0xFakeHash".to_string());
2688            }
2689
2690            // No context means no circuit breaker, should continue normally
2691            mocks
2692                .provider
2693                .expect_get_transaction_receipt()
2694                .returning(|_| Box::pin(async { Ok(None) }));
2695
2696            mocks
2697                .network_repo
2698                .expect_get_by_chain_id()
2699                .returning(|_, _| Ok(Some(create_test_network_model())));
2700
2701            mocks
2702                .job_producer
2703                .expect_produce_check_transaction_status_job()
2704                .returning(|_, _| Box::pin(async { Ok(()) }));
2705
2706            mocks
2707                .tx_repo
2708                .expect_partial_update()
2709                .returning(|_, update| {
2710                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2711                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2712                    Ok(updated_tx)
2713                });
2714
2715            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2716
2717            // Pass None for context
2718            let result = evm_transaction.handle_status_impl(tx, None).await.unwrap();
2719
2720            // Should continue normal processing
2721            assert_eq!(result.status, TransactionStatus::Submitted);
2722        }
2723
2724        #[tokio::test]
2725        async fn test_circuit_breaker_final_state_early_return() {
2726            let mocks = default_test_mocks();
2727            let relayer = create_test_relayer();
2728            // Transaction is already in final state
2729            let tx = make_test_transaction(TransactionStatus::Confirmed);
2730
2731            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2732            let ctx = create_triggered_context();
2733
2734            // Even with triggered context, final states should return early
2735            let result = evm_transaction
2736                .handle_status_impl(tx, Some(ctx))
2737                .await
2738                .unwrap();
2739
2740            assert_eq!(result.status, TransactionStatus::Confirmed);
2741        }
2742    }
2743
2744    // Tests for hash recovery functions
2745    mod hash_recovery_tests {
2746        use super::*;
2747
2748        #[tokio::test]
2749        async fn test_should_try_hash_recovery_not_submitted() {
2750            let mocks = default_test_mocks();
2751            let relayer = create_test_relayer();
2752
2753            let mut tx = make_test_transaction(TransactionStatus::Sent);
2754            tx.hashes = vec![
2755                "0xHash1".to_string(),
2756                "0xHash2".to_string(),
2757                "0xHash3".to_string(),
2758            ];
2759
2760            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2761            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2762
2763            assert!(
2764                !result,
2765                "Should not attempt recovery for non-Submitted transactions"
2766            );
2767        }
2768
2769        #[tokio::test]
2770        async fn test_should_try_hash_recovery_not_enough_hashes() {
2771            let mocks = default_test_mocks();
2772            let relayer = create_test_relayer();
2773
2774            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2775            tx.hashes = vec!["0xHash1".to_string()]; // Only 1 hash
2776            tx.sent_at = Some((Utc::now() - Duration::minutes(3)).to_rfc3339());
2777
2778            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2779            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2780
2781            assert!(
2782                !result,
2783                "Should not attempt recovery with insufficient hashes"
2784            );
2785        }
2786
2787        #[tokio::test]
2788        async fn test_should_try_hash_recovery_too_recent() {
2789            let mocks = default_test_mocks();
2790            let relayer = create_test_relayer();
2791
2792            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2793            tx.hashes = vec![
2794                "0xHash1".to_string(),
2795                "0xHash2".to_string(),
2796                "0xHash3".to_string(),
2797            ];
2798            tx.sent_at = Some(Utc::now().to_rfc3339()); // Recent
2799
2800            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2801            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2802
2803            assert!(
2804                !result,
2805                "Should not attempt recovery for recently sent transactions"
2806            );
2807        }
2808
2809        #[tokio::test]
2810        async fn test_should_try_hash_recovery_success() {
2811            let mocks = default_test_mocks();
2812            let relayer = create_test_relayer();
2813
2814            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2815            tx.hashes = vec![
2816                "0xHash1".to_string(),
2817                "0xHash2".to_string(),
2818                "0xHash3".to_string(),
2819            ];
2820            tx.sent_at = Some((Utc::now() - Duration::minutes(3)).to_rfc3339());
2821
2822            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2823            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2824
2825            assert!(
2826                result,
2827                "Should attempt recovery for stuck transactions with multiple hashes"
2828            );
2829        }
2830
2831        #[tokio::test]
2832        async fn test_try_recover_no_historical_hash_found() {
2833            let mut mocks = default_test_mocks();
2834            let relayer = create_test_relayer();
2835
2836            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2837            tx.hashes = vec![
2838                "0xHash1".to_string(),
2839                "0xHash2".to_string(),
2840                "0xHash3".to_string(),
2841            ];
2842
2843            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2844                evm_data.hash = Some("0xHash3".to_string());
2845            }
2846
2847            // Mock provider to return None for all hash lookups
2848            mocks
2849                .provider
2850                .expect_get_transaction_receipt()
2851                .returning(|_| Box::pin(async { Ok(None) }));
2852
2853            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2854            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2855            let result = evm_transaction
2856                .try_recover_with_historical_hashes(&tx, &evm_data)
2857                .await
2858                .unwrap();
2859
2860            assert!(
2861                result.is_none(),
2862                "Should return None when no historical hash is found"
2863            );
2864        }
2865
2866        #[tokio::test]
2867        async fn test_try_recover_finds_mined_historical_hash() {
2868            let mut mocks = default_test_mocks();
2869            let relayer = create_test_relayer();
2870
2871            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2872            tx.hashes = vec![
2873                "0xHash1".to_string(),
2874                "0xHash2".to_string(), // This one is mined
2875                "0xHash3".to_string(),
2876            ];
2877
2878            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2879                evm_data.hash = Some("0xHash3".to_string()); // Current hash (wrong one)
2880            }
2881
2882            // Mock provider to return None for Hash1 and Hash3, but receipt for Hash2
2883            mocks
2884                .provider
2885                .expect_get_transaction_receipt()
2886                .returning(|hash| {
2887                    if hash == "0xHash2" {
2888                        Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })
2889                    } else {
2890                        Box::pin(async { Ok(None) })
2891                    }
2892                });
2893
2894            // Mock partial_update for correcting the hash
2895            let tx_clone = tx.clone();
2896            mocks
2897                .tx_repo
2898                .expect_partial_update()
2899                .returning(move |_, update| {
2900                    let mut updated_tx = tx_clone.clone();
2901                    if let Some(status) = update.status {
2902                        updated_tx.status = status;
2903                    }
2904                    if let Some(NetworkTransactionData::Evm(ref evm_data)) = update.network_data {
2905                        if let NetworkTransactionData::Evm(ref mut updated_evm) =
2906                            updated_tx.network_data
2907                        {
2908                            updated_evm.hash = evm_data.hash.clone();
2909                        }
2910                    }
2911                    Ok(updated_tx)
2912                });
2913
2914            // Mock notification job
2915            mocks
2916                .job_producer
2917                .expect_produce_send_notification_job()
2918                .returning(|_, _| Box::pin(async { Ok(()) }));
2919
2920            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2921            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2922            let result = evm_transaction
2923                .try_recover_with_historical_hashes(&tx, &evm_data)
2924                .await
2925                .unwrap();
2926
2927            assert!(result.is_some(), "Should recover the transaction");
2928            let recovered_tx = result.unwrap();
2929            assert_eq!(recovered_tx.status, TransactionStatus::Mined);
2930        }
2931
2932        #[tokio::test]
2933        async fn test_try_recover_network_error_continues() {
2934            let mut mocks = default_test_mocks();
2935            let relayer = create_test_relayer();
2936
2937            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2938            tx.hashes = vec![
2939                "0xHash1".to_string(),
2940                "0xHash2".to_string(), // Network error
2941                "0xHash3".to_string(), // This one is mined
2942            ];
2943
2944            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2945                evm_data.hash = Some("0xHash1".to_string());
2946            }
2947
2948            // Mock provider to return error for Hash2, receipt for Hash3
2949            mocks
2950                .provider
2951                .expect_get_transaction_receipt()
2952                .returning(|hash| {
2953                    if hash == "0xHash2" {
2954                        Box::pin(async { Err(crate::services::provider::ProviderError::Timeout) })
2955                    } else if hash == "0xHash3" {
2956                        Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })
2957                    } else {
2958                        Box::pin(async { Ok(None) })
2959                    }
2960                });
2961
2962            // Mock partial_update for correcting the hash
2963            let tx_clone = tx.clone();
2964            mocks
2965                .tx_repo
2966                .expect_partial_update()
2967                .returning(move |_, update| {
2968                    let mut updated_tx = tx_clone.clone();
2969                    if let Some(status) = update.status {
2970                        updated_tx.status = status;
2971                    }
2972                    Ok(updated_tx)
2973                });
2974
2975            // Mock notification job
2976            mocks
2977                .job_producer
2978                .expect_produce_send_notification_job()
2979                .returning(|_, _| Box::pin(async { Ok(()) }));
2980
2981            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2982            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2983            let result = evm_transaction
2984                .try_recover_with_historical_hashes(&tx, &evm_data)
2985                .await
2986                .unwrap();
2987
2988            assert!(
2989                result.is_some(),
2990                "Should continue checking after network error and find mined hash"
2991            );
2992        }
2993
2994        #[tokio::test]
2995        async fn test_update_transaction_with_corrected_hash() {
2996            let mut mocks = default_test_mocks();
2997            let relayer = create_test_relayer();
2998
2999            let mut tx = make_test_transaction(TransactionStatus::Submitted);
3000            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
3001                evm_data.hash = Some("0xWrongHash".to_string());
3002            }
3003
3004            // Mock partial_update
3005            mocks
3006                .tx_repo
3007                .expect_partial_update()
3008                .returning(move |_, update| {
3009                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
3010                    if let Some(status) = update.status {
3011                        updated_tx.status = status;
3012                    }
3013                    if let Some(NetworkTransactionData::Evm(ref evm_data)) = update.network_data {
3014                        if let NetworkTransactionData::Evm(ref mut updated_evm) =
3015                            updated_tx.network_data
3016                        {
3017                            updated_evm.hash = evm_data.hash.clone();
3018                        }
3019                    }
3020                    Ok(updated_tx)
3021                });
3022
3023            // Mock notification job
3024            mocks
3025                .job_producer
3026                .expect_produce_send_notification_job()
3027                .returning(|_, _| Box::pin(async { Ok(()) }));
3028
3029            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
3030            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
3031            let result = evm_transaction
3032                .update_transaction_with_corrected_hash(
3033                    &tx,
3034                    &evm_data,
3035                    "0xCorrectHash",
3036                    TransactionStatus::Mined,
3037                )
3038                .await
3039                .unwrap();
3040
3041            assert_eq!(result.status, TransactionStatus::Mined);
3042            if let NetworkTransactionData::Evm(ref updated_evm) = result.network_data {
3043                assert_eq!(updated_evm.hash.as_ref().unwrap(), "0xCorrectHash");
3044            }
3045        }
3046    }
3047
3048    // Tests for check_transaction_status edge cases
3049    mod check_transaction_status_edge_cases {
3050        use super::*;
3051
3052        #[tokio::test]
3053        async fn test_missing_hash_returns_error() {
3054            let mocks = default_test_mocks();
3055            let relayer = create_test_relayer();
3056
3057            let tx = make_test_transaction(TransactionStatus::Submitted);
3058            // Hash is None by default
3059
3060            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
3061            let result = evm_transaction.check_transaction_status(&tx).await;
3062
3063            assert!(result.is_err(), "Should return error when hash is missing");
3064        }
3065
3066        #[tokio::test]
3067        async fn test_pending_status_early_return() {
3068            let mocks = default_test_mocks();
3069            let relayer = create_test_relayer();
3070
3071            let tx = make_test_transaction(TransactionStatus::Pending);
3072
3073            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
3074            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
3075
3076            assert_eq!(
3077                status,
3078                TransactionStatus::Pending,
3079                "Should return Pending without querying blockchain"
3080            );
3081        }
3082
3083        #[tokio::test]
3084        async fn test_sent_status_early_return() {
3085            let mocks = default_test_mocks();
3086            let relayer = create_test_relayer();
3087
3088            let tx = make_test_transaction(TransactionStatus::Sent);
3089
3090            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
3091            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
3092
3093            assert_eq!(
3094                status,
3095                TransactionStatus::Sent,
3096                "Should return Sent without querying blockchain"
3097            );
3098        }
3099
3100        #[tokio::test]
3101        async fn test_final_state_early_return() {
3102            let mocks = default_test_mocks();
3103            let relayer = create_test_relayer();
3104
3105            let tx = make_test_transaction(TransactionStatus::Confirmed);
3106
3107            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
3108            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
3109
3110            assert_eq!(
3111                status,
3112                TransactionStatus::Confirmed,
3113                "Should return final state without querying blockchain"
3114            );
3115        }
3116    }
3117}