]> Untitled Git - bdk/commitdiff
feat(rpc)!: `FilterIter` API redesign
authorvalued mammal <valuedmammal@protonmail.com>
Thu, 24 Jul 2025 18:16:31 +0000 (14:16 -0400)
committervalued mammal <valuedmammal@protonmail.com>
Mon, 1 Sep 2025 16:31:20 +0000 (12:31 -0400)
The API now consists of the methods `new` and `next`.

The local checkpoint and SPK inventory are provided to
the constructor. `next` is now responsible for locating
the point of agreement.

The next filter to fetch is determined by the `next_block_hash`
field of `GetBlockHeaderResult`. If the next header has negative
confirmations due to a reorg, we rewind the internal state until
we find a header still in the best chain.

`Event` is changed to a simple struct containing `cp` and optional
`block`. The checkpoint is updated on each iteration whether
or not it corresponds to a matching block. We use
`CheckPoint::insert` which will also purge evicted blocks
if needed.

Change implementation of `find_base` to use `get_block_header_info`
which helps to reduce the number of RPC calls.

Removed `EventInner`.

Add test `event_checkpoint_connects_to_local_chain` to check
the expected events after a reorg, and check that intermediate
updates can be applied to the local chain.

Co-authored-by: Musab1258 <habeebmusab@gmail.com>
Co-authored-by: Wei Chen <wzc110@gmail.com>
Co-authored-by: 志宇 <hello@evanlinjin.me>
crates/bitcoind_rpc/examples/filter_iter.rs
crates/bitcoind_rpc/src/bip158.rs
crates/bitcoind_rpc/tests/test_filter_iter.rs

index 61422f64b9df494cc37410ebe1a0f8fcda4996c0..174f212157263ca5ee2a2179038058272e6ba376 100644 (file)
@@ -1,15 +1,14 @@
-#![allow(clippy::print_stdout)]
+#![allow(clippy::print_stdout, clippy::print_stderr)]
 use std::time::Instant;
 
 use anyhow::Context;
-use bdk_bitcoind_rpc::bip158::{Event, EventInner, FilterIter};
+use bdk_bitcoind_rpc::bip158::{Event, FilterIter};
 use bdk_chain::bitcoin::{constants::genesis_block, secp256k1::Secp256k1, Network};
 use bdk_chain::indexer::keychain_txout::KeychainTxOutIndex;
 use bdk_chain::local_chain::LocalChain;
 use bdk_chain::miniscript::Descriptor;
 use bdk_chain::{BlockId, ConfirmationBlockTime, IndexedTxGraph, SpkIterator};
 use bdk_testenv::anyhow;
-use bitcoin::Address;
 
 // This example shows how BDK chain and tx-graph structures are updated using compact
 // filters syncing. Assumes a connection can be made to a bitcoin node via environment
@@ -17,13 +16,13 @@ use bitcoin::Address;
 
 // Usage: `cargo run -p bdk_bitcoind_rpc --example filter_iter`
 
-const EXTERNAL: &str = "tr([7d94197e]tprv8ZgxMBicQKsPe1chHGzaa84k1inY2nAXUL8iPSyWESPrEst4E5oCFXhPATqj5fvw34LDknJz7rtXyEC4fKoXryUdc9q87pTTzfQyv61cKdE/86'/1'/0'/0/*)#uswl2jj7";
-const INTERNAL: &str = "tr([7d94197e]tprv8ZgxMBicQKsPe1chHGzaa84k1inY2nAXUL8iPSyWESPrEst4E5oCFXhPATqj5fvw34LDknJz7rtXyEC4fKoXryUdc9q87pTTzfQyv61cKdE/86'/1'/0'/1/*)#dyt7h8zx";
+const EXTERNAL: &str = "tr([83737d5e/86'/1'/0']tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)";
+const INTERNAL: &str = "tr([83737d5e/86'/1'/0']tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/1/*)";
 const SPK_COUNT: u32 = 25;
 const NETWORK: Network = Network::Signet;
 
