]> Untitled Git - bdk/commitdiff
fix(chain)!: reject changesets that alter the genesis block
author志宇 <hello@evanlinjin.me>
Thu, 15 Oct 2026 09:46:13 +0000 (09:46 +0000)
committer志宇 <hello@evanlinjin.me>
Sun, 18 Oct 2026 07:17:37 +0000 (18:17 +1100)
`LocalChain::apply_changeset` could replace the genesis block when the
changeset contained a different block at height 0. Check genesis only
when there is no point of agreement below the changeset (the only path
that touches height 0) and extend from the existing genesis checkpoint.

`apply_changeset` now returns the new `ApplyChangeSetError`, since
`ApplyBlockError::MissingGenesis` does not apply to a chain that already
has a genesis. Replacing or removing genesis is reported as
`ApplyChangeSetError::CannotAlterGenesis`.

`from_changeset` now builds directly via `from_blocks`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/chain/src/local_chain.rs
crates/chain/tests/test_local_chain.rs

index ee1d67fe0de4f7b14a029192dfbcdb107d5c8d0e..8b1fef0b86370ae35cbb6a4fd68d2b17c544e763 100644 (file)
@@ -14,7 +14,7 @@ use bitcoin::BlockHash;
 fn apply_changeset_to_checkpoint<D>(
     mut init_cp: CheckPoint<D>,
     changeset: &ChangeSet<D>,
-) -> Result<CheckPoint<D>, ApplyBlockError>
+) -> Result<CheckPoint<D>, ApplyChangeSetError>
 where
     D: ToBlockHash + fmt::Debug + Clone,
 {
@@ -44,17 +44,29 @@ where
             };
         }
 
-        let new_tip = match base {
-            Some(base) => base
-                .extend(extension)
-                // 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(),
+        let base = match base {
+            Some(base) => base,
+            // No point of agreement means `changeset` touches genesis, which cannot be altered.
+            None => {
+                let genesis = init_cp.get(0).expect("`LocalChain` always has genesis");
+                let update_hash = extension.remove(&0).as_ref().map(ToBlockHash::to_blockhash);
+                if update_hash != Some(genesis.hash()) {
+                    return Err(ApplyChangeSetError::CannotAlterGenesis {
+                        original_hash: genesis.hash(),
+                        update_hash,
+                    });
+                }
+                genesis
+            }
         };
-        init_cp = new_tip;
+
+        init_cp = base
+            .extend(extension)
+            // Since `extension` is in height order, the only failure case is `prev_blockhash`
+            // mismatch.
+            .map_err(|last_cp| ApplyChangeSetError::PrevBlockhashMismatch {
+                expected: last_cp.block_id(),
+            })?;
     }
 
     Ok(init_cp)
@@ -327,15 +339,19 @@ where
     }
 
     /// Construct a [`LocalChain`] from an initial `changeset`.
+    ///
+    /// # Errors
+    ///
+    /// Fails with [`ApplyBlockError::MissingGenesis`] if `changeset` has no genesis block, or
+    /// [`ApplyBlockError::PrevBlockhashMismatch`] if its blocks do not link by `prev_blockhash`.
     pub fn from_changeset(changeset: ChangeSet<D>) -> Result<Self, ApplyBlockError> {
-        let genesis_entry = changeset.blocks.get(&0).cloned().flatten();
-        let genesis_data = match genesis_entry {
-            Some(data) => data,
-            None => return Err(ApplyBlockError::MissingGenesis),
-        };
-
-        let (mut chain, _) = Self::from_genesis(genesis_data);
-        chain.apply_changeset(&changeset)?;
+        let chain = Self::from_blocks(
+            changeset
+                .blocks
+                .iter()
+                .filter_map(|(&height, data)| Some((height, data.clone()?)))
+                .collect(),
+        )?;
         debug_assert!(chain._check_changeset_is_applied(&changeset));
         Ok(chain)
     }
