openzeppelin_relayer/domain/transaction/evm/
utils.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
use crate::constants::{
    DEFAULT_TX_VALID_TIMESPAN, MAXIMUM_NOOP_RETRY_ATTEMPTS, MAXIMUM_TX_ATTEMPTS,
};
use crate::models::{
    EvmNetwork, EvmTransactionData, TransactionError, TransactionRepoModel, TransactionStatus, U256,
};
use chrono::{DateTime, Duration, Utc};
use eyre::Result;

/// Updates an existing transaction to be a "noop" transaction (transaction to self with zero value and no data)
/// This is commonly used for cancellation and replacement transactions
pub async fn make_noop(evm_data: &mut EvmTransactionData) -> Result<(), TransactionError> {
    // Update the transaction to be a noop
    evm_data.gas_limit = 21_000;
    evm_data.value = U256::from(0u64);
    evm_data.data = Some("0x".to_string());
    evm_data.to = Some(evm_data.from.clone());

    Ok(())
}

/// Checks if a transaction is already a NOOP transaction
pub fn is_noop(evm_data: &EvmTransactionData) -> bool {
    evm_data.value == U256::from(0u64)
        && evm_data.data.as_ref().is_some_and(|data| data == "0x")
        && evm_data.to.as_ref() == Some(&evm_data.from)
        && evm_data.speed.is_some()
}

/// Checks if a transaction has too many attempts
pub fn too_many_attempts(tx: &TransactionRepoModel) -> bool {
    tx.hashes.len() > MAXIMUM_TX_ATTEMPTS
}

/// Checks if a transaction has too many NOOP attempts
pub fn too_many_noop_attempts(tx: &TransactionRepoModel) -> bool {
    tx.noop_count.unwrap_or(0) > MAXIMUM_NOOP_RETRY_ATTEMPTS
}

pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool {
    tx_status == &TransactionStatus::Pending
        || tx_status == &TransactionStatus::Sent
        || tx_status == &TransactionStatus::Submitted
}

/// Helper function to check if a transaction has enough confirmations.
pub fn has_enough_confirmations(
    tx_block_number: u64,
    current_block_number: u64,
    chain_id: u64,
) -> bool {
    let network = EvmNetwork::from_id(chain_id);
    let required_confirmations = network.required_confirmations();
    current_block_number >= tx_block_number + required_confirmations
}

/// Checks if a transaction is still valid based on its valid_until timestamp.
pub fn is_transaction_valid(created_at: &str, valid_until: &Option<String>) -> bool {
    if let Some(valid_until_str) = valid_until {
        match DateTime::parse_from_rfc3339(valid_until_str) {
            Ok(valid_until_time) => return Utc::now() < valid_until_time,
            Err(e) => {
                log::warn!("Failed to parse valid_until timestamp: {}", e);
                return false;
            }
        }
    }
    match DateTime::parse_from_rfc3339(created_at) {
        Ok(created_time) => {
            let default_valid_until =
                created_time + Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN);
            Utc::now() < default_valid_until
        }
        Err(e) => {
            log::warn!("Failed to parse created_at timestamp: {}", e);
            false
        }
    }
}

