diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 81f4a1796..1cafed252 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -6,8 +6,8 @@ use core::ops::RangeBounds; use crate::collections::BTreeMap; use crate::{BlockId, ChainOracle, Merge}; -use bdk_core::ToBlockHash; pub use bdk_core::{CheckPoint, CheckPointIter}; +use bdk_core::{CheckPointEntry, ToBlockHash}; use bitcoin::block::Header; use bitcoin::BlockHash; @@ -15,9 +15,9 @@ use bitcoin::BlockHash; fn apply_changeset_to_checkpoint( mut init_cp: CheckPoint, changeset: &ChangeSet, -) -> Result, MissingGenesisError> +) -> Result, ApplyBlockError> where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { if let Some(start_height) = changeset.blocks.keys().next().cloned() { // changes after point of agreement @@ -34,10 +34,10 @@ where } } - for (&height, &data) in &changeset.blocks { + for (&height, data) in &changeset.blocks { match data { Some(data) => { - extension.insert(height, data); + extension.insert(height, data.clone()); } None => { extension.remove(&height); @@ -48,7 +48,11 @@ where let new_tip = match base { Some(base) => base .extend(extension) - .expect("extension is strictly greater than base"), + // Since `extension` is in height order, the only failure case is `prev_blockhash` + // mismatch. + .map_err(|last_cp| ApplyBlockError::PrevBlockhashMismatch { + expected: last_cp.block_id(), + })?, None => LocalChain::from_blocks(extension)?.tip(), }; init_cp = new_tip; @@ -234,7 +238,7 @@ impl LocalChain { // Methods where `D: ToBlockHash` impl LocalChain where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { /// Constructs a [`LocalChain`] from genesis data. pub fn from_genesis(data: D) -> (Self, ChangeSet) { @@ -251,22 +255,27 @@ where /// /// The [`BTreeMap`] enforces the height order. However, the caller must ensure the blocks are /// all of the same chain. - pub fn from_blocks(blocks: BTreeMap) -> Result { + pub fn from_blocks(blocks: BTreeMap) -> Result { if !blocks.contains_key(&0) { - return Err(MissingGenesisError); + return Err(ApplyBlockError::MissingGenesis); } - Ok(Self { - tip: CheckPoint::from_blocks(blocks).expect("blocks must be in order"), - }) + CheckPoint::from_blocks(blocks) + .map(|tip| Self { tip }) + .map_err(|err| { + let last_cp = err.expect("must have at least one block (genesis)"); + ApplyBlockError::PrevBlockhashMismatch { + expected: last_cp.block_id(), + } + }) } /// Construct a [`LocalChain`] from an initial `changeset`. - pub fn from_changeset(changeset: ChangeSet) -> Result { - let genesis_entry = changeset.blocks.get(&0).copied().flatten(); + pub fn from_changeset(changeset: ChangeSet) -> Result { + let genesis_entry = changeset.blocks.get(&0).cloned().flatten(); let genesis_data = match genesis_entry { Some(data) => data, - None => return Err(MissingGenesisError), + None => return Err(ApplyBlockError::MissingGenesis), }; let (mut chain, _) = Self::from_genesis(genesis_data); @@ -310,7 +319,7 @@ where } /// Apply the given `changeset`. - pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), MissingGenesisError> { + pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ApplyBlockError> { let old_tip = self.tip.clone(); let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?; self.tip = new_tip; @@ -412,7 +421,7 @@ where match cur.get(exp_height) { Some(cp) => { if cp.height() != exp_height - || Some(cp.hash()) != exp_data.map(|d| d.to_blockhash()) + || Some(cp.hash()) != exp_data.as_ref().map(|d| d.to_blockhash()) { return false; } @@ -485,6 +494,36 @@ impl FromIterator<(u32, D)> for ChangeSet { } } +/// Error when applying blocks to a local chain. +#[derive(Clone, Debug, PartialEq)] +pub enum ApplyBlockError { + /// Genesis block is missing or would be altered. + MissingGenesis, + /// Block's `prev_blockhash` doesn't match the expected block. + PrevBlockhashMismatch { + /// The block that `prev_blockhash` should reference. + expected: BlockId, + }, +} + +impl core::fmt::Display for ApplyBlockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApplyBlockError::MissingGenesis => { + write!(f, "genesis block is missing or would be altered") + } + ApplyBlockError::PrevBlockhashMismatch { expected } => write!( + f, + "`prev_blockhash` doesn't match block at height {} ({})", + expected.height, expected.hash + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ApplyBlockError {} + /// An error which occurs when a [`LocalChain`] is constructed without a genesis checkpoint. #[derive(Clone, Debug, PartialEq)] pub struct MissingGenesisError; @@ -594,18 +633,42 @@ fn merge_chains( update_tip: CheckPoint, ) -> Result<(CheckPoint, ChangeSet), CannotConnectError> where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { + // Apply the changeset to produce the final merged chain. + // + // `PrevBlockhashMismatch` should never happen because the merge iteration detects + // `prev_blockhash` conflicts and resolves them by invalidating conflicting blocks (setting + // them to `None` in the changeset) before we reach this point. + fn finish( + original_tip: CheckPoint, + changeset: ChangeSet, + ) -> Result<(CheckPoint, ChangeSet), CannotConnectError> + where + D: ToBlockHash + fmt::Debug + Clone, + { + let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|err| { + debug_assert!( + matches!(err, ApplyBlockError::MissingGenesis), + "PrevBlockhashMismatch should never happen" + ); + CannotConnectError { + try_include_height: 0, + } + })?; + Ok((new_tip, changeset)) + } + let mut changeset = ChangeSet::::default(); - let mut orig = original_tip.iter(); - let mut update = update_tip.iter(); + let mut orig = original_tip.entry_iter(); + let mut update = update_tip.entry_iter(); let mut curr_orig = None; let mut curr_update = None; - let mut prev_orig: Option> = None; - let mut prev_update: Option> = None; + let mut prev_orig: Option> = None; + let mut prev_update: Option> = None; let mut point_of_agreement_found = false; @@ -634,13 +697,18 @@ where match (curr_orig.as_ref(), curr_update.as_ref()) { // Update block that doesn't exist in the original chain (o, Some(u)) if Some(u.height()) > o.map(|o| o.height()) => { - changeset.blocks.insert(u.height(), Some(u.data())); + // Only append to `ChangeSet` when this is an actual checkpoint. + if let Some(data) = u.data() { + changeset.blocks.insert(u.height(), Some(data)); + } prev_update = curr_update.take(); } // Original block that isn't in the update (Some(o), u) if Some(o.height()) > u.map(|u| u.height()) => { - // this block might be gone if an earlier block gets invalidated - potentially_invalidated_heights.push(o.height()); + if !o.is_placeholder() { + // this block might be gone if an earlier block gets invalidated + potentially_invalidated_heights.push(o.height()); + } prev_orig_was_invalidated = false; prev_orig = curr_orig.take(); @@ -671,21 +739,23 @@ where prev_orig_was_invalidated = false; // OPTIMIZATION 2 -- if we have the same underlying pointer at this point, we // can guarantee that no older blocks are introduced. - if o.eq_ptr(u) { + if o.source_checkpoint().eq_ptr(&u.source_checkpoint()) { if is_update_height_superset_of_original { return Ok((update_tip, changeset)); } else { - let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset) - .map_err(|_| CannotConnectError { - try_include_height: 0, - })?; - return Ok((new_tip, changeset)); + return finish(original_tip, changeset); + } + } + // Update placeholder with real data (if necessary). + if let Some(u_data) = u.data_ref() { + if o.is_placeholder() { + changeset.blocks.insert(u.height(), Some(u_data.clone())); } } } else { // We have an invalidation height so we set the height to the updated hash and // also purge all the original chain block hashes above this block. - changeset.blocks.insert(u.height(), Some(u.data())); + changeset.blocks.insert(u.height(), u.data()); for invalidated_height in potentially_invalidated_heights.drain(..) { changeset.blocks.insert(invalidated_height, None); } @@ -714,10 +784,5 @@ where } } - let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|_| { - CannotConnectError { - try_include_height: 0, - } - })?; - Ok((new_tip, changeset)) + finish(original_tip, changeset) } diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 7ad03f04f..109ac5f52 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -11,27 +11,31 @@ use bdk_chain::{ BlockId, }; use bdk_testenv::{chain_update, hash, local_chain}; +use bitcoin::consensus::encode::deserialize_hex; use bitcoin::{block::Header, hashes::Hash, BlockHash}; use proptest::prelude::*; #[derive(Debug)] -struct TestLocalChain<'a> { +struct TestLocalChain<'a, D> { name: &'static str, - chain: LocalChain, - update: CheckPoint, - exp: ExpectedResult<'a>, + chain: LocalChain, + update: CheckPoint, + exp: ExpectedResult<'a, D>, } #[derive(Debug, PartialEq)] -enum ExpectedResult<'a> { +enum ExpectedResult<'a, D> { Ok { - changeset: &'a [(u32, Option)], - init_changeset: &'a [(u32, Option)], + changeset: &'a [(u32, Option)], + init_changeset: &'a [(u32, Option)], }, Err(CannotConnectError), } -impl TestLocalChain<'_> { +impl TestLocalChain<'_, D> +where + D: bdk_core::ToBlockHash + Copy + PartialEq + std::fmt::Debug, +{ fn run(mut self) { let got_changeset = match self.chain.apply_update(self.update) { Ok(changeset) => changeset, @@ -75,7 +79,7 @@ impl TestLocalChain<'_> { #[test] fn update_local_chain() { [ - TestLocalChain { + TestLocalChain:: { name: "add first tip", chain: local_chain![(0, hash!("A"))], update: chain_update![(0, hash!("A"))], @@ -550,6 +554,53 @@ fn local_chain_disconnect_from() { } } +// Test that `apply_update` can connect 1 `Header` at a time +// and fails if a `prev_blockhash` conflict is detected. +#[test] +fn test_apply_update_single_header() { + let headers: Vec
= [ + "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000", + "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f1c96cc459dbb0c7bbc722af14f913da868779290ad48ff87ee314ebb8ae08f384b166069ffff7f2001000000", + "0000002078ce518c7dcfd99ad5859c35bd2be15794c0e5dc8e60c1fea3b0461c45da181ca80f828504f88c645ea46cfcf93156269807e3bd409e1317271a1546d238e9b24c166069ffff7f2001000000", + "000000200dfd6c5af6ea3cb341a08db344f93743d94b630d122867faff855f6310e58864e6e0c859fda703d66d051b86f16f09b0cead182f3cbe13767cd91b6371ec252c4c166069ffff7f2000000000", + ] + .into_iter() + .map(|s| deserialize_hex::
(s).expect("failed to deserialize header")) + .collect(); + + let header_0 = headers[0]; + let header_1 = headers[1]; + let header_2 = headers[2]; + let header_3 = headers[3]; + + let (mut chain, _) = LocalChain::from_genesis(header_0); + + // Apply 1 `CheckPoint
` at a time + for (height, header) in (1..).zip([header_1, header_2, header_3]) { + let changeset = chain.apply_update(CheckPoint::new(height, header)).unwrap(); + assert_eq!( + changeset, + ChangeSet { + blocks: [(height, Some(headers[height as usize]))].into() + }, + ); + } + assert_eq!(chain.tip().iter().count(), 4); + for height in 0..4 { + assert!(chain + .get(height) + .is_some_and(|cp| cp.hash() == headers[height as usize].block_hash())) + } + + // `apply_update` should error if update does not connect. + // Reset chain for error test. + chain = LocalChain::from_blocks([(0, header_0), (1, header_1)].into()).unwrap(); + let mut header_2_alt = header_2; + header_2_alt.prev_blockhash = hash!("header_1_new"); + let result = chain.apply_update(CheckPoint::new(2, header_2_alt)); + assert!(result.is_err(), "Failed to detect prev_blockhash conflict"); +} + #[test] fn checkpoint_from_block_ids() { struct TestCase<'a> { @@ -953,3 +1004,296 @@ proptest! { prop_assert_eq!(heights, exp_heights); } } + +/// A test block type that returns `Some` for `prev_blockhash`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TestBlock { + hash: BlockHash, + prev_hash: BlockHash, +} + +impl bdk_core::ToBlockHash for TestBlock { + fn to_blockhash(&self) -> BlockHash { + self.hash + } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_hash) + } +} + +/// Tests for `prev_blockhash` behavior in chain merging. +#[test] +fn merge_chains_with_prev_blockhash() { + // Common test blocks + let block_genesis = TestBlock { + hash: hash!("_"), + prev_hash: BlockHash::all_zeros(), + }; + let block_a = TestBlock { + hash: hash!("A"), + prev_hash: hash!("_"), + }; + let block_b = TestBlock { + hash: hash!("B"), + prev_hash: hash!("A"), + }; + let block_c = TestBlock { + hash: hash!("C"), + prev_hash: hash!("B"), + }; + let block_c_prime = TestBlock { + hash: hash!("C'"), + prev_hash: hash!("B'"), // conflicts with B + }; + let block_d_orphan = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C_nonexistent"), + }; + let block_d_linked = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C"), // matches block_c + }; + let block_a_prime = TestBlock { + hash: hash!("A'"), + prev_hash: hash!("_'"), // points to different genesis + }; + let block_b_prime = TestBlock { + hash: hash!("B'"), + prev_hash: hash!("A"), + }; + let block_a_alt = TestBlock { + hash: hash!("A_alt"), + prev_hash: hash!("_"), + }; + let block_d = TestBlock { + hash: hash!("D"), + prev_hash: hash!("C"), + }; + + [ + // Test: prev_blockhash can invalidate blocks in the original chain + // + // ```text + // | 0 | 1 | 2 | 3 | 4 | + // chain | _ A B D + // update | _ A C'(prev=B') + // result | _ A C' + // ``` + // + // The update at height 3 has `prev_blockhash = B'` which conflicts with the original + // `B` at height 2. This should invalidate blocks at heights 2 and 4. + TestLocalChain:: { + name: "prev_blockhash invalidates conflicting blocks", + chain: LocalChain::from_blocks( + [ + (0, block_genesis), + (1, block_a), + (2, block_b), + (4, block_d_orphan), + ] + .into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (3, block_c_prime)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(2, None), (3, Some(block_c_prime)), (4, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (3, Some(block_c_prime)), + ], + }, + }, + // Test: prev_blockhash can connect disjoint chains + // + // ```text + // | 0 | 2 | 3 | 4 | + // chain | _ B C + // update | _ B D(prev=C) + // result | _ B C D + // ``` + // + // The update at height 4 has `prev_blockhash = C` which matches height 3 in the + // original. Even though the update doesn't include height 3 explicitly, the chains + // should connect. + TestLocalChain { + name: "prev_blockhash connects disjoint chains", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b), (3, block_c)].into()) + .unwrap(), + update: CheckPoint::from_blocks([ + (0, block_genesis), + (2, block_b), + (4, block_d_linked), + ]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(4, Some(block_d_linked))], + init_changeset: &[ + (0, Some(block_genesis)), + (2, Some(block_b)), + (3, Some(block_c)), + (4, Some(block_d_linked)), + ], + }, + }, + // Test: update's block evicts chain blocks via prev_blockhash conflict + // + // ```text + // | 0 | 1 | 2 | 3 | + // chain | _ A C(prev=B) + // update | _ A B' + // result | _ A B' + // ``` + // + // The chain's block C at height 3 has `prev_blockhash = B`. The update has B' at height 2 + // which doesn't match. C gets evicted because its prev_blockhash is invalidated. + TestLocalChain { + name: "update evicts chain blocks via prev_blockhash conflict", + chain: LocalChain::from_blocks([(0, block_genesis), (1, block_a), (3, block_c)].into()) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (2, block_b_prime)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(2, Some(block_b_prime)), (3, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (2, Some(block_b_prime)), + ], + }, + }, + // Test: prev_blockhash at height 1 implies different genesis + // + // ```text + // | 0 | 1 | + // chain | _ A + // update | A'(prev=_') + // result | error + // ``` + // + // The update only has a block at height 1, whose `prev_blockhash = _'` conflicts with + // the chain's genesis `_`. This should fail to connect. + TestLocalChain { + name: "prev_blockhash implies different genesis", + chain: LocalChain::from_blocks([(0, block_genesis), (1, block_a)].into()).unwrap(), + update: CheckPoint::new(1, block_a_prime), + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 0, + }), + }, + // Test: unverifiable connection despite shared genesis + // + // ```text + // | 0 | 1 | 2 | 4 | + // chain | _ B(prev=A) + // update | _ D(prev=C) + // result | error (height 1) + // ``` + // + // Both chains share genesis, but the chain's B implies A at height 1 (via prev_blockhash). + // The update doesn't have anything at height 1 to verify this connection, so the merge + // fails at height 1. + TestLocalChain { + name: "unverifiable connection despite shared genesis", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b)].into()).unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (4, block_d_linked)]).unwrap(), + exp: ExpectedResult::Err(CannotConnectError { + try_include_height: 1, + }), + }, + // Test: update displaces invalid block below point of agreement + // + // ```text + // | 0 | 1 | 2 | 4 | + // chain | _ C(prev=B) D + // update | _ A D + // result | _ A D + // ``` + // + // Both chains agree on D at height 4. Chain has C at height 2 with `prev_blockhash = B`, + // but no B exists. Update introduces A at height 1, which displaces C because + // C's `prev_blockhash` ("B") doesn't match A's hash ("A"). + // + // Note: This can only happen if chains are constructed incorrectly. + TestLocalChain { + name: "update displaces invalid block below point of agreement", + chain: LocalChain::from_blocks( + [(0, block_genesis), (2, block_c), (4, block_d_linked)].into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([ + (0, block_genesis), + (1, block_a), + (4, block_d_linked), + ]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a)), (2, None)], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (4, Some(block_d_linked)), + ], + }, + }, + // Test: update fills gap with matching prev_blockhash + // + // ```text + // | 0 | 1 | 2 | + // chain | _ B(prev=A) + // update | _ A B + // result | _ A B + // ``` + // + // Chain has gap at height 1. Update provides A which matches B's `prev_blockhash`. + // The chains connect perfectly. + TestLocalChain { + name: "update fills gap with matching prev_blockhash", + chain: LocalChain::from_blocks([(0, block_genesis), (2, block_b)].into()).unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a), (2, block_b)]) + .unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a))], + init_changeset: &[ + (0, Some(block_genesis)), + (1, Some(block_a)), + (2, Some(block_b)), + ], + }, + }, + // Test: cascading eviction through multiple blocks + // + // ```text + // | 0 | 1 | 2 | 3 | 4 | + // chain | _ A B(prev=A) C(prev=B) D(prev=C) + // update | _ A_alt + // result | _ A_alt + // ``` + // + // Update replaces A with A_alt. B's `prev_blockhash = A` doesn't match A_alt, + // so B is evicted. C and D depend on B via prev_blockhash, so they cascade evict. + TestLocalChain { + name: "cascading eviction through multiple blocks", + chain: LocalChain::from_blocks( + [ + (0, block_genesis), + (1, block_a), + (2, block_b), + (3, block_c), + (4, block_d), + ] + .into(), + ) + .unwrap(), + update: CheckPoint::from_blocks([(0, block_genesis), (1, block_a_alt)]).unwrap(), + exp: ExpectedResult::Ok { + changeset: &[(1, Some(block_a_alt)), (2, None), (3, None), (4, None)], + init_changeset: &[(0, Some(block_genesis)), (1, Some(block_a_alt))], + }, + }, + ] + .into_iter() + .for_each(TestLocalChain::run); +} diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 0a394122a..842949b4f 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -1,11 +1,10 @@ -use core::fmt; -use core::ops::RangeBounds; - use alloc::sync::Arc; use alloc::vec::Vec; use bitcoin::{block::Header, BlockHash}; +use core::fmt; +use core::ops::RangeBounds; -use crate::BlockId; +use crate::{BlockId, CheckPointEntry, CheckPointEntryIter}; /// A checkpoint is a node of a reference-counted linked list of [`BlockId`]s. /// @@ -65,6 +64,11 @@ impl Drop for CPInner { pub trait ToBlockHash { /// Returns the [`BlockHash`] for the associated [`CheckPoint`] `data` type. fn to_blockhash(&self) -> BlockHash; + + /// Returns `None` if the type has no knowledge of the previous [`BlockHash`]. + fn prev_blockhash(&self) -> Option { + None + } } impl ToBlockHash for BlockHash { @@ -77,6 +81,10 @@ impl ToBlockHash for Header { fn to_blockhash(&self) -> BlockHash { self.block_hash() } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } } /// Trait that extracts a block time from [`CheckPoint`] `data`. @@ -201,10 +209,30 @@ impl CheckPoint { } } +impl CheckPoint +where + D: ToBlockHash, +{ + /// Iterate entries from this checkpoint in descending height. + pub fn entry_iter(&self) -> CheckPointEntryIter { + self.to_entry().into_iter() + } + + /// Transforms this checkpoint into a [`CheckPointEntry`]. + pub fn into_entry(self) -> CheckPointEntry { + CheckPointEntry::Occupied(self) + } + + /// Creates a [`CheckPointEntry`]. + pub fn to_entry(&self) -> CheckPointEntry { + CheckPointEntry::Occupied(self.clone()) + } +} + // Methods where `D: ToBlockHash` impl CheckPoint where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { const MTP_BLOCK_COUNT: u32 = 11; @@ -256,21 +284,22 @@ where /// Construct from an iterator of block data. /// /// Returns `Err(None)` if `blocks` doesn't yield any data. If the blocks are not in ascending - /// height order, then returns an `Err(..)` containing the last checkpoint that would have been - /// extended. + /// height order, or there are any `prev_blockhash` mismatches, then returns an `Err(..)` + /// containing the last checkpoint that would have been extended. pub fn from_blocks(blocks: impl IntoIterator) -> Result> { let mut blocks = blocks.into_iter(); let (height, data) = blocks.next().ok_or(None)?; let mut cp = CheckPoint::new(height, data); - cp = cp.extend(blocks)?; + cp = cp.extend(blocks).map_err(Some)?; Ok(cp) } /// Extends the checkpoint linked list by a iterator containing `height` and `data`. /// - /// Returns an `Err(self)` if there is block which does not have a greater height than the - /// previous one. + /// Returns an `Err(self)` if there is a block which does not have a greater height than the + /// previous one, or doesn't properly link to an adjacent block via its `prev_blockhash`. + /// See docs for [`CheckPoint::push`]. pub fn extend(self, blockdata: impl IntoIterator) -> Result { let mut cp = self.clone(); for (height, data) in blockdata { @@ -281,74 +310,119 @@ where /// Inserts `data` at its `height` within the chain. /// - /// The effect of `insert` depends on whether a height already exists. If it doesn't, the data - /// we inserted and all pre-existing entries higher than it will be re-inserted after it. If the - /// height already existed and has a conflicting block hash then it will be purged along with - /// all entries following it. The returned chain will have a tip of the data passed in. Of - /// course, if the data was already present then this just returns `self`. + /// If a checkpoint already exists at `height` with a matching hash, returns `self` unchanged. + /// If a checkpoint exists at `height` with a different hash, or if `prev_blockhash` conflicts + /// occur, the conflicting checkpoint and all checkpoints above it are removed. /// /// # Panics /// - /// This panics if called with a genesis block that differs from that of `self`. + /// Panics if the insertion would replace (or omit) the checkpoint at height 0 (a.k.a + /// "genesis"). Although [`CheckPoint`] isn't structurally required to contain a genesis + /// block, if one is present, it stays immutable and can't be replaced. #[must_use] pub fn insert(self, height: u32, data: D) -> Self { let mut cp = self.clone(); let mut tail = vec![]; + + // Traverse from tip to base, looking for where to insert. let base = loop { - if cp.height() == height { + // Genesis (height 0) must remain immutable. + if cp.height() == 0 { + let implied_genesis = match height { + 0 => Some(data.to_blockhash()), + 1 => data.prev_blockhash(), + _ => None, + }; + if let Some(hash) = implied_genesis { + assert_eq!(hash, cp.hash(), "inserted data implies different genesis"); + } + } + + // Above insertion: collect for potential re-insertion later. + // No need to check data.prev_blockhash here since that points below insertion. The + // reverse relationship (cp.prev_blockhash vs data.hash) is validated during rebuild. + if cp.height() > height { + tail.push((cp.height(), cp.data())); + + // At insertion: determine whether we need to clear tail, or early return. + } else if cp.height() == height { if cp.hash() == data.to_blockhash() { return self; } - assert_ne!(cp.height(), 0, "cannot replace genesis block"); - // If we have a conflict we just return the inserted data because the tail is by - // implication invalid. - tail = vec![]; - break cp.prev().expect("can't be called on genesis block"); + tail.clear(); + + // Displacement: data's prev_blockhash conflicts with this checkpoint, + // so skip it and invalidate everything above. + } else if cp.height() + 1 == height + && data.prev_blockhash().is_some_and(|h| h != cp.hash()) + { + tail.clear(); + + // Below insertion: this is our base (since data's prev_blockhash does not conflict). + } else if cp.height() < height { + break Some(cp); } - if cp.height() < height { - break cp; + // Continue traversing down (if possible). + match cp.prev() { + Some(prev) => cp = prev, + None => break None, } - - tail.push((cp.height(), cp.data())); - cp = cp.prev().expect("will break before genesis block"); }; - base.extend(core::iter::once((height, data)).chain(tail.into_iter().rev())) - .expect("tail is in order") + tail.push((height, data)); + let tail = tail.into_iter().rev(); + + // Reconstruct the chain: If a block above insertion has a prev_blockhash that doesn't match + // the inserted data's hash, that block and everything above it are evicted. + let (Ok(cp) | Err(cp)) = match base { + Some(base_cp) => base_cp.extend(tail), + None => CheckPoint::from_blocks(tail).map_err(|err| err.expect("tail is non-empty")), + }; + cp } /// Puts another checkpoint onto the linked list representing the blockchain. /// - /// Returns an `Err(self)` if the block you are pushing on is not at a greater height that the - /// one you are pushing on to. + /// Returns an `Err(self)` if: + /// * The block you are pushing on is not at a greater height that the one you are pushing on + /// to. + /// * The `prev_blockhash` does not match. pub fn push(self, height: u32, data: D) -> Result { - if self.height() < height { - Ok(Self(Arc::new(CPInner { - block_id: BlockId { - height, - hash: data.to_blockhash(), - }, - data, - prev: Some(self.0), - }))) - } else { - Err(self) + // Reject if trying to push at or below current height - chain must grow forward. + if height <= self.height() { + return Err(self); + } + + // For contiguous height, ensure prev_blockhash does not conflict. + if let Some(prev_blockhash) = data.prev_blockhash() { + if self.height() + 1 == height && self.hash() != prev_blockhash { + return Err(self); + } } + + Ok(Self(Arc::new(CPInner { + block_id: BlockId { + height, + hash: data.to_blockhash(), + }, + data, + prev: Some(self.0), + }))) } } /// Iterates over checkpoints backwards. pub struct CheckPointIter { - current: Option>>, + next: Option>>, } impl Iterator for CheckPointIter { type Item = CheckPoint; fn next(&mut self) -> Option { - let current = self.current.clone()?; - self.current.clone_from(¤t.prev); + let current = self.next.clone()?; + self.next.clone_from(¤t.prev); Some(CheckPoint(current)) } } @@ -358,9 +432,7 @@ impl IntoIterator for CheckPoint { type IntoIter = CheckPointIter; fn into_iter(self) -> Self::IntoIter { - CheckPointIter { - current: Some(self.0), - } + CheckPointIter { next: Some(self.0) } } } diff --git a/crates/core/src/checkpoint_entry.rs b/crates/core/src/checkpoint_entry.rs new file mode 100644 index 000000000..b0151ec98 --- /dev/null +++ b/crates/core/src/checkpoint_entry.rs @@ -0,0 +1,221 @@ +//! Checkpoint entries for `prev_blockhash`-aware iteration. +//! +//! A [`CheckPoint`] chain may have gaps (non-contiguous heights). However, each checkpoint's +//! data can include a `prev_blockhash` that references the block one height below. This module +//! provides [`CheckPointEntry`], which represents either: +//! +//! - **Occupied**: A real checkpoint stored at this height. +//! - **Placeholder**: No checkpoint exists at this height, but the checkpoint above references it +//! via `prev_blockhash`. The placeholder contains the implied [`BlockId`]. +//! +//! Use [`CheckPoint::entry_iter`] to iterate over entries, which yields placeholders for any +//! gaps where `prev_blockhash` implies a block. + +use core::ops::RangeBounds; + +use bitcoin::BlockHash; + +use crate::{BlockId, CheckPoint, ToBlockHash}; + +/// An entry yielded by [`CheckPointIter`]. +#[derive(Debug, Clone)] +pub enum CheckPointEntry { + /// A placeholder entry: there is no checkpoint stored at this height, + /// but the checkpoint one height above links back here via its `prev_blockhash`. + Placeholder { + /// The block ID at *this* height. + block_id: BlockId, + /// The checkpoint one height *above* that links back to `block_id`. + checkpoint_above: CheckPoint, + }, + /// A real checkpoint recorded at this height. + Occupied(CheckPoint), +} + +impl CheckPointEntry { + /// The checkpoint at this height (if any). + pub fn checkpoint(&self) -> Option> { + match self { + CheckPointEntry::Placeholder { .. } => None, + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.clone()), + } + } + + /// Returns `true` if this entry is a placeholder (inferred from `prev_blockhash`). + pub fn is_placeholder(&self) -> bool { + matches!(self, CheckPointEntry::Placeholder { .. }) + } + + /// The checkpoint that is the *source* of this entry. + /// + /// For an `Occupied` entry, this is the checkpoint itself. + /// For a `Placeholder` entry, this is the checkpoint above that references this height. + pub fn source_checkpoint(&self) -> CheckPoint { + match self { + CheckPointEntry::Placeholder { + checkpoint_above, .. + } => checkpoint_above.clone(), + CheckPointEntry::Occupied(checkpoint) => checkpoint.clone(), + } + } + + /// Returns the checkpoint at or below this entry's height. + pub fn floor_checkpoint(&self) -> Option> { + match self { + CheckPointEntry::Placeholder { + checkpoint_above: linking_checkpoint, + .. + } => linking_checkpoint.prev(), + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.clone()), + } + } + + /// Returns a reference to the data recorded at this exact height (if any). + pub fn data_ref(&self) -> Option<&D> { + match self { + CheckPointEntry::Placeholder { .. } => None, + CheckPointEntry::Occupied(checkpoint) => Some(checkpoint.data_ref()), + } + } + + /// Returns the data recorded at this exact height (if any). + pub fn data(&self) -> Option + where + D: Clone, + { + self.data_ref().cloned() + } +} + +impl CheckPointEntry { + /// The block ID of this entry. + pub fn block_id(&self) -> BlockId { + match self { + CheckPointEntry::Placeholder { block_id, .. } => *block_id, + CheckPointEntry::Occupied(checkpoint) => checkpoint.block_id(), + } + } + + /// The blockhash of this entry. + pub fn hash(&self) -> BlockHash { + self.block_id().hash + } + + /// The block height of this entry. + pub fn height(&self) -> u32 { + self.block_id().height + } + + /// Get the previous entry in the chain. + pub fn prev(&self) -> Option { + let checkpoint = match self { + Self::Placeholder { + checkpoint_above, .. + } => { + return checkpoint_above.prev().map(Self::Occupied); + } + Self::Occupied(checkpoint) => checkpoint, + }; + + let prev_height = checkpoint.height().checked_sub(1)?; + + let prev_blockhash = match checkpoint.data_ref().prev_blockhash() { + Some(blockhash) => blockhash, + None => return checkpoint.prev().map(Self::Occupied), + }; + + if let Some(prev_checkpoint) = checkpoint.prev() { + if prev_checkpoint.height() == prev_height { + return Some(Self::Occupied(prev_checkpoint)); + } + } + + Some(Self::Placeholder { + block_id: BlockId { + height: prev_height, + hash: prev_blockhash, + }, + checkpoint_above: checkpoint.clone(), + }) + } + + /// Iterate over checkpoint entries backwards. + pub fn iter(&self) -> CheckPointEntryIter + where + D: Clone, + { + self.clone().into_iter() + } + + /// Get checkpoint entry at `height`. + /// + /// Returns `None` if checkpoint at `height` does not exist. + pub fn get(&self, height: u32) -> Option + where + D: Clone, + { + self.range(height..=height).next() + } + + /// Iterate checkpoints over a height range. + pub fn range(&self, range: R) -> impl Iterator> + where + D: Clone, + R: RangeBounds, + { + let start_bound = range.start_bound().cloned(); + let end_bound = range.end_bound().cloned(); + self.iter() + .skip_while(move |cp_entry| match end_bound { + core::ops::Bound::Included(inc_bound) => cp_entry.height() > inc_bound, + core::ops::Bound::Excluded(exc_bound) => cp_entry.height() >= exc_bound, + core::ops::Bound::Unbounded => false, + }) + .take_while(move |cp_entry| match start_bound { + core::ops::Bound::Included(inc_bound) => cp_entry.height() >= inc_bound, + core::ops::Bound::Excluded(exc_bound) => cp_entry.height() > exc_bound, + core::ops::Bound::Unbounded => true, + }) + } + + /// Returns the entry at `height` if one exists, otherwise the nearest checkpoint at a lower + /// height. + pub fn floor_at(&self, height: u32) -> Option + where + D: Clone, + { + self.range(..=height).next() + } + + /// Returns the entry located a number of heights below this one. + pub fn floor_below(&self, offset: u32) -> Option + where + D: Clone, + { + self.floor_at(self.height().checked_sub(offset)?) + } +} + +/// Iterates over checkpoint entries backwards. +pub struct CheckPointEntryIter { + next: Option>, +} + +impl Iterator for CheckPointEntryIter { + type Item = CheckPointEntry; + + fn next(&mut self) -> Option { + let item = self.next.take()?; + self.next = item.prev(); + Some(item) + } +} + +impl IntoIterator for CheckPointEntry { + type Item = CheckPointEntry; + type IntoIter = CheckPointEntryIter; + + fn into_iter(self) -> Self::IntoIter { + CheckPointEntryIter { next: Some(self) } + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 95bebe907..cf02a99b0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -65,6 +65,9 @@ pub use block_id::*; mod checkpoint; pub use checkpoint::*; +mod checkpoint_entry; +pub use checkpoint_entry::*; + mod tx_update; pub use tx_update::*; diff --git a/crates/core/tests/test_checkpoint.rs b/crates/core/tests/test_checkpoint.rs index 811c56fad..19c06a417 100644 --- a/crates/core/tests/test_checkpoint.rs +++ b/crates/core/tests/test_checkpoint.rs @@ -186,3 +186,387 @@ fn test_mtp_sparse_chain() { assert_eq!(cp.median_time_past(), None); assert_eq!(cp.get(11).unwrap().median_time_past(), None); } + +// Custom struct for testing with prev_blockhash +#[derive(Debug, Clone, Copy)] +struct TestBlock { + blockhash: BlockHash, + prev_blockhash: BlockHash, +} + +impl ToBlockHash for TestBlock { + fn to_blockhash(&self) -> BlockHash { + self.blockhash + } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } +} + +/// Test inserting data with conflicting prev_blockhash should displace checkpoint and create +/// placeholder. +/// +/// When inserting data at height `h` with a `prev_blockhash` that conflicts with the checkpoint +/// at height `h-1`, the checkpoint at `h-1` should be displaced and replaced with a placeholder +/// containing the `prev_blockhash` from the inserted data. +/// +/// Expected: Checkpoint at 99 gets displaced when inserting at 100 with conflicting prev_blockhash. +#[test] +fn checkpoint_insert_conflicting_prev_blockhash() { + // Create initial checkpoint at height 99 + let block_99 = TestBlock { + blockhash: hash!("block_at_99"), + prev_blockhash: hash!("block_at_98"), + }; + let cp = CheckPoint::new(99, block_99); + + // Insert data at height 100 with a prev_blockhash that conflicts with checkpoint at 99 + let block_100_conflicting = TestBlock { + blockhash: hash!("block_at_100"), + prev_blockhash: hash!("different_block_at_99"), // Conflicts with block_99.blockhash + }; + + let result = cp.insert(100, block_100_conflicting); + + // Expected behavior: The checkpoint at 99 should be displaced + assert!(result.get(99).is_none(), "99 was displaced"); + + // The checkpoint at 100 should be inserted correctly + let height_100 = result.get(100).expect("checkpoint at 100 should exist"); + assert_eq!(height_100.hash(), block_100_conflicting.blockhash); + + // Verify chain structure + assert_eq!(result.height(), 100, "tip should be at height 100"); + assert_eq!(result.iter().count(), 1, "should have 1 checkpoints (100)"); +} + +/// Test inserting data that conflicts with prev_blockhash of higher checkpoints should purge them. +/// +/// When inserting data at height `h` where the blockhash conflicts with the `prev_blockhash` of +/// checkpoint at height `h+1`, the checkpoint at `h+1` and all checkpoints above it should be +/// purged from the chain. +/// +/// Expected: Checkpoints at 100, 101, 102 get purged when inserting at 99 with conflicting +/// blockhash. +#[test] +fn checkpoint_insert_purges_conflicting_tail() { + // Create a chain with multiple checkpoints + let block_98 = TestBlock { + blockhash: hash!("block_at_98"), + prev_blockhash: hash!("block_at_97"), + }; + let block_99 = TestBlock { + blockhash: hash!("block_at_99"), + prev_blockhash: hash!("block_at_98"), + }; + let block_100 = TestBlock { + blockhash: hash!("block_at_100"), + prev_blockhash: hash!("block_at_99"), + }; + let block_101 = TestBlock { + blockhash: hash!("block_at_101"), + prev_blockhash: hash!("block_at_100"), + }; + let block_102 = TestBlock { + blockhash: hash!("block_at_102"), + prev_blockhash: hash!("block_at_101"), + }; + + let cp = CheckPoint::from_blocks(vec![ + (98, block_98), + (99, block_99), + (100, block_100), + (101, block_101), + (102, block_102), + ]) + .expect("should create valid checkpoint chain"); + + // Verify initial chain has all checkpoints + assert_eq!(cp.iter().count(), 5); + + // Insert a conflicting block at height 99 + // The new block's hash will conflict with block_100's prev_blockhash + let conflicting_block_99 = TestBlock { + blockhash: hash!("different_block_at_99"), + prev_blockhash: hash!("block_at_98"), // Matches existing block_98 + }; + + let result = cp.insert(99, conflicting_block_99); + + // Expected: Heights 100, 101, 102 should be purged because block_100's + // prev_blockhash conflicts with the new block_99's hash + assert_eq!( + result.height(), + 99, + "tip should be at height 99 after purging higher checkpoints" + ); + + // Check that only 98 and 99 remain + assert_eq!( + result.iter().count(), + 2, + "should have 2 checkpoints (98, 99)" + ); + + // Verify height 99 has the new conflicting block + let height_99 = result.get(99).expect("checkpoint at 99 should exist"); + assert_eq!(height_99.hash(), conflicting_block_99.blockhash); + + // Verify height 98 remains unchanged + let height_98 = result.get(98).expect("checkpoint at 98 should exist"); + assert_eq!(height_98.hash(), block_98.blockhash); + + // Verify heights 100, 101, 102 are purged + assert!( + result.get(100).is_none(), + "checkpoint at 100 should be purged" + ); + assert!( + result.get(101).is_none(), + "checkpoint at 101 should be purged" + ); + assert!( + result.get(102).is_none(), + "checkpoint at 102 should be purged" + ); +} + +/// Test inserting between checkpoints with conflicts on both sides. +/// +/// When inserting at height between two checkpoints where the inserted data's `prev_blockhash` +/// conflicts with the lower checkpoint and its `blockhash` conflicts with the upper checkpoint's +/// `prev_blockhash`, both checkpoints should be handled: lower displaced, upper purged. +/// +/// Expected: Checkpoint at 4 displaced with placeholder, checkpoint at 6 purged. +#[test] +fn checkpoint_insert_between_conflicting_both_sides() { + // Create checkpoints at heights 4 and 6 + let block_4 = TestBlock { + blockhash: hash!("block_at_4"), + prev_blockhash: hash!("block_at_3"), + }; + let block_6 = TestBlock { + blockhash: hash!("block_at_6"), + prev_blockhash: hash!("block_at_5_original"), // This will conflict with inserted block 5 + }; + + let cp = CheckPoint::from_blocks(vec![(4, block_4), (6, block_6)]) + .expect("should create valid checkpoint chain"); + + // Verify initial state + assert_eq!(cp.iter().count(), 2); + + // Insert at height 5 with conflicts on both sides + let block_5_conflicting = TestBlock { + blockhash: hash!("block_at_5_new"), // Conflicts with block_6.prev_blockhash + prev_blockhash: hash!("different_block_at_4"), // Conflicts with block_4.blockhash + }; + + let result = cp.insert(5, block_5_conflicting); + + // Expected behavior: + // - Checkpoint at 4 should be displaced (omitted) + // - Checkpoint at 5 should have the inserted data + // - Checkpoint at 6 should be purged due to prev_blockhash conflict + + // Verify height 4 is displaced with placeholder + assert!(result.get(4).is_none()); + + // Verify height 5 has the inserted data + let checkpoint_5 = result.get(5).expect("checkpoint at 5 should exist"); + assert_eq!(checkpoint_5.height(), 5); + assert_eq!(checkpoint_5.hash(), block_5_conflicting.blockhash); + + // Verify height 6 is purged + assert!( + result.get(6).is_none(), + "checkpoint at 6 should be purged due to prev_blockhash conflict" + ); + + // Verify chain structure + assert_eq!(result.height(), 5, "tip should be at height 5"); + // Should have: checkpoint 5 only + assert_eq!( + result.iter().count(), + 1, + "should have 1 checkpoint(s) (4 was displaced, 6 was evicted)" + ); +} + +/// Test that push returns Err(self) when trying to push at the same height. +#[test] +fn checkpoint_push_fails_same_height() { + let cp: CheckPoint = CheckPoint::new(100, hash!("block_100")); + + // Try to push at the same height (100) + let result = cp.clone().push(100, hash!("another_block_100")); + + assert!( + result.is_err(), + "push should fail when height is same as current" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push returns Err(self) when trying to push at a lower height. +#[test] +fn checkpoint_push_fails_lower_height() { + let cp: CheckPoint = CheckPoint::new(100, hash!("block_100")); + + // Try to push at a lower height (99) + let result = cp.clone().push(99, hash!("block_99")); + + assert!( + result.is_err(), + "push should fail when height is lower than current" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push returns Err(self) when prev_blockhash conflicts with self's hash. +#[test] +fn checkpoint_push_fails_conflicting_prev_blockhash() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block with a prev_blockhash that doesn't match cp's hash + let conflicting_block = TestBlock { + blockhash: hash!("block_101"), + prev_blockhash: hash!("wrong_block_100"), // This conflicts with cp's hash + }; + + // Try to push at height 101 (contiguous) with conflicting prev_blockhash + let result = cp.clone().push(101, conflicting_block); + + assert!( + result.is_err(), + "push should fail when prev_blockhash conflicts" + ); + assert!( + result.unwrap_err().eq_ptr(&cp), + "should return self on error" + ); +} + +/// Test that push succeeds when prev_blockhash matches self's hash for contiguous height. +#[test] +fn checkpoint_push_succeeds_matching_prev_blockhash() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block with matching prev_blockhash + let matching_block = TestBlock { + blockhash: hash!("block_101"), + prev_blockhash: hash!("block_100"), // Matches cp's hash + }; + + // Push at height 101 with matching prev_blockhash + let result = cp.push(101, matching_block); + + assert!( + result.is_ok(), + "push should succeed when prev_blockhash matches" + ); + let new_cp = result.unwrap(); + assert_eq!(new_cp.height(), 101); + assert_eq!(new_cp.hash(), hash!("block_101")); +} + +/// Test that push creates a placeholder for non-contiguous heights with prev_blockhash. +#[test] +fn checkpoint_push_creates_non_contiguous_chain() { + let cp: CheckPoint = CheckPoint::new( + 100, + TestBlock { + blockhash: hash!("block_100"), + prev_blockhash: hash!("block_99"), + }, + ); + + // Create a block at non-contiguous height with prev_blockhash + let block_105 = TestBlock { + blockhash: hash!("block_105"), + prev_blockhash: hash!("block_104"), + }; + + // Push at height 105 (non-contiguous) + let result = cp.push(105, block_105); + + assert!( + result.is_ok(), + "push should succeed for non-contiguous height" + ); + let new_cp = result.unwrap(); + + // Verify the tip is at 105 + assert_eq!(new_cp.height(), 105); + assert_eq!(new_cp.hash(), hash!("block_105")); + + // Verify chain structure: 100, 105 + assert_eq!(new_cp.iter().count(), 2); +} + +/// Test `insert` should panic if trying to replace genesis with a different block. +#[test] +#[should_panic(expected = "inserted data implies different genesis")] +fn checkpoint_insert_cannot_replace_genesis() { + let block_0 = TestBlock { + blockhash: hash!("block_0"), + prev_blockhash: hash!("genesis_parent"), + }; + let block_1 = TestBlock { + blockhash: hash!("block_1"), + prev_blockhash: hash!("block_0"), + }; + + let cp = CheckPoint::from_blocks(vec![(0, block_0), (1, block_1)]) + .expect("should create valid chain"); + + // Try to replace genesis with a different block - should panic + let block_0_new = TestBlock { + blockhash: hash!("block_0_new"), + prev_blockhash: hash!("genesis_parent_new"), + }; + let _ = cp.insert(0, block_0_new); +} + +/// Test `insert` should panic if inserted data's prev_blockhash implies a different genesis. +#[test] +#[should_panic(expected = "inserted data implies different genesis")] +fn checkpoint_insert_cannot_displace_genesis() { + let block_0 = TestBlock { + blockhash: hash!("block_0"), + prev_blockhash: hash!("genesis_parent"), + }; + let block_1 = TestBlock { + blockhash: hash!("block_1"), + prev_blockhash: hash!("block_0"), + }; + + let cp = CheckPoint::from_blocks(vec![(0, block_0), (1, block_1)]) + .expect("should create valid chain"); + + // Insert at height 1 with prev_blockhash that conflicts with genesis - should panic + let block_1_new = TestBlock { + blockhash: hash!("block_1_new"), + prev_blockhash: hash!("different_block_0"), // Conflicts with block_0.hash + }; + let _ = cp.insert(1, block_1_new); +}