@@ -375,7 +391,12 @@ where
     }
 
     /// Apply the given `changeset`.
-    pub fn apply_changeset(&mut self, changeset: &ChangeSet<D>) -> Result<(), ApplyBlockError> {
+    ///
+    /// # Errors
+    ///
+    /// Fails if `changeset` replaces or removes the genesis block, or if the resulting blocks do
+    /// not link by `prev_blockhash`. `self` is unchanged on error.
+    pub fn apply_changeset(&mut self, changeset: &ChangeSet<D>) -> Result<(), ApplyChangeSetError> {
         let old_tip = self.tip.clone();
         let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?;
         self.tip = new_tip;
@@ -579,6 +600,49 @@ impl core::fmt::Display for ApplyBlockError {
 
 impl core::error::Error for ApplyBlockError {}
 
+/// Error when applying a [`ChangeSet`] to a [`LocalChain`].
+#[derive(Clone, Debug, PartialEq)]
+#[non_exhaustive]
+pub enum ApplyChangeSetError {
+    /// The changeset attempts to replace or remove the genesis block.
+    CannotAlterGenesis {
+        /// The genesis block hash which cannot be replaced or removed.
+        original_hash: BlockHash,
+        /// The attempted replacement, or `None` if the changeset removes genesis.
+        update_hash: Option<BlockHash>,
+    },
+    /// 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 ApplyChangeSetError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            ApplyChangeSetError::CannotAlterGenesis {
+                original_hash,
+                update_hash: Some(update_hash),
+            } => write!(
+                f,
+                "cannot replace genesis block: original={original_hash} update={update_hash}"
+            ),
+            ApplyChangeSetError::CannotAlterGenesis {
+                original_hash,
+                update_hash: None,
+            } => write!(f, "cannot remove genesis block: original={original_hash}"),
+            ApplyChangeSetError::PrevBlockhashMismatch { expected } => write!(
+                f,
+                "`prev_blockhash` doesn't match block at height {} ({})",
+                expected.height, expected.hash
+            ),
+        }
+    }
+}
+
+impl core::error::Error for ApplyChangeSetError {}
+
 /// An error which occurs when a [`LocalChain`] is constructed without a genesis checkpoint.
 #[derive(Clone, Debug, PartialEq)]
 pub struct MissingGenesisError;
