Skip to main content

chainrpc_core/
tx.rs

1//! Transaction lifecycle management — tracking, confirmation monitoring,
2//! nonce management, and stuck transaction detection for EVM-compatible
3//! blockchains.
4
5use std::collections::HashMap;
6use std::sync::Mutex;
7use std::time::Duration;
8
9use serde::Serialize;
10
11// ---------------------------------------------------------------------------
12// TxStatus
13// ---------------------------------------------------------------------------
14
15/// The lifecycle state of an on-chain transaction.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub enum TxStatus {
18    /// Transaction submitted but not yet seen in a block.
19    Pending,
20    /// Transaction included in a block but not yet confirmed.
21    Included {
22        block_number: u64,
23        block_hash: String,
24    },
25    /// Transaction has enough confirmations to be considered final.
26    Confirmed {
27        block_number: u64,
28        confirmations: u64,
29    },
30    /// Transaction was dropped from the mempool.
31    Dropped,
32    /// Transaction was replaced by a higher-gas transaction.
33    Replaced { replacement_hash: String },
34    /// Transaction failed on-chain.
35    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// ---------------------------------------------------------------------------
63// TrackedTx
64// ---------------------------------------------------------------------------
65
66/// A transaction that is being actively monitored.
67#[derive(Debug, Clone)]
68pub struct TrackedTx {
69    /// The transaction hash.
70    pub tx_hash: String,
71    /// The sender address.
72    pub from: String,
73    /// The nonce used by this transaction.
74    pub nonce: u64,
75    /// Unix timestamp when the transaction was submitted.
76    pub submitted_at: u64,
77    /// Current lifecycle status.
78    pub status: TxStatus,
79    /// Legacy gas price (Type 0 / Type 1 transactions).
80    pub gas_price: Option<u64>,
81    /// EIP-1559 max fee per gas.
82    pub max_fee: Option<u64>,
83    /// EIP-1559 max priority fee per gas.
84    pub max_priority_fee: Option<u64>,
85    /// Unix timestamp of the last status check.
86    pub last_checked: u64,
87}
88
89// ---------------------------------------------------------------------------
90// TxTracker
91// ---------------------------------------------------------------------------
92
93/// Configuration for [`TxTracker`].
94pub struct TxTrackerConfig {
95    /// How many confirmations needed to consider a transaction confirmed.
96    pub confirmation_depth: u64,
97    /// Max time in seconds before a pending transaction is considered stuck.
98    pub stuck_timeout_secs: u64,
99    /// Polling interval for receipt checks (in seconds).
100    pub poll_interval_secs: u64,
101    /// Maximum number of pending transactions to track.
102    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, // 5 minutes
110            poll_interval_secs: 3,
111            max_tracked: 1000,
112        }
113    }
114}
115
116/// Core transaction tracker that monitors pending transactions.
117///
118/// Thread-safe via interior `Mutex` — suitable for shared access across
119/// Tokio tasks behind an `Arc`.
120pub struct TxTracker {
121    config: TxTrackerConfig,
122    /// tx_hash -> TrackedTx
123    transactions: Mutex<HashMap<String, TrackedTx>>,
124    /// address -> last known nonce
125    nonce_tracker: Mutex<HashMap<String, u64>>,
126}
127
128impl TxTracker {
129    /// Create a new tracker with the given configuration.
130    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    /// Track a new pending transaction.
139    ///
140    /// If the tracker is already at capacity (`max_tracked`), the transaction
141    /// is silently dropped.
142    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    /// Remove a transaction from tracking.
151    pub fn untrack(&self, tx_hash: &str) {
152        let mut txs = self.transactions.lock().unwrap();
153        txs.remove(tx_hash);
154    }
155
156    /// Update the status of a tracked transaction.
157    ///
158    /// Does nothing if `tx_hash` is not currently tracked.
159    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    /// Get all transactions whose status matches `status_match`.
167    ///
168    /// Comparison uses the discriminant only for variant-carrying statuses;
169    /// for simple variants (`Pending`, `Dropped`) it uses `PartialEq`.
170    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    /// Get all pending transactions.
179    pub fn pending(&self) -> Vec<TrackedTx> {
180        self.by_status(&TxStatus::Pending)
181    }
182
183    /// Get transactions that appear stuck (pending longer than `stuck_timeout_secs`).
184    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    /// Get the next nonce for an address (local tracking).
196    ///
197    /// Returns the stored nonce + 1, or `None` if the address has never been
198    /// registered.
199    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    /// Set the nonce for an address (typically from an on-chain query).
205    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    /// Get count of tracked transactions.
211    pub fn count(&self) -> usize {
212        let txs = self.transactions.lock().unwrap();
213        txs.len()
214    }
215
216    /// Get a snapshot of a specific transaction.
217    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
223// ---------------------------------------------------------------------------
224// ReceiptPoller
225// ---------------------------------------------------------------------------
226
227/// Configuration for [`ReceiptPoller`] exponential-backoff strategy.
228pub struct ReceiptPollerConfig {
229    /// Initial poll interval.
230    pub initial_interval: Duration,
231    /// Maximum poll interval (cap).
232    pub max_interval: Duration,
233    /// Backoff multiplier applied on each successive attempt.
234    pub multiplier: f64,
235    /// Maximum number of attempts before giving up.
236    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
250/// Smart receipt poller with exponential backoff.
251///
252/// Does not perform I/O itself — it computes delays and decides when to stop
253/// polling. The caller drives the actual RPC calls.
254pub struct ReceiptPoller {
255    config: ReceiptPollerConfig,
256}
257
258impl ReceiptPoller {
259    /// Create a new poller with the given configuration.
260    pub fn new(config: ReceiptPollerConfig) -> Self {
261        Self { config }
262    }
263
264    /// Calculate the delay before the given attempt (1-indexed).
265    ///
266    /// Returns `None` when `attempt` exceeds `max_attempts`, signalling that
267    /// polling should stop.
268    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    /// Check if we should continue polling at the given attempt number.
279    pub fn should_continue(&self, attempt: u32) -> bool {
280        attempt <= self.config.max_attempts
281    }
282}
283
284// ---------------------------------------------------------------------------
285// NonceLedger
286// ---------------------------------------------------------------------------
287
288/// Nonce management for tracking local and on-chain nonces.
289///
290/// Maintains two nonce counters per address:
291/// - **confirmed** — the last nonce known to be mined on-chain.
292/// - **pending** — the highest nonce assigned locally but not yet confirmed.
293pub struct NonceLedger {
294    /// On-chain confirmed nonces per address.
295    confirmed: Mutex<HashMap<String, u64>>,
296    /// Locally assigned (pending) nonces per address.
297    pending: Mutex<HashMap<String, u64>>,
298}
299
300impl NonceLedger {
301    /// Create a new, empty ledger.
302    pub fn new() -> Self {
303        Self {
304            confirmed: Mutex::new(HashMap::new()),
305            pending: Mutex::new(HashMap::new()),
306        }
307    }
308
309    /// Set the on-chain confirmed nonce for an address.
310    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    /// Get the next available nonce — the maximum of (confirmed + 1) and
316    /// (pending + 1), falling back to 0 when neither is set.
317    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    /// Mark a nonce as used (pending).
327    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    /// Confirm a nonce — updates confirmed and clears pending when the
336    /// pending nonce is at or below the confirmed nonce.
337    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    /// Get the current confirmed nonce.
351    pub fn confirmed_nonce(&self, address: &str) -> Option<u64> {
352        let confirmed = self.confirmed.lock().unwrap();
353        confirmed.get(address).copied()
354    }
355
356    /// Get the current pending nonce.
357    pub fn pending_nonce(&self, address: &str) -> Option<u64> {
358        let pending = self.pending.lock().unwrap();
359        pending.get(address).copied()
360    }
361
362    /// Get gap nonces — nonces between confirmed and pending that have not
363    /// been observed.
364    ///
365    /// For example, if confirmed = 3 and pending = 7, the gaps are `[4, 5, 6]`.
366    /// Returns an empty vec if there are no gaps or either value is unset.
367    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// ===========================================================================
395// Tests
396// ===========================================================================
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    // -----------------------------------------------------------------------
403    // TxTracker tests
404    // -----------------------------------------------------------------------
405
406    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        // should not panic
471        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        // move one to confirmed
483        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        // at t = 1061, only 0x_old is stuck (61s > 60s)
530        let stuck = tracker.stuck(1061);
531        assert_eq!(stuck.len(), 1);
532        assert_eq!(stuck[0].tx_hash, "0x_old");
533
534        // at t = 1111, both are stuck
535        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)); // should be silently dropped
569
570        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    // -----------------------------------------------------------------------
587    // ReceiptPoller tests
588    // -----------------------------------------------------------------------
589
590    #[test]
591    fn poller_delay_first_attempt() {
592        let poller = ReceiptPoller::new(ReceiptPollerConfig::default());
593        let delay = poller.delay_for_attempt(1).unwrap();
594        // first attempt: initial_interval * 1.5^0 = 1s
595        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        // attempt 1: 1 * 2^0 = 1s
608        assert_eq!(poller.delay_for_attempt(1).unwrap(), Duration::from_secs(1));
609        // attempt 2: 1 * 2^1 = 2s
610        assert_eq!(poller.delay_for_attempt(2).unwrap(), Duration::from_secs(2));
611        // attempt 3: 1 * 2^2 = 4s
612        assert_eq!(poller.delay_for_attempt(3).unwrap(), Duration::from_secs(4));
613        // attempt 4: 1 * 2^3 = 8s
614        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        // attempt 2: 1 * 10^1 = 10s, but capped at 5s
627        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    // -----------------------------------------------------------------------
654    // NonceLedger tests
655    // -----------------------------------------------------------------------
656
657    #[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        // next = max(5+1, 0) = 6
676        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        // next = max(0, 3+1) = 4
684        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        // next = max(5+1, 8+1) = 9
693        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); // lower, should be ignored
707        assert_eq!(ledger.pending_nonce("0xAlice"), Some(5));
708
709        ledger.mark_pending("0xAlice", 7); // higher, should update
710        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        // pending <= confirmed, so it should be cleared
722        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        // pending (10) > confirmed (5), so pending is preserved
733        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    // -----------------------------------------------------------------------
785    // TxStatus display / serialize
786    // -----------------------------------------------------------------------
787
788    #[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}