openzeppelin_relayer/domain/transaction/evm/
utils.rs

1use crate::constants::{
2    ARBITRUM_GAS_LIMIT, DEFAULT_GAS_LIMIT, DEFAULT_TX_VALID_TIMESPAN, MAXIMUM_NOOP_RETRY_ATTEMPTS,
3    MAXIMUM_TX_ATTEMPTS,
4};
5use crate::models::EvmNetwork;
6use crate::models::{
7    EvmTransactionData, TransactionError, TransactionRepoModel, TransactionStatus, U256,
8};
9use crate::services::EvmProviderTrait;
10use chrono::{DateTime, Duration, Utc};
11use eyre::Result;
12
13/// Updates an existing transaction to be a "noop" transaction (transaction to self with zero value and no data)
14/// This is commonly used for cancellation and replacement transactions
15/// For Arbitrum networks, uses eth_estimateGas to account for L1 + L2 costs
16pub async fn make_noop<P: EvmProviderTrait>(
17    evm_data: &mut EvmTransactionData,
18    network: &EvmNetwork,
19    provider: Option<&P>,
20) -> Result<(), TransactionError> {
21    // Update the transaction to be a noop
22    evm_data.value = U256::from(0u64);
23    evm_data.data = Some("0x".to_string());
24    evm_data.to = Some(evm_data.from.clone());
25
26    // Set gas limit based on network type
27    if network.is_arbitrum() {
28        // For Arbitrum networks, try to estimate gas to account for L1 + L2 costs
29        if let Some(provider) = provider {
30            match provider.estimate_gas(evm_data).await {
31                Ok(estimated_gas) => {
32                    // Use the estimated gas, but ensure it's at least the default minimum
33                    evm_data.gas_limit = Some(estimated_gas.max(DEFAULT_GAS_LIMIT));
34                }
35                Err(e) => {
36                    // If estimation fails, fall back to a conservative estimate
37                    tracing::warn!(
38                        "Failed to estimate gas for Arbitrum noop transaction: {:?}",
39                        e
40                    );
41                    evm_data.gas_limit = Some(ARBITRUM_GAS_LIMIT);
42                }
43            }
44        } else {
45            // No provider available, use conservative estimate
46            evm_data.gas_limit = Some(ARBITRUM_GAS_LIMIT);
47        }
48    } else {
49        // For other networks, use the standard gas limit
50        evm_data.gas_limit = Some(DEFAULT_GAS_LIMIT);
51    }
52
53    Ok(())
54}
55
56/// Checks if a transaction is already a NOOP transaction
57pub fn is_noop(evm_data: &EvmTransactionData) -> bool {
58    evm_data.value == U256::from(0u64)
59        && evm_data.data.as_ref().is_some_and(|data| data == "0x")
60        && evm_data.to.as_ref() == Some(&evm_data.from)
61        && evm_data.speed.is_some()
62}
63
64/// Checks if a transaction has too many attempts
65pub fn too_many_attempts(tx: &TransactionRepoModel) -> bool {
66    tx.hashes.len() > MAXIMUM_TX_ATTEMPTS
67}
68
69/// Checks if a transaction has too many NOOP attempts
70pub fn too_many_noop_attempts(tx: &TransactionRepoModel) -> bool {
71    tx.noop_count.unwrap_or(0) > MAXIMUM_NOOP_RETRY_ATTEMPTS
72}
73
74pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool {
75    tx_status == &TransactionStatus::Pending
76        || tx_status == &TransactionStatus::Sent
77        || tx_status == &TransactionStatus::Submitted
78}
79
80/// Helper function to check if a transaction has enough confirmations.
81pub fn has_enough_confirmations(
82    tx_block_number: u64,
83    current_block_number: u64,
84    required_confirmations: u64,
85) -> bool {
86    current_block_number >= tx_block_number + required_confirmations
87}
88
89/// Checks if a transaction is still valid based on its valid_until timestamp.
90pub fn is_transaction_valid(created_at: &str, valid_until: &Option<String>) -> bool {
91    if let Some(valid_until_str) = valid_until {
92        match DateTime::parse_from_rfc3339(valid_until_str) {
93            Ok(valid_until_time) => return Utc::now() < valid_until_time,
94            Err(e) => {
95                tracing::warn!(error = %e, "failed to parse valid_until timestamp");
96                return false;
97            }
98        }
99    }
100    match DateTime::parse_from_rfc3339(created_at) {
101        Ok(created_time) => {
102            let default_valid_until =
103                created_time + Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN);
104            Utc::now() < default_valid_until
105        }
106        Err(e) => {
107            tracing::warn!(error = %e, "failed to parse created_at timestamp");
108            false
109        }
110    }
111}
112
113/// Gets the age of a transaction since it was sent.
114pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
115    let now = Utc::now();
116    let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| {
117        TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string())
118    })?;
119    let sent_time = DateTime::parse_from_rfc3339(sent_at_str)
120        .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))?
121        .with_timezone(&Utc);
122    Ok(now.signed_duration_since(sent_time))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::constants::{ARBITRUM_BASED_TAG, ROLLUP_TAG};
129    use crate::models::{evm::Speed, NetworkTransactionData};
130    use crate::services::{MockEvmProviderTrait, ProviderError};
131
132    fn create_standard_network() -> EvmNetwork {
133        EvmNetwork {
134            network: "ethereum".to_string(),
135            rpc_urls: vec!["https://mainnet.infura.io".to_string()],
136            explorer_urls: None,
137            average_blocktime_ms: 12000,
138            is_testnet: false,
139            tags: vec!["mainnet".to_string()],
140            chain_id: 1,
141            required_confirmations: 12,
142            features: vec!["eip1559".to_string()],
143            symbol: "ETH".to_string(),
144            gas_price_cache: None,
145        }
146    }
147
148    fn create_arbitrum_network() -> EvmNetwork {
149        EvmNetwork {
150            network: "arbitrum".to_string(),
151            rpc_urls: vec!["https://arb1.arbitrum.io/rpc".to_string()],
152            explorer_urls: None,
153            average_blocktime_ms: 1000,
154            is_testnet: false,
155            tags: vec![ROLLUP_TAG.to_string(), ARBITRUM_BASED_TAG.to_string()],
156            chain_id: 42161,
157            required_confirmations: 1,
158            features: vec!["eip1559".to_string()],
159            symbol: "ETH".to_string(),
160            gas_price_cache: None,
161        }
162    }
163
164    fn create_arbitrum_nova_network() -> EvmNetwork {
165        EvmNetwork {
166            network: "arbitrum-nova".to_string(),
167            rpc_urls: vec!["https://nova.arbitrum.io/rpc".to_string()],
168            explorer_urls: None,
169            average_blocktime_ms: 1000,
170            is_testnet: false,
171            tags: vec![ROLLUP_TAG.to_string(), ARBITRUM_BASED_TAG.to_string()],
172            chain_id: 42170,
173            required_confirmations: 1,
174            features: vec!["eip1559".to_string()],
175            symbol: "ETH".to_string(),
176            gas_price_cache: None,
177        }
178    }
179
180    #[tokio::test]
181    async fn test_make_noop_standard_network() {
182        let mut evm_data = EvmTransactionData {
183            from: "0x1234567890123456789012345678901234567890".to_string(),
184            to: Some("0xoriginal_destination".to_string()),
185            value: U256::from(1000000000000000000u64), // 1 ETH
186            data: Some("0xoriginal_data".to_string()),
187            gas_limit: Some(50000),
188            gas_price: Some(10_000_000_000),
189            max_fee_per_gas: None,
190            max_priority_fee_per_gas: None,
191            nonce: Some(42),
192            signature: None,
193            hash: Some("0xoriginal_hash".to_string()),
194            speed: Some(Speed::Fast),
195            chain_id: 1,
196            raw: Some(vec![1, 2, 3]),
197        };
198
199        let network = create_standard_network();
200        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
201        assert!(result.is_ok());
202
203        // Verify the transaction was updated correctly
204        assert_eq!(evm_data.gas_limit, Some(21_000)); // Standard gas limit
205        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
206        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
207        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
208        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
209    }
210
211    #[tokio::test]
212    async fn test_make_noop_arbitrum_network() {
213        let mut evm_data = EvmTransactionData {
214            from: "0x1234567890123456789012345678901234567890".to_string(),
215            to: Some("0xoriginal_destination".to_string()),
216            value: U256::from(1000000000000000000u64), // 1 ETH
217            data: Some("0xoriginal_data".to_string()),
218            gas_limit: Some(50000),
219            gas_price: Some(10_000_000_000),
220            max_fee_per_gas: None,
221            max_priority_fee_per_gas: None,
222            nonce: Some(42),
223            signature: None,
224            hash: Some("0xoriginal_hash".to_string()),
225            speed: Some(Speed::Fast),
226            chain_id: 42161, // Arbitrum One
227            raw: Some(vec![1, 2, 3]),
228        };
229
230        let network = create_arbitrum_network();
231        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
232        assert!(result.is_ok());
233
234        // Verify the transaction was updated correctly for Arbitrum
235        assert_eq!(evm_data.gas_limit, Some(50_000)); // Higher gas limit for Arbitrum
236        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
237        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
238        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
239        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
240        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
241    }
242
243    #[tokio::test]
244    async fn test_make_noop_arbitrum_nova() {
245        let mut evm_data = EvmTransactionData {
246            from: "0x1234567890123456789012345678901234567890".to_string(),
247            to: Some("0xoriginal_destination".to_string()),
248            value: U256::from(1000000000000000000u64), // 1 ETH
249            data: Some("0xoriginal_data".to_string()),
250            gas_limit: Some(30000),
251            gas_price: Some(10_000_000_000),
252            max_fee_per_gas: None,
253            max_priority_fee_per_gas: None,
254            nonce: Some(42),
255            signature: None,
256            hash: Some("0xoriginal_hash".to_string()),
257            speed: Some(Speed::Fast),
258            chain_id: 42170, // Arbitrum Nova
259            raw: Some(vec![1, 2, 3]),
260        };
261
262        let network = create_arbitrum_nova_network();
263        let result = make_noop(&mut evm_data, &network, None::<&MockEvmProviderTrait>).await;
264        assert!(result.is_ok());
265
266        // Verify the transaction was updated correctly for Arbitrum Nova
267        assert_eq!(evm_data.gas_limit, Some(50_000)); // Higher gas limit for Arbitrum
268        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
269        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
270        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
271        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
272        assert_eq!(evm_data.chain_id, 42170); // Chain ID preserved
273    }
274
275    #[tokio::test]
276    async fn test_make_noop_arbitrum_with_provider() {
277        let mut mock_provider = MockEvmProviderTrait::new();
278
279        // Mock the gas estimation to return a higher value (simulating L1 + L2 costs)
280        mock_provider
281            .expect_estimate_gas()
282            .times(1)
283            .returning(|_| Box::pin(async move { Ok(35_000) }));
284
285        let mut evm_data = EvmTransactionData {
286            from: "0x1234567890123456789012345678901234567890".to_string(),
287            to: Some("0xoriginal_destination".to_string()),
288            value: U256::from(1000000000000000000u64), // 1 ETH
289            data: Some("0xoriginal_data".to_string()),
290            gas_limit: Some(30000),
291            gas_price: Some(10_000_000_000),
292            max_fee_per_gas: None,
293            max_priority_fee_per_gas: None,
294            nonce: Some(42),
295            signature: None,
296            hash: Some("0xoriginal_hash".to_string()),
297            speed: Some(Speed::Fast),
298            chain_id: 42161, // Arbitrum One
299            raw: Some(vec![1, 2, 3]),
300        };
301
302        let network = create_arbitrum_network();
303        let result = make_noop(&mut evm_data, &network, Some(&mock_provider)).await;
304        assert!(result.is_ok());
305
306        // Verify the transaction was updated correctly with estimated gas
307        assert_eq!(evm_data.gas_limit, Some(35_000)); // Should use estimated gas
308        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
309        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
310        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
311        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
312        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
313    }
314
315    #[tokio::test]
316    async fn test_make_noop_arbitrum_provider_estimation_fails() {
317        let mut mock_provider = MockEvmProviderTrait::new();
318
319        // Mock the gas estimation to fail
320        mock_provider.expect_estimate_gas().times(1).returning(|_| {
321            Box::pin(async move { Err(ProviderError::Other("Network error".to_string())) })
322        });
323
324        let mut evm_data = EvmTransactionData {
325            from: "0x1234567890123456789012345678901234567890".to_string(),
326            to: Some("0xoriginal_destination".to_string()),
327            value: U256::from(1000000000000000000u64), // 1 ETH
328            data: Some("0xoriginal_data".to_string()),
329            gas_limit: Some(30000),
330            gas_price: Some(10_000_000_000),
331            max_fee_per_gas: None,
332            max_priority_fee_per_gas: None,
333            nonce: Some(42),
334            signature: None,
335            hash: Some("0xoriginal_hash".to_string()),
336            speed: Some(Speed::Fast),
337            chain_id: 42161, // Arbitrum One
338            raw: Some(vec![1, 2, 3]),
339        };
340
341        let network = create_arbitrum_network();
342        let result = make_noop(&mut evm_data, &network, Some(&mock_provider)).await;
343        assert!(result.is_ok());
344
345        // Verify the transaction falls back to conservative estimate
346        assert_eq!(evm_data.gas_limit, Some(50_000)); // Should use fallback gas limit
347        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
348        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
349        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
350        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
351        assert_eq!(evm_data.chain_id, 42161); // Chain ID preserved
352    }
353
354    #[test]
355    fn test_is_noop() {
356        // Create a NOOP transaction
357        let noop_tx = EvmTransactionData {
358            from: "0x1234567890123456789012345678901234567890".to_string(),
359            to: Some("0x1234567890123456789012345678901234567890".to_string()), // Same as from
360            value: U256::from(0u64),
361            data: Some("0x".to_string()),
362            gas_limit: Some(21000),
363            gas_price: Some(10_000_000_000),
364            max_fee_per_gas: None,
365            max_priority_fee_per_gas: None,
366            nonce: Some(42),
367            signature: None,
368            hash: None,
369            speed: Some(Speed::Fast),
370            chain_id: 1,
371            raw: None,
372        };
373        assert!(is_noop(&noop_tx));
374
375        // Test non-NOOP transactions
376        let mut non_noop = noop_tx.clone();
377        non_noop.value = U256::from(1000000000000000000u64); // 1 ETH
378        assert!(!is_noop(&non_noop));
379
380        let mut non_noop = noop_tx.clone();
381        non_noop.data = Some("0x123456".to_string());
382        assert!(!is_noop(&non_noop));
383
384        let mut non_noop = noop_tx.clone();
385        non_noop.to = Some("0x9876543210987654321098765432109876543210".to_string());
386        assert!(!is_noop(&non_noop));
387
388        let mut non_noop = noop_tx;
389        non_noop.speed = None;
390        assert!(!is_noop(&non_noop));
391    }
392
393    #[test]
394    fn test_too_many_attempts() {
395        let mut tx = TransactionRepoModel {
396            id: "test-tx".to_string(),
397            relayer_id: "test-relayer".to_string(),
398            status: TransactionStatus::Pending,
399            status_reason: None,
400            created_at: "2024-01-01T00:00:00Z".to_string(),
401            sent_at: None,
402            confirmed_at: None,
403            valid_until: None,
404            network_type: crate::models::NetworkType::Evm,
405            network_data: NetworkTransactionData::Evm(EvmTransactionData {
406                from: "0x1234".to_string(),
407                to: Some("0x5678".to_string()),
408                value: U256::from(0u64),
409                data: Some("0x".to_string()),
410                gas_limit: Some(21000),
411                gas_price: Some(10_000_000_000),
412                max_fee_per_gas: None,
413                max_priority_fee_per_gas: None,
414                nonce: Some(42),
415                signature: None,
416                hash: None,
417                speed: Some(Speed::Fast),
418                chain_id: 1,
419                raw: None,
420            }),
421            priced_at: None,
422            hashes: vec![], // Start with no attempts
423            noop_count: None,
424            is_canceled: Some(false),
425            delete_at: None,
426        };
427
428        // Test with no attempts
429        assert!(!too_many_attempts(&tx));
430
431        // Test with maximum attempts
432        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS];
433        assert!(!too_many_attempts(&tx));
434
435        // Test with too many attempts
436        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS + 1];
437        assert!(too_many_attempts(&tx));
438    }
439
440    #[test]
441    fn test_too_many_noop_attempts() {
442        let mut tx = TransactionRepoModel {
443            id: "test-tx".to_string(),
444            relayer_id: "test-relayer".to_string(),
445            status: TransactionStatus::Pending,
446            status_reason: None,
447            created_at: "2024-01-01T00:00:00Z".to_string(),
448            sent_at: None,
449            confirmed_at: None,
450            valid_until: None,
451            network_type: crate::models::NetworkType::Evm,
452            network_data: NetworkTransactionData::Evm(EvmTransactionData {
453                from: "0x1234".to_string(),
454                to: Some("0x5678".to_string()),
455                value: U256::from(0u64),
456                data: Some("0x".to_string()),
457                gas_limit: Some(21000),
458                gas_price: Some(10_000_000_000),
459                max_fee_per_gas: None,
460                max_priority_fee_per_gas: None,
461                nonce: Some(42),
462                signature: None,
463                hash: None,
464                speed: Some(Speed::Fast),
465                chain_id: 1,
466                raw: None,
467            }),
468            priced_at: None,
469            hashes: vec![],
470            noop_count: None,
471            is_canceled: Some(false),
472            delete_at: None,
473        };
474
475        // Test with no NOOP attempts
476        assert!(!too_many_noop_attempts(&tx));
477
478        // Test with maximum NOOP attempts
479        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS);
480        assert!(!too_many_noop_attempts(&tx));
481
482        // Test with too many NOOP attempts
483        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS + 1);
484        assert!(too_many_noop_attempts(&tx));
485    }
486
487    #[test]
488    fn test_has_enough_confirmations() {
489        // Not enough confirmations
490        let tx_block_number = 100;
491        let current_block_number = 110; // Only 10 confirmations
492        let required_confirmations = 12;
493        assert!(!has_enough_confirmations(
494            tx_block_number,
495            current_block_number,
496            required_confirmations
497        ));
498
499        // Exactly enough confirmations
500        let current_block_number = 112; // Exactly 12 confirmations
501        assert!(has_enough_confirmations(
502            tx_block_number,
503            current_block_number,
504            required_confirmations
505        ));
506
507        // More than enough confirmations
508        let current_block_number = 120; // 20 confirmations
509        assert!(has_enough_confirmations(
510            tx_block_number,
511            current_block_number,
512            required_confirmations
513        ));
514    }
515
516    #[test]
517    fn test_is_transaction_valid_with_future_timestamp() {
518        let now = Utc::now();
519        let valid_until = Some((now + Duration::hours(1)).to_rfc3339());
520        let created_at = now.to_rfc3339();
521
522        assert!(is_transaction_valid(&created_at, &valid_until));
523    }
524
525    #[test]
526    fn test_is_transaction_valid_with_past_timestamp() {
527        let now = Utc::now();
528        let valid_until = Some((now - Duration::hours(1)).to_rfc3339());
529        let created_at = now.to_rfc3339();
530
531        assert!(!is_transaction_valid(&created_at, &valid_until));
532    }
533
534    #[test]
535    fn test_is_transaction_valid_with_valid_until() {
536        // Test with valid_until in the future
537        let created_at = Utc::now().to_rfc3339();
538        let valid_until = Some((Utc::now() + Duration::hours(1)).to_rfc3339());
539        assert!(is_transaction_valid(&created_at, &valid_until));
540
541        // Test with valid_until in the past
542        let valid_until = Some((Utc::now() - Duration::hours(1)).to_rfc3339());
543        assert!(!is_transaction_valid(&created_at, &valid_until));
544
545        // Test with valid_until exactly at current time (should be invalid)
546        let valid_until = Some(Utc::now().to_rfc3339());
547        assert!(!is_transaction_valid(&created_at, &valid_until));
548
549        // Test with valid_until very far in the future
550        let valid_until = Some((Utc::now() + Duration::days(365)).to_rfc3339());
551        assert!(is_transaction_valid(&created_at, &valid_until));
552
553        // Test with invalid valid_until format
554        let valid_until = Some("invalid-date-format".to_string());
555        assert!(!is_transaction_valid(&created_at, &valid_until));
556
557        // Test with empty valid_until string
558        let valid_until = Some("".to_string());
559        assert!(!is_transaction_valid(&created_at, &valid_until));
560    }
561
562    #[test]
563    fn test_is_transaction_valid_without_valid_until() {
564        // Test with created_at within the default timespan
565        let created_at = Utc::now().to_rfc3339();
566        let valid_until = None;
567        assert!(is_transaction_valid(&created_at, &valid_until));
568
569        // Test with created_at older than the default timespan (8 hours)
570        let old_created_at =
571            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN + 1000)).to_rfc3339();
572        assert!(!is_transaction_valid(&old_created_at, &valid_until));
573
574        // Test with created_at exactly at the boundary
575        let boundary_created_at =
576            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN)).to_rfc3339();
577        assert!(!is_transaction_valid(&boundary_created_at, &valid_until));
578
579        // Test with created_at just within the default timespan
580        let within_boundary_created_at =
581            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN - 1000)).to_rfc3339();
582        assert!(is_transaction_valid(
583            &within_boundary_created_at,
584            &valid_until
585        ));
586
587        // Test with invalid created_at format
588        let invalid_created_at = "invalid-date-format";
589        assert!(!is_transaction_valid(invalid_created_at, &valid_until));
590
591        // Test with empty created_at string
592        assert!(!is_transaction_valid("", &valid_until));
593    }
594
595    #[test]
596    fn test_is_pending_transaction() {
597        // Test pending status
598        assert!(is_pending_transaction(&TransactionStatus::Pending));
599
600        // Test sent status
601        assert!(is_pending_transaction(&TransactionStatus::Sent));
602
603        // Test submitted status
604        assert!(is_pending_transaction(&TransactionStatus::Submitted));
605
606        // Test non-pending statuses
607        assert!(!is_pending_transaction(&TransactionStatus::Confirmed));
608        assert!(!is_pending_transaction(&TransactionStatus::Failed));
609        assert!(!is_pending_transaction(&TransactionStatus::Canceled));
610        assert!(!is_pending_transaction(&TransactionStatus::Mined));
611        assert!(!is_pending_transaction(&TransactionStatus::Expired));
612    }
613
614    #[test]
615    fn test_get_age_of_sent_at() {
616        let now = Utc::now();
617
618        // Test with valid sent_at timestamp (1 hour ago)
619        let sent_at_time = now - Duration::hours(1);
620        let tx = TransactionRepoModel {
621            id: "test-tx".to_string(),
622            relayer_id: "test-relayer".to_string(),
623            status: TransactionStatus::Sent,
624            status_reason: None,
625            created_at: "2024-01-01T00:00:00Z".to_string(),
626            sent_at: Some(sent_at_time.to_rfc3339()),
627            confirmed_at: None,
628            valid_until: None,
629            network_type: crate::models::NetworkType::Evm,
630            network_data: NetworkTransactionData::Evm(EvmTransactionData {
631                from: "0x1234".to_string(),
632                to: Some("0x5678".to_string()),
633                value: U256::from(0u64),
634                data: Some("0x".to_string()),
635                gas_limit: Some(21000),
636                gas_price: Some(10_000_000_000),
637                max_fee_per_gas: None,
638                max_priority_fee_per_gas: None,
639                nonce: Some(42),
640                signature: None,
641                hash: None,
642                speed: Some(Speed::Fast),
643                chain_id: 1,
644                raw: None,
645            }),
646            priced_at: None,
647            hashes: vec![],
648            noop_count: None,
649            is_canceled: Some(false),
650            delete_at: None,
651        };
652
653        let age_result = get_age_of_sent_at(&tx);
654        assert!(age_result.is_ok());
655        let age = age_result.unwrap();
656        // Age should be approximately 1 hour (with some tolerance for test execution time)
657        assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61);
658    }
659
660    #[test]
661    fn test_get_age_of_sent_at_missing_sent_at() {
662        let tx = TransactionRepoModel {
663            id: "test-tx".to_string(),
664            relayer_id: "test-relayer".to_string(),
665            status: TransactionStatus::Pending,
666            status_reason: None,
667            created_at: "2024-01-01T00:00:00Z".to_string(),
668            sent_at: None, // Missing sent_at
669            confirmed_at: None,
670            valid_until: None,
671            network_type: crate::models::NetworkType::Evm,
672            network_data: NetworkTransactionData::Evm(EvmTransactionData {
673                from: "0x1234".to_string(),
674                to: Some("0x5678".to_string()),
675                value: U256::from(0u64),
676                data: Some("0x".to_string()),
677                gas_limit: Some(21000),
678                gas_price: Some(10_000_000_000),
679                max_fee_per_gas: None,
680                max_priority_fee_per_gas: None,
681                nonce: Some(42),
682                signature: None,
683                hash: None,
684                speed: Some(Speed::Fast),
685                chain_id: 1,
686                raw: None,
687            }),
688            priced_at: None,
689            hashes: vec![],
690            noop_count: None,
691            is_canceled: Some(false),
692            delete_at: None,
693        };
694
695        let result = get_age_of_sent_at(&tx);
696        assert!(result.is_err());
697        match result.unwrap_err() {
698            TransactionError::UnexpectedError(msg) => {
699                assert!(msg.contains("sent_at time is missing"));
700            }
701            _ => panic!("Expected UnexpectedError for missing sent_at"),
702        }
703    }
704
705    #[test]
706    fn test_get_age_of_sent_at_invalid_timestamp() {
707        let tx = TransactionRepoModel {
708            id: "test-tx".to_string(),
709            relayer_id: "test-relayer".to_string(),
710            status: TransactionStatus::Sent,
711            status_reason: None,
712            created_at: "2024-01-01T00:00:00Z".to_string(),
713            sent_at: Some("invalid-timestamp".to_string()), // Invalid timestamp format
714            confirmed_at: None,
715            valid_until: None,
716            network_type: crate::models::NetworkType::Evm,
717            network_data: NetworkTransactionData::Evm(EvmTransactionData {
718                from: "0x1234".to_string(),
719                to: Some("0x5678".to_string()),
720                value: U256::from(0u64),
721                data: Some("0x".to_string()),
722                gas_limit: Some(21000),
723                gas_price: Some(10_000_000_000),
724                max_fee_per_gas: None,
725                max_priority_fee_per_gas: None,
726                nonce: Some(42),
727                signature: None,
728                hash: None,
729                speed: Some(Speed::Fast),
730                chain_id: 1,
731                raw: None,
732            }),
733            priced_at: None,
734            hashes: vec![],
735            noop_count: None,
736            is_canceled: Some(false),
737            delete_at: None,
738        };
739
740        let result = get_age_of_sent_at(&tx);
741        assert!(result.is_err());
742        match result.unwrap_err() {
743            TransactionError::UnexpectedError(msg) => {
744                assert!(msg.contains("Error parsing sent_at time"));
745            }
746            _ => panic!("Expected UnexpectedError for invalid timestamp"),
747        }
748    }
749}