1use crate::config::ServerConfig;
4use crate::constants::FINAL_TRANSACTION_STATUSES;
5use crate::domain::transaction::common::is_final_state;
6use crate::metrics::{
7 TRANSACTIONS_BY_STATUS, TRANSACTIONS_CREATED, TRANSACTIONS_FAILED,
8 TRANSACTIONS_INSUFFICIENT_FEE_FAILED, TRANSACTIONS_INSUFFICIENT_FEE_SUCCESS,
9 TRANSACTIONS_SUBMITTED, TRANSACTIONS_SUCCESS, TRANSACTIONS_TRY_AGAIN_LATER_FAILED,
10 TRANSACTIONS_TRY_AGAIN_LATER_SUCCESS, TRANSACTION_PROCESSING_TIME,
11};
12use crate::models::{
13 NetworkTransactionData, PaginationQuery, RepositoryError, TransactionRepoModel,
14 TransactionStatus, TransactionUpdateRequest,
15};
16use crate::repositories::redis_base::RedisRepository;
17use crate::repositories::{
18 BatchDeleteResult, BatchRetrievalResult, PaginatedResult, Repository, TransactionDeleteRequest,
19 TransactionRepository,
20};
21use crate::utils::RedisConnections;
22use async_trait::async_trait;
23use chrono::Utc;
24use redis::{AsyncCommands, Script};
25use std::fmt;
26use std::sync::Arc;
27use tracing::{debug, error, warn};
28
29const RELAYER_PREFIX: &str = "relayer";
30const TX_PREFIX: &str = "tx";
31const STATUS_PREFIX: &str = "status";
32const STATUS_SORTED_PREFIX: &str = "status_sorted";
33const NONCE_PREFIX: &str = "nonce";
34const TX_TO_RELAYER_PREFIX: &str = "tx_to_relayer";
35const RELAYER_LIST_KEY: &str = "relayer_list";
36const TX_BY_CREATED_AT_PREFIX: &str = "tx_by_created_at";
37
38#[derive(Clone)]
39pub struct RedisTransactionRepository {
40 pub connections: Arc<RedisConnections>,
41 pub key_prefix: String,
42}
43
44impl RedisRepository for RedisTransactionRepository {}
45
46impl RedisTransactionRepository {
47 pub fn new(
48 connections: Arc<RedisConnections>,
49 key_prefix: String,
50 ) -> Result<Self, RepositoryError> {
51 if key_prefix.is_empty() {
52 return Err(RepositoryError::InvalidData(
53 "Redis key prefix cannot be empty".to_string(),
54 ));
55 }
56
57 Ok(Self {
58 connections,
59 key_prefix,
60 })
61 }
62
63 fn tx_key(&self, relayer_id: &str, tx_id: &str) -> String {
65 format!(
66 "{}:{}:{}:{}:{}",
67 self.key_prefix, RELAYER_PREFIX, relayer_id, TX_PREFIX, tx_id
68 )
69 }
70
71 fn tx_to_relayer_key(&self, tx_id: &str) -> String {
73 format!(
74 "{}:{}:{}:{}",
75 self.key_prefix, RELAYER_PREFIX, TX_TO_RELAYER_PREFIX, tx_id
76 )
77 }
78
79 fn relayer_status_key(&self, relayer_id: &str, status: &TransactionStatus) -> String {
81 format!(
82 "{}:{}:{}:{}:{}",
83 self.key_prefix, RELAYER_PREFIX, relayer_id, STATUS_PREFIX, status
84 )
85 }
86
87 fn relayer_status_sorted_key(&self, relayer_id: &str, status: &TransactionStatus) -> String {
90 format!(
91 "{}:{}:{}:{}:{}",
92 self.key_prefix, RELAYER_PREFIX, relayer_id, STATUS_SORTED_PREFIX, status
93 )
94 }
95
96 fn relayer_nonce_key(&self, relayer_id: &str, nonce: u64) -> String {
98 format!(
99 "{}:{}:{}:{}:{}",
100 self.key_prefix, RELAYER_PREFIX, relayer_id, NONCE_PREFIX, nonce
101 )
102 }
103
104 fn relayer_list_key(&self) -> String {
106 format!("{}:{}", self.key_prefix, RELAYER_LIST_KEY)
107 }
108
109 fn relayer_tx_by_created_at_key(&self, relayer_id: &str) -> String {
111 format!(
112 "{}:{}:{}:{}",
113 self.key_prefix, RELAYER_PREFIX, relayer_id, TX_BY_CREATED_AT_PREFIX
114 )
115 }
116
117 fn tx_key_parts(&self, tx_id: &str) -> (String, String, String) {
122 let lookup_key = self.tx_to_relayer_key(tx_id);
123 let key_prefix = format!("{}:{}:", self.key_prefix, RELAYER_PREFIX);
124 let key_suffix = format!(":{TX_PREFIX}:{tx_id}");
125 (lookup_key, key_prefix, key_suffix)
126 }
127
128 async fn run_atomic_script(
135 &self,
136 lua: &str,
137 tx_id: &str,
138 extra_args: &[&str],
139 op_name: &str,
140 ) -> Result<TransactionRepoModel, RepositoryError> {
141 const MAX_RETRIES: u32 = 3;
142 const BASE_BACKOFF_MS: u64 = 100;
143
144 let (lookup_key, key_prefix, key_suffix) = self.tx_key_parts(tx_id);
145 let script = Script::new(lua);
146 let mut last_error = None;
147
148 for attempt in 0..MAX_RETRIES {
149 let backoff = BASE_BACKOFF_MS * 2u64.pow(attempt);
150
151 let mut conn = match self
152 .get_connection(self.connections.primary(), op_name)
153 .await
154 {
155 Ok(conn) => conn,
156 Err(e) => {
157 last_error = Some(e);
158 if attempt < MAX_RETRIES - 1 {
159 warn!(tx_id = %tx_id, attempt, op = %op_name, "connection failed, retrying");
160 tokio::time::sleep(tokio::time::Duration::from_millis(backoff)).await;
161 continue;
162 }
163 return Err(last_error.unwrap());
164 }
165 };
166
167 let mut invocation = script.prepare_invoke();
168 invocation
169 .key(&lookup_key)
170 .arg(&key_prefix)
171 .arg(&key_suffix);
172 for arg in extra_args {
173 invocation.arg(*arg);
174 }
175
176 match invocation.invoke_async::<Option<String>>(&mut conn).await {
177 Ok(result) => {
178 let json = result.ok_or_else(|| {
179 RepositoryError::NotFound(format!("Transaction with ID {tx_id} not found"))
180 })?;
181 return self.deserialize_entity::<TransactionRepoModel>(
182 &json,
183 tx_id,
184 "transaction",
185 );
186 }
187 Err(e) => {
188 last_error = Some(self.map_redis_error(e, op_name));
189 if attempt < MAX_RETRIES - 1 {
190 warn!(
191 tx_id = %tx_id, attempt, op = %op_name,
192 "atomic script failed, retrying"
193 );
194 tokio::time::sleep(tokio::time::Duration::from_millis(backoff)).await;
195 continue;
196 }
197 return Err(last_error.unwrap());
198 }
199 }
200 }
201 Err(last_error.unwrap_or_else(|| {
202 RepositoryError::UnexpectedError(format!("retry loop exhausted for {op_name}"))
203 }))
204 }
205
206 async fn run_script_with_retry_vec(
210 &self,
211 script: &Script,
212 lookup_key: &str,
213 key_prefix: &str,
214 key_suffix: &str,
215 extra_args: &[&str],
216 op_name: &str,
217 ) -> Result<Option<Vec<String>>, RepositoryError> {
218 const MAX_RETRIES: u32 = 3;
219 const BASE_BACKOFF_MS: u64 = 100;
220
221 let mut last_error = None;
222
223 for attempt in 0..MAX_RETRIES {
224 let backoff = BASE_BACKOFF_MS * 2u64.pow(attempt);
225
226 let mut conn = match self
227 .get_connection(self.connections.primary(), op_name)
228 .await
229 {
230 Ok(conn) => conn,
231 Err(e) => {
232 last_error = Some(e);
233 if attempt < MAX_RETRIES - 1 {
234 warn!(op = %op_name, attempt, "connection failed, retrying");
235 tokio::time::sleep(tokio::time::Duration::from_millis(backoff)).await;
236 continue;
237 }
238 return Err(last_error.unwrap());
239 }
240 };
241
242 let mut invocation = script.prepare_invoke();
243 invocation.key(lookup_key).arg(key_prefix).arg(key_suffix);
244 for arg in extra_args {
245 invocation.arg(*arg);
246 }
247
248 match invocation
251 .invoke_async::<Option<Vec<String>>>(&mut conn)
252 .await
253 {
254 Ok(result) => return Ok(result),
255 Err(e) => {
256 last_error = Some(self.map_redis_error(e, op_name));
257 if attempt < MAX_RETRIES - 1 {
258 warn!(op = %op_name, attempt, "script failed, retrying");
259 tokio::time::sleep(tokio::time::Duration::from_millis(backoff)).await;
260 continue;
261 }
262 return Err(last_error.unwrap());
263 }
264 }
265 }
266 Err(last_error.unwrap_or_else(|| {
267 RepositoryError::UnexpectedError(format!("retry loop exhausted for {op_name}"))
268 }))
269 }
270
271 fn timestamp_to_score(&self, timestamp: &str) -> f64 {
273 chrono::DateTime::parse_from_rfc3339(timestamp)
274 .map(|dt| dt.timestamp_millis() as f64)
275 .unwrap_or_else(|_| {
276 warn!(timestamp = %timestamp, "failed to parse timestamp, using 0");
277 0.0
278 })
279 }
280
281 fn status_sorted_score(&self, tx: &TransactionRepoModel) -> f64 {
285 if tx.status == TransactionStatus::Confirmed {
286 if let Some(ref confirmed_at) = tx.confirmed_at {
288 return self.timestamp_to_score(confirmed_at);
289 }
290 warn!(tx_id = %tx.id, "Confirmed transaction missing confirmed_at, using created_at");
292 }
293 self.timestamp_to_score(&tx.created_at)
294 }
295
296 async fn get_transactions_by_ids(
298 &self,
299 ids: &[String],
300 ) -> Result<BatchRetrievalResult<TransactionRepoModel>, RepositoryError> {
301 if ids.is_empty() {
302 debug!("no transaction IDs provided for batch fetch");
303 return Ok(BatchRetrievalResult {
304 results: vec![],
305 failed_ids: vec![],
306 });
307 }
308
309 let mut conn = self
310 .get_connection(self.connections.reader(), "batch_fetch_transactions")
311 .await?;
312
313 let reverse_keys: Vec<String> = ids.iter().map(|id| self.tx_to_relayer_key(id)).collect();
314
315 debug!(count = %ids.len(), "fetching relayer IDs for transactions");
316
317 let relayer_ids: Vec<Option<String>> = conn
318 .mget(&reverse_keys)
319 .await
320 .map_err(|e| self.map_redis_error(e, "batch_fetch_relayer_ids"))?;
321
322 let mut tx_keys = Vec::new();
323 let mut valid_ids = Vec::new();
324 let mut failed_ids = Vec::new();
325 for (i, relayer_id) in relayer_ids.into_iter().enumerate() {
326 match relayer_id {
327 Some(relayer_id) => {
328 tx_keys.push(self.tx_key(&relayer_id, &ids[i]));
329 valid_ids.push(ids[i].clone());
330 }
331 None => {
332 warn!(tx_id = %ids[i], "no relayer found for transaction");
333 failed_ids.push(ids[i].clone());
334 }
335 }
336 }
337
338 if tx_keys.is_empty() {
339 debug!("no valid transactions found for batch fetch");
340 return Ok(BatchRetrievalResult {
341 results: vec![],
342 failed_ids,
343 });
344 }
345
346 debug!(count = %tx_keys.len(), "batch fetching transaction data");
347
348 let values: Vec<Option<String>> = conn
349 .mget(&tx_keys)
350 .await
351 .map_err(|e| self.map_redis_error(e, "batch_fetch_transactions"))?;
352
353 let mut transactions = Vec::new();
354 let mut failed_count = 0;
355 for (i, value) in values.into_iter().enumerate() {
356 match value {
357 Some(json) => {
358 match self.deserialize_entity::<TransactionRepoModel>(
359 &json,
360 &valid_ids[i],
361 "transaction",
362 ) {
363 Ok(tx) => transactions.push(tx),
364 Err(e) => {
365 failed_count += 1;
366 error!(tx_id = %valid_ids[i], error = %e, "failed to deserialize transaction");
367 }
369 }
370 }
371 None => {
372 warn!(tx_id = %valid_ids[i], "transaction not found in batch fetch");
373 failed_ids.push(valid_ids[i].clone());
374 }
375 }
376 }
377
378 if failed_count > 0 {
379 warn!(failed_count = %failed_count, total_count = %valid_ids.len(), "failed to deserialize transactions in batch");
380 }
381
382 debug!(count = %transactions.len(), "successfully fetched transactions");
383 Ok(BatchRetrievalResult {
384 results: transactions,
385 failed_ids,
386 })
387 }
388
389 fn extract_nonce(&self, network_data: &NetworkTransactionData) -> Option<u64> {
391 match network_data.get_evm_transaction_data() {
392 Ok(tx_data) => tx_data.nonce,
393 Err(_) => {
394 debug!("no EVM transaction data available for nonce extraction");
395 None
396 }
397 }
398 }
399
400 async fn ensure_status_sorted_set(
417 &self,
418 relayer_id: &str,
419 status: &TransactionStatus,
420 ) -> Result<u64, RepositoryError> {
421 let sorted_key = self.relayer_status_sorted_key(relayer_id, status);
422 let legacy_key = self.relayer_status_key(relayer_id, status);
423
424 let legacy_ids = {
426 let mut conn = self
427 .get_connection(self.connections.primary(), "ensure_status_sorted_set_check")
428 .await?;
429
430 let legacy_count: u64 = conn
432 .scard(&legacy_key)
433 .await
434 .map_err(|e| self.map_redis_error(e, "ensure_status_sorted_set_scard"))?;
435
436 if legacy_count == 0 {
437 let sorted_count: u64 = conn
439 .zcard(&sorted_key)
440 .await
441 .map_err(|e| self.map_redis_error(e, "ensure_status_sorted_set_zcard"))?;
442 return Ok(sorted_count);
443 }
444
445 debug!(
447 relayer_id = %relayer_id,
448 status = %status,
449 legacy_count = %legacy_count,
450 "migrating status set to sorted set"
451 );
452
453 let ids: Vec<String> = conn
454 .smembers(&legacy_key)
455 .await
456 .map_err(|e| self.map_redis_error(e, "ensure_status_sorted_set_smembers"))?;
457
458 ids
459 };
461
462 if legacy_ids.is_empty() {
463 return Ok(0);
464 }
465
466 let transactions = self.get_transactions_by_ids(&legacy_ids).await?;
468
469 let mut conn = self
471 .get_connection(
472 self.connections.primary(),
473 "ensure_status_sorted_set_migrate",
474 )
475 .await?;
476
477 if transactions.results.is_empty() {
478 let _: () = conn
480 .del(&legacy_key)
481 .await
482 .map_err(|e| self.map_redis_error(e, "ensure_status_sorted_set_del_stale"))?;
483 return Ok(0);
484 }
485
486 let mut pipe = redis::pipe();
489 pipe.atomic();
490
491 for tx in &transactions.results {
492 let score = self.status_sorted_score(tx);
493 pipe.zadd(&sorted_key, &tx.id, score);
494 }
495
496 pipe.del(&legacy_key);
498
499 pipe.query_async::<()>(&mut conn)
500 .await
501 .map_err(|e| self.map_redis_error(e, "ensure_status_sorted_set_migrate"))?;
502
503 let migrated_count = transactions.results.len() as u64;
504 debug!(
505 relayer_id = %relayer_id,
506 status = %status,
507 migrated_count = %migrated_count,
508 "completed migration of status set to sorted set"
509 );
510
511 Ok(migrated_count)
512 }
513
514 async fn update_indexes(
516 &self,
517 tx: &TransactionRepoModel,
518 old_tx: Option<&TransactionRepoModel>,
519 ) -> Result<(), RepositoryError> {
520 let mut conn = self
521 .get_connection(self.connections.primary(), "update_indexes")
522 .await?;
523 let mut pipe = redis::pipe();
524 pipe.atomic();
525
526 debug!(tx_id = %tx.id, "updating indexes for transaction");
527
528 let relayer_list_key = self.relayer_list_key();
530 pipe.sadd(&relayer_list_key, &tx.relayer_id);
531
532 let status_score = self.status_sorted_score(tx);
535 let created_at_score = self.timestamp_to_score(&tx.created_at);
537
538 let new_status_sorted_key = self.relayer_status_sorted_key(&tx.relayer_id, &tx.status);
540 pipe.zadd(&new_status_sorted_key, &tx.id, status_score);
541 debug!(tx_id = %tx.id, status = %tx.status, score = %status_score, "added transaction to status sorted set");
542
543 if let Some(nonce) = self.extract_nonce(&tx.network_data) {
544 let nonce_key = self.relayer_nonce_key(&tx.relayer_id, nonce);
545 pipe.set(&nonce_key, &tx.id);
546 debug!(tx_id = %tx.id, nonce = %nonce, "added nonce index for transaction");
547 }
548
549 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&tx.relayer_id);
551 pipe.zadd(&relayer_sorted_key, &tx.id, created_at_score);
552 debug!(tx_id = %tx.id, score = %created_at_score, "added transaction to sorted set by created_at");
553
554 if let Some(old) = old_tx {
556 if old.status != tx.status {
557 let old_status_sorted_key =
559 self.relayer_status_sorted_key(&old.relayer_id, &old.status);
560 pipe.zrem(&old_status_sorted_key, &tx.id);
561
562 let old_status_legacy_key = self.relayer_status_key(&old.relayer_id, &old.status);
564 pipe.srem(&old_status_legacy_key, &tx.id);
565
566 debug!(tx_id = %tx.id, old_status = %old.status, new_status = %tx.status, "removing old status indexes for transaction");
567 }
568
569 if let Some(old_nonce) = self.extract_nonce(&old.network_data) {
571 let new_nonce = self.extract_nonce(&tx.network_data);
572 if Some(old_nonce) != new_nonce {
573 let old_nonce_key = self.relayer_nonce_key(&old.relayer_id, old_nonce);
574 pipe.del(&old_nonce_key);
575 debug!(tx_id = %tx.id, old_nonce = %old_nonce, new_nonce = ?new_nonce, "removing old nonce index for transaction");
576 }
577 }
578 }
579
580 pipe.exec_async(&mut conn).await.map_err(|e| {
582 error!(tx_id = %tx.id, error = %e, "index update pipeline failed for transaction");
583 self.map_redis_error(e, &format!("update_indexes_for_tx_{}", tx.id))
584 })?;
585
586 debug!(tx_id = %tx.id, "successfully updated indexes for transaction");
587 Ok(())
588 }
589
590 async fn remove_all_indexes(&self, tx: &TransactionRepoModel) -> Result<(), RepositoryError> {
592 let mut conn = self
593 .get_connection(self.connections.primary(), "remove_all_indexes")
594 .await?;
595 let mut pipe = redis::pipe();
596 pipe.atomic();
597
598 debug!(tx_id = %tx.id, "removing all indexes for transaction");
599
600 for status in &[
604 TransactionStatus::Canceled,
605 TransactionStatus::Pending,
606 TransactionStatus::Sent,
607 TransactionStatus::Submitted,
608 TransactionStatus::Mined,
609 TransactionStatus::Confirmed,
610 TransactionStatus::Failed,
611 TransactionStatus::Expired,
612 ] {
613 let status_sorted_key = self.relayer_status_sorted_key(&tx.relayer_id, status);
615 pipe.zrem(&status_sorted_key, &tx.id);
616
617 let status_legacy_key = self.relayer_status_key(&tx.relayer_id, status);
619 pipe.srem(&status_legacy_key, &tx.id);
620 }
621
622 if let Some(nonce) = self.extract_nonce(&tx.network_data) {
624 let nonce_key = self.relayer_nonce_key(&tx.relayer_id, nonce);
625 pipe.del(&nonce_key);
626 debug!(tx_id = %tx.id, nonce = %nonce, "removing nonce index for transaction");
627 }
628
629 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&tx.relayer_id);
631 pipe.zrem(&relayer_sorted_key, &tx.id);
632 debug!(tx_id = %tx.id, "removing transaction from sorted set by created_at");
633
634 let reverse_key = self.tx_to_relayer_key(&tx.id);
636 pipe.del(&reverse_key);
637
638 pipe.exec_async(&mut conn).await.map_err(|e| {
639 error!(tx_id = %tx.id, error = %e, "index removal failed for transaction");
640 self.map_redis_error(e, &format!("remove_indexes_for_tx_{}", tx.id))
641 })?;
642
643 debug!(tx_id = %tx.id, "successfully removed all indexes for transaction");
644 Ok(())
645 }
646
647 fn track_status_change_metrics(
649 &self,
650 _original_tx: &TransactionRepoModel,
651 updated_tx: &TransactionRepoModel,
652 old_status: &TransactionStatus,
653 new_status: &TransactionStatus,
654 ) {
655 let network_type = format!("{:?}", updated_tx.network_type).to_lowercase();
656 let relayer_id = updated_tx.relayer_id.as_str();
657
658 if *old_status != TransactionStatus::Submitted
660 && *new_status == TransactionStatus::Submitted
661 {
662 TRANSACTIONS_SUBMITTED
663 .with_label_values(&[relayer_id, &network_type])
664 .inc();
665
666 if let Ok(created_time) = chrono::DateTime::parse_from_rfc3339(&updated_tx.created_at) {
667 let processing_seconds =
668 (Utc::now() - created_time.with_timezone(&Utc)).num_seconds() as f64;
669 TRANSACTION_PROCESSING_TIME
670 .with_label_values(&[relayer_id, &network_type, "creation_to_submission"])
671 .observe(processing_seconds);
672 }
673 }
674
675 if old_status != new_status {
677 let old_status_str = format!("{old_status:?}").to_lowercase();
678 let old_status_gauge = TRANSACTIONS_BY_STATUS.with_label_values(&[
679 relayer_id,
680 &network_type,
681 &old_status_str,
682 ]);
683 let clamped_value = (old_status_gauge.get() - 1.0).max(0.0);
684 old_status_gauge.set(clamped_value);
685
686 let new_status_str = format!("{new_status:?}").to_lowercase();
687 TRANSACTIONS_BY_STATUS
688 .with_label_values(&[relayer_id, &network_type, &new_status_str])
689 .inc();
690 }
691
692 let was_final = is_final_state(old_status);
694 let is_final = is_final_state(new_status);
695
696 if !was_final && is_final {
697 let previous_status = format!("{old_status:?}").to_lowercase();
698 let meta = updated_tx.metadata.as_ref();
699 let had_insufficient_fee = meta.is_some_and(|m| m.insufficient_fee_retries > 0);
700 let had_try_again_later = meta.is_some_and(|m| m.try_again_later_retries > 0);
701
702 match new_status {
703 TransactionStatus::Confirmed => {
704 TRANSACTIONS_SUCCESS
705 .with_label_values(&[relayer_id, &network_type])
706 .inc();
707 if had_insufficient_fee {
708 TRANSACTIONS_INSUFFICIENT_FEE_SUCCESS
709 .with_label_values(&[relayer_id, &network_type])
710 .inc();
711 }
712 if had_try_again_later {
713 TRANSACTIONS_TRY_AGAIN_LATER_SUCCESS
714 .with_label_values(&[relayer_id, &network_type])
715 .inc();
716 }
717
718 if let (Some(sent_at_str), Some(confirmed_at_str)) =
719 (&updated_tx.sent_at, &updated_tx.confirmed_at)
720 {
721 if let (Ok(sent_time), Ok(confirmed_time)) = (
722 chrono::DateTime::parse_from_rfc3339(sent_at_str),
723 chrono::DateTime::parse_from_rfc3339(confirmed_at_str),
724 ) {
725 let processing_seconds = (confirmed_time.with_timezone(&Utc)
726 - sent_time.with_timezone(&Utc))
727 .num_seconds()
728 as f64;
729 TRANSACTION_PROCESSING_TIME
730 .with_label_values(&[
731 relayer_id,
732 &network_type,
733 "submission_to_confirmation",
734 ])
735 .observe(processing_seconds);
736 }
737 }
738
739 if let Ok(created_time) =
740 chrono::DateTime::parse_from_rfc3339(&updated_tx.created_at)
741 {
742 if let Some(confirmed_at_str) = &updated_tx.confirmed_at {
743 if let Ok(confirmed_time) =
744 chrono::DateTime::parse_from_rfc3339(confirmed_at_str)
745 {
746 let processing_seconds = (confirmed_time.with_timezone(&Utc)
747 - created_time.with_timezone(&Utc))
748 .num_seconds()
749 as f64;
750 TRANSACTION_PROCESSING_TIME
751 .with_label_values(&[
752 relayer_id,
753 &network_type,
754 "creation_to_confirmation",
755 ])
756 .observe(processing_seconds);
757 }
758 }
759 }
760 }
761 TransactionStatus::Failed => {
762 let failure_reason = updated_tx
763 .status_reason
764 .as_deref()
765 .map(|reason| {
766 if reason.starts_with("Submission failed:") {
767 "submission_failed"
768 } else if reason.starts_with("Preparation failed:") {
769 "preparation_failed"
770 } else {
771 "failed"
772 }
773 })
774 .unwrap_or("failed");
775 TRANSACTIONS_FAILED
776 .with_label_values(&[
777 relayer_id,
778 &network_type,
779 failure_reason,
780 &previous_status,
781 ])
782 .inc();
783 }
784 TransactionStatus::Expired => {
785 TRANSACTIONS_FAILED
786 .with_label_values(&[
787 relayer_id,
788 &network_type,
789 "expired",
790 &previous_status,
791 ])
792 .inc();
793 }
794 TransactionStatus::Canceled => {
795 TRANSACTIONS_FAILED
796 .with_label_values(&[
797 relayer_id,
798 &network_type,
799 "canceled",
800 &previous_status,
801 ])
802 .inc();
803 }
804 _ => {}
805 }
806
807 if *new_status != TransactionStatus::Confirmed {
809 if had_insufficient_fee {
810 TRANSACTIONS_INSUFFICIENT_FEE_FAILED
811 .with_label_values(&[relayer_id, &network_type])
812 .inc();
813 }
814 if had_try_again_later {
815 TRANSACTIONS_TRY_AGAIN_LATER_FAILED
816 .with_label_values(&[relayer_id, &network_type])
817 .inc();
818 }
819 }
820 }
821 }
822}
823
824impl fmt::Debug for RedisTransactionRepository {
825 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826 f.debug_struct("RedisTransactionRepository")
827 .field("connections", &"<RedisConnections>")
828 .field("key_prefix", &self.key_prefix)
829 .finish()
830 }
831}
832
833#[async_trait]
834impl Repository<TransactionRepoModel, String> for RedisTransactionRepository {
835 async fn create(
836 &self,
837 entity: TransactionRepoModel,
838 ) -> Result<TransactionRepoModel, RepositoryError> {
839 if entity.id.is_empty() {
840 return Err(RepositoryError::InvalidData(
841 "Transaction ID cannot be empty".to_string(),
842 ));
843 }
844
845 let key = self.tx_key(&entity.relayer_id, &entity.id);
846 let reverse_key = self.tx_to_relayer_key(&entity.id);
847 let mut conn = self
848 .get_connection(self.connections.primary(), "create")
849 .await?;
850
851 debug!(tx_id = %entity.id, "creating transaction");
852
853 let value = self.serialize_entity(&entity, |t| &t.id, "transaction")?;
854
855 let existing: Option<String> = conn
857 .get(&reverse_key)
858 .await
859 .map_err(|e| self.map_redis_error(e, "create_transaction_check"))?;
860
861 if existing.is_some() {
862 return Err(RepositoryError::ConstraintViolation(format!(
863 "Transaction with ID {} already exists",
864 entity.id
865 )));
866 }
867
868 let mut pipe = redis::pipe();
870 pipe.atomic();
871 pipe.set(&key, &value);
872 pipe.set(&reverse_key, &entity.relayer_id);
873
874 pipe.exec_async(&mut conn)
875 .await
876 .map_err(|e| self.map_redis_error(e, "create_transaction"))?;
877
878 if let Err(e) = self.update_indexes(&entity, None).await {
880 error!(tx_id = %entity.id, error = %e, "failed to update indexes for new transaction");
881 return Err(e);
882 }
883
884 let network_type = format!("{:?}", entity.network_type).to_lowercase();
886 let relayer_id = entity.relayer_id.as_str();
887 TRANSACTIONS_CREATED
888 .with_label_values(&[relayer_id, &network_type])
889 .inc();
890
891 let status = &entity.status;
893 let status_str = format!("{status:?}").to_lowercase();
894 TRANSACTIONS_BY_STATUS
895 .with_label_values(&[relayer_id, &network_type, &status_str])
896 .inc();
897
898 debug!(tx_id = %entity.id, "successfully created transaction");
899 Ok(entity)
900 }
901
902 async fn get_by_id(&self, id: String) -> Result<TransactionRepoModel, RepositoryError> {
903 if id.is_empty() {
904 return Err(RepositoryError::InvalidData(
905 "Transaction ID cannot be empty".to_string(),
906 ));
907 }
908
909 let mut conn = self
910 .get_connection(self.connections.reader(), "get_by_id")
911 .await?;
912
913 debug!(tx_id = %id, "fetching transaction");
914
915 let reverse_key = self.tx_to_relayer_key(&id);
916 let relayer_id: Option<String> = conn
917 .get(&reverse_key)
918 .await
919 .map_err(|e| self.map_redis_error(e, "get_transaction_reverse_lookup"))?;
920
921 let relayer_id = match relayer_id {
922 Some(relayer_id) => relayer_id,
923 None => {
924 debug!(tx_id = %id, "transaction not found (no reverse lookup)");
925 return Err(RepositoryError::NotFound(format!(
926 "Transaction with ID {id} not found"
927 )));
928 }
929 };
930
931 let key = self.tx_key(&relayer_id, &id);
932 let value: Option<String> = conn
933 .get(&key)
934 .await
935 .map_err(|e| self.map_redis_error(e, "get_transaction_by_id"))?;
936
937 match value {
938 Some(json) => {
939 let tx =
940 self.deserialize_entity::<TransactionRepoModel>(&json, &id, "transaction")?;
941 debug!(tx_id = %id, "successfully fetched transaction");
942 Ok(tx)
943 }
944 None => {
945 debug!(tx_id = %id, "transaction not found");
946 Err(RepositoryError::NotFound(format!(
947 "Transaction with ID {id} not found"
948 )))
949 }
950 }
951 }
952
953 async fn list_all(&self) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
955 let mut conn = self
956 .get_connection(self.connections.reader(), "list_all")
957 .await?;
958
959 debug!("fetching all transactions sorted by created_at (newest first)");
960
961 let relayer_list_key = self.relayer_list_key();
963 let relayer_ids: Vec<String> = conn
964 .smembers(&relayer_list_key)
965 .await
966 .map_err(|e| self.map_redis_error(e, "list_all_relayer_ids"))?;
967
968 debug!(count = %relayer_ids.len(), "found relayers");
969
970 let mut all_tx_ids = Vec::new();
972 for relayer_id in relayer_ids {
973 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&relayer_id);
974 let tx_ids: Vec<String> = redis::cmd("ZRANGE")
975 .arg(&relayer_sorted_key)
976 .arg(0)
977 .arg(-1)
978 .arg("REV")
979 .query_async(&mut conn)
980 .await
981 .map_err(|e| self.map_redis_error(e, "list_all_relayer_sorted"))?;
982
983 all_tx_ids.extend(tx_ids);
984 }
985
986 drop(conn);
988
989 let batch_result = self.get_transactions_by_ids(&all_tx_ids).await?;
991 let mut all_transactions = batch_result.results;
992
993 all_transactions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
995
996 debug!(count = %all_transactions.len(), "found transactions");
997 Ok(all_transactions)
998 }
999
1000 async fn list_paginated(
1002 &self,
1003 query: PaginationQuery,
1004 ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
1005 if query.per_page == 0 {
1006 return Err(RepositoryError::InvalidData(
1007 "per_page must be greater than 0".to_string(),
1008 ));
1009 }
1010
1011 let mut conn = self
1012 .get_connection(self.connections.reader(), "list_paginated")
1013 .await?;
1014
1015 debug!(page = %query.page, per_page = %query.per_page, "fetching paginated transactions sorted by created_at (newest first)");
1016
1017 let relayer_list_key = self.relayer_list_key();
1019 let relayer_ids: Vec<String> = conn
1020 .smembers(&relayer_list_key)
1021 .await
1022 .map_err(|e| self.map_redis_error(e, "list_paginated_relayer_ids"))?;
1023
1024 let mut all_tx_ids = Vec::new();
1026 for relayer_id in relayer_ids {
1027 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&relayer_id);
1028 let tx_ids: Vec<String> = redis::cmd("ZRANGE")
1029 .arg(&relayer_sorted_key)
1030 .arg(0)
1031 .arg(-1)
1032 .arg("REV")
1033 .query_async(&mut conn)
1034 .await
1035 .map_err(|e| self.map_redis_error(e, "list_paginated_relayer_sorted"))?;
1036
1037 all_tx_ids.extend(tx_ids);
1038 }
1039
1040 drop(conn);
1042
1043 let batch_result = self.get_transactions_by_ids(&all_tx_ids).await?;
1045 let mut all_transactions = batch_result.results;
1046
1047 all_transactions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
1049
1050 let total = all_transactions.len() as u64;
1051 let start = ((query.page - 1) * query.per_page) as usize;
1052 let end = (start + query.per_page as usize).min(all_transactions.len());
1053
1054 if start >= all_transactions.len() {
1055 debug!(page = %query.page, total = %total, "page is beyond available data");
1056 return Ok(PaginatedResult {
1057 items: vec![],
1058 total,
1059 page: query.page,
1060 per_page: query.per_page,
1061 });
1062 }
1063
1064 let items = all_transactions[start..end].to_vec();
1065
1066 debug!(count = %items.len(), page = %query.page, "successfully fetched transactions for page");
1067
1068 Ok(PaginatedResult {
1069 items,
1070 total,
1071 page: query.page,
1072 per_page: query.per_page,
1073 })
1074 }
1075
1076 async fn update(
1077 &self,
1078 id: String,
1079 entity: TransactionRepoModel,
1080 ) -> Result<TransactionRepoModel, RepositoryError> {
1081 if id.is_empty() {
1082 return Err(RepositoryError::InvalidData(
1083 "Transaction ID cannot be empty".to_string(),
1084 ));
1085 }
1086
1087 debug!(tx_id = %id, "updating transaction");
1088
1089 let old_tx = self.get_by_id(id.clone()).await?;
1091
1092 let key = self.tx_key(&entity.relayer_id, &id);
1093 let mut conn = self
1094 .get_connection(self.connections.primary(), "update")
1095 .await?;
1096
1097 let value = self.serialize_entity(&entity, |t| &t.id, "transaction")?;
1098
1099 let _: () = conn
1101 .set(&key, value)
1102 .await
1103 .map_err(|e| self.map_redis_error(e, "update_transaction"))?;
1104
1105 self.update_indexes(&entity, Some(&old_tx)).await?;
1107
1108 debug!(tx_id = %id, "successfully updated transaction");
1109 Ok(entity)
1110 }
1111
1112 async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError> {
1113 if id.is_empty() {
1114 return Err(RepositoryError::InvalidData(
1115 "Transaction ID cannot be empty".to_string(),
1116 ));
1117 }
1118
1119 debug!(tx_id = %id, "deleting transaction");
1120
1121 let tx = self.get_by_id(id.clone()).await?;
1123
1124 let key = self.tx_key(&tx.relayer_id, &id);
1125 let reverse_key = self.tx_to_relayer_key(&id);
1126 let mut conn = self
1127 .get_connection(self.connections.primary(), "delete_by_id")
1128 .await?;
1129
1130 let mut pipe = redis::pipe();
1131 pipe.atomic();
1132 pipe.del(&key);
1133 pipe.del(&reverse_key);
1134
1135 pipe.exec_async(&mut conn)
1136 .await
1137 .map_err(|e| self.map_redis_error(e, "delete_transaction"))?;
1138
1139 if let Err(e) = self.remove_all_indexes(&tx).await {
1141 error!(tx_id = %id, error = %e, "failed to remove indexes for deleted transaction");
1142 }
1143
1144 debug!(tx_id = %id, "successfully deleted transaction");
1145 Ok(())
1146 }
1147
1148 async fn count(&self) -> Result<usize, RepositoryError> {
1150 let mut conn = self
1151 .get_connection(self.connections.reader(), "count")
1152 .await?;
1153
1154 debug!("counting transactions");
1155
1156 let relayer_list_key = self.relayer_list_key();
1158 let relayer_ids: Vec<String> = conn
1159 .smembers(&relayer_list_key)
1160 .await
1161 .map_err(|e| self.map_redis_error(e, "count_relayer_ids"))?;
1162
1163 let mut total_count = 0usize;
1164 for relayer_id in relayer_ids {
1165 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&relayer_id);
1166 let count: usize = conn
1167 .zcard(&relayer_sorted_key)
1168 .await
1169 .map_err(|e| self.map_redis_error(e, "count_relayer_transactions"))?;
1170 total_count += count;
1171 }
1172
1173 debug!(count = %total_count, "transaction count");
1174 Ok(total_count)
1175 }
1176
1177 async fn has_entries(&self) -> Result<bool, RepositoryError> {
1178 let mut conn = self
1179 .get_connection(self.connections.reader(), "has_entries")
1180 .await?;
1181 let relayer_list_key = self.relayer_list_key();
1182
1183 debug!("checking if transaction entries exist");
1184
1185 let exists: bool = conn
1186 .exists(&relayer_list_key)
1187 .await
1188 .map_err(|e| self.map_redis_error(e, "has_entries_check"))?;
1189
1190 debug!(exists = %exists, "transaction entries exist");
1191 Ok(exists)
1192 }
1193
1194 async fn drop_all_entries(&self) -> Result<(), RepositoryError> {
1195 let mut conn = self
1196 .get_connection(self.connections.primary(), "drop_all_entries")
1197 .await?;
1198 let relayer_list_key = self.relayer_list_key();
1199
1200 debug!("dropping all transaction entries");
1201
1202 let relayer_ids: Vec<String> = conn
1204 .smembers(&relayer_list_key)
1205 .await
1206 .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_relayer_ids"))?;
1207
1208 if relayer_ids.is_empty() {
1209 debug!("no transaction entries to drop");
1210 return Ok(());
1211 }
1212
1213 let mut pipe = redis::pipe();
1215 pipe.atomic();
1216
1217 for relayer_id in &relayer_ids {
1219 let pattern = format!(
1221 "{}:{}:{}:{}:*",
1222 self.key_prefix, RELAYER_PREFIX, relayer_id, TX_PREFIX
1223 );
1224 let mut cursor = 0;
1225 let mut tx_ids = Vec::new();
1226
1227 loop {
1228 let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
1229 .cursor_arg(cursor)
1230 .arg("MATCH")
1231 .arg(&pattern)
1232 .query_async(&mut conn)
1233 .await
1234 .map_err(|e| self.map_redis_error(e, "drop_all_entries_scan"))?;
1235
1236 for key in keys {
1238 pipe.del(&key);
1239 if let Some(tx_id) = key.split(':').next_back() {
1240 tx_ids.push(tx_id.to_string());
1241 }
1242 }
1243
1244 cursor = next_cursor;
1245 if cursor == 0 {
1246 break;
1247 }
1248 }
1249
1250 for tx_id in tx_ids {
1252 let reverse_key = self.tx_to_relayer_key(&tx_id);
1253 pipe.del(&reverse_key);
1254
1255 for status in &[
1258 TransactionStatus::Canceled,
1259 TransactionStatus::Pending,
1260 TransactionStatus::Sent,
1261 TransactionStatus::Submitted,
1262 TransactionStatus::Mined,
1263 TransactionStatus::Confirmed,
1264 TransactionStatus::Failed,
1265 TransactionStatus::Expired,
1266 ] {
1267 let status_sorted_key = self.relayer_status_sorted_key(relayer_id, status);
1269 pipe.zrem(&status_sorted_key, &tx_id);
1270
1271 let status_key = self.relayer_status_key(relayer_id, status);
1273 pipe.srem(&status_key, &tx_id);
1274 }
1275 }
1276
1277 let relayer_sorted_key = self.relayer_tx_by_created_at_key(relayer_id);
1279 pipe.del(&relayer_sorted_key);
1280 }
1281
1282 pipe.del(&relayer_list_key);
1284
1285 pipe.exec_async(&mut conn)
1286 .await
1287 .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?;
1288
1289 debug!(count = %relayer_ids.len(), "dropped all transaction entries for relayers");
1290 Ok(())
1291 }
1292}
1293
1294#[async_trait]
1295impl TransactionRepository for RedisTransactionRepository {
1296 async fn find_by_relayer_id(
1297 &self,
1298 relayer_id: &str,
1299 query: PaginationQuery,
1300 ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
1301 let mut conn = self
1302 .get_connection(self.connections.reader(), "find_by_relayer_id")
1303 .await?;
1304
1305 debug!(relayer_id = %relayer_id, page = %query.page, per_page = %query.per_page, "fetching transactions for relayer sorted by created_at (newest first)");
1306
1307 let relayer_sorted_key = self.relayer_tx_by_created_at_key(relayer_id);
1308
1309 let sorted_set_count: u64 = conn
1311 .zcard(&relayer_sorted_key)
1312 .await
1313 .map_err(|e| self.map_redis_error(e, "find_by_relayer_id_count"))?;
1314
1315 if sorted_set_count == 0 {
1318 debug!(relayer_id = %relayer_id, "no transactions found for relayer (sorted set is empty)");
1319 return Ok(PaginatedResult {
1320 items: vec![],
1321 total: 0,
1322 page: query.page,
1323 per_page: query.per_page,
1324 });
1325 }
1326
1327 let total = sorted_set_count;
1328
1329 let start = ((query.page - 1) * query.per_page) as isize;
1331 let end = start + query.per_page as isize - 1;
1332
1333 if start as u64 >= total {
1334 debug!(relayer_id = %relayer_id, page = %query.page, total = %total, "page is beyond available data");
1335 return Ok(PaginatedResult {
1336 items: vec![],
1337 total,
1338 page: query.page,
1339 per_page: query.per_page,
1340 });
1341 }
1342
1343 let page_ids: Vec<String> = redis::cmd("ZRANGE")
1345 .arg(&relayer_sorted_key)
1346 .arg(start)
1347 .arg(end)
1348 .arg("REV")
1349 .query_async(&mut conn)
1350 .await
1351 .map_err(|e| self.map_redis_error(e, "find_by_relayer_id_sorted"))?;
1352
1353 drop(conn);
1355
1356 let items = self.get_transactions_by_ids(&page_ids).await?;
1357
1358 debug!(relayer_id = %relayer_id, count = %items.results.len(), page = %query.page, "successfully fetched transactions for relayer");
1359
1360 Ok(PaginatedResult {
1361 items: items.results,
1362 total,
1363 page: query.page,
1364 per_page: query.per_page,
1365 })
1366 }
1367
1368 async fn find_by_status(
1370 &self,
1371 relayer_id: &str,
1372 statuses: &[TransactionStatus],
1373 ) -> Result<Vec<TransactionRepoModel>, RepositoryError> {
1374 for status in statuses {
1376 self.ensure_status_sorted_set(relayer_id, status).await?;
1377 }
1378
1379 let mut conn = self
1381 .get_connection(self.connections.reader(), "find_by_status")
1382 .await?;
1383
1384 let mut all_ids: Vec<String> = Vec::new();
1385 for status in statuses {
1386 let sorted_key = self.relayer_status_sorted_key(relayer_id, status);
1388 let ids: Vec<String> = redis::cmd("ZRANGE")
1389 .arg(&sorted_key)
1390 .arg(0)
1391 .arg(-1)
1392 .arg("REV") .query_async(&mut conn)
1394 .await
1395 .map_err(|e| self.map_redis_error(e, "find_by_status"))?;
1396
1397 all_ids.extend(ids);
1398 }
1399
1400 drop(conn);
1402
1403 if all_ids.is_empty() {
1404 return Ok(vec![]);
1405 }
1406
1407 all_ids.sort();
1409 all_ids.dedup();
1410
1411 let mut transactions = self.get_transactions_by_ids(&all_ids).await?;
1413
1414 transactions
1416 .results
1417 .sort_by(|a, b| b.created_at.cmp(&a.created_at));
1418
1419 Ok(transactions.results)
1420 }
1421
1422 async fn find_by_status_paginated(
1423 &self,
1424 relayer_id: &str,
1425 statuses: &[TransactionStatus],
1426 query: PaginationQuery,
1427 oldest_first: bool,
1428 ) -> Result<PaginatedResult<TransactionRepoModel>, RepositoryError> {
1429 for status in statuses {
1431 self.ensure_status_sorted_set(relayer_id, status).await?;
1432 }
1433
1434 let mut conn = self
1435 .get_connection(self.connections.reader(), "find_by_status_paginated")
1436 .await?;
1437
1438 if statuses.len() == 1 {
1440 let sorted_key = self.relayer_status_sorted_key(relayer_id, &statuses[0]);
1441
1442 let total: u64 = conn
1444 .zcard(&sorted_key)
1445 .await
1446 .map_err(|e| self.map_redis_error(e, "find_by_status_paginated_count"))?;
1447
1448 if total == 0 {
1449 return Ok(PaginatedResult {
1450 items: vec![],
1451 total: 0,
1452 page: query.page,
1453 per_page: query.per_page,
1454 });
1455 }
1456
1457 let start = ((query.page.saturating_sub(1)) * query.per_page) as isize;
1459 let end = start + query.per_page as isize - 1;
1460
1461 let mut cmd = redis::cmd("ZRANGE");
1464 cmd.arg(&sorted_key).arg(start).arg(end);
1465 if !oldest_first {
1466 cmd.arg("REV");
1467 }
1468 let page_ids: Vec<String> = cmd
1469 .query_async(&mut conn)
1470 .await
1471 .map_err(|e| self.map_redis_error(e, "find_by_status_paginated"))?;
1472
1473 drop(conn);
1475
1476 let transactions = self.get_transactions_by_ids(&page_ids).await?;
1477
1478 debug!(
1479 relayer_id = %relayer_id,
1480 status = %statuses[0],
1481 total = %total,
1482 page = %query.page,
1483 page_size = %transactions.results.len(),
1484 "fetched paginated transactions by single status"
1485 );
1486
1487 return Ok(PaginatedResult {
1488 items: transactions.results,
1489 total,
1490 page: query.page,
1491 per_page: query.per_page,
1492 });
1493 }
1494
1495 let mut all_ids: Vec<(String, f64)> = Vec::new();
1497 for status in statuses {
1498 let sorted_key = self.relayer_status_sorted_key(relayer_id, status);
1499
1500 let ids_with_scores: Vec<(String, f64)> = redis::cmd("ZRANGE")
1502 .arg(&sorted_key)
1503 .arg(0)
1504 .arg(-1)
1505 .arg("WITHSCORES")
1506 .query_async(&mut conn)
1507 .await
1508 .map_err(|e| self.map_redis_error(e, "find_by_status_paginated_multi"))?;
1509
1510 all_ids.extend(ids_with_scores);
1511 }
1512
1513 drop(conn);
1515
1516 let mut id_map: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
1518 for (id, score) in all_ids {
1519 id_map
1520 .entry(id)
1521 .and_modify(|s| {
1522 if oldest_first {
1524 if score < *s {
1525 *s = score
1526 }
1527 } else if score > *s {
1528 *s = score
1529 }
1530 })
1531 .or_insert(score);
1532 }
1533
1534 let mut sorted_ids: Vec<(String, f64)> = id_map.into_iter().collect();
1536 if oldest_first {
1537 sorted_ids.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1538 } else {
1539 sorted_ids.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1540 }
1541
1542 let total = sorted_ids.len() as u64;
1543
1544 if total == 0 {
1545 return Ok(PaginatedResult {
1546 items: vec![],
1547 total: 0,
1548 page: query.page,
1549 per_page: query.per_page,
1550 });
1551 }
1552
1553 let start = ((query.page.saturating_sub(1)) * query.per_page) as usize;
1555 let page_ids: Vec<String> = sorted_ids
1556 .into_iter()
1557 .skip(start)
1558 .take(query.per_page as usize)
1559 .map(|(id, _)| id)
1560 .collect();
1561
1562 let transactions = self.get_transactions_by_ids(&page_ids).await?;
1564
1565 debug!(
1566 relayer_id = %relayer_id,
1567 total = %total,
1568 page = %query.page,
1569 page_size = %transactions.results.len(),
1570 "fetched paginated transactions by status"
1571 );
1572
1573 Ok(PaginatedResult {
1574 items: transactions.results,
1575 total,
1576 page: query.page,
1577 per_page: query.per_page,
1578 })
1579 }
1580
1581 async fn find_by_nonce(
1582 &self,
1583 relayer_id: &str,
1584 nonce: u64,
1585 ) -> Result<Option<TransactionRepoModel>, RepositoryError> {
1586 let mut conn = self
1587 .get_connection(self.connections.reader(), "find_by_nonce")
1588 .await?;
1589 let nonce_key = self.relayer_nonce_key(relayer_id, nonce);
1590
1591 let tx_id: Option<String> = conn
1593 .get(nonce_key)
1594 .await
1595 .map_err(|e| self.map_redis_error(e, "find_by_nonce"))?;
1596
1597 match tx_id {
1598 Some(tx_id) => {
1599 match self.get_by_id(tx_id.clone()).await {
1600 Ok(tx) => Ok(Some(tx)),
1601 Err(RepositoryError::NotFound(_)) => {
1602 warn!(relayer_id = %relayer_id, nonce = %nonce, "stale nonce index found for relayer");
1604 Ok(None)
1605 }
1606 Err(e) => Err(e),
1607 }
1608 }
1609 None => Ok(None),
1610 }
1611 }
1612
1613 async fn get_nonce_occupancy(
1614 &self,
1615 relayer_id: &str,
1616 from_nonce: u64,
1617 to_nonce: u64,
1618 ) -> Result<Vec<(u64, Option<TransactionStatus>)>, RepositoryError> {
1619 if from_nonce >= to_nonce {
1620 return Ok(vec![]);
1621 }
1622
1623 let nonces: Vec<u64> = (from_nonce..to_nonce).collect();
1624 let nonce_keys: Vec<String> = nonces
1625 .iter()
1626 .map(|n| self.relayer_nonce_key(relayer_id, *n))
1627 .collect();
1628
1629 let mut conn = self
1632 .get_connection(self.connections.primary(), "get_nonce_occupancy")
1633 .await?;
1634 let tx_ids: Vec<Option<String>> = redis::cmd("MGET")
1635 .arg(&nonce_keys)
1636 .query_async(&mut conn)
1637 .await
1638 .map_err(|e| self.map_redis_error(e, "get_nonce_occupancy:mget_nonces"))?;
1639
1640 let mut tx_key_entries: Vec<(usize, String)> = Vec::new();
1643 for (i, tx_id) in tx_ids.iter().enumerate() {
1644 if let Some(id) = tx_id {
1645 tx_key_entries.push((i, self.tx_key(relayer_id, id)));
1646 }
1647 }
1648
1649 let tx_statuses: Vec<Option<TransactionStatus>> = if tx_key_entries.is_empty() {
1651 vec![]
1652 } else {
1653 let data_keys: Vec<&str> = tx_key_entries.iter().map(|(_, k)| k.as_str()).collect();
1654 let raw_values: Vec<Option<String>> = redis::cmd("MGET")
1655 .arg(&data_keys)
1656 .query_async(&mut conn)
1657 .await
1658 .map_err(|e| self.map_redis_error(e, "get_nonce_occupancy:mget_txs"))?;
1659
1660 raw_values
1661 .into_iter()
1662 .enumerate()
1663 .map(|(i, v)| {
1664 v.and_then(|json| {
1665 match serde_json::from_str::<TransactionRepoModel>(&json) {
1666 Ok(tx) => Some(tx.status),
1667 Err(e) => {
1668 let nonce = tx_key_entries.get(i).map(|(idx, _)| nonces[*idx]);
1669 warn!(
1670 relayer_id = %relayer_id,
1671 nonce = ?nonce,
1672 error = %e,
1673 "get_nonce_occupancy: failed to deserialize transaction, treating as empty"
1674 );
1675 None
1676 }
1677 }
1678 })
1679 })
1680 .collect()
1681 };
1682
1683 let mut results: Vec<(u64, Option<TransactionStatus>)> =
1685 nonces.iter().map(|n| (*n, None)).collect();
1686
1687 for (idx, (original_idx, _)) in tx_key_entries.iter().enumerate() {
1688 if let Some(status) = tx_statuses.get(idx).and_then(|s| s.clone()) {
1689 results[*original_idx].1 = Some(status);
1690 }
1691 }
1692
1693 Ok(results)
1694 }
1695
1696 async fn update_status(
1697 &self,
1698 tx_id: String,
1699 status: TransactionStatus,
1700 ) -> Result<TransactionRepoModel, RepositoryError> {
1701 let update = TransactionUpdateRequest {
1702 status: Some(status),
1703 ..Default::default()
1704 };
1705 self.partial_update(tx_id, update).await
1706 }
1707
1708 async fn partial_update(
1709 &self,
1710 tx_id: String,
1711 update: TransactionUpdateRequest,
1712 ) -> Result<TransactionRepoModel, RepositoryError> {
1713 let patch_json = serde_json::to_string(&update).map_err(|e| {
1715 RepositoryError::InvalidData(format!("Failed to serialize update patch: {e}"))
1716 })?;
1717
1718 let delete_at_value = if let Some(ref status) = update.status {
1721 if FINAL_TRANSACTION_STATUSES.contains(status) {
1722 let expiration_hours = ServerConfig::get_transaction_expiration_hours();
1723 let seconds = (expiration_hours * 3600.0) as i64;
1724 let delete_time = Utc::now() + chrono::Duration::seconds(seconds);
1725 Some(delete_time.to_rfc3339())
1726 } else {
1727 None
1728 }
1729 } else {
1730 None
1731 };
1732 let delete_at_arg = delete_at_value.as_deref().unwrap_or("");
1733
1734 let (lookup_key, key_prefix, key_suffix) = self.tx_key_parts(&tx_id);
1735
1736 let patch_script = Script::new(
1742 r#"
1743 local relayer_id = redis.call('GET', KEYS[1])
1744 if not relayer_id then return false end
1745
1746 local tx_key = ARGV[1] .. relayer_id .. ARGV[2]
1747 local current = redis.call('GET', tx_key)
1748 if not current then return false end
1749
1750 local tx = cjson.decode(current)
1751 local patch = cjson.decode(ARGV[3])
1752
1753 -- Guard: reject status changes on finalized transactions.
1754 -- A stale worker must not resurrect a tx that another worker
1755 -- already moved to a terminal state.
1756 local final_states = {confirmed=true, failed=true, expired=true, canceled=true}
1757 if final_states[tx["status"]] and patch["status"] then
1758 return {current, current}
1759 end
1760
1761 local old_snapshot = current
1762
1763 -- lua-cjson cannot distinguish empty Lua tables from empty
1764 -- arrays, so a decode/encode round-trip turns [] into {}.
1765 -- Record which keys held [] in the stored doc and the patch
1766 -- so we can restore them after cjson.encode.
1767 -- NOTE: this relies on each array-typed field having a unique key
1768 -- name across the entire JSON document (including nested objects).
1769 -- If the model ever introduces duplicate key names at different
1770 -- nesting levels (e.g. metadata.hashes), the gsub below could
1771 -- restore the wrong occurrence.
1772 local empty_arrs = {}
1773 for k in string.gmatch(current, '"([^"]+)"%s*:%s*%[%s*%]') do
1774 empty_arrs[k] = true
1775 end
1776 for k in string.gmatch(ARGV[3], '"([^"]+)"%s*:%s*%[%s*%]') do
1777 empty_arrs[k] = true
1778 end
1779
1780 for k, v in pairs(patch) do
1781 tx[k] = v
1782 end
1783
1784 -- Apply delete_at if transitioning to a final state and not already set
1785 if ARGV[4] ~= '' and (not tx["delete_at"] or tx["delete_at"] == cjson.null) then
1786 tx["delete_at"] = ARGV[4]
1787 end
1788
1789 local updated = cjson.encode(tx)
1790
1791 -- Restore empty arrays that cjson.encode converted to {}
1792 for k, _ in pairs(empty_arrs) do
1793 updated = string.gsub(
1794 updated, '"'..k..'"%s*:%s*{}', '"'..k..'":[]', 1
1795 )
1796 end
1797
1798 redis.call('SET', tx_key, updated)
1799 return {old_snapshot, updated}
1800 "#,
1801 );
1802
1803 let result: Option<Vec<String>> = self
1804 .run_script_with_retry_vec(
1805 &patch_script,
1806 &lookup_key,
1807 &key_prefix,
1808 &key_suffix,
1809 &[&patch_json, delete_at_arg],
1810 "partial_update",
1811 )
1812 .await?;
1813
1814 let parts = result.ok_or_else(|| {
1815 RepositoryError::NotFound(format!("Transaction with ID {tx_id} not found"))
1816 })?;
1817
1818 if parts.len() != 2 {
1819 return Err(RepositoryError::UnexpectedError(format!(
1820 "partial_update script returned {} elements, expected 2",
1821 parts.len()
1822 )));
1823 }
1824
1825 let old_json = &parts[0];
1826 let new_json = &parts[1];
1827
1828 let original_tx =
1829 self.deserialize_entity::<TransactionRepoModel>(old_json, &tx_id, "transaction")?;
1830 let updated_tx =
1831 self.deserialize_entity::<TransactionRepoModel>(new_json, &tx_id, "transaction")?;
1832
1833 self.update_indexes(&updated_tx, Some(&original_tx)).await?;
1835
1836 debug!(tx_id = %tx_id, "successfully updated transaction via patch");
1837
1838 if original_tx.status != updated_tx.status {
1842 self.track_status_change_metrics(
1843 &original_tx,
1844 &updated_tx,
1845 &original_tx.status,
1846 &updated_tx.status,
1847 );
1848 }
1849
1850 Ok(updated_tx)
1851 }
1852
1853 async fn update_network_data(
1854 &self,
1855 tx_id: String,
1856 network_data: NetworkTransactionData,
1857 ) -> Result<TransactionRepoModel, RepositoryError> {
1858 let update = TransactionUpdateRequest {
1859 network_data: Some(network_data),
1860 ..Default::default()
1861 };
1862 self.partial_update(tx_id, update).await
1863 }
1864
1865 async fn set_sent_at(
1866 &self,
1867 tx_id: String,
1868 sent_at: String,
1869 ) -> Result<TransactionRepoModel, RepositoryError> {
1870 let update = TransactionUpdateRequest {
1871 sent_at: Some(sent_at),
1872 ..Default::default()
1873 };
1874 self.partial_update(tx_id, update).await
1875 }
1876
1877 async fn increment_status_check_failures(
1878 &self,
1879 tx_id: String,
1880 ) -> Result<TransactionRepoModel, RepositoryError> {
1881 self.run_atomic_script(
1882 r#"
1883 local function set_obj(json, key, tbl)
1884 local enc = cjson.encode(tbl)
1885 local r, n = string.gsub(json, '"'..key..'"%s*:%s*%b{}', '"'..key..'":'..enc, 1)
1886 if n > 0 then return r end
1887 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
1888 if n > 0 then return r end
1889 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
1890 end
1891
1892 local relayer_id = redis.call('GET', KEYS[1])
1893 if not relayer_id then return false end
1894
1895 local tx_key = ARGV[1] .. relayer_id .. ARGV[2]
1896 local current = redis.call('GET', tx_key)
1897 if not current then return false end
1898
1899 local tx = cjson.decode(current)
1900 local final_states = {confirmed=true, failed=true, expired=true, canceled=true}
1901 if final_states[tx["status"]] then return current end
1902
1903 local metadata = tx["metadata"]
1904 if type(metadata) ~= 'table' then metadata = {} end
1905 metadata["consecutive_failures"] = (metadata["consecutive_failures"] or 0) + 1
1906 metadata["total_failures"] = (metadata["total_failures"] or 0) + 1
1907
1908 local updated = set_obj(current, "metadata", metadata)
1909 redis.call('SET', tx_key, updated)
1910 return updated
1911 "#,
1912 &tx_id,
1913 &[],
1914 "increment_status_check_failures",
1915 )
1916 .await
1917 }
1918
1919 async fn reset_status_check_consecutive_failures(
1920 &self,
1921 tx_id: String,
1922 ) -> Result<TransactionRepoModel, RepositoryError> {
1923 self.run_atomic_script(
1924 r#"
1925 local function set_obj(json, key, tbl)
1926 local enc = cjson.encode(tbl)
1927 local r, n = string.gsub(json, '"'..key..'"%s*:%s*%b{}', '"'..key..'":'..enc, 1)
1928 if n > 0 then return r end
1929 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
1930 if n > 0 then return r end
1931 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
1932 end
1933
1934 local relayer_id = redis.call('GET', KEYS[1])
1935 if not relayer_id then return false end
1936
1937 local tx_key = ARGV[1] .. relayer_id .. ARGV[2]
1938 local current = redis.call('GET', tx_key)
1939 if not current then return false end
1940
1941 local tx = cjson.decode(current)
1942 local final_states = {confirmed=true, failed=true, expired=true, canceled=true}
1943 if final_states[tx["status"]] then return current end
1944
1945 local metadata = tx["metadata"]
1946 if type(metadata) ~= 'table' then metadata = {} end
1947 metadata["consecutive_failures"] = 0
1948
1949 local updated = set_obj(current, "metadata", metadata)
1950 redis.call('SET', tx_key, updated)
1951 return updated
1952 "#,
1953 &tx_id,
1954 &[],
1955 "reset_status_check_consecutive_failures",
1956 )
1957 .await
1958 }
1959
1960 async fn record_stellar_insufficient_fee_retry(
1961 &self,
1962 tx_id: String,
1963 sent_at: String,
1964 ) -> Result<TransactionRepoModel, RepositoryError> {
1965 self.run_atomic_script(
1966 r#"
1967 local function set_str(json, key, val)
1968 local enc = cjson.encode(val)
1969 local r, n = string.gsub(json, '"'..key..'"%s*:%s*"[^"]*"', '"'..key..'":'..enc, 1)
1970 if n > 0 then return r end
1971 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
1972 if n > 0 then return r end
1973 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
1974 end
1975 local function set_obj(json, key, tbl)
1976 local enc = cjson.encode(tbl)
1977 local r, n = string.gsub(json, '"'..key..'"%s*:%s*%b{}', '"'..key..'":'..enc, 1)
1978 if n > 0 then return r end
1979 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
1980 if n > 0 then return r end
1981 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
1982 end
1983
1984 local relayer_id = redis.call('GET', KEYS[1])
1985 if not relayer_id then return false end
1986
1987 local tx_key = ARGV[1] .. relayer_id .. ARGV[2]
1988 local current = redis.call('GET', tx_key)
1989 if not current then return false end
1990
1991 local tx = cjson.decode(current)
1992 local final_states = {confirmed=true, failed=true, expired=true, canceled=true}
1993 if final_states[tx["status"]] then return current end
1994
1995 local metadata = tx["metadata"]
1996 if type(metadata) ~= 'table' then metadata = {} end
1997 metadata["insufficient_fee_retries"] = (metadata["insufficient_fee_retries"] or 0) + 1
1998
1999 local updated = set_str(current, "sent_at", ARGV[3])
2000 updated = set_obj(updated, "metadata", metadata)
2001 redis.call('SET', tx_key, updated)
2002 return updated
2003 "#,
2004 &tx_id,
2005 &[&sent_at],
2006 "record_stellar_insufficient_fee_retry",
2007 )
2008 .await
2009 }
2010
2011 async fn record_stellar_try_again_later_retry(
2012 &self,
2013 tx_id: String,
2014 sent_at: String,
2015 ) -> Result<TransactionRepoModel, RepositoryError> {
2016 self.run_atomic_script(
2017 r#"
2018 local function set_str(json, key, val)
2019 local enc = cjson.encode(val)
2020 local r, n = string.gsub(json, '"'..key..'"%s*:%s*"[^"]*"', '"'..key..'":'..enc, 1)
2021 if n > 0 then return r end
2022 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
2023 if n > 0 then return r end
2024 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
2025 end
2026 local function set_obj(json, key, tbl)
2027 local enc = cjson.encode(tbl)
2028 local r, n = string.gsub(json, '"'..key..'"%s*:%s*%b{}', '"'..key..'":'..enc, 1)
2029 if n > 0 then return r end
2030 r, n = string.gsub(json, '"'..key..'"%s*:%s*null', '"'..key..'":'..enc, 1)
2031 if n > 0 then return r end
2032 return string.gsub(json, '}%s*$', ',"'..key..'":'..enc..'}', 1)
2033 end
2034
2035 local relayer_id = redis.call('GET', KEYS[1])
2036 if not relayer_id then return false end
2037
2038 local tx_key = ARGV[1] .. relayer_id .. ARGV[2]
2039 local current = redis.call('GET', tx_key)
2040 if not current then return false end
2041
2042 local tx = cjson.decode(current)
2043 local final_states = {confirmed=true, failed=true, expired=true, canceled=true}
2044 if final_states[tx["status"]] then return current end
2045
2046 local metadata = tx["metadata"]
2047 if type(metadata) ~= 'table' then metadata = {} end
2048 metadata["try_again_later_retries"] = (metadata["try_again_later_retries"] or 0) + 1
2049
2050 local updated = set_str(current, "sent_at", ARGV[3])
2051 updated = set_obj(updated, "metadata", metadata)
2052 redis.call('SET', tx_key, updated)
2053 return updated
2054 "#,
2055 &tx_id,
2056 &[&sent_at],
2057 "record_stellar_try_again_later_retry",
2058 )
2059 .await
2060 }
2061
2062 async fn set_confirmed_at(
2063 &self,
2064 tx_id: String,
2065 confirmed_at: String,
2066 ) -> Result<TransactionRepoModel, RepositoryError> {
2067 let update = TransactionUpdateRequest {
2068 confirmed_at: Some(confirmed_at),
2069 ..Default::default()
2070 };
2071 self.partial_update(tx_id, update).await
2072 }
2073
2074 async fn count_by_status(
2078 &self,
2079 relayer_id: &str,
2080 statuses: &[TransactionStatus],
2081 ) -> Result<u64, RepositoryError> {
2082 let mut conn = self
2083 .get_connection(self.connections.reader(), "count_by_status")
2084 .await?;
2085 let mut total_count: u64 = 0;
2086
2087 for status in statuses {
2088 self.ensure_status_sorted_set(relayer_id, status).await?;
2090
2091 let sorted_key = self.relayer_status_sorted_key(relayer_id, status);
2092 let count: u64 = conn
2093 .zcard(&sorted_key)
2094 .await
2095 .map_err(|e| self.map_redis_error(e, "count_by_status"))?;
2096 total_count += count;
2097 }
2098
2099 debug!(relayer_id = %relayer_id, count = %total_count, "counted transactions by status");
2100 Ok(total_count)
2101 }
2102
2103 async fn delete_by_ids(&self, ids: Vec<String>) -> Result<BatchDeleteResult, RepositoryError> {
2104 if ids.is_empty() {
2105 debug!("no transaction IDs provided for batch delete");
2106 return Ok(BatchDeleteResult::default());
2107 }
2108
2109 debug!(count = %ids.len(), "batch deleting transactions by IDs (with fetch)");
2110
2111 let batch_result = self.get_transactions_by_ids(&ids).await?;
2113
2114 let requests: Vec<TransactionDeleteRequest> = batch_result
2116 .results
2117 .iter()
2118 .map(|tx| TransactionDeleteRequest {
2119 id: tx.id.clone(),
2120 relayer_id: tx.relayer_id.clone(),
2121 nonce: self.extract_nonce(&tx.network_data),
2122 })
2123 .collect();
2124
2125 let mut result = self.delete_by_requests(requests).await?;
2127
2128 for id in batch_result.failed_ids {
2130 result
2131 .failed
2132 .push((id.clone(), format!("Transaction with ID {id} not found")));
2133 }
2134
2135 Ok(result)
2136 }
2137
2138 async fn delete_by_requests(
2139 &self,
2140 requests: Vec<TransactionDeleteRequest>,
2141 ) -> Result<BatchDeleteResult, RepositoryError> {
2142 if requests.is_empty() {
2143 debug!("no delete requests provided for batch delete");
2144 return Ok(BatchDeleteResult::default());
2145 }
2146
2147 debug!(count = %requests.len(), "batch deleting transactions by requests (no fetch)");
2148 let mut conn = self
2149 .get_connection(self.connections.primary(), "batch_delete_no_fetch")
2150 .await?;
2151 let mut pipe = redis::pipe();
2152 pipe.atomic();
2153
2154 let all_statuses = [
2156 TransactionStatus::Canceled,
2157 TransactionStatus::Pending,
2158 TransactionStatus::Sent,
2159 TransactionStatus::Submitted,
2160 TransactionStatus::Mined,
2161 TransactionStatus::Confirmed,
2162 TransactionStatus::Failed,
2163 TransactionStatus::Expired,
2164 ];
2165
2166 for req in &requests {
2168 let tx_key = self.tx_key(&req.relayer_id, &req.id);
2170 pipe.del(&tx_key);
2171
2172 let reverse_key = self.tx_to_relayer_key(&req.id);
2174 pipe.del(&reverse_key);
2175
2176 for status in &all_statuses {
2178 let status_sorted_key = self.relayer_status_sorted_key(&req.relayer_id, status);
2179 pipe.zrem(&status_sorted_key, &req.id);
2180
2181 let status_legacy_key = self.relayer_status_key(&req.relayer_id, status);
2182 pipe.srem(&status_legacy_key, &req.id);
2183 }
2184
2185 if let Some(nonce) = req.nonce {
2187 let nonce_key = self.relayer_nonce_key(&req.relayer_id, nonce);
2188 pipe.del(&nonce_key);
2189 }
2190
2191 let relayer_sorted_key = self.relayer_tx_by_created_at_key(&req.relayer_id);
2193 pipe.zrem(&relayer_sorted_key, &req.id);
2194 }
2195
2196 match pipe.exec_async(&mut conn).await {
2198 Ok(_) => {
2199 let deleted_count = requests.len();
2200 debug!(
2201 deleted_count = %deleted_count,
2202 "batch delete completed"
2203 );
2204 Ok(BatchDeleteResult {
2205 deleted_count,
2206 failed: vec![],
2207 })
2208 }
2209 Err(e) => {
2210 error!(error = %e, "batch delete pipeline failed");
2211 let failed: Vec<(String, String)> = requests
2213 .iter()
2214 .map(|req| (req.id.clone(), format!("Redis pipeline error: {e}")))
2215 .collect();
2216 Ok(BatchDeleteResult {
2217 deleted_count: 0,
2218 failed,
2219 })
2220 }
2221 }
2222 }
2223}
2224
2225#[cfg(test)]
2226mod tests {
2227 use super::*;
2228 use crate::models::{
2229 evm::Speed, EvmTransactionData, MemoSpec, NetworkType, StellarTransactionData,
2230 TransactionInput,
2231 };
2232 use alloy::primitives::U256;
2233 use deadpool_redis::{Config, Runtime};
2234 use lazy_static::lazy_static;
2235 use std::str::FromStr;
2236 use tokio;
2237 use uuid::Uuid;
2238
2239 use tokio::sync::Mutex;
2240
2241 lazy_static! {
2243 static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
2244 }
2245
2246 fn create_test_transaction(id: &str) -> TransactionRepoModel {
2248 TransactionRepoModel {
2249 id: id.to_string(),
2250 relayer_id: "relayer-1".to_string(),
2251 status: TransactionStatus::Pending,
2252 status_reason: None,
2253 created_at: "2025-01-27T15:31:10.777083+00:00".to_string(),
2254 sent_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
2255 confirmed_at: Some("2025-01-27T15:31:10.777083+00:00".to_string()),
2256 valid_until: None,
2257 delete_at: None,
2258 network_type: NetworkType::Evm,
2259 priced_at: None,
2260 hashes: vec![],
2261 network_data: NetworkTransactionData::Evm(EvmTransactionData {
2262 gas_price: Some(1000000000),
2263 gas_limit: Some(21000),
2264 nonce: Some(1),
2265 value: U256::from_str("1000000000000000000").unwrap(),
2266 data: Some("0x".to_string()),
2267 from: "0xSender".to_string(),
2268 to: Some("0xRecipient".to_string()),
2269 chain_id: 1,
2270 signature: None,
2271 hash: Some(format!("0x{id}")),
2272 speed: Some(Speed::Fast),
2273 max_fee_per_gas: None,
2274 max_priority_fee_per_gas: None,
2275 raw: None,
2276 }),
2277 noop_count: None,
2278 is_canceled: Some(false),
2279 metadata: None,
2280 }
2281 }
2282
2283 fn create_test_transaction_with_relayer(id: &str, relayer_id: &str) -> TransactionRepoModel {
2284 let mut tx = create_test_transaction(id);
2285 tx.relayer_id = relayer_id.to_string();
2286 tx
2287 }
2288
2289 fn create_stellar_test_transaction(
2290 id: &str,
2291 relayer_id: &str,
2292 sequence_number: i64,
2293 max_fee: i64,
2294 memo_id: u64,
2295 ) -> TransactionRepoModel {
2296 let mut tx = create_test_transaction_with_relayer(id, relayer_id);
2297 tx.network_type = NetworkType::Stellar;
2298 tx.network_data = NetworkTransactionData::Stellar(StellarTransactionData {
2299 source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
2300 fee: Some(100),
2301 sequence_number: Some(sequence_number),
2302 memo: Some(MemoSpec::Id { value: memo_id }),
2303 valid_until: None,
2304 network_passphrase: "Test SDF Network ; September 2015".to_string(),
2305 signatures: vec![],
2306 hash: None,
2307 simulation_transaction_data: None,
2308 transaction_input: TransactionInput::SignedXdr {
2309 xdr: "AAAA".to_string(),
2310 max_fee,
2311 },
2312 signed_envelope_xdr: None,
2313 transaction_result_xdr: None,
2314 });
2315 tx
2316 }
2317
2318 fn create_test_transaction_with_status(
2319 id: &str,
2320 relayer_id: &str,
2321 status: TransactionStatus,
2322 ) -> TransactionRepoModel {
2323 let mut tx = create_test_transaction_with_relayer(id, relayer_id);
2324 tx.status = status;
2325 tx
2326 }
2327
2328 fn create_test_transaction_with_nonce(
2329 id: &str,
2330 nonce: u64,
2331 relayer_id: &str,
2332 ) -> TransactionRepoModel {
2333 let mut tx = create_test_transaction_with_relayer(id, relayer_id);
2334 if let NetworkTransactionData::Evm(ref mut evm_data) = tx.network_data {
2335 evm_data.nonce = Some(nonce);
2336 }
2337 tx
2338 }
2339
2340 async fn setup_test_repo() -> RedisTransactionRepository {
2341 let redis_url = std::env::var("REDIS_TEST_URL")
2343 .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
2344
2345 let cfg = Config::from_url(&redis_url);
2346 let pool = Arc::new(
2347 cfg.builder()
2348 .expect("Failed to create pool builder")
2349 .max_size(16)
2350 .runtime(Runtime::Tokio1)
2351 .build()
2352 .expect("Failed to build Redis pool"),
2353 );
2354
2355 let connections = Arc::new(RedisConnections::new_single_pool(pool));
2357
2358 let random_id = Uuid::new_v4().to_string();
2359 let key_prefix = format!("test_prefix:{random_id}");
2360
2361 RedisTransactionRepository::new(connections, key_prefix)
2362 .expect("Failed to create RedisTransactionRepository")
2363 }
2364
2365 #[tokio::test]
2366 #[ignore = "Requires active Redis instance"]
2367 async fn test_new_repository_creation() {
2368 let repo = setup_test_repo().await;
2369 assert!(repo.key_prefix.contains("test_prefix"));
2370 }
2371
2372 #[tokio::test]
2373 #[ignore = "Requires active Redis instance"]
2374 async fn test_new_repository_empty_prefix_fails() {
2375 let redis_url = std::env::var("REDIS_TEST_URL")
2376 .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
2377 let cfg = Config::from_url(&redis_url);
2378 let pool = Arc::new(
2379 cfg.builder()
2380 .expect("Failed to create pool builder")
2381 .max_size(16)
2382 .runtime(Runtime::Tokio1)
2383 .build()
2384 .expect("Failed to build Redis pool"),
2385 );
2386 let connections = Arc::new(RedisConnections::new_single_pool(pool));
2387
2388 let result = RedisTransactionRepository::new(connections, "".to_string());
2389 assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
2390 }
2391
2392 #[tokio::test]
2393 #[ignore = "Requires active Redis instance"]
2394 async fn test_key_generation() {
2395 let repo = setup_test_repo().await;
2396
2397 assert!(repo
2398 .tx_key("relayer-1", "test-id")
2399 .contains(":relayer:relayer-1:tx:test-id"));
2400 assert!(repo
2401 .tx_to_relayer_key("test-id")
2402 .contains(":relayer:tx_to_relayer:test-id"));
2403 assert!(repo.relayer_list_key().contains(":relayer_list"));
2404 assert!(repo
2405 .relayer_status_key("relayer-1", &TransactionStatus::Pending)
2406 .contains(":relayer:relayer-1:status:Pending"));
2407 assert!(repo
2408 .relayer_nonce_key("relayer-1", 42)
2409 .contains(":relayer:relayer-1:nonce:42"));
2410 }
2411
2412 #[tokio::test]
2413 #[ignore = "Requires active Redis instance"]
2414 async fn test_serialize_deserialize_transaction() {
2415 let repo = setup_test_repo().await;
2416 let tx = create_test_transaction("test-1");
2417
2418 let serialized = repo
2419 .serialize_entity(&tx, |t| &t.id, "transaction")
2420 .expect("Serialization should succeed");
2421 let deserialized: TransactionRepoModel = repo
2422 .deserialize_entity(&serialized, "test-1", "transaction")
2423 .expect("Deserialization should succeed");
2424
2425 assert_eq!(tx.id, deserialized.id);
2426 assert_eq!(tx.relayer_id, deserialized.relayer_id);
2427 assert_eq!(tx.status, deserialized.status);
2428 }
2429
2430 #[tokio::test]
2431 #[ignore = "Requires active Redis instance"]
2432 async fn test_extract_nonce() {
2433 let repo = setup_test_repo().await;
2434 let random_id = Uuid::new_v4().to_string();
2435 let relayer_id = Uuid::new_v4().to_string();
2436 let tx_with_nonce = create_test_transaction_with_nonce(&random_id, 42, &relayer_id);
2437
2438 let nonce = repo.extract_nonce(&tx_with_nonce.network_data);
2439 assert_eq!(nonce, Some(42));
2440 }
2441
2442 #[tokio::test]
2443 #[ignore = "Requires active Redis instance"]
2444 async fn test_create_transaction() {
2445 let repo = setup_test_repo().await;
2446 let random_id = Uuid::new_v4().to_string();
2447 let tx = create_test_transaction(&random_id);
2448
2449 let result = repo.create(tx.clone()).await.unwrap();
2450 assert_eq!(result.id, tx.id);
2451 }
2452
2453 #[tokio::test]
2454 #[ignore = "Requires active Redis instance"]
2455 async fn test_get_transaction() {
2456 let repo = setup_test_repo().await;
2457 let random_id = Uuid::new_v4().to_string();
2458 let tx = create_test_transaction(&random_id);
2459
2460 repo.create(tx.clone()).await.unwrap();
2461 let stored = repo.get_by_id(random_id.to_string()).await.unwrap();
2462 assert_eq!(stored.id, tx.id);
2463 assert_eq!(stored.relayer_id, tx.relayer_id);
2464 }
2465
2466 #[tokio::test]
2467 #[ignore = "Requires active Redis instance"]
2468 async fn test_update_transaction() {
2469 let repo = setup_test_repo().await;
2470 let random_id = Uuid::new_v4().to_string();
2471 let mut tx = create_test_transaction(&random_id);
2472
2473 repo.create(tx.clone()).await.unwrap();
2474 tx.status = TransactionStatus::Confirmed;
2475
2476 let updated = repo.update(random_id.to_string(), tx).await.unwrap();
2477 assert!(matches!(updated.status, TransactionStatus::Confirmed));
2478 }
2479
2480 #[tokio::test]
2481 #[ignore = "Requires active Redis instance"]
2482 async fn test_delete_transaction() {
2483 let repo = setup_test_repo().await;
2484 let random_id = Uuid::new_v4().to_string();
2485 let tx = create_test_transaction(&random_id);
2486
2487 repo.create(tx).await.unwrap();
2488 repo.delete_by_id(random_id.to_string()).await.unwrap();
2489
2490 let result = repo.get_by_id(random_id.to_string()).await;
2491 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2492 }
2493
2494 #[tokio::test]
2495 #[ignore = "Requires active Redis instance"]
2496 async fn test_list_all_transactions() {
2497 let repo = setup_test_repo().await;
2498 let random_id = Uuid::new_v4().to_string();
2499 let random_id2 = Uuid::new_v4().to_string();
2500
2501 let tx1 = create_test_transaction(&random_id);
2502 let tx2 = create_test_transaction(&random_id2);
2503
2504 repo.create(tx1).await.unwrap();
2505 repo.create(tx2).await.unwrap();
2506
2507 let transactions = repo.list_all().await.unwrap();
2508 assert!(transactions.len() >= 2);
2509 }
2510
2511 #[tokio::test]
2512 #[ignore = "Requires active Redis instance"]
2513 async fn test_count_transactions() {
2514 let repo = setup_test_repo().await;
2515 let random_id = Uuid::new_v4().to_string();
2516 let tx = create_test_transaction(&random_id);
2517
2518 let count = repo.count().await.unwrap();
2519 repo.create(tx).await.unwrap();
2520 assert!(repo.count().await.unwrap() > count);
2521 }
2522
2523 #[tokio::test]
2524 #[ignore = "Requires active Redis instance"]
2525 async fn test_get_nonexistent_transaction() {
2526 let repo = setup_test_repo().await;
2527 let result = repo.get_by_id("nonexistent".to_string()).await;
2528 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2529 }
2530
2531 #[tokio::test]
2532 #[ignore = "Requires active Redis instance"]
2533 async fn test_duplicate_transaction_creation() {
2534 let repo = setup_test_repo().await;
2535 let random_id = Uuid::new_v4().to_string();
2536
2537 let tx = create_test_transaction(&random_id);
2538
2539 repo.create(tx.clone()).await.unwrap();
2540 let result = repo.create(tx).await;
2541
2542 assert!(matches!(
2543 result,
2544 Err(RepositoryError::ConstraintViolation(_))
2545 ));
2546 }
2547
2548 #[tokio::test]
2549 #[ignore = "Requires active Redis instance"]
2550 async fn test_update_nonexistent_transaction() {
2551 let repo = setup_test_repo().await;
2552 let tx = create_test_transaction("test-1");
2553
2554 let result = repo.update("nonexistent".to_string(), tx).await;
2555 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
2556 }
2557
2558 #[tokio::test]
2559 #[ignore = "Requires active Redis instance"]
2560 async fn test_list_paginated() {
2561 let repo = setup_test_repo().await;
2562
2563 for _ in 1..=10 {
2565 let random_id = Uuid::new_v4().to_string();
2566 let tx = create_test_transaction(&random_id);
2567 repo.create(tx).await.unwrap();
2568 }
2569
2570 let query = PaginationQuery {
2572 page: 1,
2573 per_page: 3,
2574 };
2575 let result = repo.list_paginated(query).await.unwrap();
2576 assert_eq!(result.items.len(), 3);
2577 assert!(result.total >= 10);
2578 assert_eq!(result.page, 1);
2579 assert_eq!(result.per_page, 3);
2580
2581 let query = PaginationQuery {
2583 page: 1000,
2584 per_page: 3,
2585 };
2586 let result = repo.list_paginated(query).await.unwrap();
2587 assert_eq!(result.items.len(), 0);
2588 }
2589
2590 #[tokio::test]
2591 #[ignore = "Requires active Redis instance"]
2592 async fn test_find_by_relayer_id() {
2593 let repo = setup_test_repo().await;
2594 let random_id = Uuid::new_v4().to_string();
2595 let random_id2 = Uuid::new_v4().to_string();
2596 let random_id3 = Uuid::new_v4().to_string();
2597
2598 let tx1 = create_test_transaction_with_relayer(&random_id, "relayer-1");
2599 let tx2 = create_test_transaction_with_relayer(&random_id2, "relayer-1");
2600 let tx3 = create_test_transaction_with_relayer(&random_id3, "relayer-2");
2601
2602 repo.create(tx1).await.unwrap();
2603 repo.create(tx2).await.unwrap();
2604 repo.create(tx3).await.unwrap();
2605
2606 let query = PaginationQuery {
2608 page: 1,
2609 per_page: 10,
2610 };
2611 let result = repo
2612 .find_by_relayer_id("relayer-1", query.clone())
2613 .await
2614 .unwrap();
2615 assert!(result.total >= 2);
2616 assert!(result.items.len() >= 2);
2617 assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-1"));
2618
2619 let result = repo
2621 .find_by_relayer_id("relayer-2", query.clone())
2622 .await
2623 .unwrap();
2624 assert!(result.total >= 1);
2625 assert!(!result.items.is_empty());
2626 assert!(result.items.iter().all(|tx| tx.relayer_id == "relayer-2"));
2627
2628 let result = repo
2630 .find_by_relayer_id("non-existent", query.clone())
2631 .await
2632 .unwrap();
2633 assert_eq!(result.total, 0);
2634 assert_eq!(result.items.len(), 0);
2635 }
2636
2637 #[tokio::test]
2638 #[ignore = "Requires active Redis instance"]
2639 async fn test_find_by_relayer_id_sorted_by_created_at_newest_first() {
2640 let repo = setup_test_repo().await;
2641 let relayer_id = Uuid::new_v4().to_string();
2642
2643 let mut tx1 = create_test_transaction_with_relayer("test-1", &relayer_id);
2645 tx1.created_at = "2025-01-27T10:00:00.000000+00:00".to_string(); let mut tx2 = create_test_transaction_with_relayer("test-2", &relayer_id);
2648 tx2.created_at = "2025-01-27T12:00:00.000000+00:00".to_string(); let mut tx3 = create_test_transaction_with_relayer("test-3", &relayer_id);
2651 tx3.created_at = "2025-01-27T14:00:00.000000+00:00".to_string(); repo.create(tx2.clone()).await.unwrap(); repo.create(tx1.clone()).await.unwrap(); repo.create(tx3.clone()).await.unwrap(); let query = PaginationQuery {
2659 page: 1,
2660 per_page: 10,
2661 };
2662 let result = repo.find_by_relayer_id(&relayer_id, query).await.unwrap();
2663
2664 assert_eq!(result.total, 3);
2665 assert_eq!(result.items.len(), 3);
2666
2667 assert_eq!(
2669 result.items[0].id, "test-3",
2670 "First item should be newest (test-3)"
2671 );
2672 assert_eq!(
2673 result.items[0].created_at,
2674 "2025-01-27T14:00:00.000000+00:00"
2675 );
2676
2677 assert_eq!(
2678 result.items[1].id, "test-2",
2679 "Second item should be middle (test-2)"
2680 );
2681 assert_eq!(
2682 result.items[1].created_at,
2683 "2025-01-27T12:00:00.000000+00:00"
2684 );
2685
2686 assert_eq!(
2687 result.items[2].id, "test-1",
2688 "Third item should be oldest (test-1)"
2689 );
2690 assert_eq!(
2691 result.items[2].created_at,
2692 "2025-01-27T10:00:00.000000+00:00"
2693 );
2694 }
2695
2696 #[tokio::test]
2697 #[ignore = "Requires active Redis instance"]
2698 async fn test_find_by_status() {
2699 let repo = setup_test_repo().await;
2700 let random_id = Uuid::new_v4().to_string();
2701 let random_id2 = Uuid::new_v4().to_string();
2702 let random_id3 = Uuid::new_v4().to_string();
2703 let relayer_id = Uuid::new_v4().to_string();
2704 let tx1 = create_test_transaction_with_status(
2705 &random_id,
2706 &relayer_id,
2707 TransactionStatus::Pending,
2708 );
2709 let tx2 =
2710 create_test_transaction_with_status(&random_id2, &relayer_id, TransactionStatus::Sent);
2711 let tx3 = create_test_transaction_with_status(
2712 &random_id3,
2713 &relayer_id,
2714 TransactionStatus::Confirmed,
2715 );
2716
2717 repo.create(tx1).await.unwrap();
2718 repo.create(tx2).await.unwrap();
2719 repo.create(tx3).await.unwrap();
2720
2721 let result = repo
2723 .find_by_status(&relayer_id, &[TransactionStatus::Pending])
2724 .await
2725 .unwrap();
2726 assert_eq!(result.len(), 1);
2727 assert_eq!(result[0].status, TransactionStatus::Pending);
2728
2729 let result = repo
2731 .find_by_status(
2732 &relayer_id,
2733 &[TransactionStatus::Pending, TransactionStatus::Sent],
2734 )
2735 .await
2736 .unwrap();
2737 assert_eq!(result.len(), 2);
2738
2739 let result = repo
2741 .find_by_status(&relayer_id, &[TransactionStatus::Failed])
2742 .await
2743 .unwrap();
2744 assert_eq!(result.len(), 0);
2745 }
2746
2747 #[tokio::test]
2748 #[ignore = "Requires active Redis instance"]
2749 async fn test_find_by_status_paginated() {
2750 let repo = setup_test_repo().await;
2751 let relayer_id = Uuid::new_v4().to_string();
2752
2753 for i in 1..=5 {
2755 let tx_id = Uuid::new_v4().to_string();
2756 let mut tx = create_test_transaction_with_status(
2757 &tx_id,
2758 &relayer_id,
2759 TransactionStatus::Pending,
2760 );
2761 tx.created_at = format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i);
2762 repo.create(tx).await.unwrap();
2763 }
2764
2765 for i in 6..=7 {
2767 let tx_id = Uuid::new_v4().to_string();
2768 let mut tx = create_test_transaction_with_status(
2769 &tx_id,
2770 &relayer_id,
2771 TransactionStatus::Confirmed,
2772 );
2773 tx.created_at = format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i);
2774 repo.create(tx).await.unwrap();
2775 }
2776
2777 let query = PaginationQuery {
2779 page: 1,
2780 per_page: 2,
2781 };
2782 let result = repo
2783 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Pending], query, false)
2784 .await
2785 .unwrap();
2786
2787 assert_eq!(result.total, 5);
2788 assert_eq!(result.items.len(), 2);
2789 assert_eq!(result.page, 1);
2790 assert_eq!(result.per_page, 2);
2791
2792 let query = PaginationQuery {
2794 page: 2,
2795 per_page: 2,
2796 };
2797 let result = repo
2798 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Pending], query, false)
2799 .await
2800 .unwrap();
2801
2802 assert_eq!(result.total, 5);
2803 assert_eq!(result.items.len(), 2);
2804 assert_eq!(result.page, 2);
2805
2806 let query = PaginationQuery {
2808 page: 3,
2809 per_page: 2,
2810 };
2811 let result = repo
2812 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Pending], query, false)
2813 .await
2814 .unwrap();
2815
2816 assert_eq!(result.total, 5);
2817 assert_eq!(result.items.len(), 1);
2818
2819 let query = PaginationQuery {
2821 page: 1,
2822 per_page: 10,
2823 };
2824 let result = repo
2825 .find_by_status_paginated(
2826 &relayer_id,
2827 &[TransactionStatus::Pending, TransactionStatus::Confirmed],
2828 query,
2829 false,
2830 )
2831 .await
2832 .unwrap();
2833
2834 assert_eq!(result.total, 7);
2835 assert_eq!(result.items.len(), 7);
2836
2837 let query = PaginationQuery {
2839 page: 1,
2840 per_page: 10,
2841 };
2842 let result = repo
2843 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Failed], query, false)
2844 .await
2845 .unwrap();
2846
2847 assert_eq!(result.total, 0);
2848 assert_eq!(result.items.len(), 0);
2849 }
2850
2851 #[tokio::test]
2852 #[ignore = "Requires active Redis instance"]
2853 async fn test_find_by_status_paginated_oldest_first() {
2854 let repo = setup_test_repo().await;
2855 let relayer_id = Uuid::new_v4().to_string();
2856
2857 for i in 1..=5 {
2859 let tx_id = format!("tx{}-{}", i, Uuid::new_v4());
2860 let mut tx = create_test_transaction(&tx_id);
2861 tx.relayer_id = relayer_id.clone();
2862 tx.status = TransactionStatus::Pending;
2863 tx.created_at = format!("2025-01-27T{:02}:00:00.000000+00:00", 10 + i);
2864 repo.create(tx).await.unwrap();
2865 }
2866
2867 let query = PaginationQuery {
2869 page: 1,
2870 per_page: 3,
2871 };
2872 let result = repo
2873 .find_by_status_paginated(
2874 &relayer_id,
2875 &[TransactionStatus::Pending],
2876 query.clone(),
2877 true,
2878 )
2879 .await
2880 .unwrap();
2881
2882 assert_eq!(result.total, 5);
2883 assert_eq!(result.items.len(), 3);
2884 assert!(
2886 result.items[0].created_at < result.items[1].created_at,
2887 "First item should be older than second"
2888 );
2889 assert!(
2890 result.items[1].created_at < result.items[2].created_at,
2891 "Second item should be older than third"
2892 );
2893
2894 let result_newest = repo
2896 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Pending], query, false)
2897 .await
2898 .unwrap();
2899
2900 assert_eq!(result_newest.items.len(), 3);
2901 assert!(
2903 result_newest.items[0].created_at > result_newest.items[1].created_at,
2904 "First item should be newer than second"
2905 );
2906 assert!(
2907 result_newest.items[1].created_at > result_newest.items[2].created_at,
2908 "Second item should be newer than third"
2909 );
2910 }
2911
2912 #[tokio::test]
2913 #[ignore = "Requires active Redis instance"]
2914 async fn test_find_by_status_paginated_oldest_first_single_item() {
2915 let repo = setup_test_repo().await;
2916 let relayer_id = Uuid::new_v4().to_string();
2917
2918 let timestamps = [
2920 "2025-01-27T08:00:00.000000+00:00", "2025-01-27T10:00:00.000000+00:00", "2025-01-27T12:00:00.000000+00:00", ];
2924
2925 let mut oldest_id = String::new();
2926 let mut newest_id = String::new();
2927
2928 for (i, timestamp) in timestamps.iter().enumerate() {
2929 let tx_id = format!("tx-{}-{}", i, Uuid::new_v4());
2930 if i == 0 {
2931 oldest_id = tx_id.clone();
2932 }
2933 if i == 2 {
2934 newest_id = tx_id.clone();
2935 }
2936 let mut tx = create_test_transaction(&tx_id);
2937 tx.relayer_id = relayer_id.clone();
2938 tx.status = TransactionStatus::Pending;
2939 tx.created_at = timestamp.to_string();
2940 repo.create(tx).await.unwrap();
2941 }
2942
2943 let query = PaginationQuery {
2945 page: 1,
2946 per_page: 1,
2947 };
2948 let result = repo
2949 .find_by_status_paginated(
2950 &relayer_id,
2951 &[TransactionStatus::Pending],
2952 query.clone(),
2953 true,
2954 )
2955 .await
2956 .unwrap();
2957
2958 assert_eq!(result.total, 3);
2959 assert_eq!(result.items.len(), 1);
2960 assert_eq!(
2961 result.items[0].id, oldest_id,
2962 "With oldest_first=true and per_page=1, should return the oldest transaction"
2963 );
2964
2965 let result = repo
2967 .find_by_status_paginated(&relayer_id, &[TransactionStatus::Pending], query, false)
2968 .await
2969 .unwrap();
2970
2971 assert_eq!(result.items.len(), 1);
2972 assert_eq!(
2973 result.items[0].id, newest_id,
2974 "With oldest_first=false and per_page=1, should return the newest transaction"
2975 );
2976 }
2977
2978 #[tokio::test]
2979 #[ignore = "Requires active Redis instance"]
2980 async fn test_find_by_nonce() {
2981 let repo = setup_test_repo().await;
2982 let random_id = Uuid::new_v4().to_string();
2983 let random_id2 = Uuid::new_v4().to_string();
2984 let relayer_id = Uuid::new_v4().to_string();
2985
2986 let tx1 = create_test_transaction_with_nonce(&random_id, 42, &relayer_id);
2987 let tx2 = create_test_transaction_with_nonce(&random_id2, 43, &relayer_id);
2988
2989 repo.create(tx1.clone()).await.unwrap();
2990 repo.create(tx2).await.unwrap();
2991
2992 let result = repo.find_by_nonce(&relayer_id, 42).await.unwrap();
2994 assert!(result.is_some());
2995 assert_eq!(result.unwrap().id, random_id);
2996
2997 let result = repo.find_by_nonce(&relayer_id, 99).await.unwrap();
2999 assert!(result.is_none());
3000
3001 let result = repo.find_by_nonce("non-existent", 42).await.unwrap();
3003 assert!(result.is_none());
3004 }
3005
3006 #[tokio::test]
3007 #[ignore = "Requires active Redis instance"]
3008 async fn test_get_nonce_occupancy_mixed_slots() {
3009 let repo = setup_test_repo().await;
3010 let relayer_id = Uuid::new_v4().to_string();
3011
3012 let tx1 = create_test_transaction_with_nonce(&Uuid::new_v4().to_string(), 10, &relayer_id);
3014 repo.create(tx1).await.unwrap();
3015
3016 let mut tx2 =
3017 create_test_transaction_with_nonce(&Uuid::new_v4().to_string(), 11, &relayer_id);
3018 tx2.status = TransactionStatus::Failed;
3019 repo.create(tx2).await.unwrap();
3020
3021 let result = repo.get_nonce_occupancy(&relayer_id, 10, 13).await.unwrap();
3022
3023 assert_eq!(result.len(), 3);
3024 assert_eq!(result[0], (10, Some(TransactionStatus::Pending)));
3025 assert_eq!(result[1], (11, Some(TransactionStatus::Failed)));
3026 assert_eq!(result[2], (12, None));
3027 }
3028
3029 #[tokio::test]
3030 #[ignore = "Requires active Redis instance"]
3031 async fn test_get_nonce_occupancy_empty_range() {
3032 let repo = setup_test_repo().await;
3033
3034 let result = repo.get_nonce_occupancy("any-relayer", 5, 5).await.unwrap();
3035 assert!(result.is_empty());
3036
3037 let result = repo
3038 .get_nonce_occupancy("any-relayer", 10, 5)
3039 .await
3040 .unwrap();
3041 assert!(result.is_empty());
3042 }
3043
3044 #[tokio::test]
3045 #[ignore = "Requires active Redis instance"]
3046 async fn test_get_nonce_occupancy_all_empty() {
3047 let repo = setup_test_repo().await;
3048 let relayer_id = Uuid::new_v4().to_string();
3049
3050 let result = repo
3052 .get_nonce_occupancy(&relayer_id, 100, 103)
3053 .await
3054 .unwrap();
3055
3056 assert_eq!(result.len(), 3);
3057 assert!(result.iter().all(|(_, status)| status.is_none()));
3058 }
3059
3060 #[tokio::test]
3061 #[ignore = "Requires active Redis instance"]
3062 async fn test_update_status() {
3063 let repo = setup_test_repo().await;
3064 let random_id = Uuid::new_v4().to_string();
3065 let tx = create_test_transaction(&random_id);
3066
3067 repo.create(tx).await.unwrap();
3068 let updated = repo
3069 .update_status(random_id.to_string(), TransactionStatus::Confirmed)
3070 .await
3071 .unwrap();
3072 assert_eq!(updated.status, TransactionStatus::Confirmed);
3073 }
3074
3075 #[tokio::test]
3076 #[ignore = "Requires active Redis instance"]
3077 async fn test_partial_update() {
3078 let repo = setup_test_repo().await;
3079 let random_id = Uuid::new_v4().to_string();
3080 let tx = create_test_transaction(&random_id);
3081
3082 repo.create(tx).await.unwrap();
3083
3084 let update = TransactionUpdateRequest {
3085 status: Some(TransactionStatus::Sent),
3086 status_reason: Some("Transaction sent".to_string()),
3087 sent_at: Some("2025-01-27T16:00:00.000000+00:00".to_string()),
3088 confirmed_at: None,
3089 network_data: None,
3090 hashes: None,
3091 is_canceled: None,
3092 priced_at: None,
3093 noop_count: None,
3094 delete_at: None,
3095 metadata: None,
3096 };
3097
3098 let updated = repo
3099 .partial_update(random_id.to_string(), update)
3100 .await
3101 .unwrap();
3102 assert_eq!(updated.status, TransactionStatus::Sent);
3103 assert_eq!(updated.status_reason, Some("Transaction sent".to_string()));
3104 assert_eq!(
3105 updated.sent_at,
3106 Some("2025-01-27T16:00:00.000000+00:00".to_string())
3107 );
3108 }
3109
3110 #[tokio::test]
3111 #[ignore = "Requires active Redis instance"]
3112 async fn test_partial_update_preserves_large_stellar_i64_fields() {
3113 let repo = setup_test_repo().await;
3120 let relayer_id = Uuid::new_v4().to_string();
3121 let tx_id = Uuid::new_v4().to_string();
3122 let seq = 643918676885760_i64;
3125 let max_fee = 549755813888_i64;
3126 let memo_id = 1099511627776_u64;
3127
3128 let tx = create_stellar_test_transaction(&tx_id, &relayer_id, seq, max_fee, memo_id);
3129 repo.create(tx).await.unwrap();
3130
3131 let update = TransactionUpdateRequest {
3132 status: Some(TransactionStatus::Sent),
3133 sent_at: Some("2025-01-27T16:00:00.000000+00:00".to_string()),
3134 ..Default::default()
3135 };
3136
3137 let updated = repo.partial_update(tx_id.clone(), update).await.unwrap();
3140 assert_large_stellar_fields(&updated, seq, max_fee, memo_id);
3141
3142 let reloaded = repo.get_by_id(tx_id).await.unwrap();
3145 assert_large_stellar_fields(&reloaded, seq, max_fee, memo_id);
3146 }
3147
3148 fn assert_large_stellar_fields(
3149 tx: &TransactionRepoModel,
3150 seq: i64,
3151 max_fee: i64,
3152 memo_id: u64,
3153 ) {
3154 let stellar = tx.network_data.get_stellar_transaction_data().unwrap();
3155 assert_eq!(stellar.sequence_number, Some(seq));
3156 assert_eq!(stellar.memo, Some(MemoSpec::Id { value: memo_id }));
3157 match stellar.transaction_input {
3158 TransactionInput::SignedXdr { max_fee: mf, .. } => assert_eq!(mf, max_fee),
3159 other => panic!("expected SignedXdr, got {other:?}"),
3160 }
3161 }
3162
3163 #[tokio::test]
3164 #[ignore = "Requires active Redis instance"]
3165 async fn test_set_sent_at() {
3166 let repo = setup_test_repo().await;
3167 let random_id = Uuid::new_v4().to_string();
3168 let tx = create_test_transaction(&random_id);
3169
3170 repo.create(tx).await.unwrap();
3171 let updated = repo
3172 .set_sent_at(
3173 random_id.to_string(),
3174 "2025-01-27T16:00:00.000000+00:00".to_string(),
3175 )
3176 .await
3177 .unwrap();
3178 assert_eq!(
3179 updated.sent_at,
3180 Some("2025-01-27T16:00:00.000000+00:00".to_string())
3181 );
3182 }
3183
3184 #[tokio::test]
3185 #[ignore = "Requires active Redis instance"]
3186 async fn test_set_confirmed_at() {
3187 let repo = setup_test_repo().await;
3188 let random_id = Uuid::new_v4().to_string();
3189 let tx = create_test_transaction(&random_id);
3190
3191 repo.create(tx).await.unwrap();
3192 let updated = repo
3193 .set_confirmed_at(
3194 random_id.to_string(),
3195 "2025-01-27T16:00:00.000000+00:00".to_string(),
3196 )
3197 .await
3198 .unwrap();
3199 assert_eq!(
3200 updated.confirmed_at,
3201 Some("2025-01-27T16:00:00.000000+00:00".to_string())
3202 );
3203 }
3204
3205 #[tokio::test]
3206 #[ignore = "Requires active Redis instance"]
3207 async fn test_update_network_data() {
3208 let repo = setup_test_repo().await;
3209 let random_id = Uuid::new_v4().to_string();
3210 let tx = create_test_transaction(&random_id);
3211
3212 repo.create(tx).await.unwrap();
3213
3214 let new_network_data = NetworkTransactionData::Evm(EvmTransactionData {
3215 gas_price: Some(2000000000),
3216 gas_limit: Some(42000),
3217 nonce: Some(2),
3218 value: U256::from_str("2000000000000000000").unwrap(),
3219 data: Some("0x1234".to_string()),
3220 from: "0xNewSender".to_string(),
3221 to: Some("0xNewRecipient".to_string()),
3222 chain_id: 1,
3223 signature: None,
3224 hash: Some("0xnewhash".to_string()),
3225 speed: Some(Speed::SafeLow),
3226 max_fee_per_gas: None,
3227 max_priority_fee_per_gas: None,
3228 raw: None,
3229 });
3230
3231 let updated = repo
3232 .update_network_data(random_id.to_string(), new_network_data.clone())
3233 .await
3234 .unwrap();
3235 assert_eq!(
3236 updated
3237 .network_data
3238 .get_evm_transaction_data()
3239 .unwrap()
3240 .hash,
3241 new_network_data.get_evm_transaction_data().unwrap().hash
3242 );
3243 }
3244
3245 #[tokio::test]
3246 #[ignore = "Requires active Redis instance"]
3247 async fn test_debug_implementation() {
3248 let repo = setup_test_repo().await;
3249 let debug_str = format!("{repo:?}");
3250 assert!(debug_str.contains("RedisTransactionRepository"));
3251 assert!(debug_str.contains("test_prefix"));
3252 }
3253
3254 #[tokio::test]
3255 #[ignore = "Requires active Redis instance"]
3256 async fn test_error_handling_empty_id() {
3257 let repo = setup_test_repo().await;
3258
3259 let result = repo.get_by_id("".to_string()).await;
3260 assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
3261
3262 let result = repo
3263 .update("".to_string(), create_test_transaction("test"))
3264 .await;
3265 assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
3266
3267 let result = repo.delete_by_id("".to_string()).await;
3268 assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
3269 }
3270
3271 #[tokio::test]
3272 #[ignore = "Requires active Redis instance"]
3273 async fn test_pagination_validation() {
3274 let repo = setup_test_repo().await;
3275
3276 let query = PaginationQuery {
3277 page: 1,
3278 per_page: 0,
3279 };
3280 let result = repo.list_paginated(query).await;
3281 assert!(matches!(result, Err(RepositoryError::InvalidData(_))));
3282 }
3283
3284 #[tokio::test]
3285 #[ignore = "Requires active Redis instance"]
3286 async fn test_index_consistency() {
3287 let repo = setup_test_repo().await;
3288 let random_id = Uuid::new_v4().to_string();
3289 let relayer_id = Uuid::new_v4().to_string();
3290 let tx = create_test_transaction_with_nonce(&random_id, 42, &relayer_id);
3291
3292 repo.create(tx.clone()).await.unwrap();
3294
3295 let found = repo.find_by_nonce(&relayer_id, 42).await.unwrap();
3297 assert!(found.is_some());
3298
3299 let mut updated_tx = tx.clone();
3301 if let NetworkTransactionData::Evm(ref mut evm_data) = updated_tx.network_data {
3302 evm_data.nonce = Some(43);
3303 }
3304
3305 repo.update(random_id.to_string(), updated_tx)
3306 .await
3307 .unwrap();
3308
3309 let old_nonce_result = repo.find_by_nonce(&relayer_id, 42).await.unwrap();
3311 assert!(old_nonce_result.is_none());
3312
3313 let new_nonce_result = repo.find_by_nonce(&relayer_id, 43).await.unwrap();
3315 assert!(new_nonce_result.is_some());
3316 }
3317
3318 #[tokio::test]
3319 #[ignore = "Requires active Redis instance"]
3320 async fn test_has_entries() {
3321 let repo = setup_test_repo().await;
3322 assert!(!repo.has_entries().await.unwrap());
3323
3324 let tx_id = uuid::Uuid::new_v4().to_string();
3325 let tx = create_test_transaction(&tx_id);
3326 repo.create(tx.clone()).await.unwrap();
3327
3328 assert!(repo.has_entries().await.unwrap());
3329 }
3330
3331 #[tokio::test]
3332 #[ignore = "Requires active Redis instance"]
3333 async fn test_drop_all_entries() {
3334 let repo = setup_test_repo().await;
3335 let tx_id = uuid::Uuid::new_v4().to_string();
3336 let tx = create_test_transaction(&tx_id);
3337 repo.create(tx.clone()).await.unwrap();
3338 assert!(repo.has_entries().await.unwrap());
3339
3340 repo.drop_all_entries().await.unwrap();
3341 assert!(!repo.has_entries().await.unwrap());
3342 }
3343
3344 #[tokio::test]
3346 #[ignore = "Requires active Redis instance"]
3347 async fn test_update_status_sets_delete_at_for_final_statuses() {
3348 let _lock = ENV_MUTEX.lock().await;
3349
3350 use chrono::{DateTime, Duration, Utc};
3351 use std::env;
3352
3353 env::set_var("TRANSACTION_EXPIRATION_HOURS", "6");
3355
3356 let repo = setup_test_repo().await;
3357
3358 let final_statuses = [
3359 TransactionStatus::Canceled,
3360 TransactionStatus::Confirmed,
3361 TransactionStatus::Failed,
3362 TransactionStatus::Expired,
3363 ];
3364
3365 for (i, status) in final_statuses.iter().enumerate() {
3366 let tx_id = format!("test-final-{}-{}", i, Uuid::new_v4());
3367 let mut tx = create_test_transaction(&tx_id);
3368
3369 tx.delete_at = None;
3371 tx.status = TransactionStatus::Pending;
3372
3373 repo.create(tx).await.unwrap();
3374
3375 let before_update = Utc::now();
3376
3377 let updated = repo
3379 .update_status(tx_id.clone(), status.clone())
3380 .await
3381 .unwrap();
3382
3383 assert!(
3385 updated.delete_at.is_some(),
3386 "delete_at should be set for status: {status:?}"
3387 );
3388
3389 let delete_at_str = updated.delete_at.unwrap();
3391 let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
3392 .expect("delete_at should be valid RFC3339")
3393 .with_timezone(&Utc);
3394
3395 let duration_from_before = delete_at.signed_duration_since(before_update);
3396 let expected_duration = Duration::hours(6);
3397 let tolerance = Duration::minutes(5);
3398
3399 assert!(
3400 duration_from_before >= expected_duration - tolerance
3401 && duration_from_before <= expected_duration + tolerance,
3402 "delete_at should be approximately 6 hours from now for status: {status:?}. Duration: {duration_from_before:?}"
3403 );
3404 }
3405
3406 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3408 }
3409
3410 #[tokio::test]
3411 #[ignore = "Requires active Redis instance"]
3412 async fn test_update_status_does_not_set_delete_at_for_non_final_statuses() {
3413 let _lock = ENV_MUTEX.lock().await;
3414
3415 use std::env;
3416
3417 env::set_var("TRANSACTION_EXPIRATION_HOURS", "4");
3418
3419 let repo = setup_test_repo().await;
3420
3421 let non_final_statuses = [
3422 TransactionStatus::Pending,
3423 TransactionStatus::Sent,
3424 TransactionStatus::Submitted,
3425 TransactionStatus::Mined,
3426 ];
3427
3428 for (i, status) in non_final_statuses.iter().enumerate() {
3429 let tx_id = format!("test-non-final-{}-{}", i, Uuid::new_v4());
3430 let mut tx = create_test_transaction(&tx_id);
3431 tx.delete_at = None;
3432 tx.status = TransactionStatus::Pending;
3433
3434 repo.create(tx).await.unwrap();
3435
3436 let updated = repo
3438 .update_status(tx_id.clone(), status.clone())
3439 .await
3440 .unwrap();
3441
3442 assert!(
3444 updated.delete_at.is_none(),
3445 "delete_at should NOT be set for status: {status:?}"
3446 );
3447 }
3448
3449 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3451 }
3452
3453 #[tokio::test]
3454 #[ignore = "Requires active Redis instance"]
3455 async fn test_partial_update_sets_delete_at_for_final_statuses() {
3456 let _lock = ENV_MUTEX.lock().await;
3457
3458 use chrono::{DateTime, Duration, Utc};
3459 use std::env;
3460
3461 env::set_var("TRANSACTION_EXPIRATION_HOURS", "8");
3462
3463 let repo = setup_test_repo().await;
3464 let tx_id = format!("test-partial-final-{}", Uuid::new_v4());
3465 let mut tx = create_test_transaction(&tx_id);
3466 tx.delete_at = None;
3467 tx.status = TransactionStatus::Pending;
3468
3469 repo.create(tx).await.unwrap();
3470
3471 let before_update = Utc::now();
3472
3473 let update = TransactionUpdateRequest {
3475 status: Some(TransactionStatus::Confirmed),
3476 status_reason: Some("Transaction completed".to_string()),
3477 confirmed_at: Some("2023-01-01T12:05:00Z".to_string()),
3478 ..Default::default()
3479 };
3480
3481 let updated = repo.partial_update(tx_id.clone(), update).await.unwrap();
3482
3483 assert!(
3485 updated.delete_at.is_some(),
3486 "delete_at should be set when updating to Confirmed status"
3487 );
3488
3489 let delete_at_str = updated.delete_at.unwrap();
3491 let delete_at = DateTime::parse_from_rfc3339(&delete_at_str)
3492 .expect("delete_at should be valid RFC3339")
3493 .with_timezone(&Utc);
3494
3495 let duration_from_before = delete_at.signed_duration_since(before_update);
3496 let expected_duration = Duration::hours(8);
3497 let tolerance = Duration::minutes(5);
3498
3499 assert!(
3500 duration_from_before >= expected_duration - tolerance
3501 && duration_from_before <= expected_duration + tolerance,
3502 "delete_at should be approximately 8 hours from now. Duration: {duration_from_before:?}"
3503 );
3504
3505 assert_eq!(updated.status, TransactionStatus::Confirmed);
3507 assert_eq!(
3508 updated.status_reason,
3509 Some("Transaction completed".to_string())
3510 );
3511 assert_eq!(
3512 updated.confirmed_at,
3513 Some("2023-01-01T12:05:00Z".to_string())
3514 );
3515
3516 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3518 }
3519
3520 #[tokio::test]
3521 #[ignore = "Requires active Redis instance"]
3522 async fn test_update_status_preserves_existing_delete_at() {
3523 let _lock = ENV_MUTEX.lock().await;
3524
3525 use std::env;
3526
3527 env::set_var("TRANSACTION_EXPIRATION_HOURS", "2");
3528
3529 let repo = setup_test_repo().await;
3530 let tx_id = format!("test-preserve-delete-at-{}", Uuid::new_v4());
3531 let mut tx = create_test_transaction(&tx_id);
3532
3533 let existing_delete_at = "2025-01-01T12:00:00Z".to_string();
3535 tx.delete_at = Some(existing_delete_at.clone());
3536 tx.status = TransactionStatus::Pending;
3537
3538 repo.create(tx).await.unwrap();
3539
3540 let updated = repo
3542 .update_status(tx_id.clone(), TransactionStatus::Confirmed)
3543 .await
3544 .unwrap();
3545
3546 assert_eq!(
3548 updated.delete_at,
3549 Some(existing_delete_at),
3550 "Existing delete_at should be preserved when updating to final status"
3551 );
3552
3553 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3555 }
3556 #[tokio::test]
3557 #[ignore = "Requires active Redis instance"]
3558 async fn test_partial_update_without_status_change_preserves_delete_at() {
3559 let _lock = ENV_MUTEX.lock().await;
3560
3561 use std::env;
3562
3563 env::set_var("TRANSACTION_EXPIRATION_HOURS", "3");
3564
3565 let repo = setup_test_repo().await;
3566 let tx_id = format!("test-preserve-no-status-{}", Uuid::new_v4());
3567 let mut tx = create_test_transaction(&tx_id);
3568 tx.delete_at = None;
3569 tx.status = TransactionStatus::Pending;
3570
3571 repo.create(tx).await.unwrap();
3572
3573 let updated1 = repo
3575 .update_status(tx_id.clone(), TransactionStatus::Confirmed)
3576 .await
3577 .unwrap();
3578
3579 assert!(updated1.delete_at.is_some());
3580 let original_delete_at = updated1.delete_at.clone();
3581
3582 let update = TransactionUpdateRequest {
3584 status: None, status_reason: Some("Updated reason".to_string()),
3586 confirmed_at: Some("2023-01-01T12:10:00Z".to_string()),
3587 ..Default::default()
3588 };
3589
3590 let updated2 = repo.partial_update(tx_id.clone(), update).await.unwrap();
3591
3592 assert_eq!(
3594 updated2.delete_at, original_delete_at,
3595 "delete_at should be preserved when status is not updated"
3596 );
3597
3598 assert_eq!(updated2.status, TransactionStatus::Confirmed); assert_eq!(updated2.status_reason, Some("Updated reason".to_string()));
3601 assert_eq!(
3602 updated2.confirmed_at,
3603 Some("2023-01-01T12:10:00Z".to_string())
3604 );
3605
3606 env::remove_var("TRANSACTION_EXPIRATION_HOURS");
3608 }
3609
3610 #[tokio::test]
3613 #[ignore = "Requires active Redis instance"]
3614 async fn test_delete_by_ids_empty_list() {
3615 let repo = setup_test_repo().await;
3616 let tx_id = format!("test-empty-{}", Uuid::new_v4());
3617
3618 let tx = create_test_transaction(&tx_id);
3620 repo.create(tx).await.unwrap();
3621
3622 let result = repo.delete_by_ids(vec![]).await.unwrap();
3624
3625 assert_eq!(result.deleted_count, 0);
3626 assert!(result.failed.is_empty());
3627
3628 assert!(repo.get_by_id(tx_id).await.is_ok());
3630 }
3631
3632 #[tokio::test]
3633 #[ignore = "Requires active Redis instance"]
3634 async fn test_delete_by_ids_single_transaction() {
3635 let repo = setup_test_repo().await;
3636 let tx_id = format!("test-single-{}", Uuid::new_v4());
3637
3638 let tx = create_test_transaction(&tx_id);
3639 repo.create(tx).await.unwrap();
3640
3641 let result = repo.delete_by_ids(vec![tx_id.clone()]).await.unwrap();
3642
3643 assert_eq!(result.deleted_count, 1);
3644 assert!(result.failed.is_empty());
3645
3646 assert!(repo.get_by_id(tx_id).await.is_err());
3648 }
3649
3650 #[tokio::test]
3651 #[ignore = "Requires active Redis instance"]
3652 async fn test_delete_by_ids_multiple_transactions() {
3653 let repo = setup_test_repo().await;
3654 let base_id = Uuid::new_v4();
3655
3656 let mut created_ids = Vec::new();
3658 for i in 1..=5 {
3659 let tx_id = format!("test-multi-{base_id}-{i}");
3660 let tx = create_test_transaction(&tx_id);
3661 repo.create(tx).await.unwrap();
3662 created_ids.push(tx_id);
3663 }
3664
3665 let ids_to_delete = vec![
3667 created_ids[0].clone(),
3668 created_ids[2].clone(),
3669 created_ids[4].clone(),
3670 ];
3671 let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
3672
3673 assert_eq!(result.deleted_count, 3);
3674 assert!(result.failed.is_empty());
3675
3676 assert!(repo.get_by_id(created_ids[0].clone()).await.is_err());
3678 assert!(repo.get_by_id(created_ids[1].clone()).await.is_ok()); assert!(repo.get_by_id(created_ids[2].clone()).await.is_err());
3680 assert!(repo.get_by_id(created_ids[3].clone()).await.is_ok()); assert!(repo.get_by_id(created_ids[4].clone()).await.is_err());
3682 }
3683
3684 #[tokio::test]
3685 #[ignore = "Requires active Redis instance"]
3686 async fn test_delete_by_ids_nonexistent_transactions() {
3687 let repo = setup_test_repo().await;
3688 let base_id = Uuid::new_v4();
3689
3690 let ids_to_delete = vec![
3692 format!("nonexistent-{}-1", base_id),
3693 format!("nonexistent-{}-2", base_id),
3694 ];
3695 let result = repo.delete_by_ids(ids_to_delete.clone()).await.unwrap();
3696
3697 assert_eq!(result.deleted_count, 0);
3698 assert_eq!(result.failed.len(), 2);
3699
3700 let failed_ids: Vec<&String> = result.failed.iter().map(|(id, _)| id).collect();
3702 assert!(failed_ids.contains(&&ids_to_delete[0]));
3703 assert!(failed_ids.contains(&&ids_to_delete[1]));
3704 }
3705
3706 #[tokio::test]
3707 #[ignore = "Requires active Redis instance"]
3708 async fn test_delete_by_ids_mixed_existing_and_nonexistent() {
3709 let repo = setup_test_repo().await;
3710 let base_id = Uuid::new_v4();
3711
3712 let existing_ids: Vec<String> = (1..=3)
3714 .map(|i| format!("test-mixed-existing-{base_id}-{i}"))
3715 .collect();
3716
3717 for id in &existing_ids {
3718 let tx = create_test_transaction(id);
3719 repo.create(tx).await.unwrap();
3720 }
3721
3722 let nonexistent_ids: Vec<String> = (1..=2)
3723 .map(|i| format!("test-mixed-nonexistent-{base_id}-{i}"))
3724 .collect();
3725
3726 let ids_to_delete = vec![
3728 existing_ids[0].clone(),
3729 nonexistent_ids[0].clone(),
3730 existing_ids[1].clone(),
3731 nonexistent_ids[1].clone(),
3732 ];
3733 let result = repo.delete_by_ids(ids_to_delete).await.unwrap();
3734
3735 assert_eq!(result.deleted_count, 2);
3736 assert_eq!(result.failed.len(), 2);
3737
3738 assert!(repo.get_by_id(existing_ids[0].clone()).await.is_err());
3740 assert!(repo.get_by_id(existing_ids[1].clone()).await.is_err());
3741
3742 assert!(repo.get_by_id(existing_ids[2].clone()).await.is_ok());
3744 }
3745
3746 #[tokio::test]
3747 #[ignore = "Requires active Redis instance"]
3748 async fn test_delete_by_ids_removes_all_indexes() {
3749 let repo = setup_test_repo().await;
3750 let relayer_id = format!("relayer-{}", Uuid::new_v4());
3751 let tx_id = format!("test-indexes-{}", Uuid::new_v4());
3752
3753 let mut tx = create_test_transaction(&tx_id);
3755 tx.relayer_id = relayer_id.clone();
3756 tx.status = TransactionStatus::Confirmed;
3757 repo.create(tx).await.unwrap();
3758
3759 let found = repo
3761 .find_by_status(&relayer_id, &[TransactionStatus::Confirmed])
3762 .await
3763 .unwrap();
3764 assert!(found.iter().any(|t| t.id == tx_id));
3765
3766 let result = repo.delete_by_ids(vec![tx_id.clone()]).await.unwrap();
3768 assert_eq!(result.deleted_count, 1);
3769
3770 let found_after = repo
3772 .find_by_status(&relayer_id, &[TransactionStatus::Confirmed])
3773 .await
3774 .unwrap();
3775 assert!(!found_after.iter().any(|t| t.id == tx_id));
3776
3777 assert!(repo.get_by_id(tx_id).await.is_err());
3779 }
3780
3781 #[tokio::test]
3782 #[ignore = "Requires active Redis instance"]
3783 async fn test_delete_by_ids_removes_nonce_index() {
3784 let repo = setup_test_repo().await;
3785 let relayer_id = format!("relayer-{}", Uuid::new_v4());
3786 let tx_id = format!("test-nonce-{}", Uuid::new_v4());
3787 let nonce = 12345u64;
3788
3789 let tx = create_test_transaction_with_nonce(&tx_id, nonce, &relayer_id);
3791 repo.create(tx).await.unwrap();
3792
3793 let found = repo.find_by_nonce(&relayer_id, nonce).await.unwrap();
3795 assert!(found.is_some());
3796 assert_eq!(found.unwrap().id, tx_id);
3797
3798 let result = repo.delete_by_ids(vec![tx_id.clone()]).await.unwrap();
3800 assert_eq!(result.deleted_count, 1);
3801
3802 let found_after = repo.find_by_nonce(&relayer_id, nonce).await.unwrap();
3804 assert!(found_after.is_none());
3805 }
3806
3807 #[tokio::test]
3808 #[ignore = "Requires active Redis instance"]
3809 async fn test_delete_by_ids_large_batch() {
3810 let repo = setup_test_repo().await;
3811 let base_id = Uuid::new_v4();
3812
3813 let count = 50;
3815 let mut created_ids = Vec::new();
3816
3817 for i in 0..count {
3818 let tx_id = format!("test-large-{base_id}-{i}");
3819 let tx = create_test_transaction(&tx_id);
3820 repo.create(tx).await.unwrap();
3821 created_ids.push(tx_id);
3822 }
3823
3824 let result = repo.delete_by_ids(created_ids.clone()).await.unwrap();
3826
3827 assert_eq!(result.deleted_count, count);
3828 assert!(result.failed.is_empty());
3829
3830 for id in created_ids {
3832 assert!(repo.get_by_id(id).await.is_err());
3833 }
3834 }
3835
3836 #[tokio::test]
3837 #[ignore = "Requires active Redis instance"]
3838 async fn test_delete_by_ids_preserves_other_relayer_transactions() {
3839 let repo = setup_test_repo().await;
3840 let relayer_1 = format!("relayer-1-{}", Uuid::new_v4());
3841 let relayer_2 = format!("relayer-2-{}", Uuid::new_v4());
3842 let tx_id_1 = format!("tx-relayer-1-{}", Uuid::new_v4());
3843 let tx_id_2 = format!("tx-relayer-2-{}", Uuid::new_v4());
3844
3845 let tx1 = create_test_transaction_with_relayer(&tx_id_1, &relayer_1);
3847 let tx2 = create_test_transaction_with_relayer(&tx_id_2, &relayer_2);
3848
3849 repo.create(tx1).await.unwrap();
3850 repo.create(tx2).await.unwrap();
3851
3852 let result = repo.delete_by_ids(vec![tx_id_1.clone()]).await.unwrap();
3854
3855 assert_eq!(result.deleted_count, 1);
3856
3857 assert!(repo.get_by_id(tx_id_1).await.is_err());
3859
3860 let remaining = repo.get_by_id(tx_id_2).await.unwrap();
3862 assert_eq!(remaining.relayer_id, relayer_2);
3863 }
3864
3865 #[tokio::test]
3868 #[ignore = "Requires active Redis instance"]
3869 async fn test_increment_status_check_failures_no_prior_metadata() {
3870 let _lock = ENV_MUTEX.lock().await;
3871 let repo = setup_test_repo().await;
3872 let relayer_id = Uuid::new_v4().to_string();
3873 let tx_id = Uuid::new_v4().to_string();
3874 let mut tx =
3875 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
3876 tx.metadata = None;
3877 repo.create(tx).await.unwrap();
3878
3879 let updated = repo.increment_status_check_failures(tx_id).await.unwrap();
3880
3881 let meta = updated.metadata.expect("metadata should be set");
3882 assert_eq!(meta.consecutive_failures, 1);
3883 assert_eq!(meta.total_failures, 1);
3884 assert_eq!(meta.insufficient_fee_retries, 0);
3885 }
3886
3887 #[tokio::test]
3888 #[ignore = "Requires active Redis instance"]
3889 async fn test_increment_status_check_failures_accumulates() {
3890 let _lock = ENV_MUTEX.lock().await;
3891 let repo = setup_test_repo().await;
3892 let relayer_id = Uuid::new_v4().to_string();
3893 let tx_id = Uuid::new_v4().to_string();
3894 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
3895 repo.create(tx).await.unwrap();
3896
3897 repo.increment_status_check_failures(tx_id.clone())
3898 .await
3899 .unwrap();
3900 repo.increment_status_check_failures(tx_id.clone())
3901 .await
3902 .unwrap();
3903 let updated = repo.increment_status_check_failures(tx_id).await.unwrap();
3904
3905 let meta = updated.metadata.unwrap();
3906 assert_eq!(meta.consecutive_failures, 3);
3907 assert_eq!(meta.total_failures, 3);
3908 }
3909
3910 #[tokio::test]
3911 #[ignore = "Requires active Redis instance"]
3912 async fn test_increment_status_check_failures_noop_on_final_state() {
3913 let _lock = ENV_MUTEX.lock().await;
3914 let repo = setup_test_repo().await;
3915 let relayer_id = Uuid::new_v4().to_string();
3916 let tx_id = Uuid::new_v4().to_string();
3917 let tx =
3918 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Confirmed);
3919 repo.create(tx).await.unwrap();
3920
3921 let result = repo.increment_status_check_failures(tx_id).await.unwrap();
3922
3923 assert!(result.metadata.is_none());
3925 assert_eq!(result.status, TransactionStatus::Confirmed);
3926 }
3927
3928 #[tokio::test]
3929 #[ignore = "Requires active Redis instance"]
3930 async fn test_increment_status_check_failures_not_found() {
3931 let _lock = ENV_MUTEX.lock().await;
3932 let repo = setup_test_repo().await;
3933
3934 let result = repo
3935 .increment_status_check_failures("nonexistent".to_string())
3936 .await;
3937
3938 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
3939 }
3940
3941 #[tokio::test]
3944 #[ignore = "Requires active Redis instance"]
3945 async fn test_reset_consecutive_failures() {
3946 let _lock = ENV_MUTEX.lock().await;
3947 let repo = setup_test_repo().await;
3948 let relayer_id = Uuid::new_v4().to_string();
3949 let tx_id = Uuid::new_v4().to_string();
3950 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
3951 repo.create(tx).await.unwrap();
3952
3953 repo.increment_status_check_failures(tx_id.clone())
3955 .await
3956 .unwrap();
3957 repo.increment_status_check_failures(tx_id.clone())
3958 .await
3959 .unwrap();
3960
3961 let updated = repo
3962 .reset_status_check_consecutive_failures(tx_id)
3963 .await
3964 .unwrap();
3965
3966 let meta = updated.metadata.unwrap();
3967 assert_eq!(meta.consecutive_failures, 0);
3968 assert_eq!(meta.total_failures, 2);
3970 }
3971
3972 #[tokio::test]
3973 #[ignore = "Requires active Redis instance"]
3974 async fn test_reset_consecutive_failures_noop_on_final_state() {
3975 let _lock = ENV_MUTEX.lock().await;
3976 let repo = setup_test_repo().await;
3977 let relayer_id = Uuid::new_v4().to_string();
3978 let tx_id = Uuid::new_v4().to_string();
3979 let mut tx =
3980 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Failed);
3981 tx.metadata = Some(crate::models::TransactionMetadata {
3982 consecutive_failures: 5,
3983 total_failures: 10,
3984 insufficient_fee_retries: 0,
3985 try_again_later_retries: 0,
3986 nonce_too_high_retries: 0,
3987 });
3988 repo.create(tx).await.unwrap();
3989
3990 let result = repo
3991 .reset_status_check_consecutive_failures(tx_id)
3992 .await
3993 .unwrap();
3994
3995 let meta = result.metadata.unwrap();
3997 assert_eq!(meta.consecutive_failures, 5);
3998 }
3999
4000 #[tokio::test]
4001 #[ignore = "Requires active Redis instance"]
4002 async fn test_reset_consecutive_failures_not_found() {
4003 let _lock = ENV_MUTEX.lock().await;
4004 let repo = setup_test_repo().await;
4005
4006 let result = repo
4007 .reset_status_check_consecutive_failures("nonexistent".to_string())
4008 .await;
4009
4010 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
4011 }
4012
4013 #[tokio::test]
4016 #[ignore = "Requires active Redis instance"]
4017 async fn test_record_insufficient_fee_retry() {
4018 let _lock = ENV_MUTEX.lock().await;
4019 let repo = setup_test_repo().await;
4020 let relayer_id = Uuid::new_v4().to_string();
4021 let tx_id = Uuid::new_v4().to_string();
4022 let mut tx =
4023 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4024 tx.sent_at = None;
4025 repo.create(tx).await.unwrap();
4026
4027 let updated = repo
4028 .record_stellar_insufficient_fee_retry(tx_id, "2025-03-18T10:00:00Z".to_string())
4029 .await
4030 .unwrap();
4031
4032 assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:00:00Z"));
4033 let meta = updated.metadata.unwrap();
4034 assert_eq!(meta.insufficient_fee_retries, 1);
4035 assert_eq!(meta.consecutive_failures, 0);
4036 assert_eq!(meta.total_failures, 0);
4037 }
4038
4039 #[tokio::test]
4040 #[ignore = "Requires active Redis instance"]
4041 async fn test_record_insufficient_fee_retry_accumulates() {
4042 let _lock = ENV_MUTEX.lock().await;
4043 let repo = setup_test_repo().await;
4044 let relayer_id = Uuid::new_v4().to_string();
4045 let tx_id = Uuid::new_v4().to_string();
4046 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4047 repo.create(tx).await.unwrap();
4048
4049 repo.record_stellar_insufficient_fee_retry(
4050 tx_id.clone(),
4051 "2025-03-18T10:00:00Z".to_string(),
4052 )
4053 .await
4054 .unwrap();
4055
4056 let updated = repo
4057 .record_stellar_insufficient_fee_retry(tx_id, "2025-03-18T10:01:00Z".to_string())
4058 .await
4059 .unwrap();
4060
4061 assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:01:00Z"));
4062 let meta = updated.metadata.unwrap();
4063 assert_eq!(meta.insufficient_fee_retries, 2);
4064 }
4065
4066 #[tokio::test]
4067 #[ignore = "Requires active Redis instance"]
4068 async fn test_record_insufficient_fee_retry_noop_on_final_state() {
4069 let _lock = ENV_MUTEX.lock().await;
4070 let repo = setup_test_repo().await;
4071 let relayer_id = Uuid::new_v4().to_string();
4072 let tx_id = Uuid::new_v4().to_string();
4073 let mut tx =
4074 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Confirmed);
4075 tx.sent_at = Some("old-time".to_string());
4076 repo.create(tx).await.unwrap();
4077
4078 let result = repo
4079 .record_stellar_insufficient_fee_retry(tx_id, "new-time".to_string())
4080 .await
4081 .unwrap();
4082
4083 assert_eq!(result.sent_at.as_deref(), Some("old-time"));
4085 assert!(result.metadata.is_none());
4086 }
4087
4088 #[tokio::test]
4089 #[ignore = "Requires active Redis instance"]
4090 async fn test_record_insufficient_fee_retry_not_found() {
4091 let _lock = ENV_MUTEX.lock().await;
4092 let repo = setup_test_repo().await;
4093
4094 let result = repo
4095 .record_stellar_insufficient_fee_retry(
4096 "nonexistent".to_string(),
4097 "2025-03-18T10:00:00Z".to_string(),
4098 )
4099 .await;
4100
4101 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
4102 }
4103
4104 #[tokio::test]
4107 #[ignore = "Requires active Redis instance"]
4108 async fn test_record_try_again_later_retry() {
4109 let _lock = ENV_MUTEX.lock().await;
4110 let repo = setup_test_repo().await;
4111 let relayer_id = Uuid::new_v4().to_string();
4112 let tx_id = Uuid::new_v4().to_string();
4113 let mut tx =
4114 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4115 tx.sent_at = None;
4116 repo.create(tx).await.unwrap();
4117
4118 let updated = repo
4119 .record_stellar_try_again_later_retry(tx_id, "2025-03-18T10:00:00Z".to_string())
4120 .await
4121 .unwrap();
4122
4123 assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:00:00Z"));
4124 let meta = updated.metadata.unwrap();
4125 assert_eq!(meta.try_again_later_retries, 1);
4126 assert_eq!(meta.consecutive_failures, 0);
4127 assert_eq!(meta.total_failures, 0);
4128 }
4129
4130 #[tokio::test]
4131 #[ignore = "Requires active Redis instance"]
4132 async fn test_record_try_again_later_retry_accumulates() {
4133 let _lock = ENV_MUTEX.lock().await;
4134 let repo = setup_test_repo().await;
4135 let relayer_id = Uuid::new_v4().to_string();
4136 let tx_id = Uuid::new_v4().to_string();
4137 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4138 repo.create(tx).await.unwrap();
4139
4140 repo.record_stellar_try_again_later_retry(
4141 tx_id.clone(),
4142 "2025-03-18T10:00:00Z".to_string(),
4143 )
4144 .await
4145 .unwrap();
4146
4147 let updated = repo
4148 .record_stellar_try_again_later_retry(tx_id, "2025-03-18T10:01:00Z".to_string())
4149 .await
4150 .unwrap();
4151
4152 assert_eq!(updated.sent_at.as_deref(), Some("2025-03-18T10:01:00Z"));
4153 let meta = updated.metadata.unwrap();
4154 assert_eq!(meta.try_again_later_retries, 2);
4155 }
4156
4157 #[tokio::test]
4158 #[ignore = "Requires active Redis instance"]
4159 async fn test_record_try_again_later_retry_noop_on_final_state() {
4160 let _lock = ENV_MUTEX.lock().await;
4161 let repo = setup_test_repo().await;
4162 let relayer_id = Uuid::new_v4().to_string();
4163 let tx_id = Uuid::new_v4().to_string();
4164 let mut tx =
4165 create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Confirmed);
4166 tx.sent_at = Some("old-time".to_string());
4167 repo.create(tx).await.unwrap();
4168
4169 let result = repo
4170 .record_stellar_try_again_later_retry(tx_id, "new-time".to_string())
4171 .await
4172 .unwrap();
4173
4174 assert_eq!(result.sent_at.as_deref(), Some("old-time"));
4176 assert!(result.metadata.is_none());
4177 }
4178
4179 #[tokio::test]
4180 #[ignore = "Requires active Redis instance"]
4181 async fn test_record_try_again_later_retry_not_found() {
4182 let _lock = ENV_MUTEX.lock().await;
4183 let repo = setup_test_repo().await;
4184
4185 let result = repo
4186 .record_stellar_try_again_later_retry(
4187 "nonexistent".to_string(),
4188 "2025-03-18T10:00:00Z".to_string(),
4189 )
4190 .await;
4191
4192 assert!(matches!(result, Err(RepositoryError::NotFound(_))));
4193 }
4194
4195 #[tokio::test]
4198 #[ignore = "Requires active Redis instance"]
4199 async fn test_increment_failures_preserves_try_again_later_retries() {
4200 let _lock = ENV_MUTEX.lock().await;
4201 let repo = setup_test_repo().await;
4202 let relayer_id = Uuid::new_v4().to_string();
4203 let tx_id = Uuid::new_v4().to_string();
4204 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4205 repo.create(tx).await.unwrap();
4206
4207 repo.record_stellar_try_again_later_retry(
4209 tx_id.clone(),
4210 "2025-03-18T10:00:00Z".to_string(),
4211 )
4212 .await
4213 .unwrap();
4214
4215 let updated = repo.increment_status_check_failures(tx_id).await.unwrap();
4217
4218 let meta = updated.metadata.unwrap();
4219 assert_eq!(
4220 meta.try_again_later_retries, 1,
4221 "try_again_later_retries must survive increment_status_check_failures"
4222 );
4223 assert_eq!(meta.consecutive_failures, 1);
4224 assert_eq!(meta.total_failures, 1);
4225 }
4226
4227 #[tokio::test]
4228 #[ignore = "Requires active Redis instance"]
4229 async fn test_increment_failures_preserves_insufficient_fee_retries() {
4230 let _lock = ENV_MUTEX.lock().await;
4231 let repo = setup_test_repo().await;
4232 let relayer_id = Uuid::new_v4().to_string();
4233 let tx_id = Uuid::new_v4().to_string();
4234 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4235 repo.create(tx).await.unwrap();
4236
4237 repo.record_stellar_insufficient_fee_retry(
4239 tx_id.clone(),
4240 "2025-03-18T10:00:00Z".to_string(),
4241 )
4242 .await
4243 .unwrap();
4244
4245 let updated = repo.increment_status_check_failures(tx_id).await.unwrap();
4247
4248 let meta = updated.metadata.unwrap();
4249 assert_eq!(
4250 meta.insufficient_fee_retries, 1,
4251 "insufficient_fee_retries must survive increment_status_check_failures"
4252 );
4253 assert_eq!(meta.consecutive_failures, 1);
4254 }
4255
4256 #[tokio::test]
4257 #[ignore = "Requires active Redis instance"]
4258 async fn test_reset_failures_preserves_retry_counters() {
4259 let _lock = ENV_MUTEX.lock().await;
4260 let repo = setup_test_repo().await;
4261 let relayer_id = Uuid::new_v4().to_string();
4262 let tx_id = Uuid::new_v4().to_string();
4263 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4264 repo.create(tx).await.unwrap();
4265
4266 repo.record_stellar_try_again_later_retry(
4268 tx_id.clone(),
4269 "2025-03-18T10:00:00Z".to_string(),
4270 )
4271 .await
4272 .unwrap();
4273 repo.record_stellar_insufficient_fee_retry(
4274 tx_id.clone(),
4275 "2025-03-18T10:01:00Z".to_string(),
4276 )
4277 .await
4278 .unwrap();
4279
4280 repo.increment_status_check_failures(tx_id.clone())
4282 .await
4283 .unwrap();
4284 let updated = repo
4285 .reset_status_check_consecutive_failures(tx_id)
4286 .await
4287 .unwrap();
4288
4289 let meta = updated.metadata.unwrap();
4290 assert_eq!(meta.consecutive_failures, 0);
4291 assert_eq!(meta.total_failures, 1);
4292 assert_eq!(
4293 meta.try_again_later_retries, 1,
4294 "try_again_later_retries must survive reset"
4295 );
4296 assert_eq!(
4297 meta.insufficient_fee_retries, 1,
4298 "insufficient_fee_retries must survive reset"
4299 );
4300 }
4301
4302 #[tokio::test]
4303 #[ignore = "Requires active Redis instance"]
4304 async fn test_fee_and_try_again_later_retries_independent() {
4305 let _lock = ENV_MUTEX.lock().await;
4306 let repo = setup_test_repo().await;
4307 let relayer_id = Uuid::new_v4().to_string();
4308 let tx_id = Uuid::new_v4().to_string();
4309 let tx = create_test_transaction_with_status(&tx_id, &relayer_id, TransactionStatus::Sent);
4310 repo.create(tx).await.unwrap();
4311
4312 repo.record_stellar_try_again_later_retry(
4314 tx_id.clone(),
4315 "2025-03-18T10:00:00Z".to_string(),
4316 )
4317 .await
4318 .unwrap();
4319 repo.record_stellar_try_again_later_retry(
4320 tx_id.clone(),
4321 "2025-03-18T10:01:00Z".to_string(),
4322 )
4323 .await
4324 .unwrap();
4325
4326 let updated = repo
4328 .record_stellar_insufficient_fee_retry(tx_id, "2025-03-18T10:02:00Z".to_string())
4329 .await
4330 .unwrap();
4331
4332 let meta = updated.metadata.unwrap();
4333 assert_eq!(
4334 meta.try_again_later_retries, 2,
4335 "try_again_later_retries must survive insufficient_fee_retry"
4336 );
4337 assert_eq!(meta.insufficient_fee_retries, 1);
4338 }
4339}