Skip to content

Commit 86420a2

Browse files
committed
Merge #2115: Add prev_blockhash validation to CheckPoint
c486ba7 docs: address review feedback on `prev_blockhash` validation (志宇) 031b30f docs(core): address review feedback on docs and tests (志宇) ef35b19 fix(chain)!: make genesis immutable in `merge_chains` (志宇) d8cb41f test(core): address review feedback on checkpoint tests (志宇) 9c0453c docs(core): Add module-level docs for `checkpoint_entry` (志宇) 3ef6ed8 feat(chain)!: Add `ApplyBlockError` for `prev_blockhash` validation (志宇) bf6541b feat(chain)!: Relax the generic parameter for `LocalChain<D>` (志宇) 19b9006 test(core): add tests for CheckPoint::push and insert methods (志宇) 9971fda test(chain): Test `apply_update` with a single `CheckPoint<Header>` (valued mammal) 3df0609 test(chain): make `TestLocalChain` generic and add `prev_blockhash` test (志宇) c314b25 fix(chain): `merge_chains` now takes account of `prev_blockhash`es (志宇) f10ba0e fix(core): `Checkpoint::insert` now evicts on `prev_blockhash` mismatch (志宇) 046a771 fix(core): `push` now errors on `prev_blockhash` mismatch (志宇) 68d1ef4 feat(core): Initial work on `CheckPointEntry` (志宇) d4bdff0 feat(core): Add `prev_blockhash` method to `ToBlockHash` trait (志宇) Pull request description: Closes #2021 Related to #2076 Replaces #2024 Replaces #2091 ### Description This PR adds `prev_blockhash` awareness to `CheckPoint`, enabling proper chain validation when merging checkpoint chains that store block headers or similar data with previous block hash information. ### Notes to the reviewers This PR replaces some prior attempts: * #2024 - where we made the `CheckPoint::data` optional - however this resulted in internal complexity and an API with annoying edge cases. The tests from this PR were still useful. * #2091 - This second attempt had some good ideas, but was distracted from the goal of #2021. I mostly reused the `CheckPoint::insert` implementation of that PR. ### Changelog notice ```md Added: - `ToBlockHash::prev_blockhash()` - optional method to expose previous block hash - `CheckPointEntry` - new type for iterating with `prev_blockhash` awareness, yielding "placeholder" entries for heights inferred from `prev_blockhash` - `ApplyBlockError` - this is a new error type with two variants; `MissingGenesis` and `PrevBlockhashMismatch`. The second variant is a new error case introduced by `prev_blockhash` awareness. Changed: - `CheckPoint::push` - now errors when `prev_blockhash` conflicts with current tip (contiguous heights) - `CheckPoint::insert` - now evicts/displaces checkpoints on `prev_blockhash` conflict - `merge_chains` - now validates `prev_blockhash` consistency when merging - `LocalChain<D>` generic parameter - relaxed constraint to `D: Clone` instead of `D: Copy`. Fixed: - `merge_chains` no longer replaces the genesis block. ``` ### Checklists #### All Submissions: * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature ACKs for top commit: evanlinjin: self-ACK c486ba7 Tree-SHA512: 5622a8a418034443931c8b5938e708c2222c42c05efbdac71d47a4e0ce1b4f4b0b7bcd2e1e14e2f295cc056e12c03e5750b40b1a1313cdbdf59a009d81d0b8a1
2 parents 3b778d4 + c486ba7 commit 86420a2

6 files changed

Lines changed: 1230 additions & 99 deletions

File tree

crates/chain/src/local_chain.rs

Lines changed: 123 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ use core::ops::RangeBounds;
66

77
use crate::collections::BTreeMap;
88
use crate::{BlockId, ChainOracle, Merge};
9-
use bdk_core::ToBlockHash;
109
pub use bdk_core::{CheckPoint, CheckPointIter};
10+
use bdk_core::{CheckPointEntry, ToBlockHash};
1111
use bitcoin::block::Header;
1212
use bitcoin::BlockHash;
1313

