Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 153 additions & 145 deletions crates/chain/src/local_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,24 @@ pub use bdk_core::{CheckPoint, CheckPointIter};
use bitcoin::block::Header;
use bitcoin::BlockHash;

/// Error for `apply_changeset_to_checkpoint`.
#[derive(Debug)]
struct ApplyChangeSetError<D> {
error: Option<CheckPoint<D>>,
}

/// Apply `changeset` to the checkpoint.
///
/// # Errors
///
/// - If constructing the new chain from the provided `changeset` fails, then a
/// [`ApplyChangeSetError`] is returned.
fn apply_changeset_to_checkpoint<D>(
mut init_cp: CheckPoint<D>,
changeset: &ChangeSet<D>,
) -> Result<CheckPoint<D>, MissingGenesisError>
) -> Result<CheckPoint<D>, ApplyChangeSetError<D>>
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
Expand All @@ -34,22 +45,25 @@ 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);
extension.remove(height);
}
};
}

let new_tip = match base {
Some(base) => base
.extend(extension)
.expect("extension is strictly greater than base"),
None => LocalChain::from_blocks(extension)?.tip(),
.map_err(Option::Some)
.map_err(|error| ApplyChangeSetError { error })?,
None => LocalChain::from_blocks(extension)
.map_err(|error| ApplyChangeSetError { error })?
.tip(),
};
init_cp = new_tip;
}
Expand Down Expand Up @@ -234,7 +248,7 @@ impl<D> LocalChain<D> {
// Methods where `D: ToBlockHash`
impl<D> LocalChain<D>
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<D>) {
Expand All @@ -251,19 +265,22 @@ 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<u32, D>) -> Result<Self, MissingGenesisError> {
///
/// Returns `Err(None)` if `blocks` doesn't contain a value at height `0` a.k.a
/// "genesis" block.
pub fn from_blocks(blocks: BTreeMap<u32, D>) -> Result<Self, Option<CheckPoint<D>>> {
if !blocks.contains_key(&0) {
return Err(MissingGenesisError);
return Err(None);
}

Ok(Self {
tip: CheckPoint::from_blocks(blocks).expect("blocks must be in order"),
tip: CheckPoint::from_blocks(blocks)?,
})
}

/// Construct a [`LocalChain`] from an initial `changeset`.
pub fn from_changeset(changeset: ChangeSet<D>) -> Result<Self, MissingGenesisError> {
let genesis_entry = changeset.blocks.get(&0).copied().flatten();
let genesis_entry = changeset.blocks.get(&0).cloned().flatten();
let genesis_data = match genesis_entry {
Some(data) => data,
None => return Err(MissingGenesisError),
Expand Down Expand Up @@ -303,7 +320,7 @@ where
&mut self,
update: CheckPoint<D>,
) -> Result<ChangeSet<D>, CannotConnectError> {
let (new_tip, changeset) = merge_chains(self.tip.clone(), update)?;
let (new_tip, changeset) = self.merge_chains(update)?;
self.tip = new_tip;
debug_assert!(self._check_changeset_is_applied(&changeset));
Ok(changeset)
Expand All @@ -312,7 +329,8 @@ where
/// Apply the given `changeset`.
pub fn apply_changeset(&mut self, changeset: &ChangeSet<D>) -> Result<(), MissingGenesisError> {
let old_tip = self.tip.clone();
let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?;
let new_tip =
apply_changeset_to_checkpoint(old_tip, changeset).map_err(|_| MissingGenesisError)?;
self.tip = new_tip;
debug_assert!(self._check_changeset_is_applied(changeset));
Ok(())
Expand Down Expand Up @@ -412,7 +430,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(ToBlockHash::to_blockhash)
{
return false;
}
Expand Down Expand Up @@ -576,148 +594,138 @@ impl core::fmt::Display for ApplyHeaderError {
#[cfg(feature = "std")]
impl std::error::Error for ApplyHeaderError {}

/// Applies `update_tip` onto `original_tip`.
///
/// On success, a tuple is returned ([`CheckPoint`], [`ChangeSet`]).
///
/// # Errors
///
/// [`CannotConnectError`] occurs when the `original_tip` and `update_tip` chains are disjoint:
///
/// - If no point of agreement is found between the update and original chains.
/// - A point of agreement is found but the update is ambiguous above the point of agreement (a.k.a.
/// the update and original chain both have a block above the point of agreement, but their
/// heights do not overlap).
/// - The update attempts to replace the genesis block of the original chain.
fn merge_chains<D>(
original_tip: CheckPoint<D>,
update_tip: CheckPoint<D>,
) -> Result<(CheckPoint<D>, ChangeSet<D>), CannotConnectError>
impl<D> LocalChain<D>
where
D: ToBlockHash + fmt::Debug + Copy,
D: ToBlockHash + fmt::Debug + Clone,
{
let mut changeset = ChangeSet::<D>::default();

let mut orig = original_tip.iter();
let mut update = update_tip.iter();

let mut curr_orig = None;
let mut curr_update = None;

let mut prev_orig: Option<CheckPoint<D>> = None;
let mut prev_update: Option<CheckPoint<D>> = None;

let mut point_of_agreement_found = false;

let mut prev_orig_was_invalidated = false;

let mut potentially_invalidated_heights = vec![];

// If we can, we want to return the update tip as the new tip because this allows checkpoints
// in multiple locations to keep the same `Arc` pointers when they are being updated from each
// other using this function. We can do this as long as the update contains every
// block's height of the original chain.
let mut is_update_height_superset_of_original = true;

// To find the difference between the new chain and the original we iterate over both of them
// from the tip backwards in tandem. We are always dealing with the highest one from either
// chain first and move to the next highest. The crucial logic is applied when they have
// blocks at the same height.
loop {
if curr_orig.is_none() {
curr_orig = orig.next();
}
if curr_update.is_none() {
curr_update = update.next();
}
/// Applies `update_tip` onto `original_tip`.
///
/// On success, a tuple is returned ([`CheckPoint`], [`ChangeSet`]).
///
/// # Errors
///
/// [`CannotConnectError`] occurs when the `original_tip` and `update_tip` chains are disjoint:
///
/// - If no point of agreement is found between the update and original chains, and no explicit
/// invalidation occurred.
/// - A point of agreement is found but the update is ambiguous above the point of agreement
/// (a.k.a. the update and original chain both have a block above the point of agreement, but
/// their heights do not overlap).
/// - The update attempts to replace the genesis block of the original chain.
fn merge_chains(
Copy link
Member

@evanlinjin evanlinjin Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactored merge_chains does not do anything meaningfully different. It also does not make it easier to read.

The important change to make is to make sure merge_chains takes prev_blockhashes into consideration. The refactoring does not do that.

Suggested changes

  • Iterate over CheckPointEntry instead of CheckPoint. Rationale: prev_blockhashes are also block hashes so they can be points of agreements/invalidation.
  • Only insert Some(data) into ChangeSet if there is an actual CheckPoint in the update chain at that height.
  • Only insert None into ChangeSet if there is an actual CheckPoint in the original chain at that height.

Suggested tests

These tests uses a data type that returns Some for prev_blockhash:

prev_blockhash invalidates
  • Original: 1:A, 2:B, 4:D
  • Update: 1:A, 3:C' (prev=2:B')
  • Result: 1:A, 3:C' (prev=2:B')

Connects due to prev_blockhash

  • Original: 2:B, 3:C
  • Update: 2:B, 4:D (prev=3:C)
  • Result: 2:B, 3:C, 4:D

&mut self,
update_tip: CheckPoint<D>,
) -> Result<(CheckPoint<D>, ChangeSet<D>), CannotConnectError> {
use alloc::vec::Vec;
use core::cmp::Ordering;

let mut original_iter = self.tip().iter().peekable();
let mut update_iter = update_tip.iter().peekable();

let mut point_of_agreement = Option::<u32>::None;
let mut previous_original_height = Option::<u32>::None;
let mut previous_update_height = Option::<u32>::None;
let mut is_update_height_superset_of_original = false;
let mut potentially_invalid_block_ids = Vec::<BlockId>::new();
let mut is_previous_original_invalid = false;
let mut changeset = ChangeSet::<D>::default();

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()));
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());
prev_orig_was_invalidated = false;
prev_orig = curr_orig.take();

is_update_height_superset_of_original = false;

// OPTIMIZATION: we have run out of update blocks so we don't need to continue
// iterating because there's no possibility of adding anything to changeset.
if u.is_none() {
loop {
match (original_iter.peek(), update_iter.peek()) {
// Error if attempting to change the genesis block.
(_, Some(update))
if update.height() == 0 && update.hash() != self.genesis_hash() =>
{
return Err(CannotConnectError {
try_include_height: 0,
});
}
// We're done when all updates are processed.
(_, None) => {
break;
}
}
(Some(o), Some(u)) => {
if o.hash() == u.hash() {
// We have found our point of agreement 🎉 -- we require that the previous (i.e.
// higher because we are iterating backwards) block in the original chain was
// invalidated (if it exists). This ensures that there is an unambiguous point
// of connection to the original chain from the update chain
// (i.e. we know the precisely which original blocks are
// invalid).
if !prev_orig_was_invalidated && !point_of_agreement_found {
if let (Some(prev_orig), Some(_prev_update)) = (&prev_orig, &prev_update) {
return Err(CannotConnectError {
try_include_height: prev_orig.height(),
});
(Some(original), Some(update)) => {
// First compare heights. For any updates that aren't in the original
// chain add them to the changeset. Retain block IDs of the original chain
// that are not in the update, in case we need to invalidate them.
// We only advance each iterator on the higher of the two chains, or both
// if equal height, and update the previously seen height on each turn.
match update.height().cmp(&original.height()) {
// Update height not in original.
Ordering::Greater => {
changeset
.blocks
.insert(update.height(), Some(update.data()));
previous_update_height = Some(update.height());
update_iter.next();
}
}
point_of_agreement_found = true;
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 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));
// Original height not in update.
Ordering::Less => {
potentially_invalid_block_ids.push(original.block_id());
is_previous_original_invalid = false;
previous_original_height = Some(original.height());
is_update_height_superset_of_original = false;
original_iter.next();
}
// Compare hashes.
Ordering::Equal => {
if update.hash() == original.hash() {
// Reached a point of agreement but the chains are disjoint
if !is_previous_original_invalid && point_of_agreement.is_none() {
if let (Some(previous_original_height), Some(..)) =
(previous_original_height, previous_update_height)
{
return Err(CannotConnectError {
try_include_height: previous_original_height,
});
}
}
point_of_agreement = Some(original.height());
is_previous_original_invalid = false;
// OPTIMIZATION: If we have the same underlying pointer, we can
// return the new tip as it's guaranteed to connect.
if update.eq_ptr(original) && is_update_height_superset_of_original
{
return Ok((update_tip, changeset));
}
// We have an explicit invalidation, so we need to mark all previously
// seen blocks of the original tip invalid.
} else {
for block_id in potentially_invalid_block_ids.drain(..) {
changeset.blocks.insert(block_id.height, None);
}
is_previous_original_invalid = true;
changeset
.blocks
.insert(update.height(), Some(update.data()));
}
previous_update_height = Some(update.height());
previous_original_height = Some(original.height());
update_iter.next();
original_iter.next();
}
}
} 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()));
for invalidated_height in potentially_invalidated_heights.drain(..) {
changeset.blocks.insert(invalidated_height, None);
}
prev_orig_was_invalidated = true;
}
prev_update = curr_update.take();
prev_orig = curr_orig.take();
}
(None, None) => {
break;
}
_ => {
unreachable!("compiler cannot tell that everything has been covered")
(None, Some(..)) => unreachable!("Original can't be exhausted before update"),
}
}
}

// When we don't have a point of agreement you can imagine it is implicitly the
// genesis block so we need to do the final connectivity check which in this case
// just means making sure the entire original chain was invalidated.
if !prev_orig_was_invalidated && !point_of_agreement_found {
if let Some(prev_orig) = prev_orig {
return Err(CannotConnectError {
try_include_height: prev_orig.height(),
});
// Fail if no point of agreement is found, and no explicit invalidation occurred.
if !is_previous_original_invalid && point_of_agreement.is_none() {
if let Some(previous_original_height) = previous_original_height {
return Err(CannotConnectError {
try_include_height: previous_original_height,
});
}
}
}

let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|_| {
CannotConnectError {
try_include_height: 0,
}
})?;
Ok((new_tip, changeset))
// Apply changeset to tip.
let new_tip = apply_changeset_to_checkpoint(self.tip(), &changeset).map_err(|e| {
CannotConnectError {
try_include_height: e.error.as_ref().map_or(0, CheckPoint::height),
}
})?;

Ok((new_tip, changeset))
}
}
Loading