/// Gets the age of a transaction since it was sent.
pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
    let now = Utc::now();
    let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| {
        TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string())
    })?;
    let sent_time = DateTime::parse_from_rfc3339(sent_at_str)
        .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))?
        .with_timezone(&Utc);
    Ok(now.signed_duration_since(sent_time))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{evm::Speed, NetworkTransactionData};

    #[tokio::test]
    async fn test_make_noop_standard_network() {
        let mut evm_data = EvmTransactionData {
            from: "0x1234567890123456789012345678901234567890".to_string(),
            to: Some("0xoriginal_destination".to_string()),
            value: U256::from(1000000000000000000u64), // 1 ETH
            data: Some("0xoriginal_data".to_string()),
            gas_limit: 50000,
            gas_price: Some(10_000_000_000),
            max_fee_per_gas: None,
            max_priority_fee_per_gas: None,
            nonce: Some(42),
            signature: None,
            hash: Some("0xoriginal_hash".to_string()),
            speed: Some(Speed::Fast),
            chain_id: 1,
            raw: Some(vec![1, 2, 3]),
        };

        let result = make_noop(&mut evm_data).await;
        assert!(result.is_ok());

        // Verify the transaction was updated correctly
        assert_eq!(evm_data.gas_limit, 21_000); // Standard gas limit
        assert_eq!(evm_data.to.unwrap(), evm_data.from); // Should send to self
        assert_eq!(evm_data.value, U256::from(0u64)); // Zero value
        assert_eq!(evm_data.data.unwrap(), "0x"); // Empty data
        assert_eq!(evm_data.nonce, Some(42)); // Original nonce preserved
    }

    #[test]
    fn test_is_noop() {
        // Create a NOOP transaction
        let noop_tx = EvmTransactionData {
            from: "0x1234567890123456789012345678901234567890".to_string(),
            to: Some("0x1234567890123456789012345678901234567890".to_string()), // Same as from
            value: U256::from(0u64),
            data: Some("0x".to_string()),
            gas_limit: 21000,
            gas_price: Some(10_000_000_000),
            max_fee_per_gas: None,
            max_priority_fee_per_gas: None,
            nonce: Some(42),
            signature: None,
            hash: None,
            speed: Some(Speed::Fast),
            chain_id: 1,
            raw: None,
        };
        assert!(is_noop(&noop_tx));

        // Test non-NOOP transactions
        let mut non_noop = noop_tx.clone();
        non_noop.value = U256::from(1000000000000000000u64); // 1 ETH
        assert!(!is_noop(&non_noop));

        let mut non_noop = noop_tx.clone();
        non_noop.data = Some("0x123456".to_string());
        assert!(!is_noop(&non_noop));

        let mut non_noop = noop_tx.clone();
        non_noop.to = Some("0x9876543210987654321098765432109876543210".to_string());
        assert!(!is_noop(&non_noop));

        let mut non_noop = noop_tx;
        non_noop.speed = None;
        assert!(!is_noop(&non_noop));
    }

    #[test]
    fn test_too_many_attempts() {
        let mut tx = TransactionRepoModel {
            id: "test-tx".to_string(),
            relayer_id: "test-relayer".to_string(),
            status: TransactionStatus::Pending,
            created_at: "2024-01-01T00:00:00Z".to_string(),
            sent_at: None,
            confirmed_at: None,
            valid_until: None,
            network_type: crate::models::NetworkType::Evm,
            network_data: NetworkTransactionData::Evm(EvmTransactionData {
                from: "0x1234".to_string(),
                to: Some("0x5678".to_string()),
                value: U256::from(0u64),
                data: Some("0x".to_string()),
                gas_limit: 21000,
                gas_price: Some(10_000_000_000),
                max_fee_per_gas: None,
                max_priority_fee_per_gas: None,
                nonce: Some(42),
                signature: None,
                hash: None,
                speed: Some(Speed::Fast),
                chain_id: 1,
                raw: None,
            }),
            priced_at: None,
            hashes: vec![], // Start with no attempts
            noop_count: None,
            is_canceled: Some(false),
        };

        // Test with no attempts
        assert!(!too_many_attempts(&tx));

        // Test with maximum attempts
        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS];
        assert!(!too_many_attempts(&tx));

        // Test with too many attempts
        tx.hashes = vec!["hash".to_string(); MAXIMUM_TX_ATTEMPTS + 1];
        assert!(too_many_attempts(&tx));
    }

    #[test]
    fn test_too_many_noop_attempts() {
        let mut tx = TransactionRepoModel {
            id: "test-tx".to_string(),
            relayer_id: "test-relayer".to_string(),
            status: TransactionStatus::Pending,
            created_at: "2024-01-01T00:00:00Z".to_string(),
            sent_at: None,
            confirmed_at: None,
            valid_until: None,
            network_type: crate::models::NetworkType::Evm,
            network_data: NetworkTransactionData::Evm(EvmTransactionData {
                from: "0x1234".to_string(),
                to: Some("0x5678".to_string()),
                value: U256::from(0u64),
                data: Some("0x".to_string()),
                gas_limit: 21000,
                gas_price: Some(10_000_000_000),
                max_fee_per_gas: None,
                max_priority_fee_per_gas: None,
                nonce: Some(42),
                signature: None,
                hash: None,
                speed: Some(Speed::Fast),
                chain_id: 1,
                raw: None,
            }),
            priced_at: None,
            hashes: vec![],
            noop_count: None,
            is_canceled: Some(false),
        };

        // Test with no NOOP attempts
        assert!(!too_many_noop_attempts(&tx));

        // Test with maximum NOOP attempts
        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS);
        assert!(!too_many_noop_attempts(&tx));

        // Test with too many NOOP attempts
        tx.noop_count = Some(MAXIMUM_NOOP_RETRY_ATTEMPTS + 1);
        assert!(too_many_noop_attempts(&tx));
    }

    #[test]
    fn test_has_enough_confirmations() {
        // Test Ethereum Mainnet (requires 12 confirmations)
        let chain_id = 1; // Ethereum Mainnet

        // Not enough confirmations
        let tx_block_number = 100;
        let current_block_number = 110; // Only 10 confirmations
        assert!(!has_enough_confirmations(
            tx_block_number,
            current_block_number,
            chain_id
        ));

        // Exactly enough confirmations
        let current_block_number = 112; // Exactly 12 confirmations
        assert!(has_enough_confirmations(
            tx_block_number,
            current_block_number,
            chain_id
        ));

        // More than enough confirmations
        let current_block_number = 120; // 20 confirmations
        assert!(has_enough_confirmations(
            tx_block_number,
            current_block_number,
            chain_id
        ));
    }

    #[test]
    fn test_is_transaction_valid_with_future_timestamp() {
        let now = Utc::now();
        let valid_until = Some((now + Duration::hours(1)).to_rfc3339());
        let created_at = now.to_rfc3339();

        assert!(is_transaction_valid(&created_at, &valid_until));
    }

    #[test]
    fn test_is_transaction_valid_with_past_timestamp() {
        let now = Utc::now();
        let valid_until = Some((now - Duration::hours(1)).to_rfc3339());
        let created_at = now.to_rfc3339();

        assert!(!is_transaction_valid(&created_at, &valid_until));
    }

    #[test]
    fn test_is_transaction_valid_with_valid_until() {
        // Test with valid_until in the future
        let created_at = Utc::now().to_rfc3339();
        let valid_until = Some((Utc::now() + Duration::hours(1)).to_rfc3339());
        assert!(is_transaction_valid(&created_at, &valid_until));

        // Test with valid_until in the past
        let valid_until = Some((Utc::now() - Duration::hours(1)).to_rfc3339());
        assert!(!is_transaction_valid(&created_at, &valid_until));

        // Test with valid_until exactly at current time (should be invalid)
        let valid_until = Some(Utc::now().to_rfc3339());
        assert!(!is_transaction_valid(&created_at, &valid_until));

        // Test with valid_until very far in the future
        let valid_until = Some((Utc::now() + Duration::days(365)).to_rfc3339());
        assert!(is_transaction_valid(&created_at, &valid_until));

        // Test with invalid valid_until format
        let valid_until = Some("invalid-date-format".to_string());
        assert!(!is_transaction_valid(&created_at, &valid_until));

        // Test with empty valid_until string
        let valid_until = Some("".to_string());
        assert!(!is_transaction_valid(&created_at, &valid_until));
    }

    #[test]
    fn test_is_transaction_valid_without_valid_until() {
        // Test with created_at within the default timespan
        let created_at = Utc::now().to_rfc3339();
        let valid_until = None;
        assert!(is_transaction_valid(&created_at, &valid_until));

        // Test with created_at older than the default timespan (8 hours)
        let old_created_at =
            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN + 1000)).to_rfc3339();
        assert!(!is_transaction_valid(&old_created_at, &valid_until));

        // Test with created_at exactly at the boundary
        let boundary_created_at =
            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN)).to_rfc3339();
        assert!(!is_transaction_valid(&boundary_created_at, &valid_until));

        // Test with created_at just within the default timespan
        let within_boundary_created_at =
            (Utc::now() - Duration::milliseconds(DEFAULT_TX_VALID_TIMESPAN - 1000)).to_rfc3339();
        assert!(is_transaction_valid(
            &within_boundary_created_at,
            &valid_until
        ));

        // Test with invalid created_at format
        let invalid_created_at = "invalid-date-format";
        assert!(!is_transaction_valid(invalid_created_at, &valid_until));

        // Test with empty created_at string
        assert!(!is_transaction_valid("", &valid_until));
    }
}