1414
/// Apply `changeset` to the checkpoint.
1515
fn apply_changeset_to_checkpoint<D>(
1616
mut init_cp: CheckPoint<D>,
1717
changeset: &ChangeSet<D>,
18-
) -> Result<CheckPoint<D>, MissingGenesisError>
18+
) -> Result<CheckPoint<D>, ApplyBlockError>
1919
where
20-
D: ToBlockHash + fmt::Debug + Copy,
20+
D: ToBlockHash + fmt::Debug + Clone,
2121
{
2222
if let Some(start_height) = changeset.blocks.keys().next().cloned() {
2323
// changes after point of agreement
@@ -34,10 +34,10 @@ where
3434
}
3535
}
3636

37-
for (&height, &data) in &changeset.blocks {
37+
for (&height, data) in &changeset.blocks {
3838
match data {
3939
Some(data) => {
40-
extension.insert(height, data);
40+
extension.insert(height, data.clone());
4141
}
4242
None => {
4343
extension.remove(&height);
@@ -48,7 +48,11 @@ where
4848
let new_tip = match base {
4949
Some(base) => base
5050
.extend(extension)
51-
.expect("extension is strictly greater than base"),
51+
// Since `extension` is in height order, the only failure case is `prev_blockhash`
52+
// mismatch.
53+
.map_err(|last_cp| ApplyBlockError::PrevBlockhashMismatch {
54+
expected: last_cp.block_id(),
55+
})?,
5256
None => LocalChain::from_blocks(extension)?.tip(),
5357
};
5458
init_cp = new_tip;
@@ -234,7 +238,7 @@ impl<D> LocalChain<D> {
234238
// Methods where `D: ToBlockHash`
235239
impl<D> LocalChain<D>
236240
where
237-
D: ToBlockHash + fmt::Debug + Copy,
241+
D: ToBlockHash + fmt::Debug + Clone,
238242
{
239243
/// Constructs a [`LocalChain`] from genesis data.
240244
pub fn from_genesis(data: D) -> (Self, ChangeSet<D>) {
@@ -251,22 +255,27 @@ where
251255
///
252256
/// The [`BTreeMap`] enforces the height order. However, the caller must ensure the blocks are
253257
/// all of the same chain.
254-
pub fn from_blocks(blocks: BTreeMap<u32, D>) -> Result<Self, MissingGenesisError> {
258+
pub fn from_blocks(blocks: BTreeMap<u32, D>) -> Result<Self, ApplyBlockError> {
255259
if !blocks.contains_key(&0) {
256-
return Err(MissingGenesisError);
260+
return Err(ApplyBlockError::MissingGenesis);
257261
}
258262

259-
Ok(Self {
260-
tip: CheckPoint::from_blocks(blocks).expect("blocks must be in order"),
261-
})
263+
CheckPoint::from_blocks(blocks)
264+
.map(|tip| Self { tip })
265+
.map_err(|err| {
266+
let last_cp = err.expect("must have at least one block (genesis)");
267+
ApplyBlockError::PrevBlockhashMismatch {
268+
expected: last_cp.block_id(),
269+
}
270+
})
262271
}
263272

264273
/// Construct a [`LocalChain`] from an initial `changeset`.
265-
pub fn from_changeset(changeset: ChangeSet<D>) -> Result<Self, MissingGenesisError> {
266-
let genesis_entry = changeset.blocks.get(&0).copied().flatten();
274+
pub fn from_changeset(changeset: ChangeSet<D>) -> Result<Self, ApplyBlockError> {
275+
let genesis_entry = changeset.blocks.get(&0).cloned().flatten();
267276
let genesis_data = match genesis_entry {
268277
Some(data) => data,
269-
None => return Err(MissingGenesisError),
278+
None => return Err(ApplyBlockError::MissingGenesis),
270279
};
271280

272281
let (mut chain, _) = Self::from_genesis(genesis_data);
@@ -310,7 +319,7 @@ where
310319
}
311320

312321
/// Apply the given `changeset`.
313-
pub fn apply_changeset(&mut self, changeset: &ChangeSet<D>) -> Result<(), MissingGenesisError> {
322+
pub fn apply_changeset(&mut self, changeset: &ChangeSet<D>) -> Result<(), ApplyBlockError> {
314323
let old_tip = self.tip.clone();
315324
let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?;
316325
self.tip = new_tip;
@@ -412,7 +421,7 @@ where
412421
match cur.get(exp_height) {
413422
Some(cp) => {
414423
if cp.height() != exp_height
415-
|| Some(cp.hash()) != exp_data.map(|d| d.to_blockhash())
424+
|| Some(cp.hash()) != exp_data.as_ref().map(|d| d.to_blockhash())
416425
{
417426
return false;
418427
}
@@ -485,6 +494,35 @@ impl<D> FromIterator<(u32, D)> for ChangeSet<D> {
485494
}
486495
}
487496

497+
/// Error when applying blocks to a local chain.
498+
#[derive(Clone, Debug, PartialEq)]
499+
pub enum ApplyBlockError {
500+
/// Genesis block is missing.
501+
MissingGenesis,
502+
/// Block's `prev_blockhash` doesn't match the expected block.
503+
PrevBlockhashMismatch {
504+
/// The block that `prev_blockhash` should reference.
505+
expected: BlockId,
506+
},
507+
}
508+
509+
impl core::fmt::Display for ApplyBlockError {
510+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511+
match self {
512+
ApplyBlockError::MissingGenesis => {
513+
write!(f, "genesis block is missing")
514+
}
515+
ApplyBlockError::PrevBlockhashMismatch { expected } => write!(
516+
f,
517+
"`prev_blockhash` doesn't match block at height {} ({})",
518+
expected.height, expected.hash
519+
),
520+
}
521+
}
522+
}
523+
524+
impl core::error::Error for ApplyBlockError {}
525+
488526
/// An error which occurs when a [`LocalChain`] is constructed without a genesis checkpoint.
489527
#[derive(Clone, Debug, PartialEq)]
490528
pub struct MissingGenesisError;
@@ -590,18 +628,50 @@ fn merge_chains<D>(
590628
update_tip: CheckPoint<D>,
591629
) -> Result<(CheckPoint<D>, ChangeSet<D>), CannotConnectError>
592630
where
593-
D: ToBlockHash + fmt::Debug + Copy,
631+
D: ToBlockHash + fmt::Debug + Clone,
594632
{
633+
// Apply the changeset to produce the final merged chain.
634+
fn finish<D>(
635+
original_tip: CheckPoint<D>,
636+
changeset: ChangeSet<D>,
637+
) -> Result<(CheckPoint<D>, ChangeSet<D>), CannotConnectError>
638+
where
639+
D: ToBlockHash + fmt::Debug + Clone,
640+
{
641+
let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|err| {
642+
match err {
643+
ApplyBlockError::MissingGenesis => CannotConnectError {
644+
try_include_height: 0,
645+
},
646+
// The merge iteration is supposed to detect `prev_blockhash` conflicts and resolve
647+
// them by invalidating conflicting blocks in the changeset. Reaching this arm means
648+
// either the original chain was internally inconsistent or the iteration missed a
649+
// case — a bug on our side. Debug builds panic; release builds surface the height
650+
// where the mismatch surfaced so the caller at least has a useful pointer.
651+
ApplyBlockError::PrevBlockhashMismatch { expected } => {
652+
debug_assert!(
653+
false,
654+
"merge_chains should have resolved prev_blockhash mismatch at {expected:?}",
655+
);
656+
CannotConnectError {
657+
try_include_height: expected.height,
658+
}
659+
}
660+
}
661+
})?;
662+
Ok((new_tip, changeset))
663+
}
664+
595665
let mut changeset = ChangeSet::<D>::default();
596666

597-
let mut orig = original_tip.iter();
598-
let mut update = update_tip.iter();
667+
let mut orig = original_tip.entry_iter();
668+
let mut update = update_tip.entry_iter();
599669

600670
let mut curr_orig = None;
601671
let mut curr_update = None;
602672

603-
let mut prev_orig: Option<CheckPoint<D>> = None;
604-
let mut prev_update: Option<CheckPoint<D>> = None;
673+
let mut prev_orig: Option<CheckPointEntry<D>> = None;
674+
let mut prev_update: Option<CheckPointEntry<D>> = None;
605675

606676
let mut point_of_agreement_found = false;
607677

@@ -630,13 +700,18 @@ where
630700
match (curr_orig.as_ref(), curr_update.as_ref()) {
631701
// Update block that doesn't exist in the original chain
632702
(o, Some(u)) if Some(u.height()) > o.map(|o| o.height()) => {
633-
changeset.blocks.insert(u.height(), Some(u.data()));
703+
// Only append to `ChangeSet` when this is a non-placeholder checkpoint.
704+
if let Some(data) = u.data() {
705+
changeset.blocks.insert(u.height(), Some(data));
706+
}
634707
prev_update = curr_update.take();
635708
}
636709
// Original block that isn't in the update
637710
(Some(o), u) if Some(o.height()) > u.map(|u| u.height()) => {
638-
// this block might be gone if an earlier block gets invalidated
639-
potentially_invalidated_heights.push(o.height());
711+
if !o.is_placeholder() {
712+
// this block might be gone if an earlier block gets invalidated
713+
potentially_invalidated_heights.push(o.height());
714+
}
640715
prev_orig_was_invalidated = false;
641716
prev_orig = curr_orig.take();
642717

@@ -667,21 +742,36 @@ where
667742
prev_orig_was_invalidated = false;
668743
// OPTIMIZATION 2 -- if we have the same underlying pointer at this point, we
669744
// can guarantee that no older blocks are introduced.
670-
if o.eq_ptr(u) {
745+
if o.source_checkpoint().eq_ptr(&u.source_checkpoint()) {
671746
if is_update_height_superset_of_original {
672747
return Ok((update_tip, changeset));
673748
} else {
674-
let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset)
675-
.map_err(|_| CannotConnectError {
676-
try_include_height: 0,
677-
})?;
678-
return Ok((new_tip, changeset));
749+
return finish(original_tip, changeset);
750+
}
751+
}
752+
// Update placeholder with real data (if necessary).
753+
if let Some(u_data) = u.data_ref() {
754+
if o.is_placeholder() {
755+
changeset.blocks.insert(u.height(), Some(u_data.clone()));
679756
}
680757
}
681758
} else {
759+
// Genesis block (height 0) cannot be replaced. If the original and
760+
// update disagree on genesis, they belong to different chains.
761+
if o.height() == 0 && !o.is_placeholder() {
762+
return Err(CannotConnectError {
763+
try_include_height: 0,
764+
});
765+
}
682766
// We have an invalidation height so we set the height to the updated hash and
683767
// also purge all the original chain block hashes above this block.
684-
changeset.blocks.insert(u.height(), Some(u.data()));
768+
//
769+
// `u.data()` returns `None` when `u` is a placeholder — in that case we erase
770+
// orig's checkpoint at this height without providing replacement data. The
771+
// implied block is still recoverable via the `prev_blockhash` of the occupied
772+
// checkpoint above it in the update chain (which is handled in its own
773+
// iteration).
774+
changeset.blocks.insert(u.height(), u.data());
685775
for invalidated_height in potentially_invalidated_heights.drain(..) {
686776
changeset.blocks.insert(invalidated_height, None);
687777
}
@@ -710,10 +800,5 @@ where
710800
}
711801
}
712802

713-
let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|_| {
714-
CannotConnectError {
715-
try_include_height: 0,
716-
}
717-
})?;
718-
Ok((new_tip, changeset))
803+
finish(original_tip, changeset)
719804
}

0 commit comments

Comments
 (0)