openzeppelin_relayer/domain/transaction/evm/
utils.rs

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