@@ -696,15 +760,19 @@ where
     {
         let new_tip = apply_changeset_to_checkpoint(original_tip, &changeset).map_err(|err| {
             match err {
-                ApplyBlockError::MissingGenesis => CannotConnectError {
-                    try_include_height: 0,
-                },
+                // `merge_chains` rejects a genesis mismatch before reaching here.
+                err @ ApplyChangeSetError::CannotAlterGenesis { .. } => {
+                    debug_assert!(false, "merge_chains should have rejected {err:?}");
+                    CannotConnectError {
+                        try_include_height: 0,
+                    }
+                }
                 // The merge iteration is supposed to detect `prev_blockhash` conflicts and resolve
                 // them by invalidating conflicting blocks in the changeset. Reaching this arm means
                 // either the original chain was internally inconsistent or the iteration missed a
                 // case — a bug on our side. Debug builds panic; release builds surface the height
                 // where the mismatch surfaced so the caller at least has a useful pointer.
-                ApplyBlockError::PrevBlockhashMismatch { expected } => {
+                ApplyChangeSetError::PrevBlockhashMismatch { expected } => {
                     debug_assert!(
                         false,
                         "merge_chains should have resolved prev_blockhash mismatch at {expected:?}",
index bc5151d9c5bb5c96d9d7b2f0db94b2aba2dccc01..2e9757c7a63a25401d8efb268de2be4bce5bb5c3 100644 (file)
@@ -5,8 +5,8 @@ use std::ops::{Bound, RangeBounds};
 
 use bdk_chain::{
     local_chain::{
-        AlterCheckPointError, ApplyHeaderError, CannotConnectError, ChangeSet, CheckPoint,
-        LocalChain, MissingGenesisError,
+        AlterCheckPointError, ApplyBlockError, ApplyChangeSetError, ApplyHeaderError,
+        CannotConnectError, ChangeSet, CheckPoint, LocalChain, MissingGenesisError,
     },
     BlockId,
 };
@@ -395,19 +395,19 @@ fn local_chain_insert_block() {
     }
 }
 
-#[test]
-fn local_chain_insert_header() {
-    fn header(prev_blockhash: BlockHash) -> Header {
-        Header {
-            version: bitcoin::block::Version::default(),
-            prev_blockhash,
-            merkle_root: bitcoin::hash_types::TxMerkleNode::all_zeros(),
-            time: 0,
-            bits: bitcoin::CompactTarget::default(),
-            nonce: 0,
-        }
+fn header(prev_blockhash: BlockHash) -> Header {
+    Header {
+        version: bitcoin::block::Version::default(),
+        prev_blockhash,
+        merkle_root: bitcoin::hash_types::TxMerkleNode::all_zeros(),
+        time: 0,
+        bits: bitcoin::CompactTarget::default(),
+        nonce: 0,
     }
+}
 
+#[test]
+fn local_chain_insert_header() {
     // Create consecutive headers of height `n`, where the genesis header is height 0.
     fn build_headers(n: u32) -> Vec<Header> {
         let mut headers = Vec::new();
@@ -566,6 +566,63 @@ fn local_chain_disconnect_from() {
     }
 }
 
+#[test]
+fn local_chain_apply_changeset_cannot_alter_genesis() {
+    let chain: LocalChain = local_chain![(0, hash!("G")), (1, hash!("A"))];
+    for (update_hash, changeset) in [
+        (
+            Some(hash!("not_G")),
+            ChangeSet::from([(0, Some(hash!("not_G")))]),
+        ),
+        (None, ChangeSet::from([(0, None)])),
+    ] {
+        let mut chain = chain.clone();
+        assert_eq!(
+            chain.apply_changeset(&changeset),
+            Err(ApplyChangeSetError::CannotAlterGenesis {
+                original_hash: hash!("G"),
+                update_hash,
+            }),
+        );
+        assert_eq!(chain, local_chain![(0, hash!("G")), (1, hash!("A"))]);
+    }
+
+    // A matching genesis entry is fine.
+    let mut chain = chain;
+    let changeset = ChangeSet::from([(0, Some(hash!("G"))), (2, Some(hash!("B")))]);
+    assert_eq!(chain.apply_changeset(&changeset), Ok(()));
+    assert_eq!(
+        chain,
+        local_chain![(0, hash!("G")), (1, hash!("A")), (2, hash!("B"))]
+    );
+}
+
+#[test]
+fn local_chain_apply_changeset_with_genesis_checks_prev_blockhash() {
+    let genesis = header(hash!("_"));
+    let (mut chain, _) = LocalChain::from_genesis(genesis);
+    let changeset = ChangeSet::from([(0, Some(genesis)), (1, Some(header(hash!("not_G"))))]);
+    assert_eq!(
+        chain.apply_changeset(&changeset),
+        Err(ApplyChangeSetError::PrevBlockhashMismatch {
+            expected: BlockId {
+                height: 0,
+                hash: genesis.block_hash(),
+            },
+        }),
+    );
+    assert_eq!(chain, LocalChain::from_genesis(genesis).0);
+}
+
+#[test]
+fn local_chain_from_changeset_without_genesis() {
+    let changeset: ChangeSet = [(0, None), (1, Some(hash!("A")))].into_iter().collect();
+    assert_eq!(
+        LocalChain::from_changeset(changeset),
+        Err(ApplyBlockError::MissingGenesis),
+    );
+}
+
 // Test that `apply_update` can connect 1 `Header` at a time
 // and fails if a `prev_blockhash` conflict is detected.
 #[test]