openzeppelin_relayer/domain/transaction/evm/
utils.rs

1use crate::constants::{
2    DEFAULT_TX_VALID_TIMESPAN, MAXIMUM_NOOP_RETRY_ATTEMPTS, MAXIMUM_TX_ATTEMPTS,
3};
4use crate::models::{
5    EvmTransactionData, TransactionError, TransactionRepoModel, TransactionStatus, U256,
6};
7use chrono::{DateTime, Duration, Utc};
8use eyre::Result;
9
10/// Updates an existing transaction to be a "noop" transaction (transaction to self with zero value and no data)
11/// This is commonly used for cancellation and replacement transactions
12pub async fn make_noop(evm_data: &mut EvmTransactionData) -> Result<(), TransactionError> {
13    // Update the transaction to be a noop
14    evm_data.gas_limit = 21_000;
15    evm_data.value = U256::from(0u64);
16    evm_data.data = Some("0x".to_string());
17    evm_data.to = Some(evm_data.from.clone());
18
19    Ok(())
20}
21
22/// Checks if a transaction is already a NOOP transaction
23pub fn is_noop(evm_data: &EvmTransactionData) -> bool {
24    evm_data.value == U256::from(0u64)
25        && evm_data.data.as_ref().is_some_and(|data| data == "0x")
26        && evm_data.to.as_ref() == Some(&evm_data.from)
27        && evm_data.speed.is_some()
28}
29
30/// Checks if a transaction has too many attempts
31pub fn too_many_attempts(tx: &TransactionRepoModel) -> bool {
32    tx.hashes.len() > MAXIMUM_TX_ATTEMPTS
33}
34
35/// Checks if a transaction has too many NOOP attempts
36pub fn too_many_noop_attempts(tx: &TransactionRepoModel) -> bool {
37    tx.noop_count.unwrap_or(0) > MAXIMUM_NOOP_RETRY_ATTEMPTS
38}
39
40pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool {
41    tx_status == &TransactionStatus::Pending
42        || tx_status == &TransactionStatus::Sent
43        || tx_status == &TransactionStatus::Submitted
44}
45
46/// Helper function to check if a transaction has enough confirmations.
47pub fn has_enough_confirmations(
48    tx_block_number: u64,
49    current_block_number: u64,
50    required_confirmations: u64,
51) -> bool {
52    current_block_number >= tx_block_number + required_confirmations
53}
54
55/// Checks if a transaction is still valid based on its valid_until timestamp.
56pub fn is_transaction_valid(created_at: &str, valid_until: &Option<String>) -> bool {
57    if let Some(valid_until_str) = valid_until {
58        match DateTime::parse_from_rfc3339(valid_until_str) {
59            Ok(valid_until_time) => return Utc::now() < valid_until_time,
60            Err(e) => {
61                log::warn!("Failed to parse valid_until timestamp: {}", e);
62                return false;
63            }
64        }
65    }
66    match DateTime::parse_from_rfc3339(created_at) {
67        Ok(created_time) => {
68            let default_valid_until =
69                created_time + Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN);
70            Utc::now() < default_valid_until
71        }
72        Err(e) => {
73            log::warn!("Failed to parse created_at timestamp: {}", e);
74            false
75        }
76    }
77}
78
79/// Gets the age of a transaction since it was sent.
80pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
81    let now = Utc::now();
82    let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| {
83        TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string())
84    })?;
85    let sent_time = DateTime::parse_from_rfc3339(sent_at_str)
86        .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))?
87        .with_timezone(&Utc);
88    Ok(now.signed_duration_since(sent_time))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::models::{evm::Speed, NetworkTransactionData};
95
96    #[tokio::test]
97    async fn test_make_noop_standard_network() {
98        let mut evm_data = EvmTransactionData {
99            from: "0x1234567890123456789012345678901234567890".to_string(),
100            to: Some("0xoriginal_destination".to_string()),
101            value: U256::from(1000000000000000000u64), // 1 ETH
102            data: Some("0xoriginal_data".to_string()),
103            gas_limit: 50000,
104            gas_price: Some(10_000_000_000),
105            max_fee_per_gas: None,
106            max_priority_fee_per_gas: None,
107            nonce: Some(42),
108            signature: None,
109            hash: Some("0xoriginal_hash".to_string()),
110            speed: Some(Speed::Fast),
111            chain_id: 1,
112            raw: Some(vec![1, 2, 3]),
113        };
114
115        let result = make_noop(&mut evm_data).await;
116        assert!(result.is_ok());
117
118        // Verify the transaction was updated correctly
119        assert_eq!(evm_data.gas_limit, 21_000); // Standard gas limit
120        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
121        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
122        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
123        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
124    }
125
126    #[test]
127    fn test_is_noop() {
128        // Create a NOOP transaction
129        let noop_tx = EvmTransactionData {
130            from: "0x1234567890123456789012345678901234567890".to_string(),
131            to: Some("0x1234567890123456789012345678901234567890".to_string()), // Same as from
132            value: U256::from(0u64),
133            data: Some("0x".to_string()),
134            gas_limit: 21000,
135            gas_price: Some(10_000_000_000),
136            max_fee_per_gas: None,
137            max_priority_fee_per_gas: None,
138            nonce: Some(42),
139            signature: None,
140            hash: None,
141            speed: Some(Speed::Fast),
142            chain_id: 1,
143            raw: None,
144        };
145        assert!(is_noop(&noop_tx));
146
147        // Test non-NOOP transactions
148        let mut non_noop = noop_tx.clone();
149        non_noop.value = U256::from(1000000000000000000u64); // 1 ETH
150        assert!(!is_noop(&non_noop));
151
152        let mut non_noop = noop_tx.clone();
153        non_noop.data = Some("0x123456".to_string());
154        assert!(!is_noop(&non_noop));
155
156        let mut non_noop = noop_tx.clone();
157        non_noop.to = Some("0x9876543210987654321098765432109876543210".to_string());
158        assert!(!is_noop(&non_noop));
159
160        let mut non_noop = noop_tx;
161        non_noop.speed = None;
162        assert!(!is_noop(&non_noop));
163    }
164
165    #[test]
166    fn test_too_many_attempts() {
167        let mut tx = TransactionRepoModel {
168            id: "test-tx".to_string(),
169            relayer_id: "test-relayer".to_string(),
170            status: TransactionStatus::Pending,
171            status_reason: None,
172            created_at: "2024-01-01T00:00:00Z".to_string(),
173            sent_at: None,
174            confirmed_at: None,
175            valid_until: None,
176            network_type: crate::models::NetworkType::Evm,
177            network_data: NetworkTransactionData::Evm(EvmTransactionData {
178                from: "0x1234".to_string(),
179                to: Some("0x5678".to_string()),
180                value: U256::from(0u64),
181                data: Some("0x".to_string()),
182                gas_limit: 21000,
183                gas_price: Some(10_000_000_000),
184                max_fee_per_gas: None,
185                max_priority_fee_per_gas: None,
186                nonce: Some(42),
187                signature: None,
188                hash: None,
189                speed: Some(Speed::Fast),
190                chain_id: 1,
191                raw: None,
192            }),
193            priced_at: None,
194            hashes: vec![], // Start with no attempts
195            noop_count: None,
196            is_canceled: Some(false),
197        };
198
199        // Test with no attempts
200        assert!(!too_many_attempts(&tx));
201
202        // Test with maximum attempts
203        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS];
204        assert!(!too_many_attempts(&tx));
205
206        // Test with too many attempts
207        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS + 1];
208        assert!(too_many_attempts(&tx));
209    }
210
211    #[test]
212    fn test_too_many_noop_attempts() {
213        let mut tx = TransactionRepoModel {
214            id: "test-tx".to_string(),
215            relayer_id: "test-relayer".to_string(),
216            status: TransactionStatus::Pending,
217            status_reason: None,
218            created_at: "2024-01-01T00:00:00Z".to_string(),
219            sent_at: None,
220            confirmed_at: None,
221            valid_until: None,
222            network_type: crate::models::NetworkType::Evm,
223            network_data: NetworkTransactionData::Evm(EvmTransactionData {
224                from: "0x1234".to_string(),
225                to: Some("0x5678".to_string()),
226                value: U256::from(0u64),
227                data: Some("0x".to_string()),
228                gas_limit: 21000,
229                gas_price: Some(10_000_000_000),
230                max_fee_per_gas: None,
231                max_priority_fee_per_gas: None,
232                nonce: Some(42),
233                signature: None,
234                hash: None,
235                speed: Some(Speed::Fast),
236                chain_id: 1,
237                raw: None,
238            }),
239            priced_at: None,
240            hashes: vec![],
241            noop_count: None,
242            is_canceled: Some(false),
243        };
244
245        // Test with no NOOP attempts
246        assert!(!too_many_noop_attempts(&tx));
247
248        // Test with maximum NOOP attempts
249        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS);
250        assert!(!too_many_noop_attempts(&tx));
251
252        // Test with too many NOOP attempts
253        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS + 1);
254        assert!(too_many_noop_attempts(&tx));
255    }
256
257    #[test]
258    fn test_has_enough_confirmations() {
259        // Not enough confirmations
260        let tx_block_number = 100;
261        let current_block_number = 110; // Only 10 confirmations
262        let required_confirmations = 12;
263        assert!(!has_enough_confirmations(
264            tx_block_number,
265            current_block_number,
266            required_confirmations
267        ));
268
269        // Exactly enough confirmations
270        let current_block_number = 112; // Exactly 12 confirmations
271        assert!(has_enough_confirmations(
272            tx_block_number,
273            current_block_number,
274            required_confirmations
275        ));
276
277        // More than enough confirmations
278        let current_block_number = 120; // 20 confirmations
279        assert!(has_enough_confirmations(
280            tx_block_number,
281            current_block_number,
282            required_confirmations
283        ));
284    }
285
286    #[test]
287    fn test_is_transaction_valid_with_future_timestamp() {
288        let now = Utc::now();
289        let valid_until = Some((now + Duration::hours(1)).to_rfc3339());
290        let created_at = now.to_rfc3339();
291
292        assert!(is_transaction_valid(&created_at, &valid_until));
293    }
294
295    #[test]
296    fn test_is_transaction_valid_with_past_timestamp() {
297        let now = Utc::now();
298        let valid_until = Some((now - Duration::hours(1)).to_rfc3339());
299        let created_at = now.to_rfc3339();
300
301        assert!(!is_transaction_valid(&created_at, &valid_until));
302    }
303
304    #[test]
305    fn test_is_transaction_valid_with_valid_until() {
306        // Test with valid_until in the future
307        let created_at = Utc::now().to_rfc3339();
308        let valid_until = Some((Utc::now() + Duration::hours(1)).to_rfc3339());
309        assert!(is_transaction_valid(&created_at, &valid_until));
310
311        // Test with valid_until in the past
312        let valid_until = Some((Utc::now() - Duration::hours(1)).to_rfc3339());
313        assert!(!is_transaction_valid(&created_at, &valid_until));
314
315        // Test with valid_until exactly at current time (should be invalid)
316        let valid_until = Some(Utc::now().to_rfc3339());
317        assert!(!is_transaction_valid(&created_at, &valid_until));
318
319        // Test with valid_until very far in the future
320        let valid_until = Some((Utc::now() + Duration::days(365)).to_rfc3339());
321        assert!(is_transaction_valid(&created_at, &valid_until));
322
323        // Test with invalid valid_until format
324        let valid_until = Some("invalid-date-format".to_string());
325        assert!(!is_transaction_valid(&created_at, &valid_until));
326
327        // Test with empty valid_until string
328        let valid_until = Some("".to_string());
329        assert!(!is_transaction_valid(&created_at, &valid_until));
330    }
331
332    #[test]
333    fn test_is_transaction_valid_without_valid_until() {
334        // Test with created_at within the default timespan
335        let created_at = Utc::now().to_rfc3339();
336        let valid_until = None;
337        assert!(is_transaction_valid(&created_at, &valid_until));
338
339        // Test with created_at older than the default timespan (8 hours)
340        let old_created_at =
341            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN + 1000)).to_rfc3339();
342        assert!(!is_transaction_valid(&old_created_at, &valid_until));
343
344        // Test with created_at exactly at the boundary
345        let boundary_created_at =
346            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN)).to_rfc3339();
347        assert!(!is_transaction_valid(&boundary_created_at, &valid_until));
348
349        // Test with created_at just within the default timespan
350        let within_boundary_created_at =
351            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN - 1000)).to_rfc3339();
352        assert!(is_transaction_valid(
353            &within_boundary_created_at,
354            &valid_until
355        ));
356
357        // Test with invalid created_at format
358        let invalid_created_at = "invalid-date-format";
359        assert!(!is_transaction_valid(invalid_created_at, &valid_until));
360
361        // Test with empty created_at string
362        assert!(!is_transaction_valid("", &valid_until));
363    }
364
365    #[test]
366    fn test_is_pending_transaction() {
367        // Test pending status
368        assert!(is_pending_transaction(&TransactionStatus::Pending));
369
370        // Test sent status
371        assert!(is_pending_transaction(&TransactionStatus::Sent));
372
373        // Test submitted status
374        assert!(is_pending_transaction(&TransactionStatus::Submitted));
375
376        // Test non-pending statuses
377        assert!(!is_pending_transaction(&TransactionStatus::Confirmed));
378        assert!(!is_pending_transaction(&TransactionStatus::Failed));
379        assert!(!is_pending_transaction(&TransactionStatus::Canceled));
380        assert!(!is_pending_transaction(&TransactionStatus::Mined));
381        assert!(!is_pending_transaction(&TransactionStatus::Expired));
382    }
383
384    #[test]
385    fn test_get_age_of_sent_at() {
386        let now = Utc::now();
387
388        // Test with valid sent_at timestamp (1 hour ago)
389        let sent_at_time = now - Duration::hours(1);
390        let tx = TransactionRepoModel {
391            id: "test-tx".to_string(),
392            relayer_id: "test-relayer".to_string(),
393            status: TransactionStatus::Sent,
394            status_reason: None,
395            created_at: "2024-01-01T00:00:00Z".to_string(),
396            sent_at: Some(sent_at_time.to_rfc3339()),
397            confirmed_at: None,
398            valid_until: None,
399            network_type: crate::models::NetworkType::Evm,
400            network_data: NetworkTransactionData::Evm(EvmTransactionData {
401                from: "0x1234".to_string(),
402                to: Some("0x5678".to_string()),
403                value: U256::from(0u64),
404                data: Some("0x".to_string()),
405                gas_limit: 21000,
406                gas_price: Some(10_000_000_000),
407                max_fee_per_gas: None,
408                max_priority_fee_per_gas: None,
409                nonce: Some(42),
410                signature: None,
411                hash: None,
412                speed: Some(Speed::Fast),
413                chain_id: 1,
414                raw: None,
415            }),
416            priced_at: None,
417            hashes: vec![],
418            noop_count: None,
419            is_canceled: Some(false),
420        };
421
422        let age_result = get_age_of_sent_at(&tx);
423        assert!(age_result.is_ok());
424        let age = age_result.unwrap();
425        // Age should be approximately 1 hour (with some tolerance for test execution time)
426        assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61);
427    }
428
429    #[test]
430    fn test_get_age_of_sent_at_missing_sent_at() {
431        let tx = TransactionRepoModel {
432            id: "test-tx".to_string(),
433            relayer_id: "test-relayer".to_string(),
434            status: TransactionStatus::Pending,
435            status_reason: None,
436            created_at: "2024-01-01T00:00:00Z".to_string(),
437            sent_at: None, // Missing sent_at
438            confirmed_at: None,
439            valid_until: None,
440            network_type: crate::models::NetworkType::Evm,
441            network_data: NetworkTransactionData::Evm(EvmTransactionData {
442                from: "0x1234".to_string(),
443                to: Some("0x5678".to_string()),
444                value: U256::from(0u64),
445                data: Some("0x".to_string()),
446                gas_limit: 21000,
447                gas_price: Some(10_000_000_000),
448                max_fee_per_gas: None,
449                max_priority_fee_per_gas: None,
450                nonce: Some(42),
451                signature: None,
452                hash: None,
453                speed: Some(Speed::Fast),
454                chain_id: 1,
455                raw: None,
456            }),
457            priced_at: None,
458            hashes: vec![],
459            noop_count: None,
460            is_canceled: Some(false),
461        };
462
463        let result = get_age_of_sent_at(&tx);
464        assert!(result.is_err());
465        match result.unwrap_err() {
466            TransactionError::UnexpectedError(msg) => {
467                assert!(msg.contains("sent_at time is missing"));
468            }
469            _ => panic!("Expected UnexpectedError for missing sent_at"),
470        }
471    }
472
473    #[test]
474    fn test_get_age_of_sent_at_invalid_timestamp() {
475        let tx = TransactionRepoModel {
476            id: "test-tx".to_string(),
477            relayer_id: "test-relayer".to_string(),
478            status: TransactionStatus::Sent,
479            status_reason: None,
480            created_at: "2024-01-01T00:00:00Z".to_string(),
481            sent_at: Some("invalid-timestamp".to_string()), // Invalid timestamp format
482            confirmed_at: None,
483            valid_until: None,
484            network_type: crate::models::NetworkType::Evm,
485            network_data: NetworkTransactionData::Evm(EvmTransactionData {
486                from: "0x1234".to_string(),
487                to: Some("0x5678".to_string()),
488                value: U256::from(0u64),
489                data: Some("0x".to_string()),
490                gas_limit: 21000,
491                gas_price: Some(10_000_000_000),
492                max_fee_per_gas: None,
493                max_priority_fee_per_gas: None,
494                nonce: Some(42),
495                signature: None,
496                hash: None,
497                speed: Some(Speed::Fast),
498                chain_id: 1,
499                raw: None,
500            }),
501            priced_at: None,
502            hashes: vec![],
503            noop_count: None,
504            is_canceled: Some(false),
505        };
506
507        let result = get_age_of_sent_at(&tx);
508        assert!(result.is_err());
509        match result.unwrap_err() {
510            TransactionError::UnexpectedError(msg) => {
511                assert!(msg.contains("Error parsing sent_at time"));
512            }
513            _ => panic!("Expected UnexpectedError for invalid timestamp"),
514        }
515    }
516}