openzeppelin_relayer/jobs/handlers/
transaction_status_handler.rs

1//! Transaction status monitoring handler.
2//!
3//! Monitors the status of submitted transactions by:
4//! - Checking transaction status on the network
5//! - Updating transaction status in storage
6//! - Triggering notifications on status changes
7use actix_web::web::ThinData;
8use apalis::prelude::{Attempt, Data, *};
9use eyre::Result;
10use tracing::{debug, instrument};
11
12use std::sync::Arc;
13
14use crate::{
15    domain::{get_relayer_transaction, get_transaction_by_id, is_final_state, Transaction},
16    jobs::{Job, TransactionStatusCheck},
17    models::{DefaultAppState, TransactionRepoModel},
18    observability::request_id::set_request_id,
19};
20
21#[cfg(test)]
22use crate::models::NetworkType;
23
24#[instrument(
25    level = "debug",
26    skip(job, state),
27    fields(
28        request_id = ?job.request_id,
29        job_id = %job.message_id,
30        job_type = %job.job_type.to_string(),
31        attempt = %attempt.current(),
32        tx_id = %job.data.transaction_id,
33        relayer_id = %job.data.relayer_id,
34    )
35)]
36pub async fn transaction_status_handler(
37    job: Job<TransactionStatusCheck>,
38    state: Data<ThinData<DefaultAppState>>,
39    attempt: Attempt,
40) -> Result<(), Error> {
41    if let Some(request_id) = job.request_id.clone() {
42        set_request_id(request_id);
43    }
44
45    debug!(
46        "handling transaction status check for tx_id {}",
47        job.data.transaction_id
48    );
49
50    let result = handle_request(job.data, state).await;
51
52    handle_status_check_result(result)
53}
54
55/// Handles status check results with special retry logic.
56///
57/// # Retry Strategy
58/// - If transaction is in final state → Job completes successfully
59/// - If error occurred → Retry (let handle_result decide)
60/// - If transaction still not final → Retry to keep checking
61fn handle_status_check_result(result: Result<TransactionRepoModel>) -> Result<(), Error> {
62    match result {
63        Ok(updated_tx) => {
64            // Check if transaction reached final state
65            if is_final_state(&updated_tx.status) {
66                debug!(
67                    tx_id = %updated_tx.id,
68                    status = ?updated_tx.status,
69                    "transaction reached final state, status check complete"
70                );
71                Ok(())
72            } else {
73                // Transaction still processing, retry status check
74                debug!(
75                    tx_id = %updated_tx.id,
76                    status = ?updated_tx.status,
77                    "transaction status: {:?} - not in final state, retrying status check",
78                    updated_tx.status
79                );
80                Err(Error::Failed(Arc::new(
81                    format!(
82                        "transaction status: {:?} - not in final state, retrying status check",
83                        updated_tx.status
84                    )
85                    .into(),
86                )))
87            }
88        }
89        Err(e) => {
90            // Error occurred, retry
91            Err(Error::Failed(Arc::new(format!("{e}").into())))
92        }
93    }
94}
95
96async fn handle_request(
97    status_request: TransactionStatusCheck,
98    state: Data<ThinData<DefaultAppState>>,
99) -> Result<TransactionRepoModel> {
100    let relayer_transaction =
101        get_relayer_transaction(status_request.relayer_id.clone(), &state).await?;
102
103    let transaction = get_transaction_by_id(status_request.transaction_id.clone(), &state).await?;
104
105    let updated_transaction = relayer_transaction
106        .handle_transaction_status(transaction)
107        .await?;
108
109    debug!(
110        "status check handled successfully for tx_id {}",
111        status_request.transaction_id
112    );
113
114    Ok(updated_transaction)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::models::TransactionStatus;
121    use crate::utils::mocks::mockutils::create_mock_transaction;
122    use std::collections::HashMap;
123
124    #[tokio::test]
125    async fn test_status_check_job_validation() {
126        // Create a basic status check job
127        let check_job = TransactionStatusCheck::new("tx123", "relayer-1", NetworkType::Evm);
128        let job = Job::new(crate::jobs::JobType::TransactionStatusCheck, check_job);
129
130        // Validate the job data
131        assert_eq!(job.data.transaction_id, "tx123");
132        assert_eq!(job.data.relayer_id, "relayer-1");
133        assert!(job.data.metadata.is_none());
134    }
135
136    #[tokio::test]
137    async fn test_status_check_with_metadata() {
138        // Create a job with retry metadata
139        let mut metadata = HashMap::new();
140        metadata.insert("retry_count".to_string(), "2".to_string());
141        metadata.insert("last_status".to_string(), "pending".to_string());
142
143        let check_job = TransactionStatusCheck::new("tx123", "relayer-1", NetworkType::Evm)
144            .with_metadata(metadata.clone());
145
146        // Validate the metadata
147        assert!(check_job.metadata.is_some());
148        let job_metadata = check_job.metadata.unwrap();
149        assert_eq!(job_metadata.get("retry_count").unwrap(), "2");
150        assert_eq!(job_metadata.get("last_status").unwrap(), "pending");
151    }
152
153    mod handle_status_check_result_tests {
154        use super::*;
155
156        #[test]
157        fn test_final_state_confirmed_returns_ok() {
158            let mut tx = create_mock_transaction();
159            tx.status = TransactionStatus::Confirmed;
160            let result = Ok(tx);
161
162            let check_result = handle_status_check_result(result);
163
164            assert!(
165                check_result.is_ok(),
166                "Should return Ok for Confirmed (final) state"
167            );
168        }
169
170        #[test]
171        fn test_final_state_failed_returns_ok() {
172            let mut tx = create_mock_transaction();
173            tx.status = TransactionStatus::Failed;
174            let result = Ok(tx);
175
176            let check_result = handle_status_check_result(result);
177
178            assert!(
179                check_result.is_ok(),
180                "Should return Ok for Failed (final) state"
181            );
182        }
183
184        #[test]
185        fn test_final_state_expired_returns_ok() {
186            let mut tx = create_mock_transaction();
187            tx.status = TransactionStatus::Expired;
188            let result = Ok(tx);
189
190            let check_result = handle_status_check_result(result);
191
192            assert!(
193                check_result.is_ok(),
194                "Should return Ok for Expired (final) state"
195            );
196        }
197
198        #[test]
199        fn test_final_state_canceled_returns_ok() {
200            let mut tx = create_mock_transaction();
201            tx.status = TransactionStatus::Canceled;
202            let result = Ok(tx);
203
204            let check_result = handle_status_check_result(result);
205
206            assert!(
207                check_result.is_ok(),
208                "Should return Ok for Canceled (final) state"
209            );
210        }
211
212        #[test]
213        fn test_non_final_state_pending_returns_error() {
214            let mut tx = create_mock_transaction();
215            tx.status = TransactionStatus::Pending;
216            let result = Ok(tx);
217
218            let check_result = handle_status_check_result(result);
219
220            assert!(
221                check_result.is_err(),
222                "Should return Err for Pending (non-final) state to trigger retry"
223            );
224        }
225
226        #[test]
227        fn test_non_final_state_sent_returns_error() {
228            let mut tx = create_mock_transaction();
229            tx.status = TransactionStatus::Sent;
230            let result = Ok(tx);
231
232            let check_result = handle_status_check_result(result);
233
234            assert!(
235                check_result.is_err(),
236                "Should return Err for Sent (non-final) state to trigger retry"
237            );
238        }
239
240        #[test]
241        fn test_non_final_state_submitted_returns_error() {
242            let mut tx = create_mock_transaction();
243            tx.status = TransactionStatus::Submitted;
244            let result = Ok(tx);
245
246            let check_result = handle_status_check_result(result);
247
248            assert!(
249                check_result.is_err(),
250                "Should return Err for Submitted (non-final) state to trigger retry"
251            );
252        }
253
254        #[test]
255        fn test_non_final_state_mined_returns_error() {
256            let mut tx = create_mock_transaction();
257            tx.status = TransactionStatus::Mined;
258            let result = Ok(tx);
259
260            let check_result = handle_status_check_result(result);
261
262            assert!(
263                check_result.is_err(),
264                "Should return Err for Mined (non-final) state to trigger retry"
265            );
266        }
267
268        #[test]
269        fn test_error_result_returns_error() {
270            let result: Result<TransactionRepoModel> =
271                Err(eyre::eyre!("Network timeout during status check"));
272
273            let check_result = handle_status_check_result(result);
274
275            assert!(
276                check_result.is_err(),
277                "Should return Err when original result is an error"
278            );
279        }
280
281        #[test]
282        fn test_error_message_propagation() {
283            let error_message = "RPC call failed: connection timeout";
284            let result: Result<TransactionRepoModel> = Err(eyre::eyre!(error_message));
285
286            let check_result = handle_status_check_result(result);
287
288            match check_result {
289                Err(Error::Failed(arc)) => {
290                    let err_string = arc.to_string();
291                    assert!(
292                        err_string.contains(error_message),
293                        "Error message should contain original error: {}",
294                        err_string
295                    );
296                }
297                _ => panic!("Expected Error::Failed"),
298            }
299        }
300
301        #[test]
302        fn test_non_final_state_error_message() {
303            let mut tx = create_mock_transaction();
304            tx.status = TransactionStatus::Submitted;
305            let result = Ok(tx);
306
307            let check_result = handle_status_check_result(result);
308
309            match check_result {
310                Err(Error::Failed(arc)) => {
311                    let err_string = arc.to_string();
312                    assert!(
313                        err_string.contains("not in final state"),
314                        "Error message should indicate non-final state: {}",
315                        err_string
316                    );
317                    assert!(
318                        err_string.contains("Submitted"),
319                        "Error message should mention the status: {}",
320                        err_string
321                    );
322                }
323                _ => panic!("Expected Error::Failed for non-final state"),
324            }
325        }
326    }
327}