openzeppelin_relayer/domain/transaction/
util.rs

1//! This module provides utility functions for handling transactions within the application.
2//!
3//! It includes functions to retrieve transactions by ID, create relayer transactions, and
4//! handle unsupported operations for specific relayers. The module interacts with various
5//! repositories and factories to perform these operations.
6use actix_web::web::ThinData;
7use chrono::{DateTime, Duration, Utc};
8use tracing::{instrument, warn};
9
10use crate::{
11    domain::get_relayer_by_id,
12    jobs::JobProducerTrait,
13    models::{
14        ApiError, DefaultAppState, NetworkRepoModel, NotificationRepoModel, RelayerRepoModel,
15        SignerRepoModel, ThinDataAppState, TransactionError, TransactionRepoModel,
16    },
17    repositories::{
18        ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository,
19        Repository, TransactionCounterTrait, TransactionRepository,
20    },
21};
22
23use super::{NetworkTransaction, RelayerTransactionFactory};
24
25/// Retrieves a transaction by its ID.
26///
27/// # Arguments
28///
29/// * `transaction_id` - A `String` representing the ID of the transaction to retrieve.
30/// * `state` - A reference to the application state, wrapped in `ThinData`.
31///
32/// # Returns
33///
34/// A `Result` containing a `TransactionRepoModel` if successful, or an `ApiError` if an error
35/// occurs.
36#[instrument(
37    level = "debug",
38    skip(state),
39    fields(
40        request_id = ?crate::observability::request_id::get_request_id(),
41        tx_id = %transaction_id,
42    )
43)]
44pub async fn get_transaction_by_id<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
45    transaction_id: String,
46    state: &ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
47) -> Result<TransactionRepoModel, ApiError>
48where
49    J: JobProducerTrait + Send + Sync + 'static,
50    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
51    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
52    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
53    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
54    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
55    TCR: TransactionCounterTrait + Send + Sync + 'static,
56    PR: PluginRepositoryTrait + Send + Sync + 'static,
57    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
58{
59    state
60        .transaction_repository
61        .get_by_id(transaction_id)
62        .await
63        .map_err(|e| e.into())
64}
65
66/// Creates a relayer network transaction instance based on the relayer ID.
67///
68/// # Arguments
69///
70/// * `relayer_id` - A `String` representing the ID of the relayer.
71/// * `state` - A reference to the application state, wrapped in `ThinData`.
72///
73/// # Returns
74///
75/// A `Result` containing a `NetworkTransaction` if successful, or an `ApiError` if an error occurs.
76#[instrument(
77    level = "debug",
78    skip(state),
79    fields(
80        request_id = ?crate::observability::request_id::get_request_id(),
81        relayer_id = %relayer_id,
82    )
83)]
84pub async fn get_relayer_transaction(
85    relayer_id: String,
86    state: &ThinData<DefaultAppState>,
87) -> Result<NetworkTransaction, ApiError> {
88    let relayer_model = get_relayer_by_id(relayer_id, state).await?;
89    let signer_model = state
90        .signer_repository
91        .get_by_id(relayer_model.signer_id.clone())
92        .await?;
93
94    RelayerTransactionFactory::create_transaction(
95        relayer_model,
96        signer_model,
97        state.relayer_repository(),
98        state.network_repository(),
99        state.transaction_repository(),
100        state.transaction_counter_store(),
101        state.job_producer(),
102    )
103    .await
104    .map_err(|e| e.into())
105}
106
107/// Creates a relayer network transaction using a relayer model.
108///
109/// # Arguments
110///
111/// * `relayer_model` - A `RelayerRepoModel` representing the relayer.
112/// * `state` - A reference to the application state, wrapped in `ThinData`.
113///
114/// # Returns
115///
116/// A `Result` containing a `NetworkTransaction` if successful, or an `ApiError` if an error occurs.
117#[instrument(
118    level = "debug",
119    skip(state, relayer_model),
120    fields(
121        request_id = ?crate::observability::request_id::get_request_id(),
122        relayer_id = %relayer_model.id,
123    )
124)]
125pub async fn get_relayer_transaction_by_model(
126    relayer_model: RelayerRepoModel,
127    state: &ThinData<DefaultAppState>,
128) -> Result<NetworkTransaction, ApiError> {
129    let signer_model = state
130        .signer_repository
131        .get_by_id(relayer_model.signer_id.clone())
132        .await?;
133
134    RelayerTransactionFactory::create_transaction(
135        relayer_model,
136        signer_model,
137        state.relayer_repository(),
138        state.network_repository(),
139        state.transaction_repository(),
140        state.transaction_counter_store(),
141        state.job_producer(),
142    )
143    .await
144    .map_err(|e| e.into())
145}
146
147/// Returns an error indicating that Solana relayers are not supported.
148///
149/// # Returns
150///
151/// A `Result` that always contains a `TransactionError::NotSupported` error.
152pub fn solana_not_supported_transaction<T>() -> Result<T, TransactionError> {
153    Err(TransactionError::NotSupported(
154        "Endpoint is not supported for Solana relayers".to_string(),
155    ))
156}
157
158/// Gets the age of a transaction since it was created.
159///
160/// # Arguments
161///
162/// * `tx` - The transaction repository model
163///
164/// # Returns
165///
166/// A `Result` containing the `Duration` since the transaction was created,
167/// or a `TransactionError` if the created_at timestamp cannot be parsed.
168pub fn get_age_since_created(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
169    let created = DateTime::parse_from_rfc3339(&tx.created_at)
170        .map_err(|e| {
171            TransactionError::UnexpectedError(format!("Invalid created_at timestamp: {e}"))
172        })?
173        .with_timezone(&Utc);
174    Ok(Utc::now().signed_duration_since(created))
175}
176
177/// Gets the age of a transaction since it was sent, falling back to created_at.
178///
179/// If sent_at is present but invalid, this falls back to created_at and emits a warning.
180/// Gets the age of a transaction since it was sent.
181pub fn get_age_since_sent(tx: &TransactionRepoModel) -> Result<Duration, TransactionError> {
182    let sent_at = tx.sent_at.as_deref().ok_or_else(|| {
183        TransactionError::UnexpectedError("Missing sent_at timestamp".to_string())
184    })?;
185    let sent = DateTime::parse_from_rfc3339(sent_at).map_err(|e| {
186        TransactionError::UnexpectedError(format!("Invalid sent_at timestamp: {e}"))
187    })?;
188    Ok(Utc::now().signed_duration_since(sent.with_timezone(&Utc)))
189}
190
191/// Gets the age of a transaction since it was sent, falling back to created_at.
192///
193/// If sent_at is present but invalid, this falls back to created_at and emits a warning.
194pub fn get_age_since_sent_or_created(
195    tx: &TransactionRepoModel,
196) -> Result<Duration, TransactionError> {
197    match get_age_since_sent(tx) {
198        Ok(age) => Ok(age),
199        Err(e) => {
200            if let Some(sent_at) = tx.sent_at.as_deref() {
201                warn!(
202                    tx_id = %tx.id,
203                    ts = %sent_at,
204                    error = %e,
205                    "failed to parse sent_at timestamp, falling back to created_at"
206                );
207            }
208            get_age_since_created(tx)
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::utils::mocks::mockutils::create_mock_transaction;
217
218    mod get_age_since_created_tests {
219        use super::*;
220
221        /// Helper to create a test transaction with a specific created_at timestamp
222        fn create_test_tx_with_age(seconds_ago: i64) -> TransactionRepoModel {
223            let created_at = (Utc::now() - Duration::seconds(seconds_ago)).to_rfc3339();
224            let mut tx = create_mock_transaction();
225            tx.created_at = created_at;
226            tx
227        }
228
229        #[test]
230        fn test_returns_correct_age_for_recent_transaction() {
231            let tx = create_test_tx_with_age(30); // 30 seconds ago
232            let age = get_age_since_created(&tx).unwrap();
233
234            // Allow for small timing differences (within 1 second)
235            assert!(age.num_seconds() >= 29 && age.num_seconds() <= 31);
236        }
237
238        #[test]
239        fn test_returns_correct_age_for_old_transaction() {
240            let tx = create_test_tx_with_age(3600); // 1 hour ago
241            let age = get_age_since_created(&tx).unwrap();
242
243            // Allow for small timing differences
244            assert!(age.num_seconds() >= 3599 && age.num_seconds() <= 3601);
245        }
246
247        #[test]
248        fn test_returns_zero_age_for_just_created_transaction() {
249            let tx = create_test_tx_with_age(0); // Just now
250            let age = get_age_since_created(&tx).unwrap();
251
252            // Should be very close to 0
253            assert!(age.num_seconds() >= 0 && age.num_seconds() <= 1);
254        }
255
256        #[test]
257        fn test_handles_negative_age_gracefully() {
258            // Create transaction with future timestamp (clock skew scenario)
259            let created_at = (Utc::now() + Duration::seconds(10)).to_rfc3339();
260            let mut tx = create_mock_transaction();
261            tx.created_at = created_at;
262
263            let age = get_age_since_created(&tx).unwrap();
264
265            // Age should be negative
266            assert!(age.num_seconds() < 0);
267        }
268
269        #[test]
270        fn test_returns_error_for_invalid_created_at() {
271            let mut tx = create_mock_transaction();
272            tx.created_at = "invalid-timestamp".to_string();
273
274            let result = get_age_since_created(&tx);
275            assert!(result.is_err());
276
277            match result.unwrap_err() {
278                TransactionError::UnexpectedError(msg) => {
279                    assert!(msg.contains("Invalid created_at timestamp"));
280                }
281                _ => panic!("Expected UnexpectedError"),
282            }
283        }
284
285        #[test]
286        fn test_returns_error_for_empty_created_at() {
287            let mut tx = create_mock_transaction();
288            tx.created_at = "".to_string();
289
290            let result = get_age_since_created(&tx);
291            assert!(result.is_err());
292
293            match result.unwrap_err() {
294                TransactionError::UnexpectedError(msg) => {
295                    assert!(msg.contains("Invalid created_at timestamp"));
296                }
297                _ => panic!("Expected UnexpectedError"),
298            }
299        }
300
301        #[test]
302        fn test_handles_various_rfc3339_formats() {
303            let mut tx = create_mock_transaction();
304
305            // Test with UTC timezone
306            tx.created_at = "2025-01-01T12:00:00Z".to_string();
307            assert!(get_age_since_created(&tx).is_ok());
308
309            // Test with offset timezone
310            tx.created_at = "2025-01-01T12:00:00+00:00".to_string();
311            assert!(get_age_since_created(&tx).is_ok());
312
313            // Test with milliseconds
314            tx.created_at = "2025-01-01T12:00:00.123Z".to_string();
315            assert!(get_age_since_created(&tx).is_ok());
316
317            // Test with microseconds
318            tx.created_at = "2025-01-01T12:00:00.123456Z".to_string();
319            assert!(get_age_since_created(&tx).is_ok());
320        }
321
322        #[test]
323        fn test_handles_different_timezones() {
324            let mut tx = create_mock_transaction();
325
326            // Test with positive offset
327            tx.created_at = "2025-01-01T12:00:00+05:30".to_string();
328            assert!(get_age_since_created(&tx).is_ok());
329
330            // Test with negative offset
331            tx.created_at = "2025-01-01T12:00:00-08:00".to_string();
332            assert!(get_age_since_created(&tx).is_ok());
333        }
334
335        #[test]
336        fn test_age_calculation_is_consistent() {
337            let tx = create_test_tx_with_age(60); // 1 minute ago
338
339            // Call multiple times in quick succession
340            let age1 = get_age_since_created(&tx).unwrap();
341            let age2 = get_age_since_created(&tx).unwrap();
342            let age3 = get_age_since_created(&tx).unwrap();
343
344            // All should be very close (within 1 second of each other)
345            let diff1 = (age2.num_seconds() - age1.num_seconds()).abs();
346            let diff2 = (age3.num_seconds() - age2.num_seconds()).abs();
347
348            assert!(diff1 <= 1);
349            assert!(diff2 <= 1);
350        }
351
352        #[test]
353        fn test_returns_error_for_malformed_timestamp() {
354            let mut tx = create_mock_transaction();
355
356            // Various malformed timestamps
357            let invalid_timestamps = vec![
358                "2025-13-01T12:00:00Z", // Invalid month
359                "2025-01-32T12:00:00Z", // Invalid day
360                "2025-01-01T25:00:00Z", // Invalid hour
361                "2025-01-01T12:60:00Z", // Invalid minute
362                "not-a-date",
363                "2025/01/01",
364                "12:00:00",
365                "just some text",
366                "2025-01-01", // Missing time
367                "12:00:00Z",  // Missing date
368            ];
369
370            for invalid_ts in invalid_timestamps {
371                tx.created_at = invalid_ts.to_string();
372                let result = get_age_since_created(&tx);
373                assert!(result.is_err(), "Expected error for: {invalid_ts}");
374            }
375        }
376    }
377
378    mod get_age_since_sent_or_created_tests {
379        use super::*;
380
381        /// Helper to create a test transaction with a specific timestamp.
382        fn create_test_tx_with_timestamps(
383            created_at: String,
384            sent_at: Option<String>,
385        ) -> TransactionRepoModel {
386            let mut tx = create_mock_transaction();
387            tx.created_at = created_at;
388            tx.sent_at = sent_at;
389            tx
390        }
391
392        #[test]
393        fn test_uses_sent_at_when_present() {
394            let sent_at = (Utc::now() - Duration::minutes(5)).to_rfc3339();
395            let created_at = (Utc::now() - Duration::minutes(30)).to_rfc3339();
396            let tx = create_test_tx_with_timestamps(created_at, Some(sent_at));
397
398            let age = get_age_since_sent_or_created(&tx).unwrap();
399            assert!(age.num_minutes() >= 5);
400        }
401
402        #[test]
403        fn test_falls_back_to_created_at_when_sent_at_missing() {
404            let created_at = (Utc::now() - Duration::minutes(10)).to_rfc3339();
405            let tx = create_test_tx_with_timestamps(created_at, None);
406
407            let age = get_age_since_sent_or_created(&tx).unwrap();
408            assert!(age.num_minutes() >= 10);
409        }
410
411        #[test]
412        fn test_falls_back_to_created_at_when_sent_at_invalid() {
413            let created_at = (Utc::now() - Duration::minutes(2)).to_rfc3339();
414            let tx = create_test_tx_with_timestamps(created_at, Some("not-a-date".to_string()));
415
416            let age = get_age_since_sent_or_created(&tx).unwrap();
417            assert!(age.num_minutes() >= 2);
418        }
419    }
420
421    mod solana_not_supported_transaction_tests {
422        use super::*;
423
424        #[test]
425        fn test_returns_not_supported_error() {
426            let result: Result<(), TransactionError> = solana_not_supported_transaction();
427
428            assert!(result.is_err());
429            match result.unwrap_err() {
430                TransactionError::NotSupported(msg) => {
431                    assert_eq!(msg, "Endpoint is not supported for Solana relayers");
432                }
433                _ => panic!("Expected NotSupported error"),
434            }
435        }
436
437        #[test]
438        fn test_works_with_different_return_types() {
439            // Test with String return type
440            let result: Result<String, TransactionError> = solana_not_supported_transaction();
441            assert!(result.is_err());
442
443            // Test with i32 return type
444            let result: Result<i32, TransactionError> = solana_not_supported_transaction();
445            assert!(result.is_err());
446
447            // Test with TransactionRepoModel return type
448            let result: Result<TransactionRepoModel, TransactionError> =
449                solana_not_supported_transaction();
450            assert!(result.is_err());
451        }
452
453        #[test]
454        fn test_error_message_is_descriptive() {
455            let result: Result<(), TransactionError> = solana_not_supported_transaction();
456
457            let error = result.unwrap_err();
458            let error_msg = error.to_string();
459
460            assert!(error_msg.contains("Solana"));
461            assert!(error_msg.contains("not supported"));
462        }
463
464        #[test]
465        fn test_multiple_calls_return_same_error() {
466            let result1: Result<(), TransactionError> = solana_not_supported_transaction();
467            let result2: Result<(), TransactionError> = solana_not_supported_transaction();
468            let result3: Result<(), TransactionError> = solana_not_supported_transaction();
469
470            // All should be errors
471            assert!(result1.is_err());
472            assert!(result2.is_err());
473            assert!(result3.is_err());
474
475            // All should have the same message
476            let msg1 = match result1.unwrap_err() {
477                TransactionError::NotSupported(m) => m,
478                _ => panic!("Wrong error type"),
479            };
480            let msg2 = match result2.unwrap_err() {
481                TransactionError::NotSupported(m) => m,
482                _ => panic!("Wrong error type"),
483            };
484            let msg3 = match result3.unwrap_err() {
485                TransactionError::NotSupported(m) => m,
486                _ => panic!("Wrong error type"),
487            };
488
489            assert_eq!(msg1, msg2);
490            assert_eq!(msg2, msg3);
491        }
492    }
493}