Skip to main content

chainindex_core/
reorg.rs

1//! Reorg detection and recovery logic.
2//!
3//! Handles four reorg scenarios:
4//! 1. **Short reorg (1-3 blocks)**: parent hash mismatch on the next block
5//! 2. **Deep reorg**: checkpoint hash doesn't match the chain → rewind
6//! 3. **Node switch**: provider returns a different canonical chain
7//! 4. **RPC inconsistency**: finalized block number decreases
8
9use crate::types::BlockSummary;
10
11/// Describes a detected chain reorganization.
12#[derive(Debug, Clone)]
13pub struct ReorgEvent {
14    /// The block where the fork was detected.
15    pub detected_at: u64,
16    /// The blocks that were dropped (rolled back) — most recent first.
17    pub dropped_blocks: Vec<BlockSummary>,
18    /// The depth of the reorg (number of blocks rolled back).
19    pub depth: u64,
20    /// Type of reorg detected.
21    pub reorg_type: ReorgType,
22}
23
24/// Classification of the reorg type.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ReorgType {
27    /// Parent hash mismatch — short reorg (1–3 blocks).
28    ShortReorg,
29    /// Checkpoint hash mismatch — could be a deep reorg or node switch.
30    DeepReorg,
31    /// Finalized block number decreased — RPC inconsistency or node switch.
32    RpcInconsistency,
33}
34
35impl std::fmt::Display for ReorgType {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::ShortReorg => write!(f, "short reorg"),
39            Self::DeepReorg => write!(f, "deep reorg"),
40            Self::RpcInconsistency => write!(f, "RPC inconsistency"),
41        }
42    }
43}
44
45/// Detects and classifies chain reorganizations.
46pub struct ReorgDetector {
47    /// Last known finalized block number (for RPC inconsistency detection).
48    last_finalized: Option<u64>,
49    /// Confirmation depth — blocks behind head considered finalized.
50    _confirmation_depth: u64,
51}
52
53impl ReorgDetector {
54    pub fn new(confirmation_depth: u64) -> Self {
55        Self {
56            last_finalized: None,
57            _confirmation_depth: confirmation_depth,
58        }
59    }
60
61    /// Check whether `new_block` extends `previous_head` normally.
62    ///
63    /// Returns `Some(ReorgEvent)` if a reorg is detected, `None` if the chain is canonical.
64    pub fn check(
65        &mut self,
66        new_block: &BlockSummary,
67        previous_head: &BlockSummary,
68        window: &[BlockSummary],
69    ) -> Option<ReorgEvent> {
70        // Check parent hash
71        if !new_block.extends(previous_head) {
72            let (dropped, depth) = find_dropped_blocks(new_block, window);
73            let reorg_type = if depth <= 3 {
74                ReorgType::ShortReorg
75            } else {
76                ReorgType::DeepReorg
77            };
78            tracing::warn!(
79                depth,
80                at = new_block.number,
81                reorg_type = %reorg_type,
82                "Reorg detected"
83            );
84            return Some(ReorgEvent {
85                detected_at: new_block.number,
86                dropped_blocks: dropped,
87                depth,
88                reorg_type,
89            });
90        }
91
92        None
93    }
94
95    /// Check if the node reports a lower finalized block than previously seen.
96    ///
97    /// Returns `Some(ReorgEvent)` with `RpcInconsistency` if so.
98    pub fn check_finalized(
99        &mut self,
100        new_finalized: u64,
101        window: &[BlockSummary],
102    ) -> Option<ReorgEvent> {
103        if let Some(last) = self.last_finalized {
104            if new_finalized < last {
105                tracing::warn!(
106                    last_finalized = last,
107                    new_finalized,
108                    "Finalized block decreased — possible RPC inconsistency"
109                );
110                let dropped: Vec<_> = window
111                    .iter()
112                    .filter(|b| b.number > new_finalized)
113                    .cloned()
114                    .collect();
115                self.last_finalized = Some(new_finalized);
116                return Some(ReorgEvent {
117                    detected_at: new_finalized,
118                    dropped_blocks: dropped,
119                    depth: last - new_finalized,
120                    reorg_type: ReorgType::RpcInconsistency,
121                });
122            }
123        }
124        self.last_finalized = Some(new_finalized);
125        None
126    }
127}
128
129/// Walk the window backward to find which blocks need to be rolled back.
130fn find_dropped_blocks(
131    new_block: &BlockSummary,
132    window: &[BlockSummary],
133) -> (Vec<BlockSummary>, u64) {
134    let mut dropped = Vec::new();
135    for block in window.iter().rev() {
136        if block.hash == new_block.parent_hash {
137            // Found the fork point
138            break;
139        }
140        dropped.push(block.clone());
141    }
142    let depth = dropped.len() as u64;
143    (dropped, depth)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn b(num: u64, hash: &str, parent: &str) -> BlockSummary {
151        BlockSummary {
152            number: num,
153            hash: hash.into(),
154            parent_hash: parent.into(),
155            timestamp: (num * 12) as i64,
156            tx_count: 0,
157        }
158    }
159
160    #[test]
161    fn no_reorg_on_normal_chain() {
162        let mut det = ReorgDetector::new(12);
163        let head = b(100, "0xa", "0x0");
164        let new = b(101, "0xb", "0xa");
165        assert!(det.check(&new, &head, &[head.clone()]).is_none());
166    }
167
168    #[test]
169    fn detects_short_reorg() {
170        let mut det = ReorgDetector::new(12);
171        let block_99 = b(99, "0x99", "0x98");
172        let block_100 = b(100, "0xa", "0x99");
173        let _block_100b = b(100, "0xb", "0x99"); // different block at 100
174
175        // The tracker has [99, 100] but a new block at 101 with parent 0xb (reorg)
176        let new_101 = b(101, "0xc", "0xb");
177        let window = vec![block_99.clone(), block_100.clone()];
178
179        let result = det.check(&new_101, &block_100, &window);
180        assert!(result.is_some());
181        let event = result.unwrap();
182        assert_eq!(event.reorg_type, ReorgType::ShortReorg);
183    }
184
185    #[test]
186    fn rpc_inconsistency_detected() {
187        let mut det = ReorgDetector::new(12);
188        let window = vec![b(100, "0xa", "0x0"), b(101, "0xb", "0xa")];
189        det.check_finalized(100, &window); // sets last_finalized = 100
190        let result = det.check_finalized(98, &window); // decreased!
191        assert!(result.is_some());
192        assert_eq!(result.unwrap().reorg_type, ReorgType::RpcInconsistency);
193    }
194}