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