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