openzeppelin_relayer/domain/transaction/
common.rs

1//! Common transaction utilities shared across all blockchain networks.
2//!
3//! This module contains utility functions and constants that are used
4//! across multiple blockchain domains (EVM, Solana, Stellar) to avoid
5//! cross-domain dependencies.
6
7use chrono::{DateTime, Duration, Utc};
8
9use crate::constants::FINAL_TRANSACTION_STATUSES;
10use crate::models::{TransactionError, TransactionRepoModel, TransactionStatus};
11
12/// Checks if a transaction is in a final state (confirmed, failed, canceled, or expired).
13///
14/// Final states are terminal states where no further status updates are expected.
15/// This is used across all blockchain implementations to determine if a transaction
16/// has completed processing.
17///
18/// # Arguments
19///
20/// * `tx_status` - The transaction status to check
21///
22/// # Returns
23///
24/// `true` if the transaction is in a final state, `false` otherwise
25pub fn is_final_state(tx_status: &TransactionStatus) -> bool {
26    FINAL_TRANSACTION_STATUSES.contains(tx_status)
27}
28
29/// Returns true if the status indicates the nonce slot is actively occupied
30/// (tx is in-flight or mined but not yet final). Used by nonce gap detection
31/// to distinguish real gaps from slots with active transactions.
32pub fn is_active_nonce_status(tx_status: &TransactionStatus) -> bool {
33    matches!(
34        tx_status,
35        TransactionStatus::Pending
36            | TransactionStatus::Sent
37            | TransactionStatus::Submitted
38            | TransactionStatus::Mined
39    )
40}
41
42pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool {
43    matches!(
44        tx_status,
45        TransactionStatus::Pending | TransactionStatus::Sent | TransactionStatus::Submitted
46    )
47}
48
49pub fn is_unsubmitted_transaction(tx_status: &TransactionStatus) -> bool {
50    matches!(
51        tx_status,
52        TransactionStatus::Pending | TransactionStatus::Sent
53    )
54}
55
56/// Gets the age of a transaction since it was sent.
57pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
58    let now = Utc::now();
59    let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| {
60        TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string())
61    })?;
62    let sent_time = DateTime::parse_from_rfc3339(sent_at_str)
63        .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))?
64        .with_timezone(&Utc);
65    Ok(now.signed_duration_since(sent_time))
66}
67
68#[cfg(test)]
69mod tests {
70    use crate::utils::mocks::mockutils::create_mock_transaction;
71
72    use super::*;
73
74    #[test]
75    fn test_is_final_state() {
76        // Final states should return true
77        assert!(is_final_state(&TransactionStatus::Confirmed));
78        assert!(is_final_state(&TransactionStatus::Failed));
79        assert!(is_final_state(&TransactionStatus::Expired));
80        assert!(is_final_state(&TransactionStatus::Canceled));
81
82        // Non-final states should return false
83        assert!(!is_final_state(&TransactionStatus::Pending));
84        assert!(!is_final_state(&TransactionStatus::Sent));
85        assert!(!is_final_state(&TransactionStatus::Submitted));
86        assert!(!is_final_state(&TransactionStatus::Mined));
87    }
88
89    #[test]
90    fn test_is_pending_transaction() {
91        // Test pending status
92        assert!(is_pending_transaction(&TransactionStatus::Pending));
93
94        // Test sent status
95        assert!(is_pending_transaction(&TransactionStatus::Sent));
96
97        // Test submitted status
98        assert!(is_pending_transaction(&TransactionStatus::Submitted));
99
100        // Test non-pending statuses
101        assert!(!is_pending_transaction(&TransactionStatus::Confirmed));
102        assert!(!is_pending_transaction(&TransactionStatus::Failed));
103        assert!(!is_pending_transaction(&TransactionStatus::Canceled));
104        assert!(!is_pending_transaction(&TransactionStatus::Mined));
105        assert!(!is_pending_transaction(&TransactionStatus::Expired));
106    }
107
108    #[test]
109    fn test_is_unsubmitted_transaction() {
110        // Unsubmitted statuses should return true
111        assert!(is_unsubmitted_transaction(&TransactionStatus::Pending));
112        assert!(is_unsubmitted_transaction(&TransactionStatus::Sent));
113
114        // Submitted and other statuses should return false
115        assert!(!is_unsubmitted_transaction(&TransactionStatus::Submitted));
116        assert!(!is_unsubmitted_transaction(&TransactionStatus::Mined));
117        assert!(!is_unsubmitted_transaction(&TransactionStatus::Confirmed));
118        assert!(!is_unsubmitted_transaction(&TransactionStatus::Failed));
119        assert!(!is_unsubmitted_transaction(&TransactionStatus::Canceled));
120        assert!(!is_unsubmitted_transaction(&TransactionStatus::Expired));
121    }
122
123    #[test]
124    fn test_is_active_nonce_status() {
125        // Active statuses — nonce slot is occupied by in-flight tx
126        assert!(is_active_nonce_status(&TransactionStatus::Pending));
127        assert!(is_active_nonce_status(&TransactionStatus::Sent));
128        assert!(is_active_nonce_status(&TransactionStatus::Submitted));
129        assert!(is_active_nonce_status(&TransactionStatus::Mined));
130
131        // Terminal/gap statuses — nonce slot is available for gap filling
132        assert!(!is_active_nonce_status(&TransactionStatus::Confirmed));
133        assert!(!is_active_nonce_status(&TransactionStatus::Failed));
134        assert!(!is_active_nonce_status(&TransactionStatus::Canceled));
135        assert!(!is_active_nonce_status(&TransactionStatus::Expired));
136    }
137
138    #[test]
139    fn test_get_age_of_sent_at() {
140        let now = Utc::now();
141
142        // Test with valid sent_at timestamp (1 hour ago)
143        let sent_at_time = now - Duration::hours(1);
144        let mut tx = create_mock_transaction();
145        tx.sent_at = Some(sent_at_time.to_rfc3339());
146
147        let age_result = get_age_of_sent_at(&tx);
148        assert!(age_result.is_ok());
149        let age = age_result.unwrap();
150        // Age should be approximately 1 hour (with some tolerance for test execution time)
151        assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61);
152    }
153
154    #[test]
155    fn test_get_age_of_sent_at_missing_sent_at() {
156        let mut tx = create_mock_transaction();
157        tx.sent_at = None; // Missing sent_at
158
159        let result = get_age_of_sent_at(&tx);
160        assert!(result.is_err());
161        match result.unwrap_err() {
162            TransactionError::UnexpectedError(msg) => {
163                assert!(msg.contains("sent_at time is missing"));
164            }
165            _ => panic!("Expected UnexpectedError for missing sent_at"),
166        }
167    }
168
169    #[test]
170    fn test_get_age_of_sent_at_invalid_timestamp() {
171        let mut tx = create_mock_transaction();
172        tx.sent_at = Some("invalid-timestamp".to_string()); // Invalid timestamp format
173
174        let result = get_age_of_sent_at(&tx);
175        assert!(result.is_err());
176        match result.unwrap_err() {
177            TransactionError::UnexpectedError(msg) => {
178                assert!(msg.contains("Error parsing sent_at time"));
179            }
180            _ => panic!("Expected UnexpectedError for invalid timestamp"),
181        }
182    }
183}