1use std::collections::HashMap;
6use std::sync::Mutex;
7use std::time::Duration;
8
9use serde::Serialize;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub enum TxStatus {
18 Pending,
20 Included {
22 block_number: u64,
23 block_hash: String,
24 },
25 Confirmed {
27 block_number: u64,
28 confirmations: u64,
29 },
30 Dropped,
32 Replaced { replacement_hash: String },
34 Failed { reason: String },
36}
37
38impl std::fmt::Display for TxStatus {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Pending => write!(f, "pending"),
42 Self::Included {
43 block_number,
44 block_hash,
45 } => write!(f, "included(block={block_number}, hash={block_hash})"),
46 Self::Confirmed {
47 block_number,
48 confirmations,
49 } => write!(
50 f,
51 "confirmed(block={block_number}, confirmations={confirmations})"
52 ),
53 Self::Dropped => write!(f, "dropped"),
54 Self::Replaced { replacement_hash } => {
55 write!(f, "replaced(by={replacement_hash})")
56 }
57 Self::Failed { reason } => write!(f, "failed({reason})"),
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
68pub struct TrackedTx {
69 pub tx_hash: String,
71 pub from: String,
73 pub nonce: u64,
75 pub submitted_at: u64,
77 pub status: TxStatus,
79 pub gas_price: Option<u64>,
81 pub max_fee: Option<u64>,
83 pub max_priority_fee: Option<u64>,
85 pub last_checked: u64,
87}
88
89pub struct TxTrackerConfig {
95 pub confirmation_depth: u64,
97 pub stuck_timeout_secs: u64,
99 pub poll_interval_secs: u64,
101 pub max_tracked: usize,
103}
104
105impl Default for TxTrackerConfig {
106 fn default() -> Self {
107 Self {
108 confirmation_depth: 12,
109 stuck_timeout_secs: 300, poll_interval_secs: 3,
111 max_tracked: 1000,
112 }
113 }
114}
115
116pub struct TxTracker {
121 config: TxTrackerConfig,
122 transactions: Mutex<HashMap<String, TrackedTx>>,
124 nonce_tracker: Mutex<HashMap<String, u64>>,
126}
127
128impl TxTracker {
129 pub fn new(config: TxTrackerConfig) -> Self {
131 Self {
132 config,
133 transactions: Mutex::new(HashMap::new()),
134 nonce_tracker: Mutex::new(HashMap::new()),
135 }
136 }
137
138 pub fn track(&self, tx: TrackedTx) {
143 let mut txs = self.transactions.lock().unwrap();
144 if txs.len() >= self.config.max_tracked {
145 return;
146 }
147 txs.insert(tx.tx_hash.clone(), tx);
148 }
149
150 pub fn untrack(&self, tx_hash: &str) {
152 let mut txs = self.transactions.lock().unwrap();
153 txs.remove(tx_hash);
154 }
155
156 pub fn update_status(&self, tx_hash: &str, status: TxStatus) {
160 let mut txs = self.transactions.lock().unwrap();
161 if let Some(tx) = txs.get_mut(tx_hash) {
162 tx.status = status;
163 }
164 }
165
166 pub fn by_status(&self, status_match: &TxStatus) -> Vec<TrackedTx> {
171 let txs = self.transactions.lock().unwrap();
172 txs.values()
173 .filter(|tx| std::mem::discriminant(&tx.status) == std::mem::discriminant(status_match))
174 .cloned()
175 .collect()
176 }
177
178 pub fn pending(&self) -> Vec<TrackedTx> {
180 self.by_status(&TxStatus::Pending)
181 }
182
183 pub fn stuck(&self, current_time: u64) -> Vec<TrackedTx> {
185 let txs = self.transactions.lock().unwrap();
186 txs.values()
187 .filter(|tx| {
188 tx.status == TxStatus::Pending
189 && current_time.saturating_sub(tx.submitted_at) > self.config.stuck_timeout_secs
190 })
191 .cloned()
192 .collect()
193 }
194
195 pub fn next_nonce(&self, address: &str) -> Option<u64> {
200 let nonces = self.nonce_tracker.lock().unwrap();
201 nonces.get(address).map(|n| n + 1)
202 }
203
204 pub fn set_nonce(&self, address: &str, nonce: u64) {
206 let mut nonces = self.nonce_tracker.lock().unwrap();
207 nonces.insert(address.to_string(), nonce);
208 }
209
210 pub fn count(&self) -> usize {
212 let txs = self.transactions.lock().unwrap();
213 txs.len()
214 }
215
216 pub fn get(&self, tx_hash: &str) -> Option<TrackedTx> {
218 let txs = self.transactions.lock().unwrap();
219 txs.get(tx_hash).cloned()
220 }
221}
222
223pub struct ReceiptPollerConfig {
229 pub initial_interval: Duration,
231 pub max_interval: Duration,
233 pub multiplier: f64,
235 pub max_attempts: u32,
237}
238
239impl Default for ReceiptPollerConfig {
240 fn default() -> Self {
241 Self {
242 initial_interval: Duration::from_secs(1),
243 max_interval: Duration::from_secs(30),
244 multiplier: 1.5,
245 max_attempts: 60,
246 }
247 }
248}
249
250pub struct ReceiptPoller {
255 config: ReceiptPollerConfig,
256}
257
258impl ReceiptPoller {
259 pub fn new(config: ReceiptPollerConfig) -> Self {
261 Self { config }
262 }
263
264 pub fn delay_for_attempt(&self, attempt: u32) -> Option<Duration> {
269 if attempt > self.config.max_attempts {
270 return None;
271 }
272 let delay = self.config.initial_interval.as_secs_f64()
273 * self.config.multiplier.powi((attempt - 1) as i32);
274 let capped = delay.min(self.config.max_interval.as_secs_f64());
275 Some(Duration::from_secs_f64(capped))
276 }
277
278 pub fn should_continue(&self, attempt: u32) -> bool {
280 attempt <= self.config.max_attempts
281 }
282}
283
284pub struct NonceLedger {
294 confirmed: Mutex<HashMap<String, u64>>,
296 pending: Mutex<HashMap<String, u64>>,
298}
299
300impl NonceLedger {
301 pub fn new() -> Self {
303 Self {
304 confirmed: Mutex::new(HashMap::new()),
305 pending: Mutex::new(HashMap::new()),
306 }
307 }
308
309 pub fn set_confirmed(&self, address: &str, nonce: u64) {
311 let mut confirmed = self.confirmed.lock().unwrap();
312 confirmed.insert(address.to_string(), nonce);
313 }
314
315 pub fn next(&self, address: &str) -> u64 {
318 let confirmed = self.confirmed.lock().unwrap();
319 let pending = self.pending.lock().unwrap();
320
321 let from_confirmed = confirmed.get(address).map(|n| n + 1).unwrap_or(0);
322 let from_pending = pending.get(address).map(|n| n + 1).unwrap_or(0);
323 from_confirmed.max(from_pending)
324 }
325
326 pub fn mark_pending(&self, address: &str, nonce: u64) {
328 let mut pending = self.pending.lock().unwrap();
329 let entry = pending.entry(address.to_string()).or_insert(0);
330 if nonce > *entry {
331 *entry = nonce;
332 }
333 }
334
335 pub fn confirm(&self, address: &str, nonce: u64) {
338 let mut confirmed = self.confirmed.lock().unwrap();
339 confirmed.insert(address.to_string(), nonce);
340 drop(confirmed);
341
342 let mut pending = self.pending.lock().unwrap();
343 if let Some(p) = pending.get(address) {
344 if *p <= nonce {
345 pending.remove(address);
346 }
347 }
348 }
349
350 pub fn confirmed_nonce(&self, address: &str) -> Option<u64> {
352 let confirmed = self.confirmed.lock().unwrap();
353 confirmed.get(address).copied()
354 }
355
356 pub fn pending_nonce(&self, address: &str) -> Option<u64> {
358 let pending = self.pending.lock().unwrap();
359 pending.get(address).copied()
360 }
361
362 pub fn gaps(&self, address: &str) -> Vec<u64> {
368 let confirmed = self.confirmed.lock().unwrap();
369 let pending = self.pending.lock().unwrap();
370
371 let c = match confirmed.get(address) {
372 Some(n) => *n,
373 None => return vec![],
374 };
375 let p = match pending.get(address) {
376 Some(n) => *n,
377 None => return vec![],
378 };
379
380 if p <= c + 1 {
381 return vec![];
382 }
383
384 ((c + 1)..p).collect()
385 }
386}
387
388impl Default for NonceLedger {
389 fn default() -> Self {
390 Self::new()
391 }
392}
393
394#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn sample_tx(hash: &str, nonce: u64, submitted_at: u64) -> TrackedTx {
407 TrackedTx {
408 tx_hash: hash.to_string(),
409 from: "0xAlice".to_string(),
410 nonce,
411 submitted_at,
412 status: TxStatus::Pending,
413 gas_price: Some(20_000_000_000),
414 max_fee: None,
415 max_priority_fee: None,
416 last_checked: submitted_at,
417 }
418 }
419
420 #[test]
421 fn tracker_track_and_get() {
422 let tracker = TxTracker::new(TxTrackerConfig::default());
423 let tx = sample_tx("0xabc", 0, 1000);
424 tracker.track(tx);
425
426 let fetched = tracker.get("0xabc").expect("should find tracked tx");
427 assert_eq!(fetched.tx_hash, "0xabc");
428 assert_eq!(fetched.nonce, 0);
429 assert_eq!(fetched.status, TxStatus::Pending);
430 assert_eq!(tracker.count(), 1);
431 }
432
433 #[test]
434 fn tracker_untrack() {
435 let tracker = TxTracker::new(TxTrackerConfig::default());
436 tracker.track(sample_tx("0xabc", 0, 1000));
437 assert_eq!(tracker.count(), 1);
438
439 tracker.untrack("0xabc");
440 assert_eq!(tracker.count(), 0);
441 assert!(tracker.get("0xabc").is_none());
442 }
443
444 #[test]
445 fn tracker_update_status() {
446 let tracker = TxTracker::new(TxTrackerConfig::default());
447 tracker.track(sample_tx("0xabc", 0, 1000));
448
449 tracker.update_status(
450 "0xabc",
451 TxStatus::Included {
452 block_number: 42,
453 block_hash: "0xblock".to_string(),
454 },
455 );
456
457 let tx = tracker.get("0xabc").unwrap();
458 assert_eq!(
459 tx.status,
460 TxStatus::Included {
461 block_number: 42,
462 block_hash: "0xblock".to_string(),
463 }
464 );
465 }
466
467 #[test]
468 fn tracker_update_status_unknown_hash() {
469 let tracker = TxTracker::new(TxTrackerConfig::default());
470 tracker.update_status("0xunknown", TxStatus::Dropped);
472 assert_eq!(tracker.count(), 0);
473 }
474
475 #[test]
476 fn tracker_pending_query() {
477 let tracker = TxTracker::new(TxTrackerConfig::default());
478 tracker.track(sample_tx("0x1", 0, 1000));
479 tracker.track(sample_tx("0x2", 1, 1001));
480 tracker.track(sample_tx("0x3", 2, 1002));
481
482 tracker.update_status(
484 "0x2",
485 TxStatus::Confirmed {
486 block_number: 10,
487 confirmations: 12,
488 },
489 );
490
491 let pending = tracker.pending();
492 assert_eq!(pending.len(), 2);
493 let hashes: Vec<String> = pending.iter().map(|t| t.tx_hash.clone()).collect();
494 assert!(hashes.contains(&"0x1".to_string()));
495 assert!(hashes.contains(&"0x3".to_string()));
496 }
497
498 #[test]
499 fn tracker_by_status() {
500 let tracker = TxTracker::new(TxTrackerConfig::default());
501 tracker.track(sample_tx("0x1", 0, 1000));
502 tracker.track(sample_tx("0x2", 1, 1001));
503
504 tracker.update_status(
505 "0x1",
506 TxStatus::Failed {
507 reason: "out of gas".into(),
508 },
509 );
510
511 let failed = tracker.by_status(&TxStatus::Failed {
512 reason: String::new(),
513 });
514 assert_eq!(failed.len(), 1);
515 assert_eq!(failed[0].tx_hash, "0x1");
516 }
517
518 #[test]
519 fn tracker_stuck_detection() {
520 let config = TxTrackerConfig {
521 stuck_timeout_secs: 60,
522 ..Default::default()
523 };
524 let tracker = TxTracker::new(config);
525
526 tracker.track(sample_tx("0x_old", 0, 1000));
527 tracker.track(sample_tx("0x_new", 1, 1050));
528
529 let stuck = tracker.stuck(1061);
531 assert_eq!(stuck.len(), 1);
532 assert_eq!(stuck[0].tx_hash, "0x_old");
533
534 let stuck = tracker.stuck(1111);
536 assert_eq!(stuck.len(), 2);
537 }
538
539 #[test]
540 fn tracker_stuck_ignores_non_pending() {
541 let config = TxTrackerConfig {
542 stuck_timeout_secs: 10,
543 ..Default::default()
544 };
545 let tracker = TxTracker::new(config);
546 tracker.track(sample_tx("0x1", 0, 100));
547 tracker.update_status(
548 "0x1",
549 TxStatus::Confirmed {
550 block_number: 5,
551 confirmations: 12,
552 },
553 );
554
555 let stuck = tracker.stuck(9999);
556 assert!(stuck.is_empty());
557 }
558
559 #[test]
560 fn tracker_max_tracked() {
561 let config = TxTrackerConfig {
562 max_tracked: 2,
563 ..Default::default()
564 };
565 let tracker = TxTracker::new(config);
566 tracker.track(sample_tx("0x1", 0, 1000));
567 tracker.track(sample_tx("0x2", 1, 1001));
568 tracker.track(sample_tx("0x3", 2, 1002)); assert_eq!(tracker.count(), 2);
571 assert!(tracker.get("0x3").is_none());
572 }
573
574 #[test]
575 fn tracker_nonce_tracking() {
576 let tracker = TxTracker::new(TxTrackerConfig::default());
577 assert!(tracker.next_nonce("0xAlice").is_none());
578
579 tracker.set_nonce("0xAlice", 5);
580 assert_eq!(tracker.next_nonce("0xAlice"), Some(6));
581
582 tracker.set_nonce("0xAlice", 10);
583 assert_eq!(tracker.next_nonce("0xAlice"), Some(11));
584 }
585
586 #[test]
591 fn poller_delay_first_attempt() {
592 let poller = ReceiptPoller::new(ReceiptPollerConfig::default());
593 let delay = poller.delay_for_attempt(1).unwrap();
594 assert_eq!(delay, Duration::from_secs(1));
596 }
597
598 #[test]
599 fn poller_delay_backoff_growth() {
600 let poller = ReceiptPoller::new(ReceiptPollerConfig {
601 initial_interval: Duration::from_secs(1),
602 max_interval: Duration::from_secs(100),
603 multiplier: 2.0,
604 max_attempts: 10,
605 });
606
607 assert_eq!(poller.delay_for_attempt(1).unwrap(), Duration::from_secs(1));
609 assert_eq!(poller.delay_for_attempt(2).unwrap(), Duration::from_secs(2));
611 assert_eq!(poller.delay_for_attempt(3).unwrap(), Duration::from_secs(4));
613 assert_eq!(poller.delay_for_attempt(4).unwrap(), Duration::from_secs(8));
615 }
616
617 #[test]
618 fn poller_delay_capped_at_max() {
619 let poller = ReceiptPoller::new(ReceiptPollerConfig {
620 initial_interval: Duration::from_secs(1),
621 max_interval: Duration::from_secs(5),
622 multiplier: 10.0,
623 max_attempts: 10,
624 });
625
626 assert_eq!(poller.delay_for_attempt(2).unwrap(), Duration::from_secs(5));
628 }
629
630 #[test]
631 fn poller_beyond_max_attempts() {
632 let poller = ReceiptPoller::new(ReceiptPollerConfig {
633 max_attempts: 3,
634 ..Default::default()
635 });
636
637 assert!(poller.delay_for_attempt(3).is_some());
638 assert!(poller.delay_for_attempt(4).is_none());
639 }
640
641 #[test]
642 fn poller_should_continue() {
643 let poller = ReceiptPoller::new(ReceiptPollerConfig {
644 max_attempts: 5,
645 ..Default::default()
646 });
647
648 assert!(poller.should_continue(1));
649 assert!(poller.should_continue(5));
650 assert!(!poller.should_continue(6));
651 }
652
653 #[test]
658 fn ledger_confirmed_pending_tracking() {
659 let ledger = NonceLedger::new();
660
661 assert!(ledger.confirmed_nonce("0xAlice").is_none());
662 assert!(ledger.pending_nonce("0xAlice").is_none());
663
664 ledger.set_confirmed("0xAlice", 5);
665 assert_eq!(ledger.confirmed_nonce("0xAlice"), Some(5));
666
667 ledger.mark_pending("0xAlice", 6);
668 assert_eq!(ledger.pending_nonce("0xAlice"), Some(6));
669 }
670
671 #[test]
672 fn ledger_next_nonce_confirmed_only() {
673 let ledger = NonceLedger::new();
674 ledger.set_confirmed("0xAlice", 5);
675 assert_eq!(ledger.next("0xAlice"), 6);
677 }
678
679 #[test]
680 fn ledger_next_nonce_pending_only() {
681 let ledger = NonceLedger::new();
682 ledger.mark_pending("0xAlice", 3);
683 assert_eq!(ledger.next("0xAlice"), 4);
685 }
686
687 #[test]
688 fn ledger_next_nonce_both() {
689 let ledger = NonceLedger::new();
690 ledger.set_confirmed("0xAlice", 5);
691 ledger.mark_pending("0xAlice", 8);
692 assert_eq!(ledger.next("0xAlice"), 9);
694 }
695
696 #[test]
697 fn ledger_next_nonce_unknown_address() {
698 let ledger = NonceLedger::new();
699 assert_eq!(ledger.next("0xNobody"), 0);
700 }
701
702 #[test]
703 fn ledger_mark_pending_keeps_max() {
704 let ledger = NonceLedger::new();
705 ledger.mark_pending("0xAlice", 5);
706 ledger.mark_pending("0xAlice", 3); assert_eq!(ledger.pending_nonce("0xAlice"), Some(5));
708
709 ledger.mark_pending("0xAlice", 7); assert_eq!(ledger.pending_nonce("0xAlice"), Some(7));
711 }
712
713 #[test]
714 fn ledger_confirm_clears_pending() {
715 let ledger = NonceLedger::new();
716 ledger.mark_pending("0xAlice", 5);
717 assert_eq!(ledger.pending_nonce("0xAlice"), Some(5));
718
719 ledger.confirm("0xAlice", 5);
720 assert_eq!(ledger.confirmed_nonce("0xAlice"), Some(5));
721 assert!(ledger.pending_nonce("0xAlice").is_none());
723 }
724
725 #[test]
726 fn ledger_confirm_preserves_higher_pending() {
727 let ledger = NonceLedger::new();
728 ledger.mark_pending("0xAlice", 10);
729
730 ledger.confirm("0xAlice", 5);
731 assert_eq!(ledger.confirmed_nonce("0xAlice"), Some(5));
732 assert_eq!(ledger.pending_nonce("0xAlice"), Some(10));
734 }
735
736 #[test]
737 fn ledger_gaps_basic() {
738 let ledger = NonceLedger::new();
739 ledger.set_confirmed("0xAlice", 3);
740 ledger.mark_pending("0xAlice", 7);
741
742 let gaps = ledger.gaps("0xAlice");
743 assert_eq!(gaps, vec![4, 5, 6]);
744 }
745
746 #[test]
747 fn ledger_gaps_no_gap() {
748 let ledger = NonceLedger::new();
749 ledger.set_confirmed("0xAlice", 5);
750 ledger.mark_pending("0xAlice", 6);
751
752 let gaps = ledger.gaps("0xAlice");
753 assert!(gaps.is_empty());
754 }
755
756 #[test]
757 fn ledger_gaps_no_confirmed() {
758 let ledger = NonceLedger::new();
759 ledger.mark_pending("0xAlice", 5);
760 assert!(ledger.gaps("0xAlice").is_empty());
761 }
762
763 #[test]
764 fn ledger_gaps_no_pending() {
765 let ledger = NonceLedger::new();
766 ledger.set_confirmed("0xAlice", 5);
767 assert!(ledger.gaps("0xAlice").is_empty());
768 }
769
770 #[test]
771 fn ledger_gaps_pending_equals_confirmed() {
772 let ledger = NonceLedger::new();
773 ledger.set_confirmed("0xAlice", 5);
774 ledger.mark_pending("0xAlice", 5);
775 assert!(ledger.gaps("0xAlice").is_empty());
776 }
777
778 #[test]
779 fn ledger_default_trait() {
780 let ledger = NonceLedger::default();
781 assert_eq!(ledger.next("0xAny"), 0);
782 }
783
784 #[test]
789 fn tx_status_display() {
790 assert_eq!(TxStatus::Pending.to_string(), "pending");
791 assert_eq!(TxStatus::Dropped.to_string(), "dropped");
792 assert_eq!(
793 TxStatus::Replaced {
794 replacement_hash: "0xnew".into()
795 }
796 .to_string(),
797 "replaced(by=0xnew)"
798 );
799 }
800
801 #[test]
802 fn tx_status_serialize() {
803 let json = serde_json::to_string(&TxStatus::Pending).unwrap();
804 assert!(json.contains("Pending"));
805
806 let json = serde_json::to_string(&TxStatus::Included {
807 block_number: 42,
808 block_hash: "0xblock".into(),
809 })
810 .unwrap();
811 assert!(json.contains("42"));
812 assert!(json.contains("0xblock"));
813 }
814}