openzeppelin_relayer/domain/transaction/stellar/
status.rs1use chrono::Utc;
6use log::{info, warn};
7use soroban_rs::xdr::{Error, Hash};
8
9use super::StellarRelayerTransaction;
10use crate::{
11 constants::STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS,
12 jobs::{JobProducerTrait, TransactionStatusCheck},
13 models::{RelayerRepoModel, TransactionError, TransactionRepoModel, TransactionStatus},
14 repositories::{Repository, TransactionCounterTrait, TransactionRepository},
15 services::{Signer, StellarProviderTrait},
16};
17
18impl<R, T, J, S, P, C> StellarRelayerTransaction<R, T, J, S, P, C>
19where
20 R: Repository<RelayerRepoModel, String> + Send + Sync,
21 T: TransactionRepository + Send + Sync,
22 J: JobProducerTrait + Send + Sync,
23 S: Signer + Send + Sync,
24 P: StellarProviderTrait + Send + Sync,
25 C: TransactionCounterTrait + Send + Sync,
26{
27 pub async fn handle_transaction_status_impl(
30 &self,
31 tx: TransactionRepoModel,
32 ) -> Result<TransactionRepoModel, TransactionError> {
33 info!("Handling transaction status for: {:?}", tx.id);
34
35 match self.status_core(tx.clone()).await {
37 Ok(updated_tx) => Ok(updated_tx),
38 Err(error) => {
39 match error {
41 TransactionError::ValidationError(_) => {
42 Err(error)
44 }
45 _ => {
46 self.handle_status_failure(tx, error).await
48 }
49 }
50 }
51 }
52 }
53
54 async fn status_core(
56 &self,
57 tx: TransactionRepoModel,
58 ) -> Result<TransactionRepoModel, TransactionError> {
59 let stellar_hash = self.parse_and_validate_hash(&tx)?;
60
61 let provider_response = self
62 .provider()
63 .get_transaction(&stellar_hash)
64 .await
65 .map_err(TransactionError::from)?;
66
67 match provider_response.status.as_str().to_uppercase().as_str() {
68 "SUCCESS" => self.handle_stellar_success(tx, provider_response).await,
69 "FAILED" => self.handle_stellar_failed(tx, provider_response).await,
70 _ => {
71 self.handle_stellar_pending(tx, provider_response.status)
72 .await
73 }
74 }
75 }
76
77 async fn handle_status_failure(
80 &self,
81 tx: TransactionRepoModel,
82 error: TransactionError,
83 ) -> Result<TransactionRepoModel, TransactionError> {
84 warn!(
85 "Failed to get Stellar transaction status for {}: {}. Re-queueing check.",
86 tx.id, error
87 );
88
89 if let Err(requeue_error) = self.requeue_status_check(&tx).await {
91 warn!(
92 "Failed to requeue status check for transaction {}: {}",
93 tx.id, requeue_error
94 );
95 }
97
98 info!(
100 "Transaction {} status check failure handled. Will retry later. Error: {}",
101 tx.id, error
102 );
103
104 Ok(tx)
106 }
107
108 pub async fn requeue_status_check(
110 &self,
111 tx: &TransactionRepoModel,
112 ) -> Result<(), TransactionError> {
113 self.job_producer()
114 .produce_check_transaction_status_job(
115 TransactionStatusCheck::new(tx.id.clone(), tx.relayer_id.clone()),
116 Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS),
117 )
118 .await?;
119 Ok(())
120 }
121
122 pub fn parse_and_validate_hash(
125 &self,
126 tx: &TransactionRepoModel,
127 ) -> Result<Hash, TransactionError> {
128 let stellar_network_data = tx.network_data.get_stellar_transaction_data()?;
129
130 let tx_hash_str = stellar_network_data.hash.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
131 TransactionError::ValidationError(format!(
132 "Stellar transaction {} is missing or has an empty on-chain hash in network_data. Cannot check status.",
133 tx.id
134 ))
135 })?;
136
137 let stellar_hash: Hash = tx_hash_str.parse().map_err(|e: Error| {
138 TransactionError::UnexpectedError(format!(
139 "Failed to parse transaction hash '{}' for tx {}: {:?}. This hash may be corrupted or not a valid Stellar hash.",
140 tx_hash_str, tx.id, e
141 ))
142 })?;
143
144 Ok(stellar_hash)
145 }
146
147 pub async fn handle_stellar_success(
149 &self,
150 tx: TransactionRepoModel,
151 _provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse, ) -> Result<TransactionRepoModel, TransactionError> {
153 let confirmed_tx = self
154 .finalize_transaction_state(
155 tx.id.clone(),
156 TransactionStatus::Confirmed,
157 None,
158 Some(Utc::now().to_rfc3339()),
159 )
160 .await?;
161
162 self.enqueue_next_pending_transaction(&tx.id).await?;
163
164 Ok(confirmed_tx)
165 }
166
167 pub async fn handle_stellar_failed(
169 &self,
170 tx: TransactionRepoModel,
171 provider_response: soroban_rs::stellar_rpc_client::GetTransactionResponse,
172 ) -> Result<TransactionRepoModel, TransactionError> {
173 let base_reason = "Transaction failed on-chain. Provider status: FAILED.".to_string();
174 let detailed_reason = if let Some(ref tx_result_xdr) = provider_response.result {
175 format!(
176 "{} Specific XDR reason: {}.",
177 base_reason,
178 tx_result_xdr.result.name()
179 )
180 } else {
181 format!("{} No detailed XDR result available.", base_reason)
182 };
183
184 warn!("Stellar transaction {} failed: {}", tx.id, detailed_reason);
185 let updated_tx = self
186 .finalize_transaction_state(
187 tx.id.clone(),
188 TransactionStatus::Failed,
189 Some(detailed_reason),
190 None,
191 )
192 .await?;
193
194 self.enqueue_next_pending_transaction(&tx.id).await?;
195
196 Ok(updated_tx)
197 }
198
199 pub async fn handle_stellar_pending(
201 &self,
202 tx: TransactionRepoModel,
203 original_status_str: String,
204 ) -> Result<TransactionRepoModel, TransactionError> {
205 info!(
206 "Stellar transaction {} status is still '{}'. Re-queueing check.",
207 tx.id, original_status_str
208 );
209 self.requeue_status_check(&tx).await?;
210 Ok(tx)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::models::{NetworkTransactionData, RepositoryError};
218 use mockall::predicate::eq;
219 use soroban_rs::stellar_rpc_client::GetTransactionResponse;
220
221 use crate::domain::transaction::stellar::test_helpers::*;
222
223 fn dummy_get_transaction_response(status: &str) -> GetTransactionResponse {
224 GetTransactionResponse {
225 status: status.to_string(),
226 envelope: None,
227 result: None,
228 result_meta: None,
229 }
230 }
231
232 mod handle_transaction_status_tests {
233 use super::*;
234
235 #[tokio::test]
236 async fn handle_transaction_status_confirmed_triggers_next() {
237 let relayer = create_test_relayer();
238 let mut mocks = default_test_mocks();
239
240 let mut tx_to_handle = create_test_transaction(&relayer.id);
241 tx_to_handle.id = "tx-confirm-this".to_string();
242 let tx_hash_bytes = [1u8; 32];
243 let tx_hash_hex = hex::encode(tx_hash_bytes);
244 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
245 {
246 stellar_data.hash = Some(tx_hash_hex.clone());
247 } else {
248 panic!("Expected Stellar network data for tx_to_handle");
249 }
250 tx_to_handle.status = TransactionStatus::Submitted;
251
252 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
253
254 mocks
256 .provider
257 .expect_get_transaction()
258 .with(eq(expected_stellar_hash.clone()))
259 .times(1)
260 .returning(move |_| {
261 Box::pin(async { Ok(dummy_get_transaction_response("SUCCESS")) })
262 });
263
264 mocks
266 .tx_repo
267 .expect_partial_update()
268 .withf(move |id, update| {
269 id == "tx-confirm-this"
270 && update.status == Some(TransactionStatus::Confirmed)
271 && update.confirmed_at.is_some()
272 })
273 .times(1)
274 .returning(move |id, update| {
275 let mut updated_tx = tx_to_handle.clone(); updated_tx.id = id;
277 updated_tx.status = update.status.unwrap();
278 updated_tx.confirmed_at = update.confirmed_at;
279 Ok(updated_tx)
280 });
281
282 mocks
284 .job_producer
285 .expect_produce_send_notification_job()
286 .times(1)
287 .returning(|_, _| Box::pin(async { Ok(()) }));
288
289 let mut oldest_pending_tx = create_test_transaction(&relayer.id);
291 oldest_pending_tx.id = "tx-oldest-pending".to_string();
292 oldest_pending_tx.status = TransactionStatus::Pending;
293 let captured_oldest_pending_tx = oldest_pending_tx.clone();
294 mocks
295 .tx_repo
296 .expect_find_by_status()
297 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
298 .times(1)
299 .returning(move |_, _| Ok(vec![captured_oldest_pending_tx.clone()]));
300
301 mocks
303 .job_producer
304 .expect_produce_transaction_request_job()
305 .withf(move |job, _delay| job.transaction_id == "tx-oldest-pending")
306 .times(1)
307 .returning(|_, _| Box::pin(async { Ok(()) }));
308
309 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
310 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
311 initial_tx_for_handling.id = "tx-confirm-this".to_string();
312 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
313 initial_tx_for_handling.network_data
314 {
315 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
316 } else {
317 panic!("Expected Stellar network data for initial_tx_for_handling");
318 }
319 initial_tx_for_handling.status = TransactionStatus::Submitted;
320
321 let result = handler
322 .handle_transaction_status_impl(initial_tx_for_handling)
323 .await;
324
325 assert!(result.is_ok());
326 let handled_tx = result.unwrap();
327 assert_eq!(handled_tx.id, "tx-confirm-this");
328 assert_eq!(handled_tx.status, TransactionStatus::Confirmed);
329 assert!(handled_tx.confirmed_at.is_some());
330 }
331
332 #[tokio::test]
333 async fn handle_transaction_status_still_pending() {
334 let relayer = create_test_relayer();
335 let mut mocks = default_test_mocks();
336
337 let mut tx_to_handle = create_test_transaction(&relayer.id);
338 tx_to_handle.id = "tx-pending-check".to_string();
339 let tx_hash_bytes = [2u8; 32];
340 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
341 {
342 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
343 } else {
344 panic!("Expected Stellar network data");
345 }
346 tx_to_handle.status = TransactionStatus::Submitted; let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
349
350 mocks
352 .provider
353 .expect_get_transaction()
354 .with(eq(expected_stellar_hash.clone()))
355 .times(1)
356 .returning(move |_| {
357 Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
358 });
359
360 mocks.tx_repo.expect_partial_update().never();
362
363 mocks
365 .job_producer
366 .expect_produce_check_transaction_status_job()
367 .withf(move |job, delay| {
368 job.transaction_id == "tx-pending-check"
369 && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
370 })
371 .times(1)
372 .returning(|_, _| Box::pin(async { Ok(()) }));
373
374 mocks
376 .job_producer
377 .expect_produce_send_notification_job()
378 .never();
379
380 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
381 let original_tx_clone = tx_to_handle.clone();
382
383 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
384
385 assert!(result.is_ok());
386 let returned_tx = result.unwrap();
387 assert_eq!(returned_tx.id, original_tx_clone.id);
389 assert_eq!(returned_tx.status, original_tx_clone.status);
390 assert!(returned_tx.confirmed_at.is_none()); }
392
393 #[tokio::test]
394 async fn handle_transaction_status_failed() {
395 let relayer = create_test_relayer();
396 let mut mocks = default_test_mocks();
397
398 let mut tx_to_handle = create_test_transaction(&relayer.id);
399 tx_to_handle.id = "tx-fail-this".to_string();
400 let tx_hash_bytes = [3u8; 32];
401 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
402 {
403 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
404 } else {
405 panic!("Expected Stellar network data");
406 }
407 tx_to_handle.status = TransactionStatus::Submitted;
408
409 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
410
411 mocks
413 .provider
414 .expect_get_transaction()
415 .with(eq(expected_stellar_hash.clone()))
416 .times(1)
417 .returning(move |_| {
418 Box::pin(async { Ok(dummy_get_transaction_response("FAILED")) })
419 });
420
421 let relayer_id_for_mock = relayer.id.clone();
423 mocks
424 .tx_repo
425 .expect_partial_update()
426 .times(1)
427 .returning(move |id, update| {
428 let mut updated_tx = create_test_transaction(&relayer_id_for_mock);
430 updated_tx.id = id;
431 updated_tx.status = update.status.unwrap();
432 updated_tx.status_reason = update.status_reason.clone();
433 Ok::<_, RepositoryError>(updated_tx)
434 });
435
436 mocks
438 .job_producer
439 .expect_produce_send_notification_job()
440 .times(1)
441 .returning(|_, _| Box::pin(async { Ok(()) }));
442
443 mocks
445 .tx_repo
446 .expect_find_by_status()
447 .with(eq(relayer.id.clone()), eq(vec![TransactionStatus::Pending]))
448 .times(1)
449 .returning(move |_, _| Ok(vec![])); mocks
453 .job_producer
454 .expect_produce_transaction_request_job()
455 .never();
456 mocks
458 .job_producer
459 .expect_produce_check_transaction_status_job()
460 .never();
461
462 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
463 let mut initial_tx_for_handling = create_test_transaction(&relayer.id);
464 initial_tx_for_handling.id = "tx-fail-this".to_string();
465 if let NetworkTransactionData::Stellar(ref mut stellar_data) =
466 initial_tx_for_handling.network_data
467 {
468 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
469 } else {
470 panic!("Expected Stellar network data");
471 }
472 initial_tx_for_handling.status = TransactionStatus::Submitted;
473
474 let result = handler
475 .handle_transaction_status_impl(initial_tx_for_handling)
476 .await;
477
478 assert!(result.is_ok());
479 let handled_tx = result.unwrap();
480 assert_eq!(handled_tx.id, "tx-fail-this");
481 assert_eq!(handled_tx.status, TransactionStatus::Failed);
482 assert!(handled_tx.status_reason.is_some());
483 assert_eq!(
484 handled_tx.status_reason.unwrap(),
485 "Transaction failed on-chain. Provider status: FAILED. No detailed XDR result available."
486 );
487 }
488
489 #[tokio::test]
490 async fn handle_transaction_status_provider_error() {
491 let relayer = create_test_relayer();
492 let mut mocks = default_test_mocks();
493
494 let mut tx_to_handle = create_test_transaction(&relayer.id);
495 tx_to_handle.id = "tx-provider-error".to_string();
496 let tx_hash_bytes = [4u8; 32];
497 if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx_to_handle.network_data
498 {
499 stellar_data.hash = Some(hex::encode(tx_hash_bytes));
500 } else {
501 panic!("Expected Stellar network data");
502 }
503 tx_to_handle.status = TransactionStatus::Submitted;
504
505 let expected_stellar_hash = soroban_rs::xdr::Hash(tx_hash_bytes);
506
507 mocks
509 .provider
510 .expect_get_transaction()
511 .with(eq(expected_stellar_hash.clone()))
512 .times(1)
513 .returning(move |_| Box::pin(async { Err(eyre::eyre!("RPC boom")) }));
514
515 mocks.tx_repo.expect_partial_update().never();
517
518 mocks
520 .job_producer
521 .expect_produce_check_transaction_status_job()
522 .withf(move |job, delay| {
523 job.transaction_id == "tx-provider-error"
524 && delay == &Some(STELLAR_DEFAULT_STATUS_RETRY_DELAY_SECONDS)
525 })
526 .times(1)
527 .returning(|_, _| Box::pin(async { Ok(()) }));
528
529 mocks
531 .job_producer
532 .expect_produce_send_notification_job()
533 .never();
534 mocks
536 .job_producer
537 .expect_produce_transaction_request_job()
538 .never();
539
540 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
541 let original_tx_clone = tx_to_handle.clone();
542
543 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
544
545 assert!(result.is_ok()); let returned_tx = result.unwrap();
547 assert_eq!(returned_tx.id, original_tx_clone.id);
549 assert_eq!(returned_tx.status, original_tx_clone.status);
550 }
551
552 #[tokio::test]
553 async fn handle_transaction_status_no_hashes() {
554 let relayer = create_test_relayer();
555 let mut mocks = default_test_mocks(); let mut tx_to_handle = create_test_transaction(&relayer.id);
558 tx_to_handle.id = "tx-no-hashes".to_string();
559 tx_to_handle.status = TransactionStatus::Submitted;
560
561 mocks.provider.expect_get_transaction().never();
562 mocks.tx_repo.expect_partial_update().never();
563 mocks
564 .job_producer
565 .expect_produce_check_transaction_status_job()
566 .never();
567 mocks
568 .job_producer
569 .expect_produce_send_notification_job()
570 .never();
571
572 let handler = make_stellar_tx_handler(relayer.clone(), mocks);
573 let result = handler.handle_transaction_status_impl(tx_to_handle).await;
574
575 assert!(
576 result.is_err(),
577 "Expected an error when hash is missing, but got Ok"
578 );
579 match result.unwrap_err() {
580 TransactionError::ValidationError(msg) => {
581 assert!(
582 msg.contains("Stellar transaction tx-no-hashes is missing or has an empty on-chain hash in network_data"),
583 "Unexpected error message: {}",
584 msg
585 );
586 }
587 other => panic!("Expected ValidationError, got {:?}", other),
588 }
589 }
590 }
591}