Skip to main content

abtc_application/
block_index.rs

1//! Block Index — Tracks all known block headers and best-chain selection
2//!
3//! The block index is a tree of all known block headers. It tracks which chain
4//! has the most cumulative work (proof-of-work) and provides the "best chain"
5//! used for validation and serving to peers.
6//!
7//! This corresponds to Bitcoin Core's `CBlockIndex` / `CChainState` / `mapBlockIndex`.
8//!
9//! ## Best-chain selection
10//!
11//! The "best chain" is the chain with the most cumulative proof-of-work.
12//! Work is computed from the `bits` (nBits / compact target) field in the header.
13//! When a new header arrives, we:
14//! 1. Add it to the index (linking to its parent)
15//! 2. Compute its cumulative work
16//! 3. If it has more work than the current best chain, it becomes the new tip
17
18use abtc_domain::chain_params::Checkpoint;
19use abtc_domain::consensus::{decode_compact_u256, hash_meets_target};
20use abtc_domain::primitives::{BlockHash, BlockHeader};
21use std::collections::HashMap;
22
23/// Metadata tracked for each known block header.
24#[derive(Debug, Clone)]
25pub struct BlockIndexEntry {
26    /// The block header.
27    pub header: BlockHeader,
28    /// Height of this block.
29    pub height: u32,
30    /// Cumulative proof-of-work up to and including this block.
31    /// Stored as a u128 to avoid overflow for high-difficulty chains.
32    pub chain_work: u128,
33    /// Validation status.
34    pub status: BlockValidationStatus,
35}
36
37/// Validation status of a block in the index.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum BlockValidationStatus {
40    /// Header received and valid
41    HeaderValid,
42    /// Full block data received and validated
43    FullyValidated,
44    /// Block failed validation
45    Invalid,
46    /// Block data not yet available (header-only)
47    DataUnavailable,
48}
49
50/// The in-memory block index tree.
51///
52/// Maps block hashes to their index entries. The "best chain" is the chain
53/// leading to the tip with the most cumulative proof-of-work.
54pub struct BlockIndex {
55    /// All known block headers
56    entries: HashMap<BlockHash, BlockIndexEntry>,
57    /// The current best (most-work) chain tip
58    best_tip: BlockHash,
59    /// Height-to-hash mapping for the active chain (for O(1) height lookups)
60    active_chain: Vec<BlockHash>,
61    /// Hardcoded checkpoints: height → expected hash (hex, reversed).
62    /// Headers at checkpoint heights must match the expected hash.
63    checkpoints: HashMap<u32, String>,
64    /// The network's proof-of-work limit in compact form.
65    /// Used to reject headers with targets that exceed the network limit
66    /// and to skip PoW validation for regtest (where the limit saturates).
67    pow_limit_bits: u32,
68}
69
70impl BlockIndex {
71    /// Create a new empty block index.
72    ///
73    /// `pow_limit_bits` is the network's maximum allowed target in compact
74    /// form (e.g. `0x1d00ffff` for mainnet, `0x207fffff` for regtest).
75    /// Pass 0 to disable PoW checks (for unit tests that don't care about PoW).
76    pub fn new_with_pow_limit(pow_limit_bits: u32) -> Self {
77        BlockIndex {
78            entries: HashMap::new(),
79            best_tip: BlockHash::zero(),
80            active_chain: Vec::new(),
81            checkpoints: HashMap::new(),
82            pow_limit_bits,
83        }
84    }
85
86    /// Create a new empty block index with no PoW validation.
87    ///
88    /// Equivalent to `new_with_pow_limit(0)`. PoW checks are skipped,
89    /// which is convenient for tests that only care about chain structure.
90    pub fn new() -> Self {
91        Self::new_with_pow_limit(0)
92    }
93
94    /// Load checkpoints into the block index.
95    ///
96    /// Call this after creation / before syncing so that `add_header`
97    /// can reject headers that violate a checkpoint.
98    pub fn load_checkpoints(&mut self, checkpoints: &[Checkpoint]) {
99        for cp in checkpoints {
100            self.checkpoints.insert(cp.height, cp.hash_hex.to_string());
101        }
102    }
103
104    /// Get the highest loaded checkpoint height, or 0 if none.
105    pub fn last_checkpoint_height(&self) -> u32 {
106        self.checkpoints.keys().copied().max().unwrap_or(0)
107    }
108
109    /// Initialize the block index with a genesis block header.
110    pub fn init_genesis(&mut self, header: BlockHeader) {
111        let hash = header.block_hash();
112        let work = work_from_bits(header.bits);
113
114        let entry = BlockIndexEntry {
115            header,
116            height: 0,
117            chain_work: work,
118            status: BlockValidationStatus::FullyValidated,
119        };
120
121        self.entries.insert(hash, entry);
122        self.best_tip = hash;
123        self.active_chain = vec![hash];
124    }
125
126    /// Add a new block header to the index. Returns the hash and whether
127    /// a reorg occurred (the best chain tip changed to a different branch).
128    ///
129    /// If the parent is unknown, returns an error.
130    pub fn add_header(
131        &mut self,
132        header: BlockHeader,
133    ) -> Result<(BlockHash, bool), BlockIndexError> {
134        let hash = header.block_hash();
135
136        // Already known?
137        if self.entries.contains_key(&hash) {
138            return Ok((hash, false));
139        }
140
141        // Parent must be known
142        let parent_hash = header.prev_block_hash;
143        let (parent_height, parent_work) = {
144            let parent = self
145                .entries
146                .get(&parent_hash)
147                .ok_or(BlockIndexError::OrphanHeader)?;
148            (parent.height, parent.chain_work)
149        };
150
151        let height = parent_height + 1;
152        let work = parent_work + work_from_bits(header.bits);
153
154        // ── Proof-of-work validation ─────────────────────────────
155        // Skip if pow_limit_bits is 0 (unit-test mode).
156        if self.pow_limit_bits != 0 {
157            let target_u256 = decode_compact_u256(header.bits);
158            let limit_u256 = decode_compact_u256(self.pow_limit_bits);
159
160            // Regtest fast path: if the PoW limit's top byte (byte 31 of
161            // the LE uint256) is >= 0x7f, the network allows trivially
162            // easy blocks. Regtest uses 0x207fffff → byte 31 = 0x7f.
163            // In this regime mine_block skips grinding (nonce=0) and
164            // check_block_header skips validation, so we match that here.
165            // This does NOT trigger for mainnet (0x1d00ffff → byte 31 = 0x00)
166            // or testnet.
167            if limit_u256[31] < 0x7f {
168                // Reject targets that exceed the network limit.
169                // A higher target means easier difficulty, so this prevents
170                // an attacker from submitting trivially easy headers.
171                if !hash_meets_target(&target_u256, &limit_u256) {
172                    return Err(BlockIndexError::TargetExceedsPowLimit);
173                }
174
175                // Verify the block hash actually meets the declared target.
176                if !hash_meets_target(hash.as_bytes(), &target_u256) {
177                    return Err(BlockIndexError::InsufficientProofOfWork);
178                }
179            }
180        }
181
182        // ── Checkpoint verification ──────────────────────────────
183        // If there is a checkpoint at this height, the hash must match.
184        if let Some(expected_hex) = self.checkpoints.get(&height) {
185            if hash.to_hex_reversed() != *expected_hex {
186                return Err(BlockIndexError::CheckpointMismatch {
187                    height,
188                    expected: expected_hex.clone(),
189                    got: hash.to_hex_reversed(),
190                });
191            }
192        }
193
194        let entry = BlockIndexEntry {
195            header,
196            height,
197            chain_work: work,
198            status: BlockValidationStatus::HeaderValid,
199        };
200
201        self.entries.insert(hash, entry);
202
203        // Check if this creates a new best chain
204        let current_best_work = self
205            .entries
206            .get(&self.best_tip)
207            .map(|e| e.chain_work)
208            .unwrap_or(0);
209
210        let reorged = if work > current_best_work {
211            let old_tip = self.best_tip;
212            self.best_tip = hash;
213            self.rebuild_active_chain();
214            old_tip != hash // Always true since work is strictly greater
215        } else {
216            false
217        };
218
219        Ok((hash, reorged))
220    }
221
222    /// Set the validation status of a block.
223    pub fn set_status(&mut self, hash: &BlockHash, status: BlockValidationStatus) {
224        if let Some(entry) = self.entries.get_mut(hash) {
225            entry.status = status;
226        }
227    }
228
229    /// Get a block index entry by hash.
230    pub fn get(&self, hash: &BlockHash) -> Option<&BlockIndexEntry> {
231        self.entries.get(hash)
232    }
233
234    /// Get the best chain tip hash.
235    pub fn best_tip(&self) -> BlockHash {
236        self.best_tip
237    }
238
239    /// Get the best chain tip entry.
240    pub fn best_tip_entry(&self) -> Option<&BlockIndexEntry> {
241        self.entries.get(&self.best_tip)
242    }
243
244    /// Get the block hash at a given height on the active chain.
245    pub fn get_hash_at_height(&self, height: u32) -> Option<BlockHash> {
246        self.active_chain.get(height as usize).copied()
247    }
248
249    /// Get the current best chain height.
250    pub fn best_height(&self) -> u32 {
251        self.active_chain.len().saturating_sub(1) as u32
252    }
253
254    /// Get the total number of known headers.
255    pub fn header_count(&self) -> usize {
256        self.entries.len()
257    }
258
259    /// Check if we have a header for the given hash.
260    pub fn contains(&self, hash: &BlockHash) -> bool {
261        self.entries.contains_key(hash)
262    }
263
264    /// Walk ancestors from a given hash back to genesis.
265    /// Returns hashes from the given block back to genesis (inclusive).
266    pub fn get_ancestor_chain(&self, hash: &BlockHash) -> Vec<BlockHash> {
267        let mut chain = Vec::new();
268        let mut current = *hash;
269
270        while current != BlockHash::zero() {
271            chain.push(current);
272            match self.entries.get(&current) {
273                Some(entry) => current = entry.header.prev_block_hash,
274                None => break,
275            }
276        }
277
278        chain
279    }
280
281    /// Build a block locator (list of block hashes for the `getblocks` protocol message).
282    /// Uses exponential backoff: recent blocks are listed individually, then
283    /// the step size doubles.
284    pub fn build_locator(&self) -> Vec<BlockHash> {
285        let mut locator = Vec::new();
286        let mut step = 1;
287        let mut height = self.best_height() as i64;
288
289        while height >= 0 {
290            if let Some(hash) = self.get_hash_at_height(height as u32) {
291                locator.push(hash);
292            }
293
294            if locator.len() >= 10 {
295                step *= 2;
296            }
297
298            height -= step;
299        }
300
301        // Always include genesis
302        if let Some(genesis) = self.active_chain.first() {
303            if locator.last() != Some(genesis) {
304                locator.push(*genesis);
305            }
306        }
307
308        locator
309    }
310
311    /// Compute the Median Time Past (MTP) for a block on the active chain.
312    ///
313    /// BIP113: the "time" used for time-lock evaluation is the median of
314    /// the timestamps of the previous 11 blocks (or all blocks if fewer
315    /// than 11 exist). This prevents miners from manipulating the timestamp
316    /// of a single block to bypass time locks.
317    ///
318    /// `height` is the height of the block whose MTP we want. The MTP is
319    /// computed from blocks at heights `max(0, height-10)..=height`.
320    ///
321    /// Returns `None` if the height is not on the active chain.
322    pub fn get_median_time_past(&self, height: u32) -> Option<u32> {
323        const MEDIAN_TIME_SPAN: u32 = 11;
324
325        let start = height.saturating_sub(MEDIAN_TIME_SPAN - 1);
326        let mut timestamps = Vec::with_capacity(MEDIAN_TIME_SPAN as usize);
327
328        for h in start..=height {
329            let hash = self.get_hash_at_height(h)?;
330            let entry = self.entries.get(&hash)?;
331            timestamps.push(entry.header.time);
332        }
333
334        timestamps.sort_unstable();
335        Some(timestamps[timestamps.len() / 2])
336    }
337
338    /// Compute the MTP for the current best chain tip.
339    pub fn tip_median_time_past(&self) -> Option<u32> {
340        if self.active_chain.is_empty() {
341            return None;
342        }
343        self.get_median_time_past(self.best_height())
344    }
345
346    /// Rebuild the active_chain vector by walking from best_tip back to genesis.
347    fn rebuild_active_chain(&mut self) {
348        let mut chain = Vec::new();
349        let mut current = self.best_tip;
350
351        while current != BlockHash::zero() {
352            chain.push(current);
353            match self.entries.get(&current) {
354                Some(entry) => current = entry.header.prev_block_hash,
355                None => break,
356            }
357        }
358
359        chain.reverse();
360        self.active_chain = chain;
361    }
362}
363
364impl Default for BlockIndex {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370/// Errors from block index operations
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum BlockIndexError {
373    /// The parent block hash is not in the index
374    OrphanHeader,
375    /// The block is already known
376    DuplicateBlock,
377    /// A header at a checkpoint height does not match the expected hash
378    CheckpointMismatch {
379        height: u32,
380        expected: String,
381        got: String,
382    },
383    /// The header's target (nBits) exceeds the network's proof-of-work limit
384    TargetExceedsPowLimit,
385    /// The block hash does not meet the proof-of-work target
386    InsufficientProofOfWork,
387}
388
389impl std::fmt::Display for BlockIndexError {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        match self {
392            BlockIndexError::OrphanHeader => write!(f, "orphan header (unknown parent)"),
393            BlockIndexError::DuplicateBlock => write!(f, "duplicate block"),
394            BlockIndexError::CheckpointMismatch {
395                height,
396                expected,
397                got,
398            } => {
399                write!(
400                    f,
401                    "checkpoint mismatch at height {}: expected {}, got {}",
402                    height, expected, got
403                )
404            }
405            BlockIndexError::TargetExceedsPowLimit => {
406                write!(f, "header target exceeds proof-of-work limit")
407            }
408            BlockIndexError::InsufficientProofOfWork => {
409                write!(f, "block hash does not meet proof-of-work target")
410            }
411        }
412    }
413}
414
415impl std::error::Error for BlockIndexError {}
416
417/// Compute the proof-of-work from a compact target (nBits).
418///
419/// Work is defined as 2^256 / (target + 1). We approximate this as
420/// `u128::MAX / (target as u128)` which is good enough for chain comparison.
421///
422/// The compact target encoding is:
423/// - First byte = exponent (number of bytes)
424/// - Next 3 bytes = mantissa (coefficient)
425/// - target = mantissa * 256^(exponent-3)
426fn work_from_bits(bits: u32) -> u128 {
427    let exponent = bits >> 24;
428    let mantissa = bits & 0x007fffff;
429
430    if mantissa == 0 || exponent == 0 {
431        return 0;
432    }
433
434    // target = mantissa * 2^(8*(exponent-3))
435    // work = 2^256 / (target + 1)
436    //
437    // For targets that fit in u128 (shift < 128), compute directly.
438    // For larger targets, we reformulate:
439    //   work = 2^256 / (mantissa * 2^shift + 1)
440    //        ≈ 2^(256-shift) / mantissa  (when mantissa << 2^shift)
441    let shift = 8 * (exponent.saturating_sub(3));
442
443    if shift >= 256 {
444        // Target exceeds 2^256 — work is negligible but nonzero
445        return 1;
446    }
447
448    if shift < 128 {
449        // Target fits in u128 — direct computation
450        let target = (mantissa as u128) << shift;
451        if target == 0 {
452            return u128::MAX;
453        }
454        u128::MAX / (target + 1)
455    } else {
456        // Target overflows u128. Compute work = 2^(256-shift) / mantissa.
457        // Since shift >= 128 and shift < 256, (256-shift) is in 1..=128.
458        let work_shift = 256 - shift;
459        if work_shift >= 128 {
460            // 2^128 / mantissa
461            u128::MAX / (mantissa as u128)
462        } else {
463            (1u128 << work_shift) / (mantissa as u128)
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use abtc_domain::primitives::{BlockHash, Hash256};
472
473    fn make_header(prev: BlockHash, nonce: u32) -> BlockHeader {
474        BlockHeader {
475            version: 1,
476            prev_block_hash: prev,
477            merkle_root: Hash256::from_bytes([nonce as u8; 32]),
478            time: 1231006505 + nonce,
479            bits: 0x1d00ffff, // mainnet genesis difficulty
480            nonce,
481        }
482    }
483
484    #[test]
485    fn test_genesis_init() {
486        let mut index = BlockIndex::new();
487        let genesis_header = make_header(BlockHash::zero(), 0);
488        index.init_genesis(genesis_header.clone());
489
490        assert_eq!(index.best_height(), 0);
491        assert_eq!(index.header_count(), 1);
492
493        let tip = index.best_tip_entry().unwrap();
494        assert_eq!(tip.height, 0);
495    }
496
497    #[test]
498    fn test_add_headers_sequential() {
499        let mut index = BlockIndex::new();
500        let genesis = make_header(BlockHash::zero(), 0);
501        let genesis_hash = genesis.block_hash();
502        index.init_genesis(genesis);
503
504        // Add block 1
505        let header1 = make_header(genesis_hash, 1);
506        let (hash1, reorged) = index.add_header(header1).unwrap();
507        assert!(reorged); // New best chain
508        assert_eq!(index.best_height(), 1);
509
510        // Add block 2
511        let header2 = make_header(hash1, 2);
512        let (_, reorged) = index.add_header(header2).unwrap();
513        assert!(reorged);
514        assert_eq!(index.best_height(), 2);
515    }
516
517    #[test]
518    fn test_orphan_header() {
519        let mut index = BlockIndex::new();
520        let genesis = make_header(BlockHash::zero(), 0);
521        index.init_genesis(genesis);
522
523        // Try to add a header with unknown parent
524        let orphan = make_header(BlockHash::from_hash(Hash256::from_bytes([0xff; 32])), 99);
525        let result = index.add_header(orphan);
526        assert_eq!(result, Err(BlockIndexError::OrphanHeader));
527    }
528
529    #[test]
530    fn test_duplicate_header() {
531        let mut index = BlockIndex::new();
532        let genesis = make_header(BlockHash::zero(), 0);
533        let genesis_hash = genesis.block_hash();
534        index.init_genesis(genesis);
535
536        let header1 = make_header(genesis_hash, 1);
537        let (hash1, _) = index.add_header(header1.clone()).unwrap();
538
539        // Adding same header again should succeed silently (no reorg)
540        let (hash1_again, reorged) = index.add_header(header1).unwrap();
541        assert_eq!(hash1, hash1_again);
542        assert!(!reorged);
543    }
544
545    #[test]
546    fn test_fork_and_reorg() {
547        let mut index = BlockIndex::new();
548        let genesis = make_header(BlockHash::zero(), 0);
549        let genesis_hash = genesis.block_hash();
550        index.init_genesis(genesis);
551
552        // Chain A: genesis → A1 → A2
553        let a1 = make_header(genesis_hash, 10);
554        let (a1_hash, _) = index.add_header(a1).unwrap();
555        let a2 = make_header(a1_hash, 20);
556        let (a2_hash, _) = index.add_header(a2).unwrap();
557        assert_eq!(index.best_tip(), a2_hash);
558        assert_eq!(index.best_height(), 2);
559
560        // Chain B: genesis → B1 → B2 → B3 (longer, same difficulty = more work)
561        let b1 = make_header(genesis_hash, 100);
562        let (b1_hash, _) = index.add_header(b1).unwrap();
563        let b2 = make_header(b1_hash, 200);
564        let (b2_hash, _) = index.add_header(b2).unwrap();
565        // At this point B2 is at height 2 same as A2, same work → no reorg (A2 is still tip)
566        // B has same work as A (same bits), so B doesn't become best
567
568        let b3 = make_header(b2_hash, 300);
569        let (b3_hash, reorged) = index.add_header(b3).unwrap();
570        assert!(reorged); // B3 at height 3 has more work
571        assert_eq!(index.best_tip(), b3_hash);
572        assert_eq!(index.best_height(), 3);
573    }
574
575    #[test]
576    fn test_height_lookup() {
577        let mut index = BlockIndex::new();
578        let genesis = make_header(BlockHash::zero(), 0);
579        let genesis_hash = genesis.block_hash();
580        index.init_genesis(genesis);
581
582        let h1 = make_header(genesis_hash, 1);
583        let (h1_hash, _) = index.add_header(h1).unwrap();
584
585        assert_eq!(index.get_hash_at_height(0), Some(genesis_hash));
586        assert_eq!(index.get_hash_at_height(1), Some(h1_hash));
587        assert_eq!(index.get_hash_at_height(2), None);
588    }
589
590    #[test]
591    fn test_block_locator() {
592        let mut index = BlockIndex::new();
593        let genesis = make_header(BlockHash::zero(), 0);
594        let genesis_hash = genesis.block_hash();
595        index.init_genesis(genesis);
596
597        // Build a chain of 20 blocks
598        let mut prev = genesis_hash;
599        for i in 1..=20 {
600            let h = make_header(prev, i);
601            let (hash, _) = index.add_header(h).unwrap();
602            prev = hash;
603        }
604
605        let locator = index.build_locator();
606        // Should start with the tip and end with genesis
607        assert!(!locator.is_empty());
608        assert_eq!(locator[0], prev); // tip
609        assert_eq!(*locator.last().unwrap(), genesis_hash);
610    }
611
612    #[test]
613    fn test_work_from_bits() {
614        // Zero mantissa → zero work
615        assert_eq!(work_from_bits(0), 0);
616
617        // Mainnet genesis bits: 0x1d00ffff
618        let work = work_from_bits(0x1d00ffff);
619        assert!(work > 0);
620
621        // Higher difficulty (lower target) → more work
622        let work_easy = work_from_bits(0x1d00ffff);
623        let work_hard = work_from_bits(0x1c00ffff); // smaller target
624        assert!(work_hard > work_easy);
625    }
626
627    // ── Checkpoint tests ──────────────────────────────────────
628
629    #[test]
630    fn test_checkpoint_match_passes() {
631        let mut index = BlockIndex::new();
632        let genesis = make_header(BlockHash::zero(), 0);
633        let genesis_hash = genesis.block_hash();
634        index.init_genesis(genesis);
635
636        // Build block at height 1 and record its hash
637        let h1 = make_header(genesis_hash, 1);
638        let h1_hash = h1.block_hash();
639
640        // Load a checkpoint for height 1 that matches
641        index.load_checkpoints(&[Checkpoint {
642            height: 1,
643            hash_hex: Box::leak(h1_hash.to_hex_reversed().into_boxed_str()),
644        }]);
645
646        // Should succeed — hash matches checkpoint
647        let result = index.add_header(h1);
648        assert!(result.is_ok());
649        assert_eq!(index.best_height(), 1);
650    }
651
652    #[test]
653    fn test_checkpoint_mismatch_rejected() {
654        let mut index = BlockIndex::new();
655        let genesis = make_header(BlockHash::zero(), 0);
656        let genesis_hash = genesis.block_hash();
657        index.init_genesis(genesis);
658
659        // Load a checkpoint for height 1 with a bogus hash
660        index.load_checkpoints(&[Checkpoint {
661            height: 1,
662            hash_hex: "0000000000000000000000000000000000000000000000000000000000abcdef",
663        }]);
664
665        // Adding a header at height 1 with a different hash should fail
666        let h1 = make_header(genesis_hash, 1);
667        let result = index.add_header(h1);
668        assert!(matches!(
669            result,
670            Err(BlockIndexError::CheckpointMismatch { .. })
671        ));
672        assert_eq!(index.best_height(), 0); // Still at genesis
673    }
674
675    #[test]
676    fn test_no_checkpoint_at_height_passes() {
677        let mut index = BlockIndex::new();
678        let genesis = make_header(BlockHash::zero(), 0);
679        let genesis_hash = genesis.block_hash();
680        index.init_genesis(genesis);
681
682        // Checkpoint at height 5 — should not affect height 1
683        index.load_checkpoints(&[Checkpoint {
684            height: 5,
685            hash_hex: "0000000000000000000000000000000000000000000000000000000000abcdef",
686        }]);
687
688        let h1 = make_header(genesis_hash, 1);
689        let result = index.add_header(h1);
690        assert!(result.is_ok());
691    }
692
693    #[test]
694    fn test_last_checkpoint_height() {
695        let mut index = BlockIndex::new();
696        assert_eq!(index.last_checkpoint_height(), 0);
697
698        index.load_checkpoints(&[
699            Checkpoint {
700                height: 100,
701                hash_hex: "aaa",
702            },
703            Checkpoint {
704                height: 500,
705                hash_hex: "bbb",
706            },
707            Checkpoint {
708                height: 250,
709                hash_hex: "ccc",
710            },
711        ]);
712        assert_eq!(index.last_checkpoint_height(), 500);
713    }
714
715    // ── Median Time Past tests ──────────────────────────────────
716
717    fn make_header_with_time(prev: BlockHash, nonce: u32, time: u32) -> BlockHeader {
718        BlockHeader {
719            version: 1,
720            prev_block_hash: prev,
721            merkle_root: Hash256::from_bytes([nonce as u8; 32]),
722            time,
723            bits: 0x1d00ffff,
724            nonce,
725        }
726    }
727
728    #[test]
729    fn test_mtp_genesis_only() {
730        let mut index = BlockIndex::new();
731        let genesis = make_header_with_time(BlockHash::zero(), 0, 1231006505);
732        index.init_genesis(genesis);
733
734        // MTP of genesis (only 1 block) = its own timestamp
735        assert_eq!(index.get_median_time_past(0), Some(1231006505));
736        assert_eq!(index.tip_median_time_past(), Some(1231006505));
737    }
738
739    #[test]
740    fn test_mtp_three_blocks() {
741        let mut index = BlockIndex::new();
742        let genesis = make_header_with_time(BlockHash::zero(), 0, 100);
743        let genesis_hash = genesis.block_hash();
744        index.init_genesis(genesis);
745
746        let h1 = make_header_with_time(genesis_hash, 1, 200);
747        let (h1_hash, _) = index.add_header(h1).unwrap();
748
749        let h2 = make_header_with_time(h1_hash, 2, 150); // Deliberately out of order
750        let (_h2_hash, _) = index.add_header(h2).unwrap();
751
752        // Heights 0,1,2 have timestamps [100, 200, 150]
753        // Sorted: [100, 150, 200], median = 150
754        assert_eq!(index.get_median_time_past(2), Some(150));
755    }
756
757    #[test]
758    fn test_mtp_eleven_blocks() {
759        let mut index = BlockIndex::new();
760        let genesis = make_header_with_time(BlockHash::zero(), 0, 1000);
761        let genesis_hash = genesis.block_hash();
762        index.init_genesis(genesis);
763
764        // Build a chain of 11 blocks (heights 0..=10) with timestamps
765        // chosen so the median is well-defined.
766        let timestamps = [
767            1000, 1010, 1020, 1005, 1030, 1015, 1025, 1035, 1008, 1040, 1050,
768        ];
769        let mut prev = genesis_hash;
770        for i in 1..=10u32 {
771            let h = make_header_with_time(prev, i, timestamps[i as usize]);
772            let (hash, _) = index.add_header(h).unwrap();
773            prev = hash;
774        }
775
776        // MTP at height 10: median of timestamps[0..=10]
777        // Sorted: [1000, 1005, 1008, 1010, 1015, 1020, 1025, 1030, 1035, 1040, 1050]
778        // Median (index 5 of 11) = 1020
779        assert_eq!(index.get_median_time_past(10), Some(1020));
780    }
781
782    #[test]
783    fn test_mtp_more_than_eleven_blocks() {
784        let mut index = BlockIndex::new();
785        let genesis = make_header_with_time(BlockHash::zero(), 0, 500);
786        let genesis_hash = genesis.block_hash();
787        index.init_genesis(genesis);
788
789        // Build 15 blocks, each 10 seconds apart
790        let mut prev = genesis_hash;
791        for i in 1..=15u32 {
792            let h = make_header_with_time(prev, i, 500 + i * 10);
793            let (hash, _) = index.add_header(h).unwrap();
794            prev = hash;
795        }
796
797        // MTP at height 15 uses blocks 5..=15 (11 blocks)
798        // Timestamps: [550, 560, 570, 580, 590, 600, 610, 620, 630, 640, 650]
799        // Median = 600
800        assert_eq!(index.get_median_time_past(15), Some(600));
801    }
802
803    #[test]
804    fn test_mtp_invalid_height() {
805        let mut index = BlockIndex::new();
806        let genesis = make_header_with_time(BlockHash::zero(), 0, 1000);
807        index.init_genesis(genesis);
808
809        // Height 5 doesn't exist
810        assert_eq!(index.get_median_time_past(5), None);
811    }
812
813    // ═══════════════════════════════════════════════════════════════════
814    // Regression tests — Session 15 (review finding #5: PoW in headers)
815    //
816    // These tests verify that add_header rejects headers with invalid
817    // proof of work or targets exceeding the network limit, closing the
818    // attack surface identified in the code review.
819    // ═══════════════════════════════════════════════════════════════════
820
821    #[test]
822    fn regression_pow_check_skipped_when_limit_is_zero() {
823        // BlockIndex::new() sets pow_limit_bits = 0, disabling PoW checks.
824        // Any header should be accepted regardless of its hash vs target.
825        let mut index = BlockIndex::new();
826        let genesis = make_header(BlockHash::zero(), 0);
827        let genesis_hash = genesis.block_hash();
828        index.init_genesis(genesis);
829
830        // Header with mainnet difficulty — hash almost certainly doesn't
831        // meet the target, but PoW check is disabled.
832        let h1 = make_header(genesis_hash, 1);
833        assert!(index.add_header(h1).is_ok());
834    }
835
836    #[test]
837    fn regression_pow_check_enabled_with_limit() {
838        // With a real pow_limit_bits, headers must have valid PoW.
839        // Use mainnet difficulty (0x1d00ffff) — a random header hash
840        // will not meet this target.
841        let mut index = BlockIndex::new_with_pow_limit(0x1d00ffff);
842        let genesis = make_header(BlockHash::zero(), 0);
843        let genesis_hash = genesis.block_hash();
844        index.init_genesis(genesis);
845
846        let h1 = make_header(genesis_hash, 42);
847        let result = index.add_header(h1);
848        assert_eq!(result, Err(BlockIndexError::InsufficientProofOfWork));
849    }
850
851    #[test]
852    fn regression_target_exceeds_pow_limit_rejected() {
853        // A header with bits easier than the network limit should be rejected.
854        // pow_limit = 0x1d00ffff (mainnet). Header claims 0x2100ffff (much easier).
855        let mut index = BlockIndex::new_with_pow_limit(0x1d00ffff);
856        let genesis = make_header(BlockHash::zero(), 0);
857        let genesis_hash = genesis.block_hash();
858        index.init_genesis(genesis);
859
860        let mut h1 = make_header(genesis_hash, 1);
861        h1.bits = 0x2100ffff; // Target far exceeds mainnet limit
862        let result = index.add_header(h1);
863        assert_eq!(result, Err(BlockIndexError::TargetExceedsPowLimit));
864    }
865
866    #[test]
867    fn regression_regtest_skips_pow_check() {
868        // Regtest pow_limit_bits = 0x207fffff saturates u128::MAX.
869        // All headers should pass without actual PoW grinding.
870        let mut index = BlockIndex::new_with_pow_limit(0x207fffff);
871        let genesis = make_header(BlockHash::zero(), 0);
872        let genesis_hash = genesis.block_hash();
873        index.init_genesis(genesis);
874
875        let mut h1 = make_header(genesis_hash, 1);
876        h1.bits = 0x207fffff; // regtest difficulty
877        let result = index.add_header(h1);
878        assert!(result.is_ok());
879    }
880}