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            metadata: None,
526        };
527
528        // Test with no attempts
529        assert!(!too_many_attempts(&tx));
530
531        // Test with maximum attempts
532        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS];
533        assert!(!too_many_attempts(&tx));
534
535        // Test with too many attempts
536        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS + 1];
537        assert!(too_many_attempts(&tx));
538    }
539
540    #[test]
541    fn test_too_many_noop_attempts() {
542        let mut tx = TransactionRepoModel {
543            id: "test-tx".to_string(),
544            relayer_id: "test-relayer".to_string(),
545            status: TransactionStatus::Pending,
546            status_reason: None,
547            created_at: "2024-01-01T00:00:00Z".to_string(),
548            sent_at: None,
549            confirmed_at: None,
550            valid_until: None,
551            network_type: crate::models::NetworkType::Evm,
552            network_data: NetworkTransactionData::Evm(EvmTransactionData {
553                from: "0x1234".to_string(),
554                to: Some("0x5678".to_string()),
555                value: U256::from(0u64),
556                data: Some("0x".to_string()),
557                gas_limit: Some(21000),
558                gas_price: Some(10_000_000_000),
559                max_fee_per_gas: None,
560                max_priority_fee_per_gas: None,
561                nonce: Some(42),
562                signature: None,
563                hash: None,
564                speed: Some(Speed::Fast),
565                chain_id: 1,
566                raw: None,
567            }),
568            priced_at: None,
569            hashes: vec![],
570            noop_count: None,
571            is_canceled: Some(false),
572            delete_at: None,
573            metadata: None,
574        };
575
576        // Test with no NOOP attempts
577        assert!(!too_many_noop_attempts(&tx));
578
579        // Test with maximum NOOP attempts
580        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS);
581        assert!(!too_many_noop_attempts(&tx));
582
583        // Test with too many NOOP attempts
584        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS + 1);
585        assert!(too_many_noop_attempts(&tx));
586    }
587
588    #[test]
589    fn test_has_enough_confirmations() {
590        // Not enough confirmations
591        let tx_block_number = 100;
592        let current_block_number = 110; // Only 10 confirmations
593        let required_confirmations = 12;
594        assert!(!has_enough_confirmations(
595            tx_block_number,
596            current_block_number,
597            required_confirmations
598        ));
599
600        // Exactly enough confirmations
601        let current_block_number = 112; // Exactly 12 confirmations
602        assert!(has_enough_confirmations(
603            tx_block_number,
604            current_block_number,
605            required_confirmations
606        ));
607
608        // More than enough confirmations
609        let current_block_number = 120; // 20 confirmations
610        assert!(has_enough_confirmations(
611            tx_block_number,
612            current_block_number,
613            required_confirmations
614        ));
615    }
616
617    #[test]
618    fn test_is_transaction_valid_with_future_timestamp() {
619        let now = Utc::now();
620        let valid_until = Some((now + Duration::hours(1)).to_rfc3339());
621        let created_at = now.to_rfc3339();
622
623        assert!(is_transaction_valid(&created_at, &valid_until));
624    }
625
626    #[test]
627    fn test_is_transaction_valid_with_past_timestamp() {
628        let now = Utc::now();
629        let valid_until = Some((now - Duration::hours(1)).to_rfc3339());
630        let created_at = now.to_rfc3339();
631
632        assert!(!is_transaction_valid(&created_at, &valid_until));
633    }
634
635    #[test]
636    fn test_is_transaction_valid_with_valid_until() {
637        // Test with valid_until in the future
638        let created_at = Utc::now().to_rfc3339();
639        let valid_until = Some((Utc::now() + Duration::hours(1)).to_rfc3339());
640        assert!(is_transaction_valid(&created_at, &valid_until));
641
642        // Test with valid_until in the past
643        let valid_until = Some((Utc::now() - Duration::hours(1)).to_rfc3339());
644        assert!(!is_transaction_valid(&created_at, &valid_until));
645
646        // Test with valid_until exactly at current time (should be invalid)
647        let valid_until = Some(Utc::now().to_rfc3339());
648        assert!(!is_transaction_valid(&created_at, &valid_until));
649
650        // Test with valid_until very far in the future
651        let valid_until = Some((Utc::now() + Duration::days(365)).to_rfc3339());
652        assert!(is_transaction_valid(&created_at, &valid_until));
653
654        // Test with invalid valid_until format
655        let valid_until = Some("invalid-date-format".to_string());
656        assert!(!is_transaction_valid(&created_at, &valid_until));
657
658        // Test with empty valid_until string
659        let valid_until = Some("".to_string());
660        assert!(!is_transaction_valid(&created_at, &valid_until));
661    }
662
663    #[test]
664    fn test_is_transaction_valid_without_valid_until() {
665        // Test with created_at within the default timespan
666        let created_at = Utc::now().to_rfc3339();
667        let valid_until = None;
668        assert!(is_transaction_valid(&created_at, &valid_until));
669
670        // Test with created_at older than the default timespan (8 hours)
671        let old_created_at =
672            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN + 1000)).to_rfc3339();
673        assert!(!is_transaction_valid(&old_created_at, &valid_until));
674
675        // Test with created_at exactly at the boundary
676        let boundary_created_at =
677            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN)).to_rfc3339();
678        assert!(!is_transaction_valid(&boundary_created_at, &valid_until));
679
680        // Test with created_at just within the default timespan
681        let within_boundary_created_at =
682            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN - 1000)).to_rfc3339();
683        assert!(is_transaction_valid(
684            &within_boundary_created_at,
685            &valid_until
686        ));
687
688        // Test with invalid created_at format
689        let invalid_created_at = "invalid-date-format";
690        assert!(!is_transaction_valid(invalid_created_at, &valid_until));
691
692        // Test with empty created_at string
693        assert!(!is_transaction_valid("", &valid_until));
694    }
695
696    #[test]
697    fn test_ensure_status_success() {
698        let tx = make_test_transaction(TransactionStatus::Pending);
699
700        // Should succeed when status matches
701        let result = ensure_status(&tx, TransactionStatus::Pending, Some("test_operation"));
702        assert!(result.is_ok());
703    }
704
705    #[test]
706    fn test_ensure_status_failure_with_operation() {
707        let tx = make_test_transaction(TransactionStatus::Sent);
708
709        // Should fail with operation context in error message
710        let result = ensure_status(&tx, TransactionStatus::Pending, Some("prepare_transaction"));
711        assert!(result.is_err());
712
713        if let Err(TransactionError::ValidationError(msg)) = result {
714            assert!(msg.contains("prepare_transaction"));
715            assert!(msg.contains("Sent"));
716            assert!(msg.contains("Pending"));
717        } else {
718            panic!("Expected ValidationError");
719        }
720    }
721
722    #[test]
723    fn test_ensure_status_failure_without_operation() {
724        let tx = make_test_transaction(TransactionStatus::Sent);
725
726        // Should fail without operation context
727        let result = ensure_status(&tx, TransactionStatus::Pending, None);
728        assert!(result.is_err());
729
730        if let Err(TransactionError::ValidationError(msg)) = result {
731            assert!(!msg.contains("for"));
732            assert!(msg.contains("Sent"));
733            assert!(msg.contains("Pending"));
734        } else {
735            panic!("Expected ValidationError");
736        }
737    }
738
739    #[test]
740    fn test_ensure_status_all_states() {
741        // Test that ensure_status works for all possible status values
742        let statuses = vec![
743            TransactionStatus::Pending,
744            TransactionStatus::Sent,
745            TransactionStatus::Submitted,
746            TransactionStatus::Mined,
747            TransactionStatus::Confirmed,
748            TransactionStatus::Failed,
749            TransactionStatus::Expired,
750            TransactionStatus::Canceled,
751        ];
752
753        for status in &statuses {
754            let tx = make_test_transaction(status.clone());
755
756            // Should succeed when expecting the same status
757            assert!(ensure_status(&tx, status.clone(), Some("test")).is_ok());
758
759            // Should fail when expecting a different status
760            for other_status in &statuses {
761                if other_status != status {
762                    assert!(ensure_status(&tx, other_status.clone(), Some("test")).is_err());
763                }
764            }
765        }
766    }
767
768    #[test]
769    fn test_ensure_status_one_of_success() {
770        let tx = make_test_transaction(TransactionStatus::Submitted);
771
772        // Should succeed when status is in the list
773        let result = ensure_status_one_of(
774            &tx,
775            &[TransactionStatus::Submitted, TransactionStatus::Mined],
776            Some("resubmit_transaction"),
777        );
778        assert!(result.is_ok());
779    }
780
781    #[test]
782    fn test_ensure_status_one_of_success_first_in_list() {
783        let tx = make_test_transaction(TransactionStatus::Pending);
784
785        // Should succeed when status is first in list
786        let result = ensure_status_one_of(
787            &tx,
788            &[
789                TransactionStatus::Pending,
790                TransactionStatus::Sent,
791                TransactionStatus::Submitted,
792            ],
793            Some("cancel_transaction"),
794        );
795        assert!(result.is_ok());
796    }
797
798    #[test]
799    fn test_ensure_status_one_of_success_last_in_list() {
800        let tx = make_test_transaction(TransactionStatus::Submitted);
801
802        // Should succeed when status is last in list
803        let result = ensure_status_one_of(
804            &tx,
805            &[
806                TransactionStatus::Pending,
807                TransactionStatus::Sent,
808                TransactionStatus::Submitted,
809            ],
810            Some("cancel_transaction"),
811        );
812        assert!(result.is_ok());
813    }
814
815    #[test]
816    fn test_ensure_status_one_of_failure_with_operation() {
817        let tx = make_test_transaction(TransactionStatus::Confirmed);
818
819        // Should fail with operation context when status not in list
820        let result = ensure_status_one_of(
821            &tx,
822            &[TransactionStatus::Pending, TransactionStatus::Sent],
823            Some("cancel_transaction"),
824        );
825        assert!(result.is_err());
826
827        if let Err(TransactionError::ValidationError(msg)) = result {
828            assert!(msg.contains("cancel_transaction"));
829            assert!(msg.contains("Confirmed"));
830            assert!(msg.contains("Pending"));
831            assert!(msg.contains("Sent"));
832        } else {
833            panic!("Expected ValidationError");
834        }
835    }
836
837    #[test]
838    fn test_ensure_status_one_of_failure_without_operation() {
839        let tx = make_test_transaction(TransactionStatus::Confirmed);
840
841        // Should fail without operation context
842        let result = ensure_status_one_of(
843            &tx,
844            &[TransactionStatus::Pending, TransactionStatus::Sent],
845            None,
846        );
847        assert!(result.is_err());
848
849        if let Err(TransactionError::ValidationError(msg)) = result {
850            assert!(!msg.contains("for"));
851            assert!(msg.contains("Confirmed"));
852        } else {
853            panic!("Expected ValidationError");
854        }
855    }
856
857    #[test]
858    fn test_ensure_status_one_of_single_status() {
859        let tx = make_test_transaction(TransactionStatus::Pending);
860
861        // Should work with a single status in the list
862        let result = ensure_status_one_of(&tx, &[TransactionStatus::Pending], Some("test"));
863        assert!(result.is_ok());
864
865        // Should fail when status doesn't match
866        let tx2 = make_test_transaction(TransactionStatus::Sent);
867        let result = ensure_status_one_of(&tx2, &[TransactionStatus::Pending], Some("test"));
868        assert!(result.is_err());
869    }
870
871    #[test]
872    fn test_ensure_status_one_of_all_states() {
873        let all_statuses = vec![
874            TransactionStatus::Pending,
875            TransactionStatus::Sent,
876            TransactionStatus::Submitted,
877            TransactionStatus::Mined,
878            TransactionStatus::Confirmed,
879            TransactionStatus::Failed,
880            TransactionStatus::Expired,
881            TransactionStatus::Canceled,
882        ];
883
884        // Should succeed for each status when it's in the list
885        for status in &all_statuses {
886            let tx = make_test_transaction(status.clone());
887            let result = ensure_status_one_of(&tx, &all_statuses, Some("test"));
888            assert!(result.is_ok());
889        }
890    }
891
892    #[test]
893    fn test_ensure_status_one_of_empty_list() {
894        let tx = make_test_transaction(TransactionStatus::Pending);
895
896        // Should always fail with empty list
897        let result = ensure_status_one_of(&tx, &[], Some("test"));
898        assert!(result.is_err());
899    }
900
901    #[test]
902    fn test_ensure_status_error_message_formatting() {
903        let tx = make_test_transaction(TransactionStatus::Confirmed);
904
905        // Test error message format for ensure_status
906        let result = ensure_status(&tx, TransactionStatus::Pending, Some("my_operation"));
907        if let Err(TransactionError::ValidationError(msg)) = result {
908            // Should have clear format: "Invalid transaction state for {operation}. Current: {current}, Expected: {expected}"
909            assert!(msg.starts_with("Invalid transaction state for my_operation"));
910            assert!(msg.contains("Current: Confirmed"));
911            assert!(msg.contains("Expected: Pending"));
912        } else {
913            panic!("Expected ValidationError");
914        }
915
916        // Test error message format for ensure_status_one_of
917        let result = ensure_status_one_of(
918            &tx,
919            &[TransactionStatus::Pending, TransactionStatus::Sent],
920            Some("another_operation"),
921        );
922        if let Err(TransactionError::ValidationError(msg)) = result {
923            // Should have clear format with list of expected states
924            assert!(msg.starts_with("Invalid transaction state for another_operation"));
925            assert!(msg.contains("Current: Confirmed"));
926            assert!(msg.contains("Expected one of:"));
927        } else {
928            panic!("Expected ValidationError");
929        }
930    }
931
932    #[test]
933    fn test_get_age_since_created() {
934        let now = Utc::now();
935
936        // Test with transaction created 2 hours ago
937        let created_time = now - Duration::hours(2);
938        let tx = TransactionRepoModel {
939            created_at: created_time.to_rfc3339(),
940            ..create_mock_transaction()
941        };
942
943        let age_result = get_age_since_created(&tx);
944        assert!(age_result.is_ok());
945        let age = age_result.unwrap();
946        // Age should be approximately 2 hours (with some tolerance)
947        assert!(age.num_minutes() >= 119 && age.num_minutes() <= 121);
948    }
949
950    #[test]
951    fn test_get_age_since_created_invalid_timestamp() {
952        let tx = TransactionRepoModel {
953            created_at: "invalid-timestamp".to_string(),
954            ..create_mock_transaction()
955        };
956
957        let result = get_age_since_created(&tx);
958        assert!(result.is_err());
959        match result.unwrap_err() {
960            TransactionError::UnexpectedError(msg) => {
961                assert!(msg.contains("Invalid created_at timestamp"));
962            }
963            _ => panic!("Expected UnexpectedError for invalid timestamp"),
964        }
965    }
966
967    #[test]
968    fn test_get_age_since_created_recent_transaction() {
969        let now = Utc::now();
970
971        // Test with transaction created just 1 minute ago
972        let created_time = now - Duration::minutes(1);
973        let tx = TransactionRepoModel {
974            created_at: created_time.to_rfc3339(),
975            ..create_mock_transaction()
976        };
977
978        let age_result = get_age_since_created(&tx);
979        assert!(age_result.is_ok());
980        let age = age_result.unwrap();
981        // Age should be approximately 1 minute
982        assert!(age.num_seconds() >= 59 && age.num_seconds() <= 61);
983    }
984
985    #[test]
986    fn test_get_age_since_status_change_with_sent_at() {
987        let now = Utc::now();
988
989        // Test with transaction that has sent_at (1 hour ago)
990        let sent_time = now - Duration::hours(1);
991        let created_time = now - Duration::hours(3); // Created 3 hours ago
992        let tx = TransactionRepoModel {
993            status: TransactionStatus::Sent,
994            created_at: created_time.to_rfc3339(),
995            sent_at: Some(sent_time.to_rfc3339()),
996            ..create_mock_transaction()
997        };
998
999        let age_result = get_age_since_status_change(&tx);
1000        assert!(age_result.is_ok());
1001        let age = age_result.unwrap();
1002        // Should use sent_at (1 hour), not created_at (3 hours)
1003        assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61);
1004    }
1005
1006    #[test]
1007    fn test_get_age_since_status_change_without_sent_at() {
1008        let now = Utc::now();
1009
1010        // Test with transaction that doesn't have sent_at
1011        let created_time = now - Duration::hours(2);
1012        let tx = TransactionRepoModel {
1013            created_at: created_time.to_rfc3339(),
1014            ..create_mock_transaction()
1015        };
1016
1017        let age_result = get_age_since_status_change(&tx);
1018        assert!(age_result.is_ok());
1019        let age = age_result.unwrap();
1020        // Should fall back to created_at (2 hours)
1021        assert!(age.num_minutes() >= 119 && age.num_minutes() <= 121);
1022    }
1023
1024    #[test]
1025    fn test_get_age_since_status_change_invalid_sent_at() {
1026        let now = Utc::now();
1027        let created_time = now - Duration::hours(2);
1028
1029        let tx = TransactionRepoModel {
1030            status: TransactionStatus::Sent,
1031            created_at: created_time.to_rfc3339(),
1032            sent_at: Some("invalid-timestamp".to_string()),
1033            ..create_mock_transaction()
1034        };
1035
1036        let result = get_age_since_status_change(&tx);
1037        assert!(result.is_err());
1038        match result.unwrap_err() {
1039            TransactionError::UnexpectedError(msg) => {
1040                assert!(msg.contains("Error parsing sent_at time"));
1041            }
1042            _ => panic!("Expected UnexpectedError for invalid sent_at timestamp"),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_is_too_early_to_resubmit_recent_transaction() {
1048        let now = Utc::now();
1049
1050        // Test with transaction created just 1 second ago (too early)
1051        let created_time = now - Duration::seconds(1);
1052        let tx = TransactionRepoModel {
1053            created_at: created_time.to_rfc3339(),
1054            ..create_mock_transaction()
1055        };
1056
1057        let result = is_too_early_to_resubmit(&tx);
1058        assert!(result.is_ok());
1059        assert!(result.unwrap()); // Should be true (too early)
1060    }
1061
1062    #[test]
1063    fn test_is_too_early_to_resubmit_old_transaction() {
1064        let now = Utc::now();
1065
1066        // Test with transaction created well past the minimum age
1067        let created_time = now - Duration::seconds(EVM_MIN_AGE_FOR_RESUBMIT_SECONDS + 10);
1068        let tx = TransactionRepoModel {
1069            created_at: created_time.to_rfc3339(),
1070            ..create_mock_transaction()
1071        };
1072
1073        let result = is_too_early_to_resubmit(&tx);
1074        assert!(result.is_ok());
1075        assert!(!result.unwrap()); // Should be false (old enough to resubmit)
1076    }
1077
1078    #[test]
1079    fn test_is_too_early_to_resubmit_boundary() {
1080        let now = Utc::now();
1081
1082        // Test with transaction created exactly at the boundary
1083        let created_time = now - Duration::seconds(EVM_MIN_AGE_FOR_RESUBMIT_SECONDS);
1084        let tx = TransactionRepoModel {
1085            created_at: created_time.to_rfc3339(),
1086            ..create_mock_transaction()
1087        };
1088
1089        let result = is_too_early_to_resubmit(&tx);
1090        assert!(result.is_ok());
1091        // At the exact boundary, should be false (not too early)
1092        assert!(!result.unwrap());
1093    }
1094
1095    #[test]
1096    fn test_is_too_early_to_resubmit_invalid_timestamp() {
1097        let tx = TransactionRepoModel {
1098            created_at: "invalid-timestamp".to_string(),
1099            ..create_mock_transaction()
1100        };
1101
1102        let result = is_too_early_to_resubmit(&tx);
1103        assert!(result.is_err());
1104        match result.unwrap_err() {
1105            TransactionError::UnexpectedError(msg) => {
1106                assert!(msg.contains("Invalid created_at timestamp"));
1107            }
1108            _ => panic!("Expected UnexpectedError for invalid timestamp"),
1109        }
1110    }
1111}