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, info, 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,
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!(tx_hash = %tx_hash, "transaction mined but not confirmed");
110                return Ok(TransactionStatus::Mined);
111            }
112            Ok(TransactionStatus::Confirmed)
113        } else {
114            debug!(tx_hash = %tx_hash, "transaction not yet mined");
115
116            // FALLBACK: Try to find transaction by checking all historical hashes
117            // Only do this for transactions that have multiple resubmission attempts
118            // and have been stuck in Submitted for a while
119            if tx.hashes.len() > 1 && self.should_try_hash_recovery(tx)? {
120                if let Some(recovered_tx) = self
121                    .try_recover_with_historical_hashes(tx, &evm_data)
122                    .await?
123                {
124                    // Return the status from the recovered (updated) transaction
125                    return Ok(recovered_tx.status);
126                }
127            }
128
129            Ok(TransactionStatus::Submitted)
130        }
131    }
132
133    /// Determines if a transaction should be resubmitted.
134    pub(super) async fn should_resubmit(
135        &self,
136        tx: &TransactionRepoModel,
137    ) -> Result<bool, TransactionError> {
138        // Validate transaction is in correct state for resubmission
139        ensure_status(tx, TransactionStatus::Submitted, Some("should_resubmit"))?;
140
141        let evm_data = tx.network_data.get_evm_transaction_data()?;
142        let age = get_age_of_sent_at(tx)?;
143
144        // Check if network lacks mempool and determine appropriate timeout
145        let network_model = self
146            .network_repository()
147            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
148            .await?
149            .ok_or(TransactionError::UnexpectedError(format!(
150                "Network with chain id {} not found",
151                evm_data.chain_id
152            )))?;
153
154        let network = EvmNetwork::try_from(network_model).map_err(|e| {
155            TransactionError::UnexpectedError(format!(
156                "Error converting network model to EvmNetwork: {e}"
157            ))
158        })?;
159
160        let timeout = match network.is_arbitrum() {
161            true => ARBITRUM_TIME_TO_RESUBMIT,
162            false => get_resubmit_timeout_for_speed(&evm_data.speed),
163        };
164
165        let timeout_with_backoff = match network.is_arbitrum() {
166            true => timeout, // Use base timeout without backoff for Arbitrum
167            false => get_resubmit_timeout_with_backoff(timeout, tx.hashes.len()),
168        };
169
170        if age > Duration::milliseconds(timeout_with_backoff) {
171            info!("Transaction has been pending for too long, resubmitting");
172            return Ok(true);
173        }
174        Ok(false)
175    }
176
177    /// Determines if a transaction should be replaced with a NOOP transaction.
178    ///
179    /// Returns a tuple `(should_noop, reason)` where:
180    /// - `should_noop`: `true` if transaction should be replaced with NOOP
181    /// - `reason`: Optional reason string explaining why NOOP is needed (only set when `should_noop` is `true`)
182    ///
183    /// # Arguments
184    ///
185    /// * `tx` - The transaction to check
186    pub(super) async fn should_noop(
187        &self,
188        tx: &TransactionRepoModel,
189    ) -> Result<(bool, Option<String>), TransactionError> {
190        if too_many_noop_attempts(tx) {
191            info!("Transaction has too many NOOP attempts already");
192            return Ok((false, None));
193        }
194
195        let evm_data = tx.network_data.get_evm_transaction_data()?;
196        if is_noop(&evm_data) {
197            return Ok((false, None));
198        }
199
200        let network_model = self
201            .network_repository()
202            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
203            .await?
204            .ok_or(TransactionError::UnexpectedError(format!(
205                "Network with chain id {} not found",
206                evm_data.chain_id
207            )))?;
208
209        let network = EvmNetwork::try_from(network_model).map_err(|e| {
210            TransactionError::UnexpectedError(format!(
211                "Error converting network model to EvmNetwork: {e}"
212            ))
213        })?;
214
215        if network.is_rollup() && too_many_attempts(tx) {
216            let reason =
217                "Rollup transaction has too many attempts. Replacing with NOOP.".to_string();
218            info!("{}", reason);
219            return Ok((true, Some(reason)));
220        }
221
222        if !is_transaction_valid(&tx.created_at, &tx.valid_until) {
223            let reason = "Transaction is expired. Replacing with NOOP.".to_string();
224            info!("{}", reason);
225            return Ok((true, Some(reason)));
226        }
227
228        if tx.status == TransactionStatus::Pending {
229            let created_at = &tx.created_at;
230            let created_time = DateTime::parse_from_rfc3339(created_at)
231                .map_err(|e| {
232                    TransactionError::UnexpectedError(format!("Invalid created_at timestamp: {e}"))
233                })?
234                .with_timezone(&Utc);
235            let age = Utc::now().signed_duration_since(created_time);
236            if age > get_evm_prepare_timeout() {
237                let reason = format!(
238                    "Transaction in Pending state for over {} minutes. Replacing with NOOP.",
239                    get_evm_prepare_timeout().num_minutes()
240                );
241                info!("{}", reason);
242                return Ok((true, Some(reason)));
243            }
244        }
245
246        let latest_block = self.provider().get_block_by_number().await;
247        if let Ok(block) = latest_block {
248            let block_gas_limit = block.header.gas_limit;
249            if let Some(gas_limit) = evm_data.gas_limit {
250                if gas_limit > block_gas_limit {
251                    let reason = format!(
252                                "Transaction gas limit ({gas_limit}) exceeds block gas limit ({block_gas_limit}). Replacing with NOOP.",
253                            );
254                    warn!(
255                        tx_id = %tx.id,
256                        tx_gas_limit = %gas_limit,
257                        block_gas_limit = %block_gas_limit,
258                        "transaction gas limit exceeds block gas limit, replacing with NOOP"
259                    );
260                    return Ok((true, Some(reason)));
261                }
262            }
263        }
264
265        Ok((false, None))
266    }
267
268    /// Helper method that updates transaction status only if it's different from the current status.
269    pub(super) async fn update_transaction_status_if_needed(
270        &self,
271        tx: TransactionRepoModel,
272        new_status: TransactionStatus,
273    ) -> Result<TransactionRepoModel, TransactionError> {
274        if tx.status != new_status {
275            return self.update_transaction_status(tx, new_status).await;
276        }
277        Ok(tx)
278    }
279
280    /// Prepares a NOOP transaction update request.
281    pub(super) async fn prepare_noop_update_request(
282        &self,
283        tx: &TransactionRepoModel,
284        is_cancellation: bool,
285        reason: Option<String>,
286    ) -> Result<TransactionUpdateRequest, TransactionError> {
287        let mut evm_data = tx.network_data.get_evm_transaction_data()?;
288        let network_model = self
289            .network_repository()
290            .get_by_chain_id(NetworkType::Evm, evm_data.chain_id)
291            .await?
292            .ok_or(TransactionError::UnexpectedError(format!(
293                "Network with chain id {} not found",
294                evm_data.chain_id
295            )))?;
296
297        let network = EvmNetwork::try_from(network_model).map_err(|e| {
298            TransactionError::UnexpectedError(format!(
299                "Error converting network model to EvmNetwork: {e}"
300            ))
301        })?;
302
303        make_noop(&mut evm_data, &network, Some(self.provider())).await?;
304
305        let noop_count = tx.noop_count.unwrap_or(0) + 1;
306        let update_request = TransactionUpdateRequest {
307            network_data: Some(NetworkTransactionData::Evm(evm_data)),
308            noop_count: Some(noop_count),
309            status_reason: reason,
310            is_canceled: if is_cancellation {
311                Some(true)
312            } else {
313                tx.is_canceled
314            },
315            ..Default::default()
316        };
317        Ok(update_request)
318    }
319
320    /// Handles transactions in the Submitted state.
321    async fn handle_submitted_state(
322        &self,
323        tx: TransactionRepoModel,
324    ) -> Result<TransactionRepoModel, TransactionError> {
325        if self.should_resubmit(&tx).await? {
326            let resubmitted_tx = self.handle_resubmission(tx).await?;
327            return Ok(resubmitted_tx);
328        }
329
330        self.update_transaction_status_if_needed(tx, TransactionStatus::Submitted)
331            .await
332    }
333
334    /// Processes transaction resubmission logic
335    async fn handle_resubmission(
336        &self,
337        tx: TransactionRepoModel,
338    ) -> Result<TransactionRepoModel, TransactionError> {
339        debug!("scheduling resubmit job for transaction");
340
341        // Check if transaction gas limit exceeds block gas limit before resubmitting
342        let (should_noop, reason) = self.should_noop(&tx).await?;
343        let tx_to_process = if should_noop {
344            self.process_noop_transaction(&tx, reason).await?
345        } else {
346            tx
347        };
348
349        self.send_transaction_resubmit_job(&tx_to_process).await?;
350        Ok(tx_to_process)
351    }
352
353    /// Handles NOOP transaction processing before resubmission
354    async fn process_noop_transaction(
355        &self,
356        tx: &TransactionRepoModel,
357        reason: Option<String>,
358    ) -> Result<TransactionRepoModel, TransactionError> {
359        debug!("preparing transaction NOOP before resubmission");
360        let update = self.prepare_noop_update_request(tx, false, reason).await?;
361        let updated_tx = self
362            .transaction_repository()
363            .partial_update(tx.id.clone(), update)
364            .await?;
365
366        let res = self.send_transaction_update_notification(&updated_tx).await;
367        if let Err(e) = res {
368            error!(
369                tx_id = %updated_tx.id,
370                status = ?updated_tx.status,
371                "sending transaction update notification failed for NOOP transaction: {:?}",
372                e
373            );
374        }
375        Ok(updated_tx)
376    }
377
378    /// Handles transactions in the Pending state.
379    async fn handle_pending_state(
380        &self,
381        tx: TransactionRepoModel,
382    ) -> Result<TransactionRepoModel, TransactionError> {
383        let (should_noop, reason) = self.should_noop(&tx).await?;
384        if should_noop {
385            // For Pending state transactions, nonces are not yet assigned, so we mark as Failed
386            // instead of NOOP. This matches prepare_transaction behavior.
387            debug!(
388                tx_id = %tx.id,
389                reason = %reason.as_ref().unwrap_or(&"unknown".to_string()),
390                "marking pending transaction as Failed (nonce not assigned, no NOOP needed)"
391            );
392            let update = TransactionUpdateRequest {
393                status: Some(TransactionStatus::Failed),
394                status_reason: reason,
395                ..Default::default()
396            };
397            let updated_tx = self
398                .transaction_repository()
399                .partial_update(tx.id.clone(), update)
400                .await?;
401
402            let res = self.send_transaction_update_notification(&updated_tx).await;
403            if let Err(e) = res {
404                error!(
405                    tx_id = %updated_tx.id,
406                    status = ?updated_tx.status,
407                    "sending transaction update notification failed: {:?}",
408                    e
409                );
410            }
411            return Ok(updated_tx);
412        }
413
414        // Check if transaction is stuck in Pending (prepare job may have failed)
415        let age = get_age_since_created(&tx)?;
416        if age > get_evm_pending_recovery_trigger_timeout() {
417            warn!(
418                tx_id = %tx.id,
419                age_seconds = age.num_seconds(),
420                "transaction stuck in Pending, queuing prepare job"
421            );
422
423            // Re-queue prepare job
424            self.send_transaction_request_job(&tx).await?;
425        }
426
427        Ok(tx)
428    }
429
430    /// Handles transactions in the Mined state.
431    async fn handle_mined_state(
432        &self,
433        tx: TransactionRepoModel,
434    ) -> Result<TransactionRepoModel, TransactionError> {
435        self.update_transaction_status_if_needed(tx, TransactionStatus::Mined)
436            .await
437    }
438
439    /// Handles transactions in final states (Confirmed, Failed, Expired).
440    async fn handle_final_state(
441        &self,
442        tx: TransactionRepoModel,
443        status: TransactionStatus,
444    ) -> Result<TransactionRepoModel, TransactionError> {
445        self.update_transaction_status_if_needed(tx, status).await
446    }
447
448    /// Inherent status-handling method.
449    ///
450    /// This method encapsulates the full logic for handling transaction status,
451    /// including resubmission, NOOP replacement, timeout detection, and updating status.
452    pub async fn handle_status_impl(
453        &self,
454        tx: TransactionRepoModel,
455    ) -> Result<TransactionRepoModel, TransactionError> {
456        debug!("checking transaction status {}", tx.id);
457
458        // 1. Early return if final state
459        if is_final_state(&tx.status) {
460            debug!(status = ?tx.status, "transaction already in final state");
461            return Ok(tx);
462        }
463
464        // 2. Check transaction status first
465        // This allows fast transactions to update their status immediately,
466        // even if they're young (<20s). For Pending/Sent states, this returns
467        // early without querying the blockchain.
468        let status = self.check_transaction_status(&tx).await?;
469
470        debug!(
471            tx_id = %tx.id,
472            previous_status = ?tx.status,
473            new_status = ?status,
474            "transaction status check completed"
475        );
476
477        // 2.1. Reload transaction from DB if status changed
478        // This ensures we have fresh data if check_transaction_status triggered a recovery
479        // or any other update that modified the transaction in the database.
480        let tx = if status != tx.status {
481            debug!(
482                tx_id = %tx.id,
483                old_status = ?tx.status,
484                new_status = ?status,
485                "status changed during check, reloading transaction from DB to ensure fresh data"
486            );
487            self.transaction_repository()
488                .get_by_id(tx.id.clone())
489                .await?
490        } else {
491            tx
492        };
493
494        // 3. Check if too early for resubmission on in-progress transactions
495        // For Pending/Sent/Submitted states, defer resubmission logic and timeout checks
496        // if the transaction is too young. Just update status and return.
497        // For other states (Mined/Confirmed/Failed/etc), process immediately regardless of age.
498        if is_too_early_to_resubmit(&tx)? && is_pending_transaction(&status) {
499            // Update status if it changed, then return
500            return self.update_transaction_status_if_needed(tx, status).await;
501        }
502
503        // 4. Handle based on status (including complex operations like resubmission)
504        match status {
505            TransactionStatus::Pending => self.handle_pending_state(tx).await,
506            TransactionStatus::Sent => self.handle_sent_state(tx).await,
507            TransactionStatus::Submitted => self.handle_submitted_state(tx).await,
508            TransactionStatus::Mined => self.handle_mined_state(tx).await,
509            TransactionStatus::Confirmed
510            | TransactionStatus::Failed
511            | TransactionStatus::Expired
512            | TransactionStatus::Canceled => self.handle_final_state(tx, status).await,
513        }
514    }
515
516    /// Handle transactions stuck in Sent (prepared but not submitted)
517    async fn handle_sent_state(
518        &self,
519        tx: TransactionRepoModel,
520    ) -> Result<TransactionRepoModel, TransactionError> {
521        debug!(tx_id = %tx.id, "handling Sent state");
522
523        // Check if transaction should be replaced with NOOP (expired, too many attempts on rollup, etc.)
524        let (should_noop, reason) = self.should_noop(&tx).await?;
525        if should_noop {
526            debug!("preparing NOOP for sent transaction {}", tx.id);
527            let update = self.prepare_noop_update_request(&tx, false, reason).await?;
528            let updated_tx = self
529                .transaction_repository()
530                .partial_update(tx.id.clone(), update)
531                .await?;
532
533            self.send_transaction_submit_job(&updated_tx).await?;
534            let res = self.send_transaction_update_notification(&updated_tx).await;
535            if let Err(e) = res {
536                error!(
537                    tx_id = %updated_tx.id,
538                    status = ?updated_tx.status,
539                    "sending transaction update notification failed for Sent state NOOP: {:?}",
540                    e
541                );
542            }
543            return Ok(updated_tx);
544        }
545
546        // Transaction was prepared but submission job may have failed
547        // Re-queue a resend job if it's been stuck for a while
548        let age_since_sent = get_age_since_status_change(&tx)?;
549
550        if age_since_sent > get_evm_resend_timeout() {
551            warn!(
552                tx_id = %tx.id,
553                age_seconds = age_since_sent.num_seconds(),
554                "transaction stuck in Sent, queuing resubmit job with repricing"
555            );
556
557            // Queue resubmit job to reprice the transaction for better acceptance
558            self.send_transaction_resubmit_job(&tx).await?;
559        }
560
561        self.update_transaction_status_if_needed(tx, TransactionStatus::Sent)
562            .await
563    }
564
565    /// Determines if we should attempt hash recovery for a stuck transaction.
566    ///
567    /// This is an expensive operation, so we only do it when:
568    /// - Transaction has been in Submitted status for a while (> 2 minutes)
569    /// - Transaction has had at least 2 resubmission attempts (hashes.len() > 1)
570    /// - Haven't tried recovery too recently (to avoid repeated attempts)
571    fn should_try_hash_recovery(
572        &self,
573        tx: &TransactionRepoModel,
574    ) -> Result<bool, TransactionError> {
575        // Only try recovery for transactions stuck in Submitted
576        if tx.status != TransactionStatus::Submitted {
577            return Ok(false);
578        }
579
580        // Must have multiple hashes (indicating resubmissions happened)
581        if tx.hashes.len() <= 1 {
582            return Ok(false);
583        }
584
585        // Only try if transaction has been stuck for a while
586        let age = get_age_of_sent_at(tx)?;
587        let min_age_for_recovery = get_evm_min_age_for_hash_recovery();
588
589        if age < min_age_for_recovery {
590            return Ok(false);
591        }
592
593        // Check if we've had enough resubmission attempts (more attempts = more likely to have wrong hash)
594        // Only try recovery if we have at least 3 hashes (2 resubmissions)
595        if tx.hashes.len() < EVM_MIN_HASHES_FOR_RECOVERY {
596            return Ok(false);
597        }
598
599        Ok(true)
600    }
601
602    /// Attempts to recover transaction status by checking all historical hashes.
603    ///
604    /// When a transaction is resubmitted multiple times due to timeouts, the database
605    /// may contain multiple hashes. The "current" hash (network_data.hash) might not
606    /// be the one that actually got mined. This method checks all historical hashes
607    /// to find if any were mined, and updates the database with the correct one.
608    ///
609    /// Returns the updated transaction model if recovery was successful, None otherwise.
610    async fn try_recover_with_historical_hashes(
611        &self,
612        tx: &TransactionRepoModel,
613        evm_data: &crate::models::EvmTransactionData,
614    ) -> Result<Option<TransactionRepoModel>, TransactionError> {
615        warn!(
616            tx_id = %tx.id,
617            current_hash = ?evm_data.hash,
618            total_hashes = %tx.hashes.len(),
619            "attempting hash recovery - checking historical hashes"
620        );
621
622        // Check each historical hash (most recent first, since it's more likely)
623        for (idx, historical_hash) in tx.hashes.iter().rev().enumerate() {
624            // Skip if this is the current hash (already checked)
625            if Some(historical_hash) == evm_data.hash.as_ref() {
626                continue;
627            }
628
629            debug!(
630                tx_id = %tx.id,
631                hash = %historical_hash,
632                index = %idx,
633                "checking historical hash"
634            );
635
636            // Try to get receipt for this hash
637            match self
638                .provider()
639                .get_transaction_receipt(historical_hash)
640                .await
641            {
642                Ok(Some(receipt)) => {
643                    warn!(
644                        tx_id = %tx.id,
645                        mined_hash = %historical_hash,
646                        wrong_hash = ?evm_data.hash,
647                        block_number = ?receipt.block_number,
648                        "RECOVERED: found mined transaction with historical hash - correcting database"
649                    );
650
651                    // Update with correct hash and Mined status
652                    // Let the normal status check flow handle confirmation checking
653                    let updated_tx = self
654                        .update_transaction_with_corrected_hash(
655                            tx,
656                            evm_data,
657                            historical_hash,
658                            TransactionStatus::Mined,
659                        )
660                        .await?;
661
662                    return Ok(Some(updated_tx));
663                }
664                Ok(None) => {
665                    // This hash not found either, continue to next
666                    continue;
667                }
668                Err(e) => {
669                    // Network error, log but continue checking other hashes
670                    warn!(
671                        tx_id = %tx.id,
672                        hash = %historical_hash,
673                        error = %e,
674                        "error checking historical hash, continuing to next"
675                    );
676                    continue;
677                }
678            }
679        }
680
681        // None of the historical hashes found on-chain
682        debug!(
683            tx_id = %tx.id,
684            "hash recovery completed - no historical hashes found on-chain"
685        );
686        Ok(None)
687    }
688
689    /// Updates transaction with the corrected hash and status
690    ///
691    /// Returns the updated transaction model and sends a notification about the status change.
692    async fn update_transaction_with_corrected_hash(
693        &self,
694        tx: &TransactionRepoModel,
695        evm_data: &crate::models::EvmTransactionData,
696        correct_hash: &str,
697        status: TransactionStatus,
698    ) -> Result<TransactionRepoModel, TransactionError> {
699        let mut corrected_data = evm_data.clone();
700        corrected_data.hash = Some(correct_hash.to_string());
701
702        let updated_tx = self
703            .transaction_repository()
704            .partial_update(
705                tx.id.clone(),
706                TransactionUpdateRequest {
707                    network_data: Some(NetworkTransactionData::Evm(corrected_data)),
708                    status: Some(status),
709                    ..Default::default()
710                },
711            )
712            .await?;
713
714        // Send notification about the recovered transaction
715        if let Err(e) = self.send_transaction_update_notification(&updated_tx).await {
716            error!(
717                tx_id = %updated_tx.id,
718                error = %e,
719                "failed to send notification after hash recovery"
720            );
721        }
722
723        Ok(updated_tx)
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use crate::{
730        config::{EvmNetworkConfig, NetworkConfigCommon},
731        domain::transaction::evm::{EvmRelayerTransaction, MockPriceCalculatorTrait},
732        jobs::MockJobProducerTrait,
733        models::{
734            evm::Speed, EvmTransactionData, NetworkConfigData, NetworkRepoModel,
735            NetworkTransactionData, NetworkType, RelayerEvmPolicy, RelayerNetworkPolicy,
736            RelayerRepoModel, TransactionReceipt, TransactionRepoModel, TransactionStatus, U256,
737        },
738        repositories::{
739            MockNetworkRepository, MockRelayerRepository, MockTransactionCounterTrait,
740            MockTransactionRepository,
741        },
742        services::{provider::MockEvmProviderTrait, signer::MockSigner},
743    };
744    use alloy::{
745        consensus::{Eip658Value, Receipt, ReceiptWithBloom},
746        network::AnyReceiptEnvelope,
747        primitives::{b256, Address, BlockHash, Bloom, TxHash},
748    };
749    use chrono::{Duration, Utc};
750    use std::sync::Arc;
751
752    /// Helper struct holding all the mocks we often need
753    pub struct TestMocks {
754        pub provider: MockEvmProviderTrait,
755        pub relayer_repo: MockRelayerRepository,
756        pub network_repo: MockNetworkRepository,
757        pub tx_repo: MockTransactionRepository,
758        pub job_producer: MockJobProducerTrait,
759        pub signer: MockSigner,
760        pub counter: MockTransactionCounterTrait,
761        pub price_calc: MockPriceCalculatorTrait,
762    }
763
764    /// Returns a default `TestMocks` with zero-configuration stubs.
765    /// You can override expectations in each test as needed.
766    pub fn default_test_mocks() -> TestMocks {
767        TestMocks {
768            provider: MockEvmProviderTrait::new(),
769            relayer_repo: MockRelayerRepository::new(),
770            network_repo: MockNetworkRepository::new(),
771            tx_repo: MockTransactionRepository::new(),
772            job_producer: MockJobProducerTrait::new(),
773            signer: MockSigner::new(),
774            counter: MockTransactionCounterTrait::new(),
775            price_calc: MockPriceCalculatorTrait::new(),
776        }
777    }
778
779    /// Returns a `TestMocks` with network repository configured for prepare_noop_update_request tests.
780    pub fn default_test_mocks_with_network() -> TestMocks {
781        let mut mocks = default_test_mocks();
782        // Set up default expectation for get_by_chain_id that prepare_noop_update_request tests need
783        mocks
784            .network_repo
785            .expect_get_by_chain_id()
786            .returning(|network_type, chain_id| {
787                if network_type == NetworkType::Evm && chain_id == 1 {
788                    Ok(Some(create_test_network_model()))
789                } else {
790                    Ok(None)
791                }
792            });
793        mocks
794    }
795
796    /// Creates a test NetworkRepoModel for chain_id 1 (mainnet)
797    pub fn create_test_network_model() -> NetworkRepoModel {
798        let evm_config = EvmNetworkConfig {
799            common: NetworkConfigCommon {
800                network: "mainnet".to_string(),
801                from: None,
802                rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
803                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
804                average_blocktime_ms: Some(12000),
805                is_testnet: Some(false),
806                tags: Some(vec!["mainnet".to_string()]),
807            },
808            chain_id: Some(1),
809            required_confirmations: Some(12),
810            features: Some(vec!["eip1559".to_string()]),
811            symbol: Some("ETH".to_string()),
812            gas_price_cache: None,
813        };
814        NetworkRepoModel {
815            id: "evm:mainnet".to_string(),
816            name: "mainnet".to_string(),
817            network_type: NetworkType::Evm,
818            config: NetworkConfigData::Evm(evm_config),
819        }
820    }
821
822    /// Creates a test NetworkRepoModel for chain_id 42161 (Arbitrum-like) with no-mempool tag
823    pub fn create_test_no_mempool_network_model() -> NetworkRepoModel {
824        let evm_config = EvmNetworkConfig {
825            common: NetworkConfigCommon {
826                network: "arbitrum".to_string(),
827                from: None,
828                rpc_urls: Some(vec!["https://arb-rpc.example.com".to_string()]),
829                explorer_urls: Some(vec!["https://arb-explorer.example.com".to_string()]),
830                average_blocktime_ms: Some(1000),
831                is_testnet: Some(false),
832                tags: Some(vec![
833                    "arbitrum".to_string(),
834                    "rollup".to_string(),
835                    "no-mempool".to_string(),
836                ]),
837            },
838            chain_id: Some(42161),
839            required_confirmations: Some(12),
840            features: Some(vec!["eip1559".to_string()]),
841            symbol: Some("ETH".to_string()),
842            gas_price_cache: None,
843        };
844        NetworkRepoModel {
845            id: "evm:arbitrum".to_string(),
846            name: "arbitrum".to_string(),
847            network_type: NetworkType::Evm,
848            config: NetworkConfigData::Evm(evm_config),
849        }
850    }
851
852    /// Minimal "builder" for TransactionRepoModel.
853    /// Allows quick creation of a test transaction with default fields,
854    /// then updates them based on the provided status or overrides.
855    pub fn make_test_transaction(status: TransactionStatus) -> TransactionRepoModel {
856        TransactionRepoModel {
857            id: "test-tx-id".to_string(),
858            relayer_id: "test-relayer-id".to_string(),
859            status,
860            status_reason: None,
861            created_at: Utc::now().to_rfc3339(),
862            sent_at: None,
863            confirmed_at: None,
864            valid_until: None,
865            delete_at: None,
866            network_type: NetworkType::Evm,
867            network_data: NetworkTransactionData::Evm(EvmTransactionData {
868                chain_id: 1,
869                from: "0xSender".to_string(),
870                to: Some("0xRecipient".to_string()),
871                value: U256::from(0),
872                data: Some("0xData".to_string()),
873                gas_limit: Some(21000),
874                gas_price: Some(20000000000),
875                max_fee_per_gas: None,
876                max_priority_fee_per_gas: None,
877                nonce: None,
878                signature: None,
879                hash: None,
880                speed: Some(Speed::Fast),
881                raw: None,
882            }),
883            priced_at: None,
884            hashes: Vec::new(),
885            noop_count: None,
886            is_canceled: Some(false),
887        }
888    }
889
890    /// Minimal "builder" for EvmRelayerTransaction.
891    /// Takes mock dependencies as arguments.
892    pub fn make_test_evm_relayer_transaction(
893        relayer: RelayerRepoModel,
894        mocks: TestMocks,
895    ) -> EvmRelayerTransaction<
896        MockEvmProviderTrait,
897        MockRelayerRepository,
898        MockNetworkRepository,
899        MockTransactionRepository,
900        MockJobProducerTrait,
901        MockSigner,
902        MockTransactionCounterTrait,
903        MockPriceCalculatorTrait,
904    > {
905        EvmRelayerTransaction::new(
906            relayer,
907            mocks.provider,
908            Arc::new(mocks.relayer_repo),
909            Arc::new(mocks.network_repo),
910            Arc::new(mocks.tx_repo),
911            Arc::new(mocks.counter),
912            Arc::new(mocks.job_producer),
913            mocks.price_calc,
914            mocks.signer,
915        )
916        .unwrap()
917    }
918
919    fn create_test_relayer() -> RelayerRepoModel {
920        RelayerRepoModel {
921            id: "test-relayer-id".to_string(),
922            name: "Test Relayer".to_string(),
923            paused: false,
924            system_disabled: false,
925            network: "test_network".to_string(),
926            network_type: NetworkType::Evm,
927            policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()),
928            signer_id: "test_signer".to_string(),
929            address: "0x".to_string(),
930            notification_id: None,
931            custom_rpc_urls: None,
932            ..Default::default()
933        }
934    }
935
936    fn make_mock_receipt(status: bool, block_number: Option<u64>) -> TransactionReceipt {
937        // Use some placeholder values for minimal completeness
938        let tx_hash = TxHash::from(b256!(
939            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
940        ));
941        let block_hash = BlockHash::from(b256!(
942            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
943        ));
944        let from_address = Address::from([0x11; 20]);
945
946        TransactionReceipt {
947            inner: alloy::rpc::types::TransactionReceipt {
948                inner: AnyReceiptEnvelope {
949                    inner: ReceiptWithBloom {
950                        receipt: Receipt {
951                            status: Eip658Value::Eip658(status), // determines success/fail
952                            cumulative_gas_used: 0,
953                            logs: vec![],
954                        },
955                        logs_bloom: Bloom::ZERO,
956                    },
957                    r#type: 0, // Legacy transaction type
958                },
959                transaction_hash: tx_hash,
960                transaction_index: Some(0),
961                block_hash: block_number.map(|_| block_hash), // only set if mined
962                block_number,
963                gas_used: 21000,
964                effective_gas_price: 1000,
965                blob_gas_used: None,
966                blob_gas_price: None,
967                from: from_address,
968                to: None,
969                contract_address: None,
970            },
971            other: Default::default(),
972        }
973    }
974
975    // Tests for `check_transaction_status`
976    mod check_transaction_status_tests {
977        use super::*;
978
979        #[tokio::test]
980        async fn test_not_mined() {
981            let mut mocks = default_test_mocks();
982            let relayer = create_test_relayer();
983            let mut tx = make_test_transaction(TransactionStatus::Submitted);
984
985            // Provide a hash so we can check for receipt
986            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
987                evm_data.hash = Some("0xFakeHash".to_string());
988            }
989
990            // Mock that get_transaction_receipt returns None (not mined)
991            mocks
992                .provider
993                .expect_get_transaction_receipt()
994                .returning(|_| Box::pin(async { Ok(None) }));
995
996            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
997
998            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
999            assert_eq!(status, TransactionStatus::Submitted);
1000        }
1001
1002        #[tokio::test]
1003        async fn test_mined_but_not_confirmed() {
1004            let mut mocks = default_test_mocks();
1005            let relayer = create_test_relayer();
1006            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1007
1008            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1009                evm_data.hash = Some("0xFakeHash".to_string());
1010            }
1011
1012            // Mock a mined receipt with block_number = 100
1013            mocks
1014                .provider
1015                .expect_get_transaction_receipt()
1016                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
1017
1018            // Mock block_number that hasn't reached the confirmation threshold
1019            mocks
1020                .provider
1021                .expect_get_block_number()
1022                .return_once(|| Box::pin(async { Ok(100) }));
1023
1024            // Mock network repository to return a test network model
1025            mocks
1026                .network_repo
1027                .expect_get_by_chain_id()
1028                .returning(|_, _| Ok(Some(create_test_network_model())));
1029
1030            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1031
1032            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1033            assert_eq!(status, TransactionStatus::Mined);
1034        }
1035
1036        #[tokio::test]
1037        async fn test_confirmed() {
1038            let mut mocks = default_test_mocks();
1039            let relayer = create_test_relayer();
1040            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1041
1042            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1043                evm_data.hash = Some("0xFakeHash".to_string());
1044            }
1045
1046            // Mock a mined receipt with block_number = 100
1047            mocks
1048                .provider
1049                .expect_get_transaction_receipt()
1050                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
1051
1052            // Mock block_number that meets the confirmation threshold
1053            mocks
1054                .provider
1055                .expect_get_block_number()
1056                .return_once(|| Box::pin(async { Ok(113) }));
1057
1058            // Mock network repository to return a test network model
1059            mocks
1060                .network_repo
1061                .expect_get_by_chain_id()
1062                .returning(|_, _| Ok(Some(create_test_network_model())));
1063
1064            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1065
1066            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1067            assert_eq!(status, TransactionStatus::Confirmed);
1068        }
1069
1070        #[tokio::test]
1071        async fn test_failed() {
1072            let mut mocks = default_test_mocks();
1073            let relayer = create_test_relayer();
1074            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1075
1076            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1077                evm_data.hash = Some("0xFakeHash".to_string());
1078            }
1079
1080            // Mock a mined receipt with failure
1081            mocks
1082                .provider
1083                .expect_get_transaction_receipt()
1084                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(false, Some(100)))) }));
1085
1086            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1087
1088            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
1089            assert_eq!(status, TransactionStatus::Failed);
1090        }
1091    }
1092
1093    // Tests for `should_resubmit`
1094    mod should_resubmit_tests {
1095        use super::*;
1096        use crate::models::TransactionError;
1097
1098        #[tokio::test]
1099        async fn test_should_resubmit_true() {
1100            let mut mocks = default_test_mocks();
1101            let relayer = create_test_relayer();
1102
1103            // Set sent_at to 600 seconds ago to force resubmission
1104            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1105            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1106
1107            // Mock network repository to return a regular network model
1108            mocks
1109                .network_repo
1110                .expect_get_by_chain_id()
1111                .returning(|_, _| Ok(Some(create_test_network_model())));
1112
1113            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1114            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1115            assert!(res, "Transaction should be resubmitted after timeout.");
1116        }
1117
1118        #[tokio::test]
1119        async fn test_should_resubmit_false() {
1120            let mut mocks = default_test_mocks();
1121            let relayer = create_test_relayer();
1122
1123            // Make a transaction with status Submitted but recently sent
1124            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1125            tx.sent_at = Some(Utc::now().to_rfc3339());
1126
1127            // Mock network repository to return a regular network model
1128            mocks
1129                .network_repo
1130                .expect_get_by_chain_id()
1131                .returning(|_, _| Ok(Some(create_test_network_model())));
1132
1133            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1134            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1135            assert!(!res, "Transaction should not be resubmitted immediately.");
1136        }
1137
1138        #[tokio::test]
1139        async fn test_should_resubmit_true_for_no_mempool_network() {
1140            let mut mocks = default_test_mocks();
1141            let relayer = create_test_relayer();
1142
1143            // Set up a transaction that would normally be resubmitted (sent_at long ago)
1144            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1145            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1146
1147            // Set chain_id to match the no-mempool network
1148            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1149                evm_data.chain_id = 42161; // Arbitrum chain ID
1150            }
1151
1152            // Mock network repository to return a no-mempool network model
1153            mocks
1154                .network_repo
1155                .expect_get_by_chain_id()
1156                .returning(|_, _| Ok(Some(create_test_no_mempool_network_model())));
1157
1158            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1159            let res = evm_transaction.should_resubmit(&tx).await.unwrap();
1160            assert!(
1161                res,
1162                "Transaction should be resubmitted for no-mempool networks."
1163            );
1164        }
1165
1166        #[tokio::test]
1167        async fn test_should_resubmit_network_not_found() {
1168            let mut mocks = default_test_mocks();
1169            let relayer = create_test_relayer();
1170
1171            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1172            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1173
1174            // Mock network repository to return None (network not found)
1175            mocks
1176                .network_repo
1177                .expect_get_by_chain_id()
1178                .returning(|_, _| Ok(None));
1179
1180            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1181            let result = evm_transaction.should_resubmit(&tx).await;
1182
1183            assert!(
1184                result.is_err(),
1185                "should_resubmit should return error when network not found"
1186            );
1187            let error = result.unwrap_err();
1188            match error {
1189                TransactionError::UnexpectedError(msg) => {
1190                    assert!(msg.contains("Network with chain id 1 not found"));
1191                }
1192                _ => panic!("Expected UnexpectedError for network not found"),
1193            }
1194        }
1195
1196        #[tokio::test]
1197        async fn test_should_resubmit_network_conversion_error() {
1198            let mut mocks = default_test_mocks();
1199            let relayer = create_test_relayer();
1200
1201            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1202            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1203
1204            // Create a network model with invalid EVM config (missing chain_id)
1205            let invalid_evm_config = EvmNetworkConfig {
1206                common: NetworkConfigCommon {
1207                    network: "invalid-network".to_string(),
1208                    from: None,
1209                    rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
1210                    explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
1211                    average_blocktime_ms: Some(12000),
1212                    is_testnet: Some(false),
1213                    tags: Some(vec!["testnet".to_string()]),
1214                },
1215                chain_id: None, // This will cause the conversion to fail
1216                required_confirmations: Some(12),
1217                features: Some(vec!["eip1559".to_string()]),
1218                symbol: Some("ETH".to_string()),
1219                gas_price_cache: None,
1220            };
1221            let invalid_network = NetworkRepoModel {
1222                id: "evm:invalid".to_string(),
1223                name: "invalid-network".to_string(),
1224                network_type: NetworkType::Evm,
1225                config: NetworkConfigData::Evm(invalid_evm_config),
1226            };
1227
1228            // Mock network repository to return the invalid network model
1229            mocks
1230                .network_repo
1231                .expect_get_by_chain_id()
1232                .returning(move |_, _| Ok(Some(invalid_network.clone())));
1233
1234            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1235            let result = evm_transaction.should_resubmit(&tx).await;
1236
1237            assert!(
1238                result.is_err(),
1239                "should_resubmit should return error when network conversion fails"
1240            );
1241            let error = result.unwrap_err();
1242            match error {
1243                TransactionError::UnexpectedError(msg) => {
1244                    assert!(msg.contains("Error converting network model to EvmNetwork"));
1245                }
1246                _ => panic!("Expected UnexpectedError for network conversion failure"),
1247            }
1248        }
1249    }
1250
1251    // Tests for `should_noop`
1252    mod should_noop_tests {
1253        use super::*;
1254
1255        #[tokio::test]
1256        async fn test_expired_transaction_triggers_noop() {
1257            let mut mocks = default_test_mocks();
1258            let relayer = create_test_relayer();
1259
1260            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1261            // Force the transaction to be "expired" by setting valid_until in the past
1262            tx.valid_until = Some((Utc::now() - Duration::seconds(10)).to_rfc3339());
1263
1264            // Mock network repository to return a test network model
1265            mocks
1266                .network_repo
1267                .expect_get_by_chain_id()
1268                .returning(|_, _| Ok(Some(create_test_network_model())));
1269
1270            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1271            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1272            assert!(res, "Expired transaction should be replaced with a NOOP.");
1273            assert!(
1274                reason.is_some(),
1275                "Reason should be provided for expired transaction"
1276            );
1277            assert!(
1278                reason.unwrap().contains("expired"),
1279                "Reason should mention expiration"
1280            );
1281        }
1282
1283        #[tokio::test]
1284        async fn test_too_many_noop_attempts_returns_false() {
1285            let mocks = default_test_mocks();
1286            let relayer = create_test_relayer();
1287
1288            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1289            tx.noop_count = Some(51); // Max is 50, so this should return false
1290
1291            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1292            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1293            assert!(
1294                !res,
1295                "Transaction with too many NOOP attempts should not be replaced."
1296            );
1297            assert!(
1298                reason.is_none(),
1299                "Reason should not be provided when should_noop is false"
1300            );
1301        }
1302
1303        #[tokio::test]
1304        async fn test_already_noop_returns_false() {
1305            let mut mocks = default_test_mocks();
1306            let relayer = create_test_relayer();
1307
1308            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1309            // Make it a NOOP by setting to=None and value=0
1310            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1311                evm_data.to = None;
1312                evm_data.value = U256::from(0);
1313            }
1314
1315            mocks
1316                .network_repo
1317                .expect_get_by_chain_id()
1318                .returning(|_, _| Ok(Some(create_test_network_model())));
1319
1320            // Mock get_block_by_number for gas limit validation (won't be called since is_noop returns early, but needed for compilation)
1321            mocks.provider.expect_get_block_by_number().returning(|| {
1322                Box::pin(async {
1323                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1324                    let mut block: Block = Block::default();
1325                    block.header.gas_limit = 30_000_000u64;
1326                    Ok(AnyRpcBlock::from(block))
1327                })
1328            });
1329
1330            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1331            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1332            assert!(
1333                !res,
1334                "Transaction that is already a NOOP should not be replaced."
1335            );
1336            assert!(
1337                reason.is_none(),
1338                "Reason should not be provided when should_noop is false"
1339            );
1340        }
1341
1342        #[tokio::test]
1343        async fn test_rollup_with_too_many_attempts_triggers_noop() {
1344            let mut mocks = default_test_mocks();
1345            let relayer = create_test_relayer();
1346
1347            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1348            // Set chain_id to Arbitrum (rollup network)
1349            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1350                evm_data.chain_id = 42161; // Arbitrum
1351            }
1352            // Set enough hashes to trigger too_many_attempts (> 50)
1353            tx.hashes = vec!["0xHash1".to_string(); 51];
1354
1355            // Mock network repository to return Arbitrum network
1356            mocks
1357                .network_repo
1358                .expect_get_by_chain_id()
1359                .returning(|_, _| Ok(Some(create_test_no_mempool_network_model())));
1360
1361            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1362            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1363            assert!(
1364                res,
1365                "Rollup transaction with too many attempts should be replaced with NOOP."
1366            );
1367            assert!(
1368                reason.is_some(),
1369                "Reason should be provided for rollup transaction"
1370            );
1371            assert!(
1372                reason.unwrap().contains("too many attempts"),
1373                "Reason should mention too many attempts"
1374            );
1375        }
1376
1377        #[tokio::test]
1378        async fn test_pending_state_timeout_triggers_noop() {
1379            let mut mocks = default_test_mocks();
1380            let relayer = create_test_relayer();
1381
1382            let mut tx = make_test_transaction(TransactionStatus::Pending);
1383            // Set created_at to 3 minutes ago (> 2 minute timeout)
1384            tx.created_at = (Utc::now() - Duration::minutes(3)).to_rfc3339();
1385
1386            mocks
1387                .network_repo
1388                .expect_get_by_chain_id()
1389                .returning(|_, _| Ok(Some(create_test_network_model())));
1390
1391            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1392            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1393            assert!(
1394                res,
1395                "Pending transaction stuck for >2 minutes should be replaced with NOOP."
1396            );
1397            assert!(
1398                reason.is_some(),
1399                "Reason should be provided for pending timeout"
1400            );
1401            assert!(
1402                reason.unwrap().contains("Pending state"),
1403                "Reason should mention Pending state"
1404            );
1405        }
1406
1407        #[tokio::test]
1408        async fn test_valid_transaction_returns_false() {
1409            let mut mocks = default_test_mocks();
1410            let relayer = create_test_relayer();
1411
1412            let tx = make_test_transaction(TransactionStatus::Submitted);
1413            // Transaction is recent, not expired, not on rollup, no issues
1414
1415            mocks
1416                .network_repo
1417                .expect_get_by_chain_id()
1418                .returning(|_, _| Ok(Some(create_test_network_model())));
1419
1420            // Mock get_block_by_number for gas limit validation
1421            mocks.provider.expect_get_block_by_number().returning(|| {
1422                Box::pin(async {
1423                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1424                    let mut block: Block = Block::default();
1425                    block.header.gas_limit = 30_000_000u64;
1426                    Ok(AnyRpcBlock::from(block))
1427                })
1428            });
1429
1430            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1431            let (res, reason) = evm_transaction.should_noop(&tx).await.unwrap();
1432            assert!(!res, "Valid transaction should not be replaced with NOOP.");
1433            assert!(
1434                reason.is_none(),
1435                "Reason should not be provided when should_noop is false"
1436            );
1437        }
1438    }
1439
1440    // Tests for `update_transaction_status_if_needed`
1441    mod update_transaction_status_tests {
1442        use super::*;
1443
1444        #[tokio::test]
1445        async fn test_no_update_when_status_is_same() {
1446            // Create mocks, relayer, and a transaction with status Submitted.
1447            let mocks = default_test_mocks();
1448            let relayer = create_test_relayer();
1449            let tx = make_test_transaction(TransactionStatus::Submitted);
1450            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1451
1452            // When new status is the same as current, update_transaction_status_if_needed
1453            // should simply return the original transaction.
1454            let updated_tx = evm_transaction
1455                .update_transaction_status_if_needed(tx.clone(), TransactionStatus::Submitted)
1456                .await
1457                .unwrap();
1458            assert_eq!(updated_tx.status, TransactionStatus::Submitted);
1459            assert_eq!(updated_tx.id, tx.id);
1460        }
1461
1462        #[tokio::test]
1463        async fn test_updates_when_status_differs() {
1464            let mut mocks = default_test_mocks();
1465            let relayer = create_test_relayer();
1466            let tx = make_test_transaction(TransactionStatus::Submitted);
1467
1468            // Mock partial_update to return a transaction with new status
1469            mocks
1470                .tx_repo
1471                .expect_partial_update()
1472                .returning(|_, update| {
1473                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1474                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1475                    Ok(updated_tx)
1476                });
1477
1478            // Mock notification job
1479            mocks
1480                .job_producer
1481                .expect_produce_send_notification_job()
1482                .returning(|_, _| Box::pin(async { Ok(()) }));
1483
1484            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1485            let updated_tx = evm_transaction
1486                .update_transaction_status_if_needed(tx.clone(), TransactionStatus::Mined)
1487                .await
1488                .unwrap();
1489
1490            assert_eq!(updated_tx.status, TransactionStatus::Mined);
1491        }
1492    }
1493
1494    // Tests for `handle_sent_state`
1495    mod handle_sent_state_tests {
1496        use super::*;
1497
1498        #[tokio::test]
1499        async fn test_sent_state_recent_no_resend() {
1500            let mut mocks = default_test_mocks();
1501            let relayer = create_test_relayer();
1502
1503            let mut tx = make_test_transaction(TransactionStatus::Sent);
1504            // Set sent_at to recent (e.g., 10 seconds ago)
1505            tx.sent_at = Some((Utc::now() - Duration::seconds(10)).to_rfc3339());
1506
1507            // Mock network repository to return a test network model for should_noop check
1508            mocks
1509                .network_repo
1510                .expect_get_by_chain_id()
1511                .returning(|_, _| Ok(Some(create_test_network_model())));
1512
1513            // Mock get_block_by_number for gas limit validation in handle_sent_state
1514            mocks.provider.expect_get_block_by_number().returning(|| {
1515                Box::pin(async {
1516                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1517                    let mut block: Block = Block::default();
1518                    block.header.gas_limit = 30_000_000u64;
1519                    Ok(AnyRpcBlock::from(block))
1520                })
1521            });
1522
1523            // Mock status check job scheduling
1524            mocks
1525                .job_producer
1526                .expect_produce_check_transaction_status_job()
1527                .returning(|_, _| Box::pin(async { Ok(()) }));
1528
1529            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1530            let result = evm_transaction.handle_sent_state(tx.clone()).await.unwrap();
1531
1532            assert_eq!(result.status, TransactionStatus::Sent);
1533        }
1534
1535        #[tokio::test]
1536        async fn test_sent_state_stuck_schedules_resubmit() {
1537            let mut mocks = default_test_mocks();
1538            let relayer = create_test_relayer();
1539
1540            let mut tx = make_test_transaction(TransactionStatus::Sent);
1541            // Set sent_at to long ago (> 30 seconds for resend timeout)
1542            tx.sent_at = Some((Utc::now() - Duration::seconds(60)).to_rfc3339());
1543
1544            // Mock network repository to return a test network model for should_noop check
1545            mocks
1546                .network_repo
1547                .expect_get_by_chain_id()
1548                .returning(|_, _| Ok(Some(create_test_network_model())));
1549
1550            // Mock get_block_by_number for gas limit validation in handle_sent_state
1551            mocks.provider.expect_get_block_by_number().returning(|| {
1552                Box::pin(async {
1553                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1554                    let mut block: Block = Block::default();
1555                    block.header.gas_limit = 30_000_000u64;
1556                    Ok(AnyRpcBlock::from(block))
1557                })
1558            });
1559
1560            // Mock resubmit job scheduling
1561            mocks
1562                .job_producer
1563                .expect_produce_submit_transaction_job()
1564                .returning(|_, _| Box::pin(async { Ok(()) }));
1565
1566            // Mock status check job scheduling
1567            mocks
1568                .job_producer
1569                .expect_produce_check_transaction_status_job()
1570                .returning(|_, _| Box::pin(async { Ok(()) }));
1571
1572            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1573            let result = evm_transaction.handle_sent_state(tx.clone()).await.unwrap();
1574
1575            assert_eq!(result.status, TransactionStatus::Sent);
1576        }
1577    }
1578
1579    // Tests for `prepare_noop_update_request`
1580    mod prepare_noop_update_request_tests {
1581        use super::*;
1582
1583        #[tokio::test]
1584        async fn test_noop_request_without_cancellation() {
1585            // Create a transaction with an initial noop_count of 2 and is_canceled set to false.
1586            let mocks = default_test_mocks_with_network();
1587            let relayer = create_test_relayer();
1588            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1589            tx.noop_count = Some(2);
1590            tx.is_canceled = Some(false);
1591
1592            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1593            let update_req = evm_transaction
1594                .prepare_noop_update_request(&tx, false, None)
1595                .await
1596                .unwrap();
1597
1598            // NOOP count should be incremented: 2 becomes 3.
1599            assert_eq!(update_req.noop_count, Some(3));
1600            // When not cancelling, the is_canceled flag should remain as in the original transaction.
1601            assert_eq!(update_req.is_canceled, Some(false));
1602        }
1603
1604        #[tokio::test]
1605        async fn test_noop_request_with_cancellation() {
1606            // Create a transaction with no initial noop_count (None) and is_canceled false.
1607            let mocks = default_test_mocks_with_network();
1608            let relayer = create_test_relayer();
1609            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1610            tx.noop_count = None;
1611            tx.is_canceled = Some(false);
1612
1613            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1614            let update_req = evm_transaction
1615                .prepare_noop_update_request(&tx, true, None)
1616                .await
1617                .unwrap();
1618
1619            // NOOP count should default to 1.
1620            assert_eq!(update_req.noop_count, Some(1));
1621            // When cancelling, the is_canceled flag should be forced to true.
1622            assert_eq!(update_req.is_canceled, Some(true));
1623        }
1624    }
1625
1626    // Tests for `handle_submitted_state`
1627    mod handle_submitted_state_tests {
1628        use super::*;
1629
1630        #[tokio::test]
1631        async fn test_schedules_resubmit_job() {
1632            let mut mocks = default_test_mocks();
1633            let relayer = create_test_relayer();
1634
1635            // Set sent_at far in the past to force resubmission
1636            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1637            tx.sent_at = Some((Utc::now() - Duration::seconds(600)).to_rfc3339());
1638
1639            // Mock network repository to return a test network model for should_noop check
1640            mocks
1641                .network_repo
1642                .expect_get_by_chain_id()
1643                .returning(|_, _| Ok(Some(create_test_network_model())));
1644
1645            // Mock get_block_by_number for gas limit validation
1646            mocks.provider.expect_get_block_by_number().returning(|| {
1647                Box::pin(async {
1648                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1649                    let mut block: Block = Block::default();
1650                    block.header.gas_limit = 30_000_000u64;
1651                    Ok(AnyRpcBlock::from(block))
1652                })
1653            });
1654
1655            // Expect the resubmit job to be produced
1656            mocks
1657                .job_producer
1658                .expect_produce_submit_transaction_job()
1659                .returning(|_, _| Box::pin(async { Ok(()) }));
1660
1661            // Expect status check to be scheduled
1662            mocks
1663                .job_producer
1664                .expect_produce_check_transaction_status_job()
1665                .returning(|_, _| Box::pin(async { Ok(()) }));
1666
1667            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1668            let updated_tx = evm_transaction.handle_submitted_state(tx).await.unwrap();
1669
1670            // We remain in "Submitted" after scheduling the resubmit
1671            assert_eq!(updated_tx.status, TransactionStatus::Submitted);
1672        }
1673    }
1674
1675    // Tests for `handle_pending_state`
1676    mod handle_pending_state_tests {
1677        use super::*;
1678
1679        #[tokio::test]
1680        async fn test_pending_state_no_noop() {
1681            // Create a pending transaction that is fresh (created now).
1682            let mut mocks = default_test_mocks();
1683            let relayer = create_test_relayer();
1684            let mut tx = make_test_transaction(TransactionStatus::Pending);
1685            tx.created_at = Utc::now().to_rfc3339(); // less than one minute old
1686
1687            // Mock network repository to return a test network model
1688            mocks
1689                .network_repo
1690                .expect_get_by_chain_id()
1691                .returning(|_, _| Ok(Some(create_test_network_model())));
1692
1693            // Mock get_block_by_number for gas limit validation
1694            mocks.provider.expect_get_block_by_number().returning(|| {
1695                Box::pin(async {
1696                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1697                    let mut block: Block = Block::default();
1698                    block.header.gas_limit = 30_000_000u64;
1699                    Ok(AnyRpcBlock::from(block))
1700                })
1701            });
1702
1703            // Expect status check to be scheduled when not doing NOOP
1704            mocks
1705                .job_producer
1706                .expect_produce_check_transaction_status_job()
1707                .returning(|_, _| Box::pin(async { Ok(()) }));
1708
1709            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1710            let result = evm_transaction
1711                .handle_pending_state(tx.clone())
1712                .await
1713                .unwrap();
1714
1715            // When should_noop returns false the original transaction is returned unchanged.
1716            assert_eq!(result.id, tx.id);
1717            assert_eq!(result.status, tx.status);
1718            assert_eq!(result.noop_count, tx.noop_count);
1719        }
1720
1721        #[tokio::test]
1722        async fn test_pending_state_with_noop() {
1723            // Create a pending transaction that is old (created 2 minutes ago)
1724            let mut mocks = default_test_mocks();
1725            let relayer = create_test_relayer();
1726            let mut tx = make_test_transaction(TransactionStatus::Pending);
1727            tx.created_at = (Utc::now() - Duration::minutes(2)).to_rfc3339();
1728
1729            // Mock network repository to return a test network model
1730            mocks
1731                .network_repo
1732                .expect_get_by_chain_id()
1733                .returning(|_, _| Ok(Some(create_test_network_model())));
1734
1735            // Mock get_block_by_number for gas limit validation
1736            mocks.provider.expect_get_block_by_number().returning(|| {
1737                Box::pin(async {
1738                    use alloy::{network::AnyRpcBlock, rpc::types::Block};
1739                    let mut block: Block = Block::default();
1740                    block.header.gas_limit = 30_000_000u64;
1741                    Ok(AnyRpcBlock::from(block))
1742                })
1743            });
1744
1745            // Expect partial_update to be called and simulate a Failed update
1746            // (Pending state transactions are marked as Failed, not NOOP, since nonces aren't assigned)
1747            let tx_clone = tx.clone();
1748            mocks
1749                .tx_repo
1750                .expect_partial_update()
1751                .withf(move |id, update| {
1752                    id == "test-tx-id"
1753                        && update.status == Some(TransactionStatus::Failed)
1754                        && update.status_reason.is_some()
1755                })
1756                .returning(move |_, update| {
1757                    let mut updated_tx = tx_clone.clone();
1758                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1759                    updated_tx.status_reason = update.status_reason.clone();
1760                    Ok(updated_tx)
1761                });
1762            // Expect that a notification is produced (no submit job needed for Failed status)
1763            mocks
1764                .job_producer
1765                .expect_produce_send_notification_job()
1766                .returning(|_, _| Box::pin(async { Ok(()) }));
1767
1768            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1769            let result = evm_transaction
1770                .handle_pending_state(tx.clone())
1771                .await
1772                .unwrap();
1773
1774            // Since should_noop returns true for pending timeout, transaction should be marked as Failed
1775            assert_eq!(result.status, TransactionStatus::Failed);
1776            assert!(result.status_reason.is_some());
1777            assert!(result.status_reason.unwrap().contains("Pending state"));
1778        }
1779    }
1780
1781    // Tests for `handle_mined_state`
1782    mod handle_mined_state_tests {
1783        use super::*;
1784
1785        #[tokio::test]
1786        async fn test_updates_status_and_schedules_check() {
1787            let mut mocks = default_test_mocks();
1788            let relayer = create_test_relayer();
1789            // Create a transaction in Submitted state (the mined branch is reached via status check).
1790            let tx = make_test_transaction(TransactionStatus::Submitted);
1791
1792            // Expect schedule_status_check to be called with delay 5.
1793            mocks
1794                .job_producer
1795                .expect_produce_check_transaction_status_job()
1796                .returning(|_, _| Box::pin(async { Ok(()) }));
1797            // Expect partial_update to update the transaction status to Mined.
1798            mocks
1799                .tx_repo
1800                .expect_partial_update()
1801                .returning(|_, update| {
1802                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1803                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1804                    Ok(updated_tx)
1805                });
1806
1807            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1808            let result = evm_transaction
1809                .handle_mined_state(tx.clone())
1810                .await
1811                .unwrap();
1812            assert_eq!(result.status, TransactionStatus::Mined);
1813        }
1814    }
1815
1816    // Tests for `handle_final_state`
1817    mod handle_final_state_tests {
1818        use super::*;
1819
1820        #[tokio::test]
1821        async fn test_final_state_confirmed() {
1822            let mut mocks = default_test_mocks();
1823            let relayer = create_test_relayer();
1824            let tx = make_test_transaction(TransactionStatus::Submitted);
1825
1826            // Expect partial_update to update status to Confirmed.
1827            mocks
1828                .tx_repo
1829                .expect_partial_update()
1830                .returning(|_, update| {
1831                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1832                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1833                    Ok(updated_tx)
1834                });
1835
1836            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1837            let result = evm_transaction
1838                .handle_final_state(tx.clone(), TransactionStatus::Confirmed)
1839                .await
1840                .unwrap();
1841            assert_eq!(result.status, TransactionStatus::Confirmed);
1842        }
1843
1844        #[tokio::test]
1845        async fn test_final_state_failed() {
1846            let mut mocks = default_test_mocks();
1847            let relayer = create_test_relayer();
1848            let tx = make_test_transaction(TransactionStatus::Submitted);
1849
1850            // Expect partial_update to update status to Failed.
1851            mocks
1852                .tx_repo
1853                .expect_partial_update()
1854                .returning(|_, update| {
1855                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1856                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1857                    Ok(updated_tx)
1858                });
1859
1860            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1861            let result = evm_transaction
1862                .handle_final_state(tx.clone(), TransactionStatus::Failed)
1863                .await
1864                .unwrap();
1865            assert_eq!(result.status, TransactionStatus::Failed);
1866        }
1867
1868        #[tokio::test]
1869        async fn test_final_state_expired() {
1870            let mut mocks = default_test_mocks();
1871            let relayer = create_test_relayer();
1872            let tx = make_test_transaction(TransactionStatus::Submitted);
1873
1874            // Expect partial_update to update status to Expired.
1875            mocks
1876                .tx_repo
1877                .expect_partial_update()
1878                .returning(|_, update| {
1879                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1880                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1881                    Ok(updated_tx)
1882                });
1883
1884            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1885            let result = evm_transaction
1886                .handle_final_state(tx.clone(), TransactionStatus::Expired)
1887                .await
1888                .unwrap();
1889            assert_eq!(result.status, TransactionStatus::Expired);
1890        }
1891    }
1892
1893    // Integration tests for `handle_status_impl`
1894    mod handle_status_impl_tests {
1895        use super::*;
1896
1897        #[tokio::test]
1898        async fn test_impl_submitted_branch() {
1899            let mut mocks = default_test_mocks();
1900            let relayer = create_test_relayer();
1901            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1902            tx.sent_at = Some((Utc::now() - Duration::seconds(120)).to_rfc3339());
1903            // Set a dummy hash so check_transaction_status can proceed.
1904            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1905                evm_data.hash = Some("0xFakeHash".to_string());
1906            }
1907            // Simulate no receipt found.
1908            mocks
1909                .provider
1910                .expect_get_transaction_receipt()
1911                .returning(|_| Box::pin(async { Ok(None) }));
1912            // Mock network repository for should_resubmit check
1913            mocks
1914                .network_repo
1915                .expect_get_by_chain_id()
1916                .returning(|_, _| Ok(Some(create_test_network_model())));
1917            // Expect that a status check job is scheduled.
1918            mocks
1919                .job_producer
1920                .expect_produce_check_transaction_status_job()
1921                .returning(|_, _| Box::pin(async { Ok(()) }));
1922            // Expect update_transaction_status_if_needed to update status to Submitted.
1923            mocks
1924                .tx_repo
1925                .expect_partial_update()
1926                .returning(|_, update| {
1927                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1928                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1929                    Ok(updated_tx)
1930                });
1931
1932            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1933            let result = evm_transaction.handle_status_impl(tx).await.unwrap();
1934            assert_eq!(result.status, TransactionStatus::Submitted);
1935        }
1936
1937        #[tokio::test]
1938        async fn test_impl_mined_branch() {
1939            let mut mocks = default_test_mocks();
1940            let relayer = create_test_relayer();
1941            let mut tx = make_test_transaction(TransactionStatus::Submitted);
1942            // Set created_at to be old enough to pass is_too_early_to_resubmit
1943            tx.created_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
1944            // Set a dummy hash.
1945            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
1946                evm_data.hash = Some("0xFakeHash".to_string());
1947            }
1948            // Simulate a receipt with a block number of 100 and a successful receipt.
1949            mocks
1950                .provider
1951                .expect_get_transaction_receipt()
1952                .returning(|_| Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) }));
1953            // Simulate that the current block number is 100 (so confirmations are insufficient).
1954            mocks
1955                .provider
1956                .expect_get_block_number()
1957                .return_once(|| Box::pin(async { Ok(100) }));
1958            // Mock network repository to return a test network model
1959            mocks
1960                .network_repo
1961                .expect_get_by_chain_id()
1962                .returning(|_, _| Ok(Some(create_test_network_model())));
1963            // Mock the notification job that gets sent after status update
1964            mocks
1965                .job_producer
1966                .expect_produce_send_notification_job()
1967                .returning(|_, _| Box::pin(async { Ok(()) }));
1968            // Expect get_by_id to reload the transaction after status change
1969            mocks.tx_repo.expect_get_by_id().returning(|_| {
1970                let updated_tx = make_test_transaction(TransactionStatus::Mined);
1971                Ok(updated_tx)
1972            });
1973            // Expect update_transaction_status_if_needed to update status to Mined.
1974            mocks
1975                .tx_repo
1976                .expect_partial_update()
1977                .returning(|_, update| {
1978                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
1979                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
1980                    Ok(updated_tx)
1981                });
1982
1983            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
1984            let result = evm_transaction.handle_status_impl(tx).await.unwrap();
1985            assert_eq!(result.status, TransactionStatus::Mined);
1986        }
1987
1988        #[tokio::test]
1989        async fn test_impl_final_confirmed_branch() {
1990            let mut mocks = default_test_mocks();
1991            let relayer = create_test_relayer();
1992            // Create a transaction with status Confirmed.
1993            let tx = make_test_transaction(TransactionStatus::Confirmed);
1994
1995            // In this branch, check_transaction_status returns the final status immediately,
1996            // so we expect partial_update to update the transaction status to Confirmed.
1997            mocks
1998                .tx_repo
1999                .expect_partial_update()
2000                .returning(|_, update| {
2001                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2002                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2003                    Ok(updated_tx)
2004                });
2005
2006            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2007            let result = evm_transaction.handle_status_impl(tx).await.unwrap();
2008            assert_eq!(result.status, TransactionStatus::Confirmed);
2009        }
2010
2011        #[tokio::test]
2012        async fn test_impl_final_failed_branch() {
2013            let mut mocks = default_test_mocks();
2014            let relayer = create_test_relayer();
2015            // Create a transaction with status Failed.
2016            let tx = make_test_transaction(TransactionStatus::Failed);
2017
2018            mocks
2019                .tx_repo
2020                .expect_partial_update()
2021                .returning(|_, update| {
2022                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2023                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2024                    Ok(updated_tx)
2025                });
2026
2027            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2028            let result = evm_transaction.handle_status_impl(tx).await.unwrap();
2029            assert_eq!(result.status, TransactionStatus::Failed);
2030        }
2031
2032        #[tokio::test]
2033        async fn test_impl_final_expired_branch() {
2034            let mut mocks = default_test_mocks();
2035            let relayer = create_test_relayer();
2036            // Create a transaction with status Expired.
2037            let tx = make_test_transaction(TransactionStatus::Expired);
2038
2039            mocks
2040                .tx_repo
2041                .expect_partial_update()
2042                .returning(|_, update| {
2043                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2044                    updated_tx.status = update.status.unwrap_or(updated_tx.status);
2045                    Ok(updated_tx)
2046                });
2047
2048            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2049            let result = evm_transaction.handle_status_impl(tx).await.unwrap();
2050            assert_eq!(result.status, TransactionStatus::Expired);
2051        }
2052    }
2053
2054    // Tests for hash recovery functions
2055    mod hash_recovery_tests {
2056        use super::*;
2057
2058        #[tokio::test]
2059        async fn test_should_try_hash_recovery_not_submitted() {
2060            let mocks = default_test_mocks();
2061            let relayer = create_test_relayer();
2062
2063            let mut tx = make_test_transaction(TransactionStatus::Sent);
2064            tx.hashes = vec![
2065                "0xHash1".to_string(),
2066                "0xHash2".to_string(),
2067                "0xHash3".to_string(),
2068            ];
2069
2070            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2071            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2072
2073            assert!(
2074                !result,
2075                "Should not attempt recovery for non-Submitted transactions"
2076            );
2077        }
2078
2079        #[tokio::test]
2080        async fn test_should_try_hash_recovery_not_enough_hashes() {
2081            let mocks = default_test_mocks();
2082            let relayer = create_test_relayer();
2083
2084            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2085            tx.hashes = vec!["0xHash1".to_string()]; // Only 1 hash
2086            tx.sent_at = Some((Utc::now() - Duration::minutes(3)).to_rfc3339());
2087
2088            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2089            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2090
2091            assert!(
2092                !result,
2093                "Should not attempt recovery with insufficient hashes"
2094            );
2095        }
2096
2097        #[tokio::test]
2098        async fn test_should_try_hash_recovery_too_recent() {
2099            let mocks = default_test_mocks();
2100            let relayer = create_test_relayer();
2101
2102            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2103            tx.hashes = vec![
2104                "0xHash1".to_string(),
2105                "0xHash2".to_string(),
2106                "0xHash3".to_string(),
2107            ];
2108            tx.sent_at = Some(Utc::now().to_rfc3339()); // Recent
2109
2110            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2111            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2112
2113            assert!(
2114                !result,
2115                "Should not attempt recovery for recently sent transactions"
2116            );
2117        }
2118
2119        #[tokio::test]
2120        async fn test_should_try_hash_recovery_success() {
2121            let mocks = default_test_mocks();
2122            let relayer = create_test_relayer();
2123
2124            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2125            tx.hashes = vec![
2126                "0xHash1".to_string(),
2127                "0xHash2".to_string(),
2128                "0xHash3".to_string(),
2129            ];
2130            tx.sent_at = Some((Utc::now() - Duration::minutes(3)).to_rfc3339());
2131
2132            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2133            let result = evm_transaction.should_try_hash_recovery(&tx).unwrap();
2134
2135            assert!(
2136                result,
2137                "Should attempt recovery for stuck transactions with multiple hashes"
2138            );
2139        }
2140
2141        #[tokio::test]
2142        async fn test_try_recover_no_historical_hash_found() {
2143            let mut mocks = default_test_mocks();
2144            let relayer = create_test_relayer();
2145
2146            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2147            tx.hashes = vec![
2148                "0xHash1".to_string(),
2149                "0xHash2".to_string(),
2150                "0xHash3".to_string(),
2151            ];
2152
2153            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2154                evm_data.hash = Some("0xHash3".to_string());
2155            }
2156
2157            // Mock provider to return None for all hash lookups
2158            mocks
2159                .provider
2160                .expect_get_transaction_receipt()
2161                .returning(|_| Box::pin(async { Ok(None) }));
2162
2163            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2164            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2165            let result = evm_transaction
2166                .try_recover_with_historical_hashes(&tx, &evm_data)
2167                .await
2168                .unwrap();
2169
2170            assert!(
2171                result.is_none(),
2172                "Should return None when no historical hash is found"
2173            );
2174        }
2175
2176        #[tokio::test]
2177        async fn test_try_recover_finds_mined_historical_hash() {
2178            let mut mocks = default_test_mocks();
2179            let relayer = create_test_relayer();
2180
2181            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2182            tx.hashes = vec![
2183                "0xHash1".to_string(),
2184                "0xHash2".to_string(), // This one is mined
2185                "0xHash3".to_string(),
2186            ];
2187
2188            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2189                evm_data.hash = Some("0xHash3".to_string()); // Current hash (wrong one)
2190            }
2191
2192            // Mock provider to return None for Hash1 and Hash3, but receipt for Hash2
2193            mocks
2194                .provider
2195                .expect_get_transaction_receipt()
2196                .returning(|hash| {
2197                    if hash == "0xHash2" {
2198                        Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })
2199                    } else {
2200                        Box::pin(async { Ok(None) })
2201                    }
2202                });
2203
2204            // Mock partial_update for correcting the hash
2205            let tx_clone = tx.clone();
2206            mocks
2207                .tx_repo
2208                .expect_partial_update()
2209                .returning(move |_, update| {
2210                    let mut updated_tx = tx_clone.clone();
2211                    if let Some(status) = update.status {
2212                        updated_tx.status = status;
2213                    }
2214                    if let Some(NetworkTransactionData::Evm(ref evm_data)) = update.network_data {
2215                        if let NetworkTransactionData::Evm(ref mut updated_evm) =
2216                            updated_tx.network_data
2217                        {
2218                            updated_evm.hash = evm_data.hash.clone();
2219                        }
2220                    }
2221                    Ok(updated_tx)
2222                });
2223
2224            // Mock notification job
2225            mocks
2226                .job_producer
2227                .expect_produce_send_notification_job()
2228                .returning(|_, _| Box::pin(async { Ok(()) }));
2229
2230            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2231            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2232            let result = evm_transaction
2233                .try_recover_with_historical_hashes(&tx, &evm_data)
2234                .await
2235                .unwrap();
2236
2237            assert!(result.is_some(), "Should recover the transaction");
2238            let recovered_tx = result.unwrap();
2239            assert_eq!(recovered_tx.status, TransactionStatus::Mined);
2240        }
2241
2242        #[tokio::test]
2243        async fn test_try_recover_network_error_continues() {
2244            let mut mocks = default_test_mocks();
2245            let relayer = create_test_relayer();
2246
2247            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2248            tx.hashes = vec![
2249                "0xHash1".to_string(),
2250                "0xHash2".to_string(), // Network error
2251                "0xHash3".to_string(), // This one is mined
2252            ];
2253
2254            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2255                evm_data.hash = Some("0xHash1".to_string());
2256            }
2257
2258            // Mock provider to return error for Hash2, receipt for Hash3
2259            mocks
2260                .provider
2261                .expect_get_transaction_receipt()
2262                .returning(|hash| {
2263                    if hash == "0xHash2" {
2264                        Box::pin(async { Err(crate::services::provider::ProviderError::Timeout) })
2265                    } else if hash == "0xHash3" {
2266                        Box::pin(async { Ok(Some(make_mock_receipt(true, Some(100)))) })
2267                    } else {
2268                        Box::pin(async { Ok(None) })
2269                    }
2270                });
2271
2272            // Mock partial_update for correcting the hash
2273            let tx_clone = tx.clone();
2274            mocks
2275                .tx_repo
2276                .expect_partial_update()
2277                .returning(move |_, update| {
2278                    let mut updated_tx = tx_clone.clone();
2279                    if let Some(status) = update.status {
2280                        updated_tx.status = status;
2281                    }
2282                    Ok(updated_tx)
2283                });
2284
2285            // Mock notification job
2286            mocks
2287                .job_producer
2288                .expect_produce_send_notification_job()
2289                .returning(|_, _| Box::pin(async { Ok(()) }));
2290
2291            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2292            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2293            let result = evm_transaction
2294                .try_recover_with_historical_hashes(&tx, &evm_data)
2295                .await
2296                .unwrap();
2297
2298            assert!(
2299                result.is_some(),
2300                "Should continue checking after network error and find mined hash"
2301            );
2302        }
2303
2304        #[tokio::test]
2305        async fn test_update_transaction_with_corrected_hash() {
2306            let mut mocks = default_test_mocks();
2307            let relayer = create_test_relayer();
2308
2309            let mut tx = make_test_transaction(TransactionStatus::Submitted);
2310            if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2311                evm_data.hash = Some("0xWrongHash".to_string());
2312            }
2313
2314            // Mock partial_update
2315            mocks
2316                .tx_repo
2317                .expect_partial_update()
2318                .returning(move |_, update| {
2319                    let mut updated_tx = make_test_transaction(TransactionStatus::Submitted);
2320                    if let Some(status) = update.status {
2321                        updated_tx.status = status;
2322                    }
2323                    if let Some(NetworkTransactionData::Evm(ref evm_data)) = update.network_data {
2324                        if let NetworkTransactionData::Evm(ref mut updated_evm) =
2325                            updated_tx.network_data
2326                        {
2327                            updated_evm.hash = evm_data.hash.clone();
2328                        }
2329                    }
2330                    Ok(updated_tx)
2331                });
2332
2333            // Mock notification job
2334            mocks
2335                .job_producer
2336                .expect_produce_send_notification_job()
2337                .returning(|_, _| Box::pin(async { Ok(()) }));
2338
2339            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2340            let evm_data = tx.network_data.get_evm_transaction_data().unwrap();
2341            let result = evm_transaction
2342                .update_transaction_with_corrected_hash(
2343                    &tx,
2344                    &evm_data,
2345                    "0xCorrectHash",
2346                    TransactionStatus::Mined,
2347                )
2348                .await
2349                .unwrap();
2350
2351            assert_eq!(result.status, TransactionStatus::Mined);
2352            if let NetworkTransactionData::Evm(ref updated_evm) = result.network_data {
2353                assert_eq!(updated_evm.hash.as_ref().unwrap(), "0xCorrectHash");
2354            }
2355        }
2356    }
2357
2358    // Tests for check_transaction_status edge cases
2359    mod check_transaction_status_edge_cases {
2360        use super::*;
2361
2362        #[tokio::test]
2363        async fn test_missing_hash_returns_error() {
2364            let mocks = default_test_mocks();
2365            let relayer = create_test_relayer();
2366
2367            let tx = make_test_transaction(TransactionStatus::Submitted);
2368            // Hash is None by default
2369
2370            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2371            let result = evm_transaction.check_transaction_status(&tx).await;
2372
2373            assert!(result.is_err(), "Should return error when hash is missing");
2374        }
2375
2376        #[tokio::test]
2377        async fn test_pending_status_early_return() {
2378            let mocks = default_test_mocks();
2379            let relayer = create_test_relayer();
2380
2381            let tx = make_test_transaction(TransactionStatus::Pending);
2382
2383            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2384            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
2385
2386            assert_eq!(
2387                status,
2388                TransactionStatus::Pending,
2389                "Should return Pending without querying blockchain"
2390            );
2391        }
2392
2393        #[tokio::test]
2394        async fn test_sent_status_early_return() {
2395            let mocks = default_test_mocks();
2396            let relayer = create_test_relayer();
2397
2398            let tx = make_test_transaction(TransactionStatus::Sent);
2399
2400            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2401            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
2402
2403            assert_eq!(
2404                status,
2405                TransactionStatus::Sent,
2406                "Should return Sent without querying blockchain"
2407            );
2408        }
2409
2410        #[tokio::test]
2411        async fn test_final_state_early_return() {
2412            let mocks = default_test_mocks();
2413            let relayer = create_test_relayer();
2414
2415            let tx = make_test_transaction(TransactionStatus::Confirmed);
2416
2417            let evm_transaction = make_test_evm_relayer_transaction(relayer, mocks);
2418            let status = evm_transaction.check_transaction_status(&tx).await.unwrap();
2419
2420            assert_eq!(
2421                status,
2422                TransactionStatus::Confirmed,
2423                "Should return final state without querying blockchain"
2424            );
2425        }
2426    }
2427}