diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index 81f4a1796..ade83479a 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -26,11 +26,14 @@ where let mut base: Option> = None; for cp in init_cp.iter() { - if cp.height() >= start_height { - extension.insert(cp.height(), cp.data()); - } else { - base = Some(cp); - break; + // Base tip should always have data. + if let Some(data) = cp.data() { + if cp.height() >= start_height { + extension.insert(cp.height(), data); + } else { + base = Some(cp); + break; + } } } @@ -51,6 +54,7 @@ where .expect("extension is strictly greater than base"), None => LocalChain::from_blocks(extension)?.tip(), }; + init_cp = new_tip; } @@ -322,11 +326,7 @@ where /// recover the current chain. pub fn initial_changeset(&self) -> ChangeSet { ChangeSet { - blocks: self - .tip - .iter() - .map(|cp| (cp.height(), Some(cp.data()))) - .collect(), + blocks: self.tip.iter().map(|cp| (cp.height(), cp.data())).collect(), } } @@ -349,6 +349,20 @@ where update_hash: Some(data.to_blockhash()), }); } + + // If this `CheckPoint` is an empty placeholder, append the `data` to it. + if original_cp.data_ref().is_none() { + let mut changeset = ChangeSet::::default(); + changeset.blocks.insert(height, Some(data)); + self.apply_changeset(&changeset) + .map_err(|_| AlterCheckPointError { + height: 0, + original_hash: self.genesis_hash(), + update_hash: None, + })?; + return Ok(changeset); + } + return Ok(ChangeSet::default()); } @@ -634,7 +648,12 @@ 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 the update has complete data. Entries where + // `data` does not exist that are created via `prev_blockhash` should not alter the + // `ChangeSet`. + 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 @@ -682,10 +701,15 @@ where return Ok((new_tip, changeset)); } } + // Even if the hashes are the same, the update may contain data which the + // original does not have. + if let (None, Some(u_data)) = (o.data_ref(), u.data()) { + changeset.blocks.insert(u.height(), Some(u_data)); + } } 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); } diff --git a/crates/chain/tests/test_local_chain.rs b/crates/chain/tests/test_local_chain.rs index 7ad03f04f..014fb6fe6 100644 --- a/crates/chain/tests/test_local_chain.rs +++ b/crates/chain/tests/test_local_chain.rs @@ -11,7 +11,7 @@ use bdk_chain::{ BlockId, }; use bdk_testenv::{chain_update, hash, local_chain}; -use bitcoin::{block::Header, hashes::Hash, BlockHash}; +use bitcoin::{block::Header, hashes::Hash, BlockHash, CompactTarget, TxMerkleNode}; use proptest::prelude::*; #[derive(Debug)] @@ -474,6 +474,160 @@ fn local_chain_insert_header() { } } +/// Validates `merge_chains` behavior on chains that contain placeholder checkpoints (`data: None`). +/// +/// Placeholders are created when a `CheckPoint`’s `prev_blockhash` references a block at a height +/// with no stored checkpoint. This test ensures `merge_chains` handles them correctly and that the +/// resulting chain never exposes a placeholder checkpoint. +#[test] +fn merge_chains_handles_placeholders() { + fn header(prev_blockhash: bitcoin::BlockHash, nonce: u32) -> Header { + Header { + version: bitcoin::block::Version::default(), + prev_blockhash, + merkle_root: TxMerkleNode::all_zeros(), + time: 0, + bits: CompactTarget::default(), + nonce, + } + } + + fn local_chain(blocks: Vec<(u32, Header)>) -> LocalChain
{ + LocalChain::from_blocks(blocks.into_iter().collect::>()) + .expect("chain must have genesis block") + } + + fn update_chain(blocks: &[(u32, Header)]) -> CheckPoint
{ + CheckPoint::from_blocks(blocks.iter().copied()).expect("checkpoint must be valid") + } + + let a = header(hash!("genesis"), 0); + let b = header(a.block_hash(), 0); + let c = header(b.block_hash(), 0); + let d = header(c.block_hash(), 0); + let e = header(d.block_hash(), 0); + + // Set a different `nonce` for conflicting `Header`s to ensure different `BlockHash`. + let c_conflict = header(b.block_hash(), 1); + let d_conflict = header(c_conflict.block_hash(), 1); + + struct TestCase { + name: &'static str, + updates: Vec>, + invalidate_heights: Vec, + expected_placeholder_heights: Vec, + expected_chain: LocalChain
, + } + + let test_cases = [ + // Test case 1: Create a placeholder for B via C and a placeholder for D via E. + TestCase { + name: "insert_placeholder", + updates: vec![update_chain(&[(0, a), (2, c), (4, e)])], + invalidate_heights: vec![], + expected_placeholder_heights: vec![1, 3], + expected_chain: local_chain(vec![(0, a), (2, c), (4, e)]), + }, + // Test cast 2: Create a placeholder for B via C, then update provides conflicting C'. + TestCase { + name: "conflict_at_tip_keeps_placeholder", + updates: vec![ + update_chain(&[(0, a), (2, c)]), + update_chain(&[(2, c_conflict)]), + ], + invalidate_heights: vec![], + expected_placeholder_heights: vec![1], + expected_chain: local_chain(vec![(0, a), (1, b), (2, c_conflict)]), + }, + // Test case 3: Create placeholder for C via D. + TestCase { + name: "conflict_at_filled_height", + updates: vec![update_chain(&[(0, a), (3, d)])], + invalidate_heights: vec![], + expected_placeholder_heights: vec![2], + expected_chain: local_chain(vec![(0, a), (3, d)]), + }, + // Test case 4: Create placeholder for C via D, then insert conflicting C' which should + // drop D and replace C. + TestCase { + name: "conflict_at_filled_height", + updates: vec![ + update_chain(&[(0, a), (3, d)]), + update_chain(&[(0, a), (2, c_conflict)]), + ], + invalidate_heights: vec![], + expected_placeholder_heights: vec![1], + expected_chain: local_chain(vec![(0, a), (2, c_conflict)]), + }, + // Test case 5: Create placeholder for B via C, then invalidate C. + TestCase { + name: "invalidate_tip_falls_back", + updates: vec![update_chain(&[(0, a), (2, c)])], + invalidate_heights: vec![2], + expected_placeholder_heights: vec![], + expected_chain: local_chain(vec![(0, a)]), + }, + // Test case 6: Create placeholder for C via D, then insert D' which has `prev_blockhash` + // that does not point to C. TODO: Handle error? + TestCase { + name: "expected_error", + updates: vec![ + update_chain(&[(0, a), (3, d)]), + update_chain(&[(3, d_conflict)]), + ], + invalidate_heights: vec![], + expected_placeholder_heights: vec![2], + expected_chain: local_chain(vec![(0, a), (3, d)]), + }, + ]; + + for (i, t) in test_cases.into_iter().enumerate() { + let mut chain = local_chain(vec![(0, a)]); + for upd in t.updates { + // If `apply_update` errors, it is because the new chain cannot be merged. So it should + // follow that this validates behavior if the final `expected_chain` state is correct. + if chain.apply_update(upd).is_ok() { + if !t.invalidate_heights.is_empty() { + let cs: ChangeSet
= t + .invalidate_heights + .iter() + .copied() + .map(|h| (h, None)) + .collect(); + chain.apply_changeset(&cs).expect("changeset should apply"); + } + + // Ensure we never end up with a placeholder tip. + assert!( + chain.tip().data_ref().is_some(), + "[{}] {}: tip must always be materialized", + i, + t.name + ); + } + } + + let mut placeholder_heights = chain + .tip() + .iter() + .filter(|cp| cp.data_ref().is_none()) + .map(|cp| cp.height()) + .collect::>(); + placeholder_heights.sort(); + assert_eq!( + placeholder_heights, t.expected_placeholder_heights, + "[{}] {}: placeholder height mismatch", + i, t.name + ); + + assert_eq!( + chain, t.expected_chain, + "[{}] {}: unexpected final chain", + i, t.name + ); + } +} + #[test] fn local_chain_disconnect_from() { struct TestCase { diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index d0a9bacd7..0e944486d 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -2,6 +2,7 @@ use core::fmt; use core::ops::RangeBounds; use alloc::sync::Arc; +use alloc::vec::Vec; use bitcoin::{block::Header, BlockHash}; use crate::BlockId; @@ -24,8 +25,8 @@ impl Clone for CheckPoint { struct CPInner { /// Block id block_id: BlockId, - /// Data. - data: D, + /// Data (if any). + data: Option, /// Previous checkpoint (if any). prev: Option>>, } @@ -68,6 +69,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 { @@ -80,6 +86,10 @@ impl ToBlockHash for Header { fn to_blockhash(&self) -> BlockHash { self.block_hash() } + + fn prev_blockhash(&self) -> Option { + Some(self.prev_blockhash) + } } impl PartialEq for CheckPoint { @@ -92,13 +102,13 @@ impl PartialEq for CheckPoint { // Methods for any `D` impl CheckPoint { - /// Get a reference of the `data` of the checkpoint. - pub fn data_ref(&self) -> &D { - &self.0.data + /// Get a reference of the `data` of the checkpoint if it exists. + pub fn data_ref(&self) -> Option<&D> { + self.0.data.as_ref() } - /// Get the `data` of a the checkpoint. - pub fn data(&self) -> D + /// Get the `data` of the checkpoint if it exists. + pub fn data(&self) -> Option where D: Clone, { @@ -170,6 +180,17 @@ impl CheckPoint { self.range(..=height).next() } + /// Finds the checkpoint with `data` at `height` if one exists, otherwise the neareast + /// checkpoint with `data` at a lower height. + /// + /// This is equivalent to taking the “floor” of "height" over this checkpoint chain, filtering + /// out any placeholder entries that do not contain any `data`. + /// + /// Returns `None` if no checkpoint with `data` exists at or below the given height. + pub fn find_data(&self, height: u32) -> Option { + self.range(..=height).find(|cp| cp.data_ref().is_some()) + } + /// Returns the checkpoint located a number of heights below this one. /// /// This is a convenience wrapper for [`CheckPoint::floor_at`], subtracting `to_subtract` from @@ -197,14 +218,36 @@ where { /// Construct a new base [`CheckPoint`] from given `height` and `data` at the front of a linked /// list. + /// + /// If `data` contains previous block via [`ToBlockHash::prev_blockhash`], this will also create + /// a placeholder checkpoint at `height - 1` with that hash and with `data: None`, and link the + /// new checkpoint to it. The placeholder can be materialized later by inserting data at its + /// height. pub fn new(height: u32, data: D) -> Self { + // If `data` has a `prev_blockhash`, create a placeholder checkpoint one height below. + let prev = if height > 0 { + match data.prev_blockhash() { + Some(prev_blockhash) => Some(Arc::new(CPInner { + block_id: BlockId { + height: height - 1, + hash: prev_blockhash, + }, + data: None, + prev: None, + })), + None => None, + } + } else { + None + }; + Self(Arc::new(CPInner { block_id: BlockId { height, hash: data.to_blockhash(), }, - data, - prev: None, + data: Some(data), + prev, })) } @@ -239,8 +282,15 @@ where /// 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`. + /// all entries following it. If the existing checkpoint at height is a placeholder where + /// `data: None` with the same hash, then the `data` is inserted to make a complete checkpoint. + /// The returned chain will have a tip of the data passed in. If the data was already present + /// then this just returns `self`. + /// + /// When inserting data with a `prev_blockhash` that conflicts with existing checkpoints, + /// those checkpoints will be displaced and replaced with placeholders. When inserting data + /// whose block hash conflicts with the `prev_blockhash` of higher checkpoints, those higher + /// checkpoints will be purged. /// /// # Panics /// @@ -248,48 +298,137 @@ where #[must_use] pub fn insert(self, height: u32, data: D) -> Self { let mut cp = self.clone(); - let mut tail = vec![]; - let base = loop { + let mut tail: Vec<(u32, D)> = vec![]; + let mut base = loop { if cp.height() == height { - if cp.hash() == data.to_blockhash() { - return self; + let same_hash = cp.hash() == data.to_blockhash(); + if same_hash { + if cp.data().is_some() { + return self; + } else { + // If `CheckPoint` is a placeholder, return previous `CheckPoint`. + break cp.prev().expect("can't be called on genesis block"); + } + } else { + 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"); } - 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"); } if cp.height() < height { break cp; } - tail.push((cp.height(), cp.data())); + if let Some(d) = cp.data() { + tail.push((cp.height(), d)); + } cp = cp.prev().expect("will break before genesis block"); }; - base.extend(core::iter::once((height, data)).chain(tail.into_iter().rev())) + if let Some(prev_hash) = data.prev_blockhash() { + // Check if the new data's `prev_blockhash` conflicts with the checkpoint at height - 1. + if let Some(lower_cp) = base.get(height.saturating_sub(1)) { + // New data's `prev_blockhash` conflicts with existing checkpoint, so we displace + // the existing checkpoint and create a placeholder. + if lower_cp.hash() != prev_hash { + // Find the base to link to at height - 2 or lower with actual data. + // We skip placeholders because when we displace a checkpoint, we can't ensure + // that placeholders below it still maintain proper chain continuity. + let link_base = if height > 1 { + base.find_data(height - 2) + } else { + None + }; + + // Create a new placeholder at height - 1 with the required `prev_blockhash`. + base = Self(Arc::new(CPInner { + block_id: BlockId { + height: height - 1, + hash: prev_hash, + }, + data: None, + prev: link_base.map(|cb| cb.0), + })); + } + } else { + // No checkpoint at height - 1, but we may need to create a placeholder. + if height > 0 { + base = Self(Arc::new(CPInner { + block_id: BlockId { + height: height - 1, + hash: prev_hash, + }, + data: None, + prev: base.0.prev.clone(), + })); + } + } + } + + // Check for conflicts with higher checkpoints and purge if necessary. + let mut filtered_tail = Vec::new(); + for (tail_height, tail_data) in tail.into_iter().rev() { + // Check if this tail entry's `prev_blockhash` conflicts with our new data's blockhash. + if let Some(tail_prev_hash) = tail_data.prev_blockhash() { + // Conflict detected, so purge this and all tail entries. + if tail_prev_hash != data.to_blockhash() { + break; + } + } + filtered_tail.push((tail_height, tail_data)); + } + + base.extend(core::iter::once((height, data)).chain(filtered_tail)) .expect("tail is in order") } - /// Puts another checkpoint onto the linked list representing the blockchain. + /// Extends the chain by pushing a new checkpoint. /// - /// 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. - 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) + /// Returns `Err(self)` if the height is not greater than the current height, or if the data's + /// `prev_blockhash` conflicts with `self`. + /// + /// Creates a placeholder at height - 1 if the height is non-contiguous and + /// `data.prev_blockhash()` is available. + pub fn push(mut self, height: u32, data: D) -> Result { + // Reject if trying to push at or below current height - chain must grow forward + if height <= self.height() { + return Err(self); } + + if let Some(prev_hash) = data.prev_blockhash() { + if height == self.height() + 1 { + // For contiguous height, validate that prev_blockhash matches our hash + // to ensure chain continuity + if self.hash() != prev_hash { + return Err(self); + } + } else { + // For non-contiguous heights, create placeholder to maintain chain linkage + // This allows sparse chains while preserving block relationships + self = CheckPoint(Arc::new(CPInner { + block_id: BlockId { + height: height + .checked_sub(1) + .expect("height has previous blocks so must be greater than 0"), + hash: prev_hash, + }, + data: None, + prev: Some(self.0), + })); + } + } + + Ok(Self(Arc::new(CPInner { + block_id: BlockId { + height, + hash: data.to_blockhash(), + }, + data: Some(data), + prev: Some(self.0), + }))) } } diff --git a/crates/core/tests/test_checkpoint.rs b/crates/core/tests/test_checkpoint.rs index a47567618..565eac80e 100644 --- a/crates/core/tests/test_checkpoint.rs +++ b/crates/core/tests/test_checkpoint.rs @@ -1,4 +1,4 @@ -use bdk_core::CheckPoint; +use bdk_core::{CheckPoint, ToBlockHash}; use bdk_testenv::{block_id, hash}; use bitcoin::BlockHash; @@ -55,3 +55,402 @@ fn checkpoint_destruction_is_sound() { } assert_eq!(cp.iter().count() as u32, end); } + +// 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); + + // The initial chain has a placeholder at 98 (from block_99's prev_blockhash) + assert_eq!(cp.iter().count(), 2); + let height_98_before = cp.get(98).expect("should have checkpoint at 98"); + assert_eq!(height_98_before.hash(), block_99.prev_blockhash); + assert!( + height_98_before.data_ref().is_none(), + "98 should be placeholder initially" + ); + + // 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 and replaced with a placeholder + let height_99_after = result.get(99).expect("checkpoint at 99 should exist"); + assert_eq!( + height_99_after.hash(), + block_100_conflicting.prev_blockhash, + "checkpoint at 99 should be displaced and have the prev_blockhash from inserted data" + ); + assert!( + height_99_after.data_ref().is_none(), + "checkpoint at 99 should be a placeholder (no data) after displacement" + ); + + // 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); + assert!( + height_100.data_ref().is_some(), + "checkpoint at 100 should have data" + ); + + // Verify chain structure + assert_eq!(result.height(), 100, "tip should be at height 100"); + // Should have: 99 (placeholder), 100 + // Note: The placeholder at height 98 (from block_99's prev_blockhash) is removed + // because when we displace block_99, we can't ensure the placeholder at 98 connects + // properly with the new placeholder at 99. + assert_eq!( + result.iter().count(), + 2, + "should have 2 checkpoints (99 placeholder, 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(), 6); // 97 (placeholder), 98, 99, 100, 101, 102 + + // 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 (plus placeholder at 97) + assert_eq!( + result.iter().count(), + 3, + "should have 3 checkpoints (97 placeholder, 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); + assert!( + height_99.data_ref().is_some(), + "checkpoint at 99 should have data" + ); + + // 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); + assert!( + height_98.data_ref().is_some(), + "checkpoint at 98 should have data" + ); + + // 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(), 4); // 3 (placeholder), 4, 5 (placeholder from 6's prev), 6 + + // 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 with a placeholder containing block_5's prev_blockhash + // - 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 + let height_4 = result.get(4).expect("checkpoint at 4 should exist"); + assert_eq!(height_4.height(), 4); + assert_eq!( + height_4.hash(), + block_5_conflicting.prev_blockhash, + "checkpoint at 4 should be displaced with block 5's prev_blockhash" + ); + assert!( + height_4.data_ref().is_none(), + "checkpoint at 4 should be a placeholder" + ); + + // Verify height 5 has the inserted data + let height_5 = result.get(5).expect("checkpoint at 5 should exist"); + assert_eq!(height_5.height(), 5); + assert_eq!(height_5.hash(), block_5_conflicting.blockhash); + assert!( + height_5.data_ref().is_some(), + "checkpoint at 5 should have data" + ); + + // 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: 4 (placeholder), 5 + // Note: The placeholder at height 3 (from block_4's prev_blockhash) is removed + // because when we displace block_4, we can't ensure the placeholder at 3 connects + // properly with the new placeholder at 4. + assert_eq!( + result.iter().count(), + 2, + "should have 2 checkpoints (4 placeholder, 5)" + ); +} + +/// 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_placeholder_non_contiguous() { + 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 placeholder was created at 104 + let placeholder = new_cp.get(104).expect("should have placeholder at 104"); + assert_eq!(placeholder.hash(), hash!("block_104")); + assert!( + placeholder.data_ref().is_none(), + "placeholder should have no data" + ); + + // Verify chain structure: 100 -> 99 (placeholder) -> 104 (placeholder) -> 105 + assert_eq!(new_cp.iter().count(), 4); +}