openzeppelin_relayer/domain/transaction/evm/
utils.rs

1use crate::constants::{
2    ARBITRUM_GAS_LIMIT, DEFAULT_GAS_LIMIT, DEFAULT_TRANSACTION_SPEED, DEFAULT_TX_VALID_TIMESPAN,
3    EVM_MIN_AGE_FOR_RESUBMIT_SECONDS, MAXIMUM_NOOP_RETRY_ATTEMPTS, MAXIMUM_TX_ATTEMPTS,
4};
5use crate::domain::get_age_since_created;
6use crate::models::EvmNetwork;
7use crate::models::{
8    EvmTransactionData, TransactionError, TransactionRepoModel, TransactionStatus, U256,
9};
10use crate::services::provider::EvmProviderTrait;
11use chrono::{DateTime, Duration, Utc};
12use eyre::Result;
13
14/// Updates an existing transaction to be a "noop" transaction (transaction to self with zero value and no data)
15/// This is commonly used for cancellation and replacement transactions
16/// For Arbitrum networks, uses eth_estimateGas to account for L1 + L2 costs
17pub async fn make_noop<P: EvmProviderTrait>(
18    evm_data: &mut EvmTransactionData,
19    network: &EvmNetwork,
20    provider: Option<&P>,
21) -> Result<(), TransactionError> {
22    // Update the transaction to be a noop
23    evm_data.value = U256::from(0u64);
24    evm_data.data = Some("0x".to_string());
25    evm_data.to = Some(evm_data.from.clone());
26    evm_data.speed = Some(DEFAULT_TRANSACTION_SPEED);
27
28    // Set gas limit based on network type
29    if network.is_arbitrum() {
30        // For Arbitrum networks, try to estimate gas to account for L1 + L2 costs
31        if let Some(provider) = provider {
32            match provider.estimate_gas(evm_data).await {
33                Ok(estimated_gas) => {
34                    // Use the estimated gas, but ensure it's at least the default minimum
35                    evm_data.gas_limit = Some(estimated_gas.max(DEFAULT_GAS_LIMIT));
36                }
37                Err(e) => {
38                    // If estimation fails, fall back to a conservative estimate
39                    tracing::warn!(
40                        "Failed to estimate gas for Arbitrum noop transaction: {:?}",
41                        e
42                    );
43                    evm_data.gas_limit = Some(ARBITRUM_GAS_LIMIT);
44                }
45            }
46        } else {
47            // No provider available, use conservative estimate
48            evm_data.gas_limit = Some(ARBITRUM_GAS_LIMIT);
49        }
50    } else {
51        // For other networks, use the standard gas limit
52        evm_data.gas_limit = Some(DEFAULT_GAS_LIMIT);
53    }
54
55    Ok(())
56}
57
58/// Checks if a transaction is already a NOOP transaction
59pub fn is_noop(evm_data: &EvmTransactionData) -> bool {
60    evm_data.value == U256::from(0u64)
61        && evm_data.data.as_ref().is_some_and(|data| data == "0x")
62        && evm_data.to.as_ref() == Some(&evm_data.from)
63        && evm_data.speed.is_some()
64}
65
66/// Checks if a transaction has too many attempts
67pub fn too_many_attempts(tx: &TransactionRepoModel) -> bool {
68    tx.hashes.len() > MAXIMUM_TX_ATTEMPTS
69}
70
71/// Checks if a transaction has too many NOOP attempts
72pub fn too_many_noop_attempts(tx: &TransactionRepoModel) -> bool {
73    tx.noop_count.unwrap_or(0) > MAXIMUM_NOOP_RETRY_ATTEMPTS
74}
75
76/// Validates that a transaction is in the expected state.
77///
78/// This enforces state machine invariants and prevents invalid state transitions.
79/// Used for domain-level validation to ensure business rules are always enforced.
80///
81/// # Arguments
82///
83/// * `tx` - The transaction to validate
84/// * `expected` - The expected status
85/// * `operation` - Optional operation name for better error messages (e.g., "prepare_transaction")
86///
87/// # Returns
88///
89/// `Ok(())` if the status matches, `Err(TransactionError)` otherwise
90pub fn ensure_status(
91    tx: &TransactionRepoModel,
92    expected: TransactionStatus,
93    operation: Option<&str>,
94) -> Result<(), TransactionError> {
95    if tx.status != expected {
96        let error_msg = if let Some(op) = operation {
97            format!(
98                "Invalid transaction state for {}. Current: {:?}, Expected: {:?}",
99                op, tx.status, expected
100            )
101        } else {
102            format!(
103                "Invalid transaction state. Current: {:?}, Expected: {:?}",
104                tx.status, expected
105            )
106        };
107        return Err(TransactionError::ValidationError(error_msg));
108    }
109    Ok(())
110}
111
112/// Validates that a transaction is in one of the expected states.
113///
114/// This enforces state machine invariants for operations that are valid
115/// in multiple states (e.g., cancel, replace).
116///
117/// # Arguments
118///
119/// * `tx` - The transaction to validate
120/// * `expected` - Slice of acceptable statuses
121/// * `operation` - Optional operation name for better error messages (e.g., "cancel_transaction")
122///
123/// # Returns
124///
125/// `Ok(())` if the status is one of the expected values, `Err(TransactionError)` otherwise
126pub fn ensure_status_one_of(
127    tx: &TransactionRepoModel,
128    expected: &[TransactionStatus],
129    operation: Option<&str>,
130) -> Result<(), TransactionError> {
131    if !expected.contains(&tx.status) {
132        let error_msg = if let Some(op) = operation {
133            format!(
134                "Invalid transaction state for {}. Current: {:?}, Expected one of: {:?}",
135                op, tx.status, expected
136            )
137        } else {
138            format!(
139                "Invalid transaction state. Current: {:?}, Expected one of: {:?}",
140                tx.status, expected
141            )
142        };
143        return Err(TransactionError::ValidationError(error_msg));
144    }
145    Ok(())
146}
147
148/// Helper function to check if a transaction has enough confirmations.
149pub fn has_enough_confirmations(
150    tx_block_number: u64,
151    current_block_number: u64,
152    required_confirmations: u64,
153) -> bool {
154    current_block_number >= tx_block_number + required_confirmations
155}
156
157/// Checks if a transaction is still valid based on its valid_until timestamp.
158pub fn is_transaction_valid(created_at: &str, valid_until: &Option<String>) -> bool {
159    if let Some(valid_until_str) = valid_until {
160        match DateTime::parse_from_rfc3339(valid_until_str) {
161            Ok(valid_until_time) => return Utc::now() < valid_until_time,
162            Err(e) => {
163                tracing::warn!(error = %e, "failed to parse valid_until timestamp");
164                return false;
165            }
166        }
167    }
168    match DateTime::parse_from_rfc3339(created_at) {
169        Ok(created_time) => {
170            let default_valid_until =
171                created_time + Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN);
172            Utc::now() < default_valid_until
173        }
174        Err(e) => {
175            tracing::warn!(error = %e, "failed to parse created_at timestamp");
176            false
177        }
178    }
179}
180
181/// Get age since status last changed
182/// Uses sent_at, otherwise falls back to created_at
183pub fn get_age_since_status_change(
184    tx: &TransactionRepoModel,
185) -> Result<Duration, TransactionError> {
186    // For Sent/Submitted status, use sent_at if available
187    if let Some(sent_at) = &tx.sent_at {
188        let sent = DateTime::parse_from_rfc3339(sent_at)
189            .map_err(|e| {
190                TransactionError::UnexpectedError(format!("Error parsing sent_at time: {e}"))
191            })?
192            .with_timezone(&Utc);
193        return Ok(Utc::now().signed_duration_since(sent));
194    }
195
196    // Fallback to created_at
197    get_age_since_created(tx)
198}
199
200/// Check if transaction is too young for resubmission and timeout checks.
201///
202/// Returns true if the transaction was created less than EVM_MIN_AGE_FOR_RESUBMIT_SECONDS ago.
203/// This is used to defer resubmission logic and timeout checks for newly created transactions,
204/// while still allowing basic status updates from the blockchain.
205pub fn is_too_early_to_resubmit(tx: &TransactionRepoModel) -> Result<bool, TransactionError> {
206    let age = get_age_since_created(tx)?;
207    Ok(age < Duration::seconds(EVM_MIN_AGE_FOR_RESUBMIT_SECONDS))
208}
209
210/// Deprecated: Use `is_too_early_to_resubmit` instead.
211/// This alias exists for backward compatibility.
212#[deprecated(since = "1.1.0", note = "Use `is_too_early_to_resubmit` instead")]
213pub fn is_too_early_to_check(tx: &TransactionRepoModel) -> Result<bool, TransactionError> {
214    is_too_early_to_resubmit(tx)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::constants::{ARBITRUM_BASED_TAG, ROLLUP_TAG};
221    use crate::domain::transaction::evm::test_helpers::test_utils::make_test_transaction;
222    use crate::models::{evm::Speed, EvmTransactionData, NetworkTransactionData, U256};
223    use crate::services::provider::{MockEvmProviderTrait, ProviderError};
224    use crate::utils::mocks::mockutils::create_mock_transaction;
225
226    fn create_standard_network() -> EvmNetwork {
227        EvmNetwork {
228            network: "ethereum".to_string(),
229            rpc_urls: vec![crate::models::RpcConfig::new(
230                "https://mainnet.infura.io".to_string(),
231            )],
232            explorer_urls: None,
233            average_blocktime_ms: 12000,
234            is_testnet: false,
235            tags: vec!["mainnet".to_string()],
236            chain_id: 1,
237            required_confirmations: 12,
238            features: vec!["eip1559".to_string()],
239            symbol: "ETH".to_string(),
240            gas_price_cache: None,
241        }
242    }
243
244    fn create_arbitrum_network() -> EvmNetwork {
245        use crate::models::RpcConfig;
246        EvmNetwork {
247            network: "arbitrum".to_string(),
248            rpc_urls: vec![RpcConfig::new("https://arb1.arbitrum.io/rpc".to_string())],
249            explorer_urls: None,
250            average_blocktime_ms: 1000,
251            is_testnet: false,
252            tags: vec![ROLLUP_TAG.to_string(), ARBITRUM_BASED_TAG.to_string()],
253            chain_id: 42161,
254            required_confirmations: 1,
255            features: vec!["eip1559".to_string()],
256            symbol: "ETH".to_string(),
257            gas_price_cache: None,
258        }
259    }
260
261    fn create_arbitrum_nova_network() -> EvmNetwork {
262        use crate::models::RpcConfig;
263        EvmNetwork {
264            network: "arbitrum-nova".to_string(),
265            rpc_urls: vec![RpcConfig::new("https://nova.arbitrum.io/rpc".to_string())],
266            explorer_urls: None,
267            average_blocktime_ms: 1000,
268            is_testnet: false,
269            tags: vec![ROLLUP_TAG.to_string(), ARBITRUM_BASED_TAG.to_string()],
270            chain_id: 42170,
271            required_confirmations: 1,
272            features: vec!["eip1559".to_string()],
273            symbol: "ETH".to_string(),
274            gas_price_cache: None,
275        }
276    }
277
278    #[tokio::test]
279    async fn test_make_noop_standard_network() {
280        let mut evm_data = EvmTransactionData {
281            from: "0x1234567890123456789012345678901234567890".to_string(),
282            to: Some("0xoriginal_destination".to_string()),
283            value: U256::from(1000000000000000000u64), // 1 ETH
284            data: Some("0xoriginal_data".to_string()),
285            gas_limit: Some(50000),
286            gas_price: Some(10_000_000_000),
287            max_fee_per_gas: None,
288            max_priority_fee_per_gas: None,
289            nonce: Some(42),
290            signature: None,
291            hash: Some("0xoriginal_hash".to_string()),
292            speed: Some(Speed::Fast),
293            chain_id: 1,
294            raw: Some(vec![1, 2, 3]),
295        };
296
297        let network = create_standard_network();
298        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
299        assert!(result.is_ok());
300
301        // Verify the transaction was updated correctly
302        assert_eq!(evm_data.gas_limit, Some(21_000)); // Standard gas limit
303        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
304        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
305        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
306        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
307        assert_eq!(evm_data.speed, Some(DEFAULT_TRANSACTION_SPEED));
308    }
309
310    #[tokio::test]
311    async fn test_make_noop_arbitrum_network() {
312        let mut evm_data = EvmTransactionData {
313            from: "0x1234567890123456789012345678901234567890".to_string(),
314            to: Some("0xoriginal_destination".to_string()),
315            value: U256::from(1000000000000000000u64), // 1 ETH
316            data: Some("0xoriginal_data".to_string()),
317            gas_limit: Some(50000),
318            gas_price: Some(10_000_000_000),
319            max_fee_per_gas: None,
320            max_priority_fee_per_gas: None,
321            nonce: Some(42),
322            signature: None,
323            hash: Some("0xoriginal_hash".to_string()),
324            speed: Some(Speed::Fast),
325            chain_id: 42161, // Arbitrum One
326            raw: Some(vec![1, 2, 3]),
327        };
328
329        let network = create_arbitrum_network();
330        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
331        assert!(result.is_ok());
332
333        // Verify the transaction was updated correctly for Arbitrum
334        assert_eq!(evm_data.gas_limit, Some(50_000)); // Higher gas limit for Arbitrum
335        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
336        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
337        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
338        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
339        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
340    }
341
342    #[tokio::test]
343    async fn test_make_noop_arbitrum_nova() {
344        let mut evm_data = EvmTransactionData {
345            from: "0x1234567890123456789012345678901234567890".to_string(),
346            to: Some("0xoriginal_destination".to_string()),
347            value: U256::from(1000000000000000000u64), // 1 ETH
348            data: Some("0xoriginal_data".to_string()),
349            gas_limit: Some(30000),
350            gas_price: Some(10_000_000_000),
351            max_fee_per_gas: None,
352            max_priority_fee_per_gas: None,
353            nonce: Some(42),
354            signature: None,
355            hash: Some("0xoriginal_hash".to_string()),
356            speed: Some(Speed::Fast),
357            chain_id: 42170, // Arbitrum Nova
358            raw: Some(vec![1, 2, 3]),
359        };
360
361        let network = create_arbitrum_nova_network();
362        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
363        assert!(result.is_ok());
364
365        // Verify the transaction was updated correctly for Arbitrum Nova
366        assert_eq!(evm_data.gas_limit, Some(50_000)); // Higher gas limit for Arbitrum
367        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
368        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
369        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
370        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
371        assert_eq!(evm_data.chain_id, 42170); // Chain ID preserved
372    }
373
374    #[tokio::test]
375    async fn test_make_noop_arbitrum_with_provider() {
376        let mut mock_provider = MockEvmProviderTrait::new();
377
378        // Mock the gas estimation to return a higher value (simulating L1 + L2 costs)
379        mock_provider
380            .expect_estimate_gas()
381            .times(1)
382            .returning(|_| Box::pin(async move { Ok(35_000) }));
383
384        let mut evm_data = EvmTransactionData {
385            from: "0x1234567890123456789012345678901234567890".to_string(),
386            to: Some("0xoriginal_destination".to_string()),
387            value: U256::from(1000000000000000000u64), // 1 ETH
388            data: Some("0xoriginal_data".to_string()),
389            gas_limit: Some(30000),
390            gas_price: Some(10_000_000_000),
391            max_fee_per_gas: None,
392            max_priority_fee_per_gas: None,
393            nonce: Some(42),
394            signature: None,
395            hash: Some("0xoriginal_hash".to_string()),
396            speed: Some(Speed::Fast),
397            chain_id: 42161, // Arbitrum One
398            raw: Some(vec![1, 2, 3]),
399        };
400
401        let network = create_arbitrum_network();
402        let result = make_noop(&mut evm_data, &network, Some(&mock_provider)).await;
403        assert!(result.is_ok());
404
405        // Verify the transaction was updated correctly with estimated gas
406        assert_eq!(evm_data.gas_limit, Some(35_000)); // Should use estimated gas
407        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
408        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
409        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
410        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
411        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
412    }
413
414    #[tokio::test]
415    async fn test_make_noop_arbitrum_provider_estimation_fails() {
416        let mut mock_provider = MockEvmProviderTrait::new();
417
418        // Mock the gas estimation to fail
419        mock_provider.expect_estimate_gas().times(1).returning(|_| {
420            Box::pin(async move { Err(ProviderError::Other("Network error".to_string())) })
421        });
422
423        let mut evm_data = EvmTransactionData {
424            from: "0x1234567890123456789012345678901234567890".to_string(),
425            to: Some("0xoriginal_destination".to_string()),
426            value: U256::from(1000000000000000000u64), // 1 ETH
427            data: Some("0xoriginal_data".to_string()),
428            gas_limit: Some(30000),
429            gas_price: Some(10_000_000_000),
430            max_fee_per_gas: None,
431            max_priority_fee_per_gas: None,
432            nonce: Some(42),
433            signature: None,
434            hash: Some("0xoriginal_hash".to_string()),
435            speed: Some(Speed::Fast),
436            chain_id: 42161, // Arbitrum One
437            raw: Some(vec![1, 2, 3]),
438        };
439
440        let network = create_arbitrum_network();
441        let result = make_noop(&mut evm_data, &network, Some(&mock_provider)).await;
442        assert!(result.is_ok());
443
444        // Verify the transaction falls back to conservative estimate
445        assert_eq!(evm_data.gas_limit, Some(50_000)); // Should use fallback gas limit
446        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
447        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
448        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
449        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
450        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
451    }
452
453    #[test]
454    fn test_is_noop() {
455        // Create a NOOP transaction
456        let noop_tx = EvmTransactionData {
457            from: "0x1234567890123456789012345678901234567890".to_string(),
458            to: Some("0x1234567890123456789012345678901234567890".to_string()), // Same as from
459            value: U256::from(0u64),
460            data: Some("0x".to_string()),
461            gas_limit: Some(21000),
462            gas_price: Some(10_000_000_000),
463            max_fee_per_gas: None,
464            max_priority_fee_per_gas: None,
465            nonce: Some(42),
466            signature: None,
467            hash: None,
468            speed: Some(Speed::Fast),
469            chain_id: 1,
470            raw: None,
471        };
472        assert!(is_noop(&noop_tx));
473
474        // Test non-NOOP transactions
475        let mut non_noop = noop_tx.clone();
476        non_noop.value = U256::from(1000000000000000000u64); // 1 ETH
477        assert!(!is_noop(&non_noop));
478
479        let mut non_noop = noop_tx.clone();
480        non_noop.data = Some("0x123456".to_string());
481        assert!(!is_noop(&non_noop));
482
483        let mut non_noop = noop_tx.clone();
484        non_noop.to = Some("0x9876543210987654321098765432109876543210".to_string());
485        assert!(!is_noop(&non_noop));
486
487        let mut non_noop = noop_tx;
488        non_noop.speed = None;
489        assert!(!is_noop(&non_noop));
490    }
491
492    #[test]
493    fn test_too_many_attempts() {
494        let mut tx = TransactionRepoModel {
495            id: "test-tx".to_string(),
496            relayer_id: "test-relayer".to_string(),
497            status: TransactionStatus::Pending,
498            status_reason: None,
499            created_at: "2024-01-01T00:00:00Z".to_string(),
500            sent_at: None,
501            confirmed_at: None,
502            valid_until: None,
503            network_type: crate::models::NetworkType::Evm,
504            network_data: NetworkTransactionData::Evm(EvmTransactionData {
505                from: "0x1234".to_string(),
506                to: Some("0x5678".to_string()),
507                value: U256::from(0u64),
508                data: Some("0x".to_string()),
509                gas_limit: Some(21000),
510                gas_price: Some(10_000_000_000),
511                max_fee_per_gas: None,
512                max_priority_fee_per_gas: None,
513                nonce: Some(42),
514                signature: None,
515                hash: None,
516                speed: Some(Speed::Fast),
517                chain_id: 1,
518                raw: None,
519            }),
520            priced_at: None,
521            hashes: vec![], // Start with no attempts
522            noop_count: None,
523            is_canceled: Some(false),
524            delete_at: None,
525        };
526
527        // Test with no attempts
528        assert!(!too_many_attempts(&tx));
529
530        // Test with maximum attempts
531        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS];
532        assert!(!too_many_attempts(&tx));
533
534        // Test with too many attempts
535        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS + 1];
536        assert!(too_many_attempts(&tx));
537    }
538
539    #[test]
540    fn test_too_many_noop_attempts() {
541        let mut tx = TransactionRepoModel {
542            id: "test-tx".to_string(),
543            relayer_id: "test-relayer".to_string(),
544            status: TransactionStatus::Pending,
545            status_reason: None,
546            created_at: "2024-01-01T00:00:00Z".to_string(),
547            sent_at: None,
548            confirmed_at: None,
549            valid_until: None,
550            network_type: crate::models::NetworkType::Evm,
551            network_data: NetworkTransactionData::Evm(EvmTransactionData {
552                from: "0x1234".to_string(),
553                to: Some("0x5678".to_string()),
554                value: U256::from(0u64),
555                data: Some("0x".to_string()),
556                gas_limit: Some(21000),
557                gas_price: Some(10_000_000_000),
558                max_fee_per_gas: None,
559                max_priority_fee_per_gas: None,
560                nonce: Some(42),
561                signature: None,
562                hash: None,
563                speed: Some(Speed::Fast),
564                chain_id: 1,
565                raw: None,
566            }),
567            priced_at: None,
568            hashes: vec![],
569            noop_count: None,
570            is_canceled: Some(false),
571            delete_at: None,
572        };
573
574        // Test with no NOOP attempts
575        assert!(!too_many_noop_attempts(&tx));
576
577        // Test with maximum NOOP attempts
578        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS);
579        assert!(!too_many_noop_attempts(&tx));
580
581        // Test with too many NOOP attempts
582        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS + 1);
583        assert!(too_many_noop_attempts(&tx));
584    }
585
586    #[test]
587    fn test_has_enough_confirmations() {
588        // Not enough confirmations
589        let tx_block_number = 100;
590        let current_block_number = 110; // Only 10 confirmations
591        let required_confirmations = 12;
592        assert!(!has_enough_confirmations(
593            tx_block_number,
594            current_block_number,
595            required_confirmations
596        ));
597
598        // Exactly enough confirmations
599        let current_block_number = 112; // Exactly 12 confirmations
600        assert!(has_enough_confirmations(
601            tx_block_number,
602            current_block_number,
603            required_confirmations
604        ));
605
606        // More than enough confirmations
607        let current_block_number = 120; // 20 confirmations
608        assert!(has_enough_confirmations(
609            tx_block_number,
610            current_block_number,
611            required_confirmations
612        ));
613    }
614
615    #[test]
616    fn test_is_transaction_valid_with_future_timestamp() {
617        let now = Utc::now();
618        let valid_until = Some((now + Duration::hours(1)).to_rfc3339());
619        let created_at = now.to_rfc3339();
620
621        assert!(is_transaction_valid(&created_at, &valid_until));
622    }
623
624    #[test]
625    fn test_is_transaction_valid_with_past_timestamp() {
626        let now = Utc::now();
627        let valid_until = Some((now - Duration::hours(1)).to_rfc3339());
628        let created_at = now.to_rfc3339();
629
630        assert!(!is_transaction_valid(&created_at, &valid_until));
631    }
632
633    #[test]
634    fn test_is_transaction_valid_with_valid_until() {
635        // Test with valid_until in the future
636        let created_at = Utc::now().to_rfc3339();
637        let valid_until = Some((Utc::now() + Duration::hours(1)).to_rfc3339());
638        assert!(is_transaction_valid(&created_at, &valid_until));
639
640        // Test with valid_until in the past
641        let valid_until = Some((Utc::now() - Duration::hours(1)).to_rfc3339());
642        assert!(!is_transaction_valid(&created_at, &valid_until));
643
644        // Test with valid_until exactly at current time (should be invalid)
645        let valid_until = Some(Utc::now().to_rfc3339());
646        assert!(!is_transaction_valid(&created_at, &valid_until));
647
648        // Test with valid_until very far in the future
649        let valid_until = Some((Utc::now() + Duration::days(365)).to_rfc3339());
650        assert!(is_transaction_valid(&created_at, &valid_until));
651
652        // Test with invalid valid_until format
653        let valid_until = Some("invalid-date-format".to_string());
654        assert!(!is_transaction_valid(&created_at, &valid_until));
655
656        // Test with empty valid_until string
657        let valid_until = Some("".to_string());
658        assert!(!is_transaction_valid(&created_at, &valid_until));
659    }
660
661    #[test]
662    fn test_is_transaction_valid_without_valid_until() {
663        // Test with created_at within the default timespan
664        let created_at = Utc::now().to_rfc3339();
665        let valid_until = None;
666        assert!(is_transaction_valid(&created_at, &valid_until));
667
668        // Test with created_at older than the default timespan (8 hours)
669        let old_created_at =
670            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN + 1000)).to_rfc3339();
671        assert!(!is_transaction_valid(&old_created_at, &valid_until));
672
673        // Test with created_at exactly at the boundary
674        let boundary_created_at =
675            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN)).to_rfc3339();
676        assert!(!is_transaction_valid(&boundary_created_at, &valid_until));
677
678        // Test with created_at just within the default timespan
679        let within_boundary_created_at =
680            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN - 1000)).to_rfc3339();
681        assert!(is_transaction_valid(
682            &within_boundary_created_at,
683            &valid_until
684        ));
685
686        // Test with invalid created_at format
687        let invalid_created_at = "invalid-date-format";
688        assert!(!is_transaction_valid(invalid_created_at, &valid_until));
689
690        // Test with empty created_at string
691        assert!(!is_transaction_valid("", &valid_until));
692    }
693
694    #[test]
695    fn test_ensure_status_success() {
696        let tx = make_test_transaction(TransactionStatus::Pending);
697
698        // Should succeed when status matches
699        let result = ensure_status(&tx, TransactionStatus::Pending, Some("test_operation"));
700        assert!(result.is_ok());
701    }
702
703    #[test]
704    fn test_ensure_status_failure_with_operation() {
705        let tx = make_test_transaction(TransactionStatus::Sent);
706
707        // Should fail with operation context in error message
708        let result = ensure_status(&tx, TransactionStatus::Pending, Some("prepare_transaction"));
709        assert!(result.is_err());
710
711        if let Err(TransactionError::ValidationError(msg)) = result {
712            assert!(msg.contains("prepare_transaction"));
713            assert!(msg.contains("Sent"));
714            assert!(msg.contains("Pending"));
715        } else {
716            panic!("Expected ValidationError");
717        }
718    }
719
720    #[test]
721    fn test_ensure_status_failure_without_operation() {
722        let tx = make_test_transaction(TransactionStatus::Sent);
723
724        // Should fail without operation context
725        let result = ensure_status(&tx, TransactionStatus::Pending, None);
726        assert!(result.is_err());
727
728        if let Err(TransactionError::ValidationError(msg)) = result {
729            assert!(!msg.contains("for"));
730            assert!(msg.contains("Sent"));
731            assert!(msg.contains("Pending"));
732        } else {
733            panic!("Expected ValidationError");
734        }
735    }
736
737    #[test]
738    fn test_ensure_status_all_states() {
739        // Test that ensure_status works for all possible status values
740        let statuses = vec![
741            TransactionStatus::Pending,
742            TransactionStatus::Sent,
743            TransactionStatus::Submitted,
744            TransactionStatus::Mined,
745            TransactionStatus::Confirmed,
746            TransactionStatus::Failed,
747            TransactionStatus::Expired,
748            TransactionStatus::Canceled,
749        ];
750
751        for status in &statuses {
752            let tx = make_test_transaction(status.clone());
753
754            // Should succeed when expecting the same status
755            assert!(ensure_status(&tx, status.clone(), Some("test")).is_ok());
756
757            // Should fail when expecting a different status
758            for other_status in &statuses {
759                if other_status != status {
760                    assert!(ensure_status(&tx, other_status.clone(), Some("test")).is_err());
761                }
762            }
763        }
764    }
765
766    #[test]
767    fn test_ensure_status_one_of_success() {
768        let tx = make_test_transaction(TransactionStatus::Submitted);
769
770        // Should succeed when status is in the list
771        let result = ensure_status_one_of(
772            &tx,
773            &[TransactionStatus::Submitted, TransactionStatus::Mined],
774            Some("resubmit_transaction"),
775        );
776        assert!(result.is_ok());
777    }
778
779    #[test]
780    fn test_ensure_status_one_of_success_first_in_list() {
781        let tx = make_test_transaction(TransactionStatus::Pending);
782
783        // Should succeed when status is first in list
784        let result = ensure_status_one_of(
785            &tx,
786            &[
787                TransactionStatus::Pending,
788                TransactionStatus::Sent,
789                TransactionStatus::Submitted,
790            ],
791            Some("cancel_transaction"),
792        );
793        assert!(result.is_ok());
794    }
795
796    #[test]
797    fn test_ensure_status_one_of_success_last_in_list() {
798        let tx = make_test_transaction(TransactionStatus::Submitted);
799
800        // Should succeed when status is last in list
801        let result = ensure_status_one_of(
802            &tx,
803            &[
804                TransactionStatus::Pending,
805                TransactionStatus::Sent,
806                TransactionStatus::Submitted,
807            ],
808            Some("cancel_transaction"),
809        );
810        assert!(result.is_ok());
811    }
812
813    #[test]
814    fn test_ensure_status_one_of_failure_with_operation() {
815        let tx = make_test_transaction(TransactionStatus::Confirmed);
816
817        // Should fail with operation context when status not in list
818        let result = ensure_status_one_of(
819            &tx,
820            &[TransactionStatus::Pending, TransactionStatus::Sent],
821            Some("cancel_transaction"),
822        );
823        assert!(result.is_err());
824
825        if let Err(TransactionError::ValidationError(msg)) = result {
826            assert!(msg.contains("cancel_transaction"));
827            assert!(msg.contains("Confirmed"));
828            assert!(msg.contains("Pending"));
829            assert!(msg.contains("Sent"));
830        } else {
831            panic!("Expected ValidationError");
832        }
833    }
834
835    #[test]
836    fn test_ensure_status_one_of_failure_without_operation() {
837        let tx = make_test_transaction(TransactionStatus::Confirmed);
838
839        // Should fail without operation context
840        let result = ensure_status_one_of(
841            &tx,
842            &[TransactionStatus::Pending, TransactionStatus::Sent],
843            None,
844        );
845        assert!(result.is_err());
846
847        if let Err(TransactionError::ValidationError(msg)) = result {
848            assert!(!msg.contains("for"));
849            assert!(msg.contains("Confirmed"));
850        } else {
851            panic!("Expected ValidationError");
852        }
853    }
854
855    #[test]
856    fn test_ensure_status_one_of_single_status() {
857        let tx = make_test_transaction(TransactionStatus::Pending);
858
859        // Should work with a single status in the list
860        let result = ensure_status_one_of(&tx, &[TransactionStatus::Pending], Some("test"));
861        assert!(result.is_ok());
862
863        // Should fail when status doesn't match
864        let tx2 = make_test_transaction(TransactionStatus::Sent);
865        let result = ensure_status_one_of(&tx2, &[TransactionStatus::Pending], Some("test"));
866        assert!(result.is_err());
867    }
868
869    #[test]
870    fn test_ensure_status_one_of_all_states() {
871        let all_statuses = vec![
872            TransactionStatus::Pending,
873            TransactionStatus::Sent,
874            TransactionStatus::Submitted,
875            TransactionStatus::Mined,
876            TransactionStatus::Confirmed,
877            TransactionStatus::Failed,
878            TransactionStatus::Expired,
879            TransactionStatus::Canceled,
880        ];
881
882        // Should succeed for each status when it's in the list
883        for status in &all_statuses {
884            let tx = make_test_transaction(status.clone());
885            let result = ensure_status_one_of(&tx, &all_statuses, Some("test"));
886            assert!(result.is_ok());
887        }
888    }
889
890    #[test]
891    fn test_ensure_status_one_of_empty_list() {
892        let tx = make_test_transaction(TransactionStatus::Pending);
893
894        // Should always fail with empty list
895        let result = ensure_status_one_of(&tx, &[], Some("test"));
896        assert!(result.is_err());
897    }
898
899    #[test]
900    fn test_ensure_status_error_message_formatting() {
901        let tx = make_test_transaction(TransactionStatus::Confirmed);
902
903        // Test error message format for ensure_status
904        let result = ensure_status(&tx, TransactionStatus::Pending, Some("my_operation"));
905        if let Err(TransactionError::ValidationError(msg)) = result {
906            // Should have clear format: "Invalid transaction state for {operation}. Current: {current}, Expected: {expected}"
907            assert!(msg.starts_with("Invalid transaction state for my_operation"));
908            assert!(msg.contains("Current: Confirmed"));
909            assert!(msg.contains("Expected: Pending"));
910        } else {
911            panic!("Expected ValidationError");
912        }
913
914        // Test error message format for ensure_status_one_of
915        let result = ensure_status_one_of(
916            &tx,
917            &[TransactionStatus::Pending, TransactionStatus::Sent],
918            Some("another_operation"),
919        );
920        if let Err(TransactionError::ValidationError(msg)) = result {
921            // Should have clear format with list of expected states
922            assert!(msg.starts_with("Invalid transaction state for another_operation"));
923            assert!(msg.contains("Current: Confirmed"));
924            assert!(msg.contains("Expected one of:"));
925        } else {
926            panic!("Expected ValidationError");
927        }
928    }
929
930    #[test]
931    fn test_get_age_since_created() {
932        let now = Utc::now();
933
934        // Test with transaction created 2 hours ago
935        let created_time = now - Duration::hours(2);
936        let tx = TransactionRepoModel {
937            created_at: created_time.to_rfc3339(),
938            ..create_mock_transaction()
939        };
940
941        let age_result = get_age_since_created(&tx);
942        assert!(age_result.is_ok());
943        let age = age_result.unwrap();
944        // Age should be approximately 2 hours (with some tolerance)
945        assert!(age.num_minutes() >= 119 && age.num_minutes() <= 121);
946    }
947
948    #[test]
949    fn test_get_age_since_created_invalid_timestamp() {
950        let tx = TransactionRepoModel {
951            created_at: "invalid-timestamp".to_string(),
952            ..create_mock_transaction()
953        };
954
955        let result = get_age_since_created(&tx);
956        assert!(result.is_err());
957        match result.unwrap_err() {
958            TransactionError::UnexpectedError(msg) => {
959                assert!(msg.contains("Invalid created_at timestamp"));
960            }
961            _ => panic!("Expected UnexpectedError for invalid timestamp"),
962        }
963    }
964
965    #[test]
966    fn test_get_age_since_created_recent_transaction() {
967        let now = Utc::now();
968
969        // Test with transaction created just 1 minute ago
970        let created_time = now - Duration::minutes(1);
971        let tx = TransactionRepoModel {
972            created_at: created_time.to_rfc3339(),
973            ..create_mock_transaction()
974        };
975
976        let age_result = get_age_since_created(&tx);
977        assert!(age_result.is_ok());
978        let age = age_result.unwrap();
979        // Age should be approximately 1 minute
980        assert!(age.num_seconds() >= 59 && age.num_seconds() <= 61);
981    }
982
983    #[test]
984    fn test_get_age_since_status_change_with_sent_at() {
985        let now = Utc::now();
986
987        // Test with transaction that has sent_at (1 hour ago)
988        let sent_time = now - Duration::hours(1);
989        let created_time = now - Duration::hours(3); // Created 3 hours ago
990        let tx = TransactionRepoModel {
991            status: TransactionStatus::Sent,
992            created_at: created_time.to_rfc3339(),
993            sent_at: Some(sent_time.to_rfc3339()),
994            ..create_mock_transaction()
995        };
996
997        let age_result = get_age_since_status_change(&tx);
998        assert!(age_result.is_ok());
999        let age = age_result.unwrap();
1000        // Should use sent_at (1 hour), not created_at (3 hours)
1001        assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61);
1002    }
1003
1004    #[test]
1005    fn test_get_age_since_status_change_without_sent_at() {
1006        let now = Utc::now();
1007
1008        // Test with transaction that doesn't have sent_at
1009        let created_time = now - Duration::hours(2);
1010        let tx = TransactionRepoModel {
1011            created_at: created_time.to_rfc3339(),
1012            ..create_mock_transaction()
1013        };
1014
1015        let age_result = get_age_since_status_change(&tx);
1016        assert!(age_result.is_ok());
1017        let age = age_result.unwrap();
1018        // Should fall back to created_at (2 hours)
1019        assert!(age.num_minutes() >= 119 && age.num_minutes() <= 121);
1020    }
1021
1022    #[test]
1023    fn test_get_age_since_status_change_invalid_sent_at() {
1024        let now = Utc::now();
1025        let created_time = now - Duration::hours(2);
1026
1027        let tx = TransactionRepoModel {
1028            status: TransactionStatus::Sent,
1029            created_at: created_time.to_rfc3339(),
1030            sent_at: Some("invalid-timestamp".to_string()),
1031            ..create_mock_transaction()
1032        };
1033
1034        let result = get_age_since_status_change(&tx);
1035        assert!(result.is_err());
1036        match result.unwrap_err() {
1037            TransactionError::UnexpectedError(msg) => {
1038                assert!(msg.contains("Error parsing sent_at time"));
1039            }
1040            _ => panic!("Expected UnexpectedError for invalid sent_at timestamp"),
1041        }
1042    }
1043
1044    #[test]
1045    fn test_is_too_early_to_resubmit_recent_transaction() {
1046        let now = Utc::now();
1047
1048        // Test with transaction created just 1 second ago (too early)
1049        let created_time = now - Duration::seconds(1);
1050        let tx = TransactionRepoModel {
1051            created_at: created_time.to_rfc3339(),
1052            ..create_mock_transaction()
1053        };
1054
1055        let result = is_too_early_to_resubmit(&tx);
1056        assert!(result.is_ok());
1057        assert!(result.unwrap()); // Should be true (too early)
1058    }
1059
1060    #[test]
1061    fn test_is_too_early_to_resubmit_old_transaction() {
1062        let now = Utc::now();
1063
1064        // Test with transaction created well past the minimum age
1065        let created_time = now - Duration::seconds(EVM_MIN_AGE_FOR_RESUBMIT_SECONDS + 10);
1066        let tx = TransactionRepoModel {
1067            created_at: created_time.to_rfc3339(),
1068            ..create_mock_transaction()
1069        };
1070
1071        let result = is_too_early_to_resubmit(&tx);
1072        assert!(result.is_ok());
1073        assert!(!result.unwrap()); // Should be false (old enough to resubmit)
1074    }
1075
1076    #[test]
1077    fn test_is_too_early_to_resubmit_boundary() {
1078        let now = Utc::now();
1079
1080        // Test with transaction created exactly at the boundary
1081        let created_time = now - Duration::seconds(EVM_MIN_AGE_FOR_RESUBMIT_SECONDS);
1082        let tx = TransactionRepoModel {
1083            created_at: created_time.to_rfc3339(),
1084            ..create_mock_transaction()
1085        };
1086
1087        let result = is_too_early_to_resubmit(&tx);
1088        assert!(result.is_ok());
1089        // At the exact boundary, should be false (not too early)
1090        assert!(!result.unwrap());
1091    }
1092
1093    #[test]
1094    fn test_is_too_early_to_resubmit_invalid_timestamp() {
1095        let tx = TransactionRepoModel {
1096            created_at: "invalid-timestamp".to_string(),
1097            ..create_mock_transaction()
1098        };
1099
1100        let result = is_too_early_to_resubmit(&tx);
1101        assert!(result.is_err());
1102        match result.unwrap_err() {
1103            TransactionError::UnexpectedError(msg) => {
1104                assert!(msg.contains("Invalid created_at timestamp"));
1105            }
1106            _ => panic!("Expected UnexpectedError for invalid timestamp"),
1107        }
1108    }
1109}