-const START_HEIGHT: u32 = 170_000;
-const START_HASH: &str = "00000041c812a89f084f633e4cf47e819a2f6b1c0a15162355a930410522c99d";
+const START_HEIGHT: u32 = 205_000;
+const START_HASH: &str = "0000002bd0f82f8c0c0f1e19128f84c938763641dba85c44bdb6aed1678d16cb";
 
 fn main() -> anyhow::Result<()> {
     // Setup receiving chain and graph structures.
@@ -52,37 +51,22 @@ fn main() -> anyhow::Result<()> {
     let rpc_client =
         bitcoincore_rpc::Client::new(&url, bitcoincore_rpc::Auth::CookieFile(cookie.into()))?;
 
-    // Initialize block emitter
-    let cp = chain.tip();
-    let start_height = cp.height();
-    let mut emitter = FilterIter::new_with_checkpoint(&rpc_client, cp);
+    // Initialize `FilterIter`
+    let mut spks = vec![];
     for (_, desc) in graph.index.keychains() {
-        let spks = SpkIterator::new_with_range(desc, 0..SPK_COUNT).map(|(_, spk)| spk);
-        emitter.add_spks(spks);
+        spks.extend(SpkIterator::new_with_range(desc, 0..SPK_COUNT).map(|(_, s)| s));
     }
+    let iter = FilterIter::new(&rpc_client, chain.tip(), spks);
 
     let start = Instant::now();
 
-    // Sync
-    if let Some(tip) = emitter.get_tip()? {
-        let blocks_to_scan = tip.height - start_height;
-
-        for event in emitter.by_ref() {
-            let event = event?;
-            let curr = event.height();
-            // apply relevant blocks
-            if let Event::Block(EventInner { height, ref block }) = event {
-                let _ = graph.apply_block_relevant(block, height);
-                println!("Matched block {curr}");
-            }
-            if curr % 1000 == 0 {
-                let progress = (curr - start_height) as f32 / blocks_to_scan as f32;
-                println!("[{:.2}%]", progress * 100.0);
-            }
-        }
-        // update chain
-        if let Some(cp) = emitter.chain_update() {
-            let _ = chain.apply_update(cp)?;
+    for res in iter {
+        let Event { cp, block } = res?;
+        let height = cp.height();
+        let _ = chain.apply_update(cp)?;
+        if let Some(block) = block {
+            let _ = graph.apply_block_relevant(&block, height);
+            println!("Matched block {height}");
         }
     }
 
@@ -105,9 +89,18 @@ fn main() -> anyhow::Result<()> {
         }
     }
 
-    let unused_spk = graph.index.reveal_next_spk("external").unwrap().0 .1;
-    let unused_address = Address::from_script(&unused_spk, NETWORK)?;
-    println!("Next external address: {unused_address}");
+    for canon_tx in graph.graph().list_canonical_txs(
+        &chain,
+        chain.tip().block_id(),
+        bdk_chain::CanonicalizationParams::default(),
+    ) {
+        if !canon_tx.chain_position.is_confirmed() {
+            eprintln!(
+                "ERROR: canonical tx should be confirmed {}",
+                canon_tx.tx_node.txid
+            );
+        }
+    }
 
     Ok(())
 }
index 5419716b1124ed522ff2696e7494c93105bc9e47..3956d892d0ad0181de507f357d007b237f50751f 100644 (file)
 //! [0]: https://github.com/bitcoin/bips/blob/master/bip-0157.mediawiki
 //! [1]: https://github.com/bitcoin/bips/blob/master/bip-0158.mediawiki
 
-use bdk_core::collections::BTreeMap;
-use core::fmt;
-
 use bdk_core::bitcoin;
 use bdk_core::{BlockId, CheckPoint};
-use bitcoin::{
-    bip158::{self, BlockFilter},
-    Block, BlockHash, ScriptBuf,
-};
+use bitcoin::{bip158::BlockFilter, Block, ScriptBuf};
 use bitcoincore_rpc;
-use bitcoincore_rpc::RpcApi;
-
-/// Block height
-type Height = u32;
+use bitcoincore_rpc::{json::GetBlockHeaderResult, RpcApi};
 
-/// Type that generates block [`Event`]s by matching a list of script pubkeys against a
-/// [`BlockFilter`].
+/// Type that returns Bitcoin blocks by matching a list of script pubkeys (SPKs) against a
+/// [`bip158::BlockFilter`].
 #[derive(Debug)]
-pub struct FilterIter<'c, C> {
-    // RPC client
-    client: &'c C,
-    // SPK inventory
+pub struct FilterIter<'a> {
+    /// RPC client
+    client: &'a bitcoincore_rpc::Client,
+    /// SPK inventory
     spks: Vec<ScriptBuf>,
-    // local cp
-    cp: Option<CheckPoint>,
-    // blocks map
-    blocks: BTreeMap<Height, BlockHash>,
-    // best height counter
-    height: Height,
-    // stop height
-    stop: Height,
+    /// checkpoint
+    cp: CheckPoint,
+    /// Header info, contains the prev and next hashes for each header.
+    header: Option<GetBlockHeaderResult>,
 }
 
-impl<'c, C: RpcApi> FilterIter<'c, C> {
-    /// Construct [`FilterIter`] from a given `client` and start `height`.
-    pub fn new_with_height(client: &'c C, height: u32) -> Self {
+impl<'a> FilterIter<'a> {
+    /// Construct [`FilterIter`] with checkpoint, RPC client and SPKs.
+    pub fn new(
+        client: &'a bitcoincore_rpc::Client,
+        cp: CheckPoint,
+        spks: impl IntoIterator<Item = ScriptBuf>,
+    ) -> Self {
         Self {
             client,
-            spks: vec![],
-            cp: None,
-            blocks: BTreeMap::new(),
-            height,
-            stop: 0,
+            spks: spks.into_iter().collect(),
+            cp,
+            header: None,
         }
     }
 
-    /// Construct [`FilterIter`] from a given `client` and [`CheckPoint`].
-    pub fn new_with_checkpoint(client: &'c C, cp: CheckPoint) -> Self {
-        let mut filter_iter = Self::new_with_height(client, cp.height());
-        filter_iter.cp = Some(cp);
-        filter_iter
-    }
-
-    /// Extends `self` with an iterator of spks.
-    pub fn add_spks(&mut self, spks: impl IntoIterator<Item = ScriptBuf>) {
-        self.spks.extend(spks)
-    }
-
-    /// Add spk to the list of spks to scan with.
-    pub fn add_spk(&mut self, spk: ScriptBuf) {
-        self.spks.push(spk);
-    }
-
-    /// Get the next filter and increment the current best height.
-    ///
-    /// Returns `Ok(None)` when the stop height is exceeded.
-    fn next_filter(&mut self) -> Result<Option<NextFilter>, Error> {
-        if self.height > self.stop {
-            return Ok(None);
-        }
-        let height = self.height;
-        let hash = match self.blocks.get(&height) {
-            Some(h) => *h,
-            None => self.client.get_block_hash(height as u64)?,
-        };
-        let filter_bytes = self.client.get_block_filter(&hash)?.filter;
-        let filter = BlockFilter::new(&filter_bytes);
-        self.height += 1;
-        Ok(Some((BlockId { height, hash }, filter)))
-    }
-
-    /// Get the remote tip.
+    /// Return the agreement header with the remote node.
     ///
-    /// Returns `None` if the remote height is not strictly greater than the height of this
-    /// [`FilterIter`].
-    pub fn get_tip(&mut self) -> Result<Option<BlockId>, Error> {
-        let tip_hash = self.client.get_best_block_hash()?;
-        let mut header = self.client.get_block_header_info(&tip_hash)?;
-        let tip_height = header.height as u32;
-        if self.height >= tip_height {
-            // nothing to do
-            return Ok(None);
-        }
-        self.blocks.insert(tip_height, tip_hash);
-
-        // if we have a checkpoint we use a lookback of ten blocks
-        // to ensure consistency of the local chain
-        if let Some(cp) = self.cp.as_ref() {
-            // adjust start height to point of agreement + 1
-            let base = self.find_base_with(cp.clone())?;
-            self.height = base.height + 1;
-
-            for _ in 0..9 {
-                let hash = match header.previous_block_hash {
-                    Some(hash) => hash,
-                    None => break,
-                };
-                header = self.client.get_block_header_info(&hash)?;
-                let height = header.height as u32;
-                if height < self.height {
-                    break;
-                }
-                self.blocks.insert(height, hash);
+    /// Error if no agreement header is found.
+    fn find_base(&self) -> Result<GetBlockHeaderResult, Error> {
+        for cp in self.cp.iter() {
+            match self.client.get_block_header_info(&cp.hash()) {
+                Err(e) if is_not_found(&e) => continue,
+                Ok(header) if header.confirmations <= 0 => continue,
+                Ok(header) => return Ok(header),
+                Err(e) => return Err(Error::Rpc(e)),
             }
         }
-
-        self.stop = tip_height;
-
-        Ok(Some(BlockId {
-            height: tip_height,
-            hash: tip_hash,
-        }))
+        Err(Error::ReorgDepthExceeded)
     }
 }
 
-/// Alias for a compact filter and associated block id.
-type NextFilter = (BlockId, BlockFilter);
-
-/// Event inner type
+/// Event returned by [`FilterIter`].
 #[derive(Debug, Clone)]
-pub struct EventInner {
-    /// Height
-    pub height: Height,
-    /// Block
-    pub block: Block,
-}
-
-/// Kind of event produced by [`FilterIter`].
-#[derive(Debug, Clone)]
-pub enum Event {
-    /// Block
-    Block(EventInner),
-    /// No match
-    NoMatch(Height),
+pub struct Event {
+    /// Checkpoint
+    pub cp: CheckPoint,
+    /// Block, will be `Some(..)` for matching blocks
+    pub block: Option<Block>,
 }
 
 impl Event {
     /// Whether this event contains a matching block.
     pub fn is_match(&self) -> bool {
-        matches!(self, Event::Block(_))
+        self.block.is_some()
     }
 
-    /// Get the height of this event.
-    pub fn height(&self) -> Height {
-        match self {
-            Self::Block(EventInner { height, .. }) => *height,
-            Self::NoMatch(h) => *h,
-        }
+    /// Return the height of the event.
+    pub fn height(&self) -> u32 {
+        self.cp.height()
     }
 }
 
-impl<C: RpcApi> Iterator for FilterIter<'_, C> {
+impl Iterator for FilterIter<'_> {
     type Item = Result<Event, Error>;
 
     fn next(&mut self) -> Option<Self::Item> {
-        (|| -> Result<_, Error> {
-            // if the next filter matches any of our watched spks, get the block
-            // and return it, inserting relevant block ids along the way
-            self.next_filter()?.map_or(Ok(None), |(block, filter)| {
-                let height = block.height;
-                let hash = block.hash;
-
-                if self.spks.is_empty() {
-                    Err(Error::NoScripts)
-                } else if filter
-                    .match_any(&hash, self.spks.iter().map(|script| script.as_bytes()))
-                    .map_err(Error::Bip158)?
-                {
-                    let block = self.client.get_block(&hash)?;
-                    self.blocks.insert(height, hash);
-                    let inner = EventInner { height, block };
-                    Ok(Some(Event::Block(inner)))
-                } else {
-                    Ok(Some(Event::NoMatch(height)))
-                }
-            })
-        })()
-        .transpose()
-    }
-}
+        (|| -> Result<Option<_>, Error> {
+            let mut cp = self.cp.clone();
+
+            let header = match self.header.take() {
+                Some(header) => header,
+                // If no header is cached we need to locate a base of the local
+                // checkpoint from which the scan may proceed.
+                None => self.find_base()?,
+            };
 
-impl<C: RpcApi> FilterIter<'_, C> {
-    /// Returns the point of agreement between `self` and the given `cp`.
-    fn find_base_with(&mut self, mut cp: CheckPoint) -> Result<BlockId, Error> {
-        loop {
-            let height = cp.height();
-            let fetched_hash = match self.blocks.get(&height) {
-                Some(hash) => *hash,
-                None if height == 0 => cp.hash(),
-                _ => self.client.get_block_hash(height as _)?,
+            let mut next_hash = match header.next_block_hash {
+                Some(hash) => hash,
+                None => return Ok(None),
             };
-            if cp.hash() == fetched_hash {
-                // ensure this block also exists in self
-                self.blocks.insert(height, cp.hash());
-                return Ok(cp.block_id());
+
+            let mut next_header = self.client.get_block_header_info(&next_hash)?;
+
+            // In case of a reorg, rewind by fetching headers of previous hashes until we find
+            // one with enough confirmations.
+            while next_header.confirmations < 0 {
+                let prev_hash = next_header
+                    .previous_block_hash
+                    .ok_or(Error::ReorgDepthExceeded)?;
+                let prev_header = self.client.get_block_header_info(&prev_hash)?;
+                next_header = prev_header;
             }
-            // remember conflicts
-            self.blocks.insert(height, fetched_hash);
-            cp = cp.prev().expect("must break before genesis");
-        }
-    }
 
-    /// Returns a chain update from the newly scanned blocks.
-    ///
-    /// Returns `None` if this [`FilterIter`] was not constructed using a [`CheckPoint`], or
-    /// if no blocks have been fetched for example by using [`get_tip`](Self::get_tip).
-    pub fn chain_update(&mut self) -> Option<CheckPoint> {
-        if self.cp.is_none() || self.blocks.is_empty() {
-            return None;
-        }
+            next_hash = next_header.hash;
+            let next_height: u32 = next_header.height.try_into()?;
+
+            cp = cp.insert(BlockId {
+                height: next_height,
+                hash: next_hash,
+            });
+
+            let mut block = None;
+            let filter =
+                BlockFilter::new(self.client.get_block_filter(&next_hash)?.filter.as_slice());
+            if filter
+                .match_any(&next_hash, self.spks.iter().map(ScriptBuf::as_ref))
+                .map_err(Error::Bip158)?
+            {
+                block = Some(self.client.get_block(&next_hash)?);
+            }
 
-        // note: to connect with the local chain we must guarantee that `self.blocks.first()`
-        // is also the point of agreement with `self.cp`.
-        Some(
-            CheckPoint::from_block_ids(self.blocks.iter().map(BlockId::from))
-                .expect("blocks must be in order"),
-        )
+            // Store the next header
+            self.header = Some(next_header);
+            // Update self.cp
+            self.cp = cp.clone();
+
+            Ok(Some(Event { cp, block }))
+        })()
+        .transpose()
     }
 }
 
-/// Errors that may occur during a compact filters sync.
+/// Error that may be thrown by [`FilterIter`].
 #[derive(Debug)]
 pub enum Error {
-    /// bitcoin bip158 error
-    Bip158(bip158::Error),
-    /// attempted to scan blocks without any script pubkeys
-    NoScripts,
-    /// `bitcoincore_rpc` error
+    /// RPC error
     Rpc(bitcoincore_rpc::Error),
+    /// `bitcoin::bip158` error
+    Bip158(bitcoin::bip158::Error),
+    /// Max reorg depth exceeded.
+    ReorgDepthExceeded,
+    /// Error converting an integer
+    TryFromInt(core::num::TryFromIntError),
 }
 
+impl core::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Rpc(e) => write!(f, "{e}"),
+            Self::Bip158(e) => write!(f, "{e}"),
+            Self::ReorgDepthExceeded => write!(f, "maximum reorg depth exceeded"),
+            Self::TryFromInt(e) => write!(f, "{e}"),
+        }
+    }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for Error {}
+
 impl From<bitcoincore_rpc::Error> for Error {
     fn from(e: bitcoincore_rpc::Error) -> Self {
         Self::Rpc(e)
     }
 }
 
-impl fmt::Display for Error {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            Self::Bip158(e) => e.fmt(f),
-            Self::NoScripts => write!(f, "no script pubkeys were provided to match with"),
-            Self::Rpc(e) => e.fmt(f),
-        }
+impl From<core::num::TryFromIntError> for Error {
+    fn from(e: core::num::TryFromIntError) -> Self {
+        Self::TryFromInt(e)
     }
 }
 
-#[cfg(feature = "std")]
-impl std::error::Error for Error {}
+/// Whether the RPC error is a "not found" error (code: `-5`).
+fn is_not_found(e: &bitcoincore_rpc::Error) -> bool {
+    matches!(
+        e,
+        bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc(e))
+        if e.code == -5
+    )
+}
index c8d3335a20155d1286a3965b32b4495489dbf3af..10f01e6129ea18a3b70ffbb49918b37b57f3ffd6 100644 (file)
@@ -1,8 +1,7 @@
-use bitcoin::{constants, Address, Amount, Network, ScriptBuf};
-
-use bdk_bitcoind_rpc::bip158::FilterIter;
+use bdk_bitcoind_rpc::bip158::{Error, FilterIter};
 use bdk_core::{BlockId, CheckPoint};
-use bdk_testenv::{anyhow, bitcoind, block_id, TestEnv};
+use bdk_testenv::{anyhow, bitcoind, TestEnv};
+use bitcoin::{Address, Amount, Network, ScriptBuf};
 use bitcoincore_rpc::RpcApi;
 
 fn testenv() -> anyhow::Result<TestEnv> {
@@ -15,151 +14,144 @@ fn testenv() -> anyhow::Result<TestEnv> {
     })
 }
 
-// Test the result of `chain_update` given a local checkpoint.
-//
-// new blocks
-//       2--3--4--5--6--7--8--9--10--11
-//
-// case 1: base below new blocks
-// 0-
-// case 2: base overlaps with new blocks
-// 0--1--2--3--4
-// case 3: stale tip (with overlap)
-// 0--1--2--3--4--x
-// case 4: stale tip (no overlap)
-// 0--x
 #[test]
-fn get_tip_and_chain_update() -> anyhow::Result<()> {
+fn filter_iter_matches_blocks() -> anyhow::Result<()> {
     let env = testenv()?;
+    let addr = env
+        .rpc_client()
+        .get_new_address(None, None)?
+        .assume_checked();
+
+    let _ = env.mine_blocks(100, Some(addr.clone()))?;
+    assert_eq!(env.rpc_client().get_block_count()?, 101);
+
+    // Send tx to external address to confirm at height = 102
+    let _txid = env.send(
+        &Address::from_script(
+            &ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa")?,
+            Network::Regtest,
+        )?,
+        Amount::from_btc(0.42)?,
+    )?;
+    let _ = env.mine_blocks(1, None);
 
-    let genesis_hash = constants::genesis_block(Network::Regtest).block_hash();
-    let genesis = BlockId {
+    let genesis_hash = env.genesis_hash()?;
+    let cp = CheckPoint::new(BlockId {
         height: 0,
         hash: genesis_hash,
-    };
+    });
 
-    let hash = env.rpc_client().get_best_block_hash()?;
-    let header = env.rpc_client().get_block_header_info(&hash)?;
-    assert_eq!(header.height, 1);
-    let block_1 = BlockId {
-        height: header.height as u32,
-        hash,
-    };
+    let iter = FilterIter::new(&env.bitcoind.client, cp, [addr.script_pubkey()]);
 
-    // `FilterIter` will try to return up to ten recent blocks
-    // so we keep them for reference
-    let new_blocks: Vec<BlockId> = (2..12)
-        .zip(env.mine_blocks(10, None)?)
-        .map(BlockId::from)
-        .collect();
+    for res in iter {
+        let event = res?;
+        let height = event.height();
+        if (2..102).contains(&height) {
+            assert!(event.is_match(), "expected to match height {height}");
+        } else {
+            assert!(!event.is_match());
+        }
+    }
 
-    let new_tip = *new_blocks.last().unwrap();
+    Ok(())
+}
 
-    struct TestCase {
-        // name
-        name: &'static str,
-        // local blocks
-        chain: Vec<BlockId>,
-        // expected blocks
-        exp: Vec<BlockId>,
-    }
+#[test]
+fn filter_iter_error_wrong_network() -> anyhow::Result<()> {
+    let env = testenv()?;
+    let _ = env.mine_blocks(10, None)?;
 
-    // For each test we create a new `FilterIter` with the checkpoint given
-    // by the blocks in the test chain. Then we sync to the remote tip and
-    // check the blocks that are returned in the chain update.
-    [
-        TestCase {
-            name: "point of agreement below new blocks, expect base + new",
-            chain: vec![genesis, block_1],
-            exp: [block_1].into_iter().chain(new_blocks.clone()).collect(),
-        },
-        TestCase {
-            name: "point of agreement genesis, expect base + new",
-            chain: vec![genesis],
-            exp: [genesis].into_iter().chain(new_blocks.clone()).collect(),
-        },
-        TestCase {
-            name: "point of agreement within new blocks, expect base + remaining",
-            chain: new_blocks[..=2].to_vec(),
-            exp: new_blocks[2..].to_vec(),
-        },
-        TestCase {
-            name: "stale tip within new blocks, expect base + corrected + remaining",
-            // base height: 4, stale height: 5
-            chain: vec![new_blocks[2], block_id!(5, "E")],
-            exp: new_blocks[2..].to_vec(),
-        },
-        TestCase {
-            name: "stale tip below new blocks, expect base + corrected + new",
-            chain: vec![genesis, block_id!(1, "A")],
-            exp: [genesis, block_1].into_iter().chain(new_blocks).collect(),
-        },
-    ]
-    .into_iter()
-    .for_each(|test| {
-        let cp = CheckPoint::from_block_ids(test.chain).unwrap();
-        let mut iter = FilterIter::new_with_checkpoint(env.rpc_client(), cp);
-        assert_eq!(iter.get_tip().unwrap(), Some(new_tip));
-        let update_cp = iter.chain_update().unwrap();
-        let mut update_blocks: Vec<_> = update_cp.iter().map(|cp| cp.block_id()).collect();
-        update_blocks.reverse();
-        assert_eq!(update_blocks, test.exp, "{}", test.name);
-    });
+    // Try to initialize FilterIter with a CP on the wrong network
+    let block_id = BlockId {
+        height: 0,
+        hash: bitcoin::hashes::Hash::hash(b"wrong-hash"),
+    };
+    let cp = CheckPoint::new(block_id);
+    let mut iter = FilterIter::new(&env.bitcoind.client, cp, [ScriptBuf::new()]);
+    assert!(matches!(iter.next(), Some(Err(Error::ReorgDepthExceeded))));
 
     Ok(())
 }
 
+// Test that while a reorg is detected we delay incrementing the best height
 #[test]
-fn filter_iter_returns_matched_blocks() -> anyhow::Result<()> {
-    use bdk_bitcoind_rpc::bip158::{Event, EventInner};
+fn filter_iter_detects_reorgs() -> anyhow::Result<()> {
+    const MINE_TO: u32 = 16;
+
     let env = testenv()?;
     let rpc = env.rpc_client();
-    while rpc.get_block_count()? < 101 {
+    while rpc.get_block_count()? < MINE_TO as u64 {
         let _ = env.mine_blocks(1, None)?;
     }
 
-    // send tx
-    let spk = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa")?;
-    let txid = env.send(
-        &Address::from_script(&spk, Network::Regtest)?,
-        Amount::from_btc(0.42)?,
-    )?;
-    let _ = env.mine_blocks(1, None);
+    let genesis_hash = env.genesis_hash()?;
+    let cp = CheckPoint::new(BlockId {
+        height: 0,
+        hash: genesis_hash,
+    });
 
-    // match blocks
-    let mut iter = FilterIter::new_with_height(rpc, 1);
-    iter.add_spk(spk);
-    assert_eq!(iter.get_tip()?.unwrap().height, 102);
+    let spk = ScriptBuf::from_hex("0014446906a6560d8ad760db3156706e72e171f3a2aa")?;
+    let mut iter = FilterIter::new(&env.bitcoind.client, cp, [spk]);
 
-    for res in iter {
-        let event = res?;
-        match event {
-            event if event.height() <= 101 => assert!(!event.is_match()),
-            Event::Block(EventInner { height, block }) => {
-                assert_eq!(height, 102);
-                assert!(block.txdata.iter().any(|tx| tx.compute_txid() == txid));
-            }
-            Event::NoMatch(_) => panic!("expected to match block 102"),
+    // Process events to height (MINE_TO - 1)
+    loop {
+        if iter.next().unwrap()?.height() == MINE_TO - 1 {
+            break;
         }
     }
 
+    for _ in 0..3 {
+        // Invalidate and remine 1 block
+        let _ = env.reorg(1)?;
+
+        // Call next. If we detect a reorg, we'll see no change in the event height
+        assert_eq!(iter.next().unwrap()?.height(), MINE_TO - 1);
+    }
+
+    // If no reorg, then height should increment normally from here on
+    assert_eq!(iter.next().unwrap()?.height(), MINE_TO);
+    assert!(iter.next().is_none());
+
     Ok(())
 }
 
 #[test]
-fn filter_iter_error_no_scripts() -> anyhow::Result<()> {
-    use bdk_bitcoind_rpc::bip158::Error;
+fn event_checkpoint_connects_to_local_chain() -> anyhow::Result<()> {
+    use bitcoin::BlockHash;
+    use std::collections::BTreeMap;
     let env = testenv()?;
-    let _ = env.mine_blocks(2, None)?;
+    let _ = env.mine_blocks(15, None)?;
+
+    let cp = env.make_checkpoint_tip();
+    let mut chain = bdk_chain::local_chain::LocalChain::from_tip(cp.clone())?;
+    assert_eq!(chain.tip().height(), 16);
+    let old_hashes: Vec<BlockHash> = [14, 15, 16]
+        .into_iter()
+        .map(|height| chain.get(height).unwrap().hash())
+        .collect();
 
-    let mut iter = FilterIter::new_with_height(env.rpc_client(), 1);
-    assert_eq!(iter.get_tip()?.unwrap().height, 3);
+    // Construct iter
+    let mut iter = FilterIter::new(&env.bitcoind.client, cp, vec![ScriptBuf::new()]);
 
-    // iterator should return three errors
-    for _ in 0..3 {
-        assert!(matches!(iter.next().unwrap(), Err(Error::NoScripts)));
+    // Now reorg 3 blocks (14, 15, 16)
+    let new_hashes: BTreeMap<u32, BlockHash> = (14..=16).zip(env.reorg(3)?).collect();
+
+    // Expect events from height 14 on...
+    while let Some(event) = iter.next().transpose()? {
+        let _ = chain
+            .apply_update(event.cp)
+            .expect("chain update should connect");
+    }
+
+    for height in 14..=16 {
+        let hash = chain.get(height).unwrap().hash();
+        assert!(!old_hashes.contains(&hash), "Old hashes were reorged out");
+        assert_eq!(
+            new_hashes.get(&height),
+            Some(&hash),
+            "Chain must include new hashes"
+        );
     }
-    assert!(iter.next().is_none());
 
     Ok(())
 }