diff --git a/crates/bitcoind_rpc/src/fetch_headers.rs b/crates/bitcoind_rpc/src/fetch_headers.rs new file mode 100644 index 000000000..17c907b75 --- /dev/null +++ b/crates/bitcoind_rpc/src/fetch_headers.rs @@ -0,0 +1,128 @@ +//! On-demand header fetch at caller-requested heights. + +use bdk_core::collections::{BTreeMap, BTreeSet}; +use bdk_core::{bridge_heights, build_fetch_headers_update, CheckPoint}; +use bitcoin::block::Header; +use bitcoincore_rpc::RpcApi; +use core::ops::Deref; + +use alloc::vec::Vec; + +/// Error returned by [`fetch_headers_at_heights`]. +#[derive(Debug)] +pub enum FetchHeadersError { + /// An RPC error occurred. + Rpc(bitcoincore_rpc::Error), + /// No common ancestor was found between `local_tip` and the node's best chain. + NoAgreement, +} + +impl core::fmt::Display for FetchHeadersError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Rpc(err) => write!(f, "bitcoind rpc error: {err}"), + Self::NoAgreement => write!(f, "no agreement point with bitcoind best chain"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for FetchHeadersError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Rpc(err) => Some(err), + Self::NoAgreement => None, + } + } +} + +impl From for FetchHeadersError { + fn from(err: bitcoincore_rpc::Error) -> Self { + Self::Rpc(err) + } +} + +/// Fetch a reorg-aware [`CheckPoint
`] update covering `heights`. +/// +/// Heights already present in `local_tip` are not fetched from the network. When every requested +/// height is already known and no reorg is detected, returns `local_tip` unchanged (a no-op update +/// that applies with an empty [`bdk_chain::local_chain::ChangeSet`]). +/// +/// The returned checkpoint may include bridge blocks and reorg replacement headers beyond the +/// requested set — only what is needed for [`bdk_chain::local_chain::LocalChain::apply_update`] +/// to connect unambiguously. +pub fn fetch_headers_at_heights( + client: &C, + local_tip: CheckPoint
, + heights: impl IntoIterator, +) -> Result, FetchHeadersError> +where + C: RpcApi, +{ + let requested: BTreeSet = heights.into_iter().collect(); + let requested_vec: Vec = requested.iter().copied().collect(); + let missing: Vec = requested + .iter() + .copied() + .filter(|h| local_tip.get(*h).is_none()) + .collect(); + + let tip_height = client.get_block_count()? as u32; + + let mut point_of_agreement = None; + let mut conflicts = Vec::new(); + + for local_cp in local_tip.iter() { + let height = local_cp.height(); + if height > tip_height { + continue; + } + let remote_hash = client.get_block_hash(height as u64)?; + if remote_hash == local_cp.hash() { + point_of_agreement = Some(local_cp); + break; + } + let header = client.get_block_header(&remote_hash)?; + conflicts.push((height, header)); + } + + let agreement = match point_of_agreement { + Some(cp) => cp, + None => return Err(FetchHeadersError::NoAgreement), + }; + + let reorg_detected = !conflicts.is_empty(); + if missing.is_empty() && !reorg_detected { + return Ok(local_tip); + } + + let mut fetched = BTreeMap::new(); + for &height in &missing { + if height > tip_height { + return Err(FetchHeadersError::Rpc( + client.get_block_hash(height as u64).unwrap_err(), + )); + } + let hash = client.get_block_hash(height as u64)?; + let header = client.get_block_header(&hash)?; + fetched.insert(height, header); + } + + let bridges = bridge_heights(&local_tip, agreement.height(), &requested_vec); + Ok(build_fetch_headers_update( + agreement, conflicts, fetched, &local_tip, &bridges, + )) +} + +/// Fetch headers at caller-requested heights using a dereferencing client wrapper. +pub fn fetch_headers_at_heights_with( + client: C, + local_tip: CheckPoint
, + heights: impl IntoIterator, +) -> Result, FetchHeadersError> +where + C: Deref, + C::Target: RpcApi, +{ + fetch_headers_at_heights(&*client, local_tip, heights) +} diff --git a/crates/bitcoind_rpc/src/lib.rs b/crates/bitcoind_rpc/src/lib.rs index 35e6e2427..8e8f82a62 100644 --- a/crates/bitcoind_rpc/src/lib.rs +++ b/crates/bitcoind_rpc/src/lib.rs @@ -22,8 +22,12 @@ use bitcoincore_rpc::{bitcoincore_rpc_json, RpcApi}; use core::ops::Deref; pub mod bip158; +mod fetch_headers; pub use bitcoincore_rpc; +pub use fetch_headers::{ + fetch_headers_at_heights, fetch_headers_at_heights_with, FetchHeadersError, +}; /// The [`Emitter`] is used to emit data sourced from [`bitcoincore_rpc::Client`]. /// diff --git a/crates/bitcoind_rpc/tests/test_fetch_headers.rs b/crates/bitcoind_rpc/tests/test_fetch_headers.rs new file mode 100644 index 000000000..9780134f2 --- /dev/null +++ b/crates/bitcoind_rpc/tests/test_fetch_headers.rs @@ -0,0 +1,150 @@ +use std::collections::BTreeMap; + +use bdk_bitcoind_rpc::{fetch_headers_at_heights, FetchHeadersError}; +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_testenv::{anyhow, TestEnv}; +use bitcoin::block::Header; + +use crate::common::ClientExt; + +mod common; + +fn header_at(env: &TestEnv, height: u32) -> anyhow::Result
{ + let hash = env.get_block_hash(height as u64)?; + Ok(env.rpc_client().get_block_header(&hash)?.into_model()?.0) +} + +fn local_chain_from_headers(blocks: &[(u32, Header)]) -> LocalChain
{ + let map: BTreeMap = blocks.iter().copied().collect(); + LocalChain::from_blocks(map).expect("valid sparse chain") +} + +#[test] +fn fetch_headers_at_heights_exact_heights() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(30, None)?; + + let genesis = header_at(&env, 0)?; + let h21 = header_at(&env, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + let update = + fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), [22, 25, 28])?; + + for h in [22, 25, 28] { + assert!(update.get(h).is_some(), "update must contain height {h}"); + } + + chain.apply_update(update)?; + for h in [22, 25, 28] { + assert_eq!(chain.get(h).map(|cp| cp.height()), Some(h)); + } + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_skip_already_known() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(10, None)?; + + let blocks: Vec<(u32, Header)> = (0..=5) + .map(|h| Ok((h, header_at(&env, h)?))) + .collect::>()?; + let mut chain = local_chain_from_headers(&blocks); + + let update = + fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), [3, 4, 5, 6])?; + + assert!(update.get(6).is_some()); + + let changeset = chain.apply_update(update)?; + assert_eq!(changeset.blocks.get(&3), None); + assert_eq!(changeset.blocks.get(&4), None); + assert_eq!(changeset.blocks.get(&5), None); + assert!(changeset.blocks.contains_key(&6)); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_no_op_empty_changeset() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(5, None)?; + + let blocks: Vec<(u32, Header)> = (0..=3) + .map(|h| Ok((h, header_at(&env, h)?))) + .collect::>()?; + let chain = local_chain_from_headers(&blocks); + + let update = + fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), [1, 2, 3])?; + + assert_eq!(update, chain.tip()); + + let mut chain = chain; + let changeset = chain.apply_update(update)?; + assert_eq!(changeset, ChangeSet::default()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_mtp_recovery() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(30, None)?; + + let genesis = header_at(&env, 0)?; + let h21 = header_at(&env, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + assert_eq!(chain.get(21).unwrap().median_time_past(), None); + + let missing: Vec = (11..=21).collect(); + let update = fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), missing)?; + + chain.apply_update(update)?; + assert!(chain.get(21).unwrap().median_time_past().is_some()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_sparse_gap_bridge() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(10, None)?; + + let blocks = [ + (0, header_at(&env, 0)?), + (1, header_at(&env, 1)?), + (5, header_at(&env, 5)?), + ]; + let mut chain = local_chain_from_headers(&blocks); + + let update = fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), [4])?; + + assert!(update.get(4).is_some()); + assert!(update.get(5).is_some()); + + chain.apply_update(update)?; + assert_eq!(chain.get(4).map(|cp| cp.height()), Some(4)); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_height_above_tip_errors() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(5, None)?; + + let genesis = header_at(&env, 0)?; + let chain = local_chain_from_headers(&[(0, genesis)]); + let tip = env.rpc_client().get_block_count()?.into_model().0 as u32; + + let err = fetch_headers_at_heights(&ClientExt::get_rpc_client(&env)?, chain.tip(), [tip + 100]) + .unwrap_err(); + + assert!(matches!(err, FetchHeadersError::Rpc(_))); + + Ok(()) +} diff --git a/crates/core/src/fetch_headers_update.rs b/crates/core/src/fetch_headers_update.rs new file mode 100644 index 000000000..3f8bf6a5c --- /dev/null +++ b/crates/core/src/fetch_headers_update.rs @@ -0,0 +1,101 @@ +//! Helpers for building sparse [`CheckPoint
`] updates from on-demand height fetches. + +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::vec::Vec; + +use bitcoin::block::Header; + +use crate::CheckPoint; + +/// For each requested height, the nearest local checkpoint strictly above it (if any). +/// +/// Bridge blocks connect sparse gaps so [`bdk_chain::local_chain::LocalChain::apply_update`] +/// can merge the update unambiguously. +pub fn bridge_heights( + local_tip: &CheckPoint
, + agreement_height: u32, + requested_heights: &[u32], +) -> BTreeSet { + let mut bridges = BTreeSet::new(); + for &h in requested_heights { + for local_cp in local_tip.iter() { + let lh = local_cp.height(); + if lh > h && lh > agreement_height { + bridges.insert(lh); + break; + } + } + } + bridges +} + +/// Assemble a sparse header checkpoint update after the backend has resolved agreement and fetches. +/// +/// `conflicts` must be in descending height order (as collected from an agreement walk from tip). +pub fn build_fetch_headers_update( + agreement: CheckPoint
, + conflicts: Vec<(u32, Header)>, + fetched: BTreeMap, + local_tip: &CheckPoint
, + bridge_heights: &BTreeSet, +) -> CheckPoint
{ + let mut tip = agreement; + + if !conflicts.is_empty() { + tip = tip + .extend(conflicts.into_iter().rev()) + .expect("conflict headers must be in ascending height order"); + } + + for (height, header) in fetched { + if tip.get(height).is_none() { + tip = tip.insert(height, header); + } + } + + for &height in bridge_heights { + if tip.get(height).is_none() { + if let Some(cp) = local_tip.get(height) { + tip = tip.insert(height, cp.data()); + } + } + } + + tip +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::block::Header; + use bitcoin::hashes::Hash; + use bitcoin::BlockHash; + use bitcoin::CompactTarget; + + fn dummy_header(prev: BlockHash, n: u32) -> Header { + Header { + version: bitcoin::block::Version::from_consensus(1), + prev_blockhash: prev, + merkle_root: bitcoin::hashes::Hash::hash(&n.to_le_bytes()), + time: 1_000 + n, + bits: CompactTarget::from_consensus(0), + nonce: n, + } + } + + fn sparse_local_chain() -> CheckPoint
{ + let h0 = dummy_header(BlockHash::all_zeros(), 0); + let h1 = dummy_header(h0.block_hash(), 1); + let h5 = dummy_header(h1.block_hash(), 5); + CheckPoint::new(0, h0) + .insert(1, h1) + .insert(5, h5) + } + + #[test] + fn bridge_heights_picks_nearest_above() { + let local = sparse_local_chain(); + let bridges = bridge_heights(&local, 1, &[4]); + assert_eq!(bridges, BTreeSet::from([5])); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3d9b47aa5..9d67daa6f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -65,6 +65,9 @@ pub use block_id::*; mod checkpoint; pub use checkpoint::*; +mod fetch_headers_update; +pub use fetch_headers_update::*; + mod checkpoint_entry; pub use checkpoint_entry::*; diff --git a/crates/electrum/src/bdk_electrum_client.rs b/crates/electrum/src/bdk_electrum_client.rs index a7c943150..70880cbba 100644 --- a/crates/electrum/src/bdk_electrum_client.rs +++ b/crates/electrum/src/bdk_electrum_client.rs @@ -4,7 +4,7 @@ use bdk_core::{ opcodes::{all::OP_RETURN, OP_FALSE}, BlockHash, OutPoint, Transaction, Txid, }, - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, spk_client::{ FullScanRequest, FullScanResponse, SpkWithExpectedTxids, SyncRequest, SyncResponse, }, @@ -101,6 +101,88 @@ impl BdkElectrumClient { self.inner.transaction_broadcast(tx) } + /// Fetch a reorg-aware [`CheckPoint
`] update covering `heights`. + /// + /// Heights already present in `local_tip` are not fetched from the network. When every requested + /// height is already known and no reorg is detected, returns `local_tip` unchanged. + pub fn fetch_headers_at_heights( + &self, + local_tip: CheckPoint
, + heights: impl IntoIterator, + ) -> Result, Error> { + use bdk_core::{bridge_heights, build_fetch_headers_update}; + + let requested: BTreeSet = heights.into_iter().collect(); + let requested_vec: Vec = requested.iter().copied().collect(); + let missing: Vec = requested + .iter() + .copied() + .filter(|h| local_tip.get(*h).is_none()) + .collect(); + + let HeaderNotification { height, .. } = self.inner.block_headers_subscribe()?; + let tip_height = height as u32; + + let mut point_of_agreement = None; + let mut conflicts = Vec::new(); + + for local_cp in local_tip.iter() { + let h = local_cp.height(); + if h > tip_height { + continue; + } + let remote_header = self.fetch_header_at_height(h)?; + if remote_header.block_hash() == local_cp.hash() { + point_of_agreement = Some(local_cp); + break; + } + conflicts.push((h, remote_header)); + } + + let agreement = point_of_agreement + .ok_or_else(|| Error::Message("cannot find agreement block with server".to_string()))?; + + let reorg_detected = !conflicts.is_empty(); + if missing.is_empty() && !reorg_detected { + return Ok(local_tip); + } + + let mut fetched = BTreeMap::new(); + if !missing.is_empty() { + let headers = self.inner.batch_block_header(missing.iter().copied())?; + if headers.len() != missing.len() { + return Err(Error::Message( + "electrum batch_block_header returned unexpected count".to_string(), + )); + } + let mut cache = self.block_header_cache.lock().unwrap(); + for (&height, header) in missing.iter().zip(headers) { + if height > tip_height { + return Err(Error::Message(format!( + "height {height} above electrum tip {tip_height}" + ))); + } + cache.insert(height, header); + fetched.insert(height, header); + } + } + + let bridges = bridge_heights(&local_tip, agreement.height(), &requested_vec); + Ok(build_fetch_headers_update( + agreement, conflicts, fetched, &local_tip, &bridges, + )) + } + + /// Fetch a single block header at `height`, using the internal cache when valid. + fn fetch_header_at_height(&self, height: u32) -> Result { + let header = self.inner.block_header(height as usize)?; + self.block_header_cache + .lock() + .unwrap() + .insert(height, header); + Ok(header) + } + /// Full scan the keychain scripts specified with the blockchain (via an Electrum client) and /// returns updates for [`bdk_chain`] data structures. /// diff --git a/crates/electrum/tests/test_fetch_headers.rs b/crates/electrum/tests/test_fetch_headers.rs new file mode 100644 index 000000000..700d56138 --- /dev/null +++ b/crates/electrum/tests/test_fetch_headers.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeMap; + +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_core::bitcoin::block::Header; +use bdk_electrum::BdkElectrumClient; +use bdk_testenv::{anyhow, TestEnv}; +use electrum_client::ElectrumApi; + +fn setup(blocks: usize) -> anyhow::Result<(TestEnv, BdkElectrumClient)> { + let env = TestEnv::new()?; + env.mine_blocks(blocks, None)?; + let env = env.reset_electrsd()?; + let client = electrum_client(&env)?; + Ok((env, client)) +} + +fn header_at( + client: &BdkElectrumClient, + height: u32, +) -> anyhow::Result
{ + Ok(client.inner.block_header(height as usize)?) +} + +fn local_chain_from_headers(blocks: &[(u32, Header)]) -> LocalChain
{ + let map: BTreeMap = blocks.iter().copied().collect(); + LocalChain::from_blocks(map).expect("valid sparse chain") +} + +fn electrum_client(env: &TestEnv) -> anyhow::Result> { + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + Ok(BdkElectrumClient::new(electrum_client)) +} + +#[test] +fn fetch_headers_at_heights_exact_heights() -> anyhow::Result<()> { + let (_env, client) = setup(30)?; + + let genesis = header_at(&client, 0)?; + let h21 = header_at(&client, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + let update = client.fetch_headers_at_heights(chain.tip(), [22, 25, 28])?; + + for h in [22, 25, 28] { + assert!(update.get(h).is_some(), "update must contain height {h}"); + } + + chain.apply_update(update)?; + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_skip_already_known() -> anyhow::Result<()> { + let (_env, client) = setup(10)?; + + let blocks: Vec<(u32, Header)> = (0..=5) + .map(|h| Ok((h, header_at(&client, h)?))) + .collect::>()?; + let mut chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [3, 4, 5, 6])?; + let changeset = chain.apply_update(update)?; + assert!(changeset.blocks.contains_key(&6)); + assert_eq!(changeset.blocks.get(&3), None); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_no_op_empty_changeset() -> anyhow::Result<()> { + let (_env, client) = setup(5)?; + + let blocks: Vec<(u32, Header)> = (0..=3) + .map(|h| Ok((h, header_at(&client, h)?))) + .collect::>()?; + let chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [1, 2, 3])?; + assert_eq!(update, chain.tip()); + + let mut chain = chain; + let changeset = chain.apply_update(update)?; + assert_eq!(changeset, ChangeSet::default()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_mtp_recovery() -> anyhow::Result<()> { + let (_env, client) = setup(30)?; + + let genesis = header_at(&client, 0)?; + let h21 = header_at(&client, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + assert_eq!(chain.get(21).unwrap().median_time_past(), None); + + let update = client.fetch_headers_at_heights(chain.tip(), (11..=21).collect::>())?; + chain.apply_update(update)?; + assert!(chain.get(21).unwrap().median_time_past().is_some()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_sparse_gap_bridge() -> anyhow::Result<()> { + let (_env, client) = setup(10)?; + + let blocks = [ + (0, header_at(&client, 0)?), + (1, header_at(&client, 1)?), + (5, header_at(&client, 5)?), + ]; + let mut chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [4])?; + assert!(update.get(4).is_some()); + assert!(update.get(5).is_some()); + + chain.apply_update(update)?; + assert_eq!(chain.get(4).map(|cp| cp.height()), Some(4)); + + Ok(()) +} diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index 2b3828952..37cbe432d 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -49,6 +49,13 @@ pub trait EsploraAsyncExt { request: R, parallel_requests: usize, ) -> Result; + + /// Fetch a reorg-aware [`CheckPoint
`] update covering `heights`. + async fn fetch_headers_at_heights( + &self, + local_tip: CheckPoint, + heights: impl IntoIterator + Send, + ) -> Result, Error>; } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -173,6 +180,17 @@ where tx_update, }) } + + async fn fetch_headers_at_heights( + &self, + local_tip: CheckPoint, + heights: impl IntoIterator + Send, + ) -> Result, Error> { + crate::headers_at_heights::async_impl::fetch_headers_at_heights_async( + self, local_tip, heights, + ) + .await + } } /// Fetch latest blocks from Esplora in an atomic call. diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 225b574ea..ef6cd0ed6 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -46,6 +46,13 @@ pub trait EsploraExt { request: R, parallel_requests: usize, ) -> Result; + + /// Fetch a reorg-aware [`CheckPoint
`] update covering `heights`. + fn fetch_headers_at_heights( + &self, + local_tip: CheckPoint, + heights: impl IntoIterator, + ) -> Result, Error>; } impl EsploraExt for esplora_client::BlockingClient { @@ -159,6 +166,14 @@ impl EsploraExt for esplora_client::BlockingClient { tx_update, }) } + + fn fetch_headers_at_heights( + &self, + local_tip: CheckPoint, + heights: impl IntoIterator, + ) -> Result, Error> { + crate::headers_at_heights::fetch_headers_at_heights_blocking(self, local_tip, heights) + } } /// Fetch latest blocks from Esplora in an atomic call. diff --git a/crates/esplora/src/headers_at_heights.rs b/crates/esplora/src/headers_at_heights.rs new file mode 100644 index 000000000..7a39f1fd0 --- /dev/null +++ b/crates/esplora/src/headers_at_heights.rs @@ -0,0 +1,166 @@ +//! On-demand header fetch at caller-requested heights. + +use bdk_core::bitcoin::block::Header; +use bdk_core::collections::{BTreeMap, BTreeSet}; +use bdk_core::{bridge_heights, build_fetch_headers_update, CheckPoint}; + +type Error = Box; + +/// Fetch a reorg-aware [`CheckPoint
`] update covering `heights` (blocking client). +pub fn fetch_headers_at_heights_blocking( + client: &esplora_client::BlockingClient, + local_tip: CheckPoint
, + heights: impl IntoIterator, +) -> Result, Error> { + let requested: BTreeSet = heights.into_iter().collect(); + let requested_vec: Vec = requested.iter().copied().collect(); + let missing: Vec = requested + .iter() + .copied() + .filter(|h| local_tip.get(*h).is_none()) + .collect(); + + let latest_blocks = client.get_block_infos(None)?; + let tip_height = latest_blocks + .iter() + .map(|b| b.height) + .max() + .ok_or_else(|| Box::new(esplora_client::Error::HeaderHashNotFound(local_tip.hash())))?; + + let mut point_of_agreement = None; + let mut conflicts = Vec::new(); + + for local_cp in local_tip.iter() { + let height = local_cp.height(); + if height > tip_height { + continue; + } + let remote_header = fetch_header_at_height_blocking(client, height)?; + if remote_header.block_hash() == local_cp.hash() { + point_of_agreement = Some(local_cp); + break; + } + conflicts.push((height, remote_header)); + } + + let agreement = match point_of_agreement { + Some(cp) => cp, + None => { + return Err(Box::new(esplora_client::Error::HeaderHashNotFound( + local_tip.hash(), + ))); + } + }; + + let reorg_detected = !conflicts.is_empty(); + if missing.is_empty() && !reorg_detected { + return Ok(local_tip); + } + + let mut fetched = BTreeMap::new(); + for &height in &missing { + if height > tip_height { + return Err(Box::new(esplora_client::Error::HeaderHeightNotFound( + height, + ))); + } + let header = fetch_header_at_height_blocking(client, height)?; + fetched.insert(height, header); + } + + let bridges = bridge_heights(&local_tip, agreement.height(), &requested_vec); + Ok(build_fetch_headers_update( + agreement, conflicts, fetched, &local_tip, &bridges, + )) +} + +fn fetch_header_at_height_blocking( + client: &esplora_client::BlockingClient, + height: u32, +) -> Result { + let hash = client.get_block_hash(height)?; + Ok(client.get_header_by_hash(&hash)?) +} + +#[cfg(feature = "async")] +pub mod async_impl { + use super::*; + + use esplora_client::Sleeper; + + /// Fetch a reorg-aware [`CheckPoint
`] update covering `heights` (async client). + pub async fn fetch_headers_at_heights_async( + client: &esplora_client::AsyncClient, + local_tip: CheckPoint
, + heights: impl IntoIterator, + ) -> Result, Error> { + let requested: BTreeSet = heights.into_iter().collect(); + let requested_vec: Vec = requested.iter().copied().collect(); + let missing: Vec = requested + .iter() + .copied() + .filter(|h| local_tip.get(*h).is_none()) + .collect(); + + let latest_blocks = client.get_block_infos(None).await?; + let tip_height = latest_blocks + .iter() + .map(|b| b.height) + .max() + .ok_or_else(|| Box::new(esplora_client::Error::HeaderHashNotFound(local_tip.hash())))?; + + let mut point_of_agreement = None; + let mut conflicts = Vec::new(); + + for local_cp in local_tip.iter() { + let height = local_cp.height(); + if height > tip_height { + continue; + } + let remote_header = fetch_header_at_height_async(client, height).await?; + if remote_header.block_hash() == local_cp.hash() { + point_of_agreement = Some(local_cp); + break; + } + conflicts.push((height, remote_header)); + } + + let agreement = match point_of_agreement { + Some(cp) => cp, + None => { + return Err(Box::new(esplora_client::Error::HeaderHashNotFound( + local_tip.hash(), + ))); + } + }; + + let reorg_detected = !conflicts.is_empty(); + if missing.is_empty() && !reorg_detected { + return Ok(local_tip); + } + + let mut fetched = BTreeMap::new(); + for &height in &missing { + if height > tip_height { + return Err(Box::new(esplora_client::Error::HeaderHashNotFound( + local_tip.hash(), + ))); + } + let header = fetch_header_at_height_async(client, height).await?; + fetched.insert(height, header); + } + + let bridges = bridge_heights(&local_tip, agreement.height(), &requested_vec); + Ok(build_fetch_headers_update( + agreement, conflicts, fetched, &local_tip, &bridges, + )) + } + + async fn fetch_header_at_height_async( + client: &esplora_client::AsyncClient, + height: u32, + ) -> Result { + let hash = client.get_block_hash(height).await?; + Ok(client.get_header_by_hash(&hash).await?) + } +} diff --git a/crates/esplora/src/lib.rs b/crates/esplora/src/lib.rs index 60b4f1eb3..e32257ab3 100644 --- a/crates/esplora/src/lib.rs +++ b/crates/esplora/src/lib.rs @@ -32,6 +32,12 @@ mod blocking_ext; #[cfg(feature = "blocking")] pub use blocking_ext::*; +mod headers_at_heights; +#[cfg(feature = "blocking")] +pub use headers_at_heights::fetch_headers_at_heights_blocking; +#[cfg(feature = "async")] +pub use headers_at_heights::async_impl::fetch_headers_at_heights_async; + #[cfg(feature = "async")] mod async_ext; #[cfg(feature = "async")] diff --git a/crates/esplora/tests/test_fetch_headers.rs b/crates/esplora/tests/test_fetch_headers.rs new file mode 100644 index 000000000..233c58c1a --- /dev/null +++ b/crates/esplora/tests/test_fetch_headers.rs @@ -0,0 +1,150 @@ +use std::collections::BTreeMap; + +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_core::bitcoin::block::Header; +use bdk_esplora::EsploraExt; +use bdk_testenv::{anyhow, TestEnv}; +use esplora_client::Builder; + +fn header_at(client: &esplora_client::BlockingClient, height: u32) -> anyhow::Result
{ + let hash = client.get_block_hash(height)?; + Ok(client.get_header_by_hash(&hash)?) +} + +fn local_chain_from_headers(blocks: &[(u32, Header)]) -> LocalChain
{ + let map: BTreeMap = blocks.iter().copied().collect(); + LocalChain::from_blocks(map).expect("valid sparse chain") +} + +fn esplora_client(env: &TestEnv) -> anyhow::Result { + let base_url = format!("http://{}", env.electrsd.esplora_url.clone().unwrap()); + Ok(Builder::new(base_url.as_str()).build_blocking()) +} + +#[test] +fn fetch_headers_at_heights_exact_heights() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(30, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let genesis = header_at(&client, 0)?; + let h21 = header_at(&client, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + let update = client.fetch_headers_at_heights(chain.tip(), [22, 25, 28])?; + + for h in [22, 25, 28] { + assert!(update.get(h).is_some(), "update must contain height {h}"); + } + + chain.apply_update(update)?; + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_skip_already_known() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(10, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let blocks: Vec<(u32, Header)> = (0..=5) + .map(|h| Ok((h, header_at(&client, h)?))) + .collect::>()?; + let mut chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [3, 4, 5, 6])?; + let changeset = chain.apply_update(update)?; + assert!(changeset.blocks.contains_key(&6)); + assert_eq!(changeset.blocks.get(&3), None); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_no_op_empty_changeset() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(5, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let blocks: Vec<(u32, Header)> = (0..=3) + .map(|h| Ok((h, header_at(&client, h)?))) + .collect::>()?; + let chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [1, 2, 3])?; + assert_eq!(update, chain.tip()); + + let mut chain = chain; + let changeset = chain.apply_update(update)?; + assert_eq!(changeset, ChangeSet::default()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_mtp_recovery() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(30, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let genesis = header_at(&client, 0)?; + let h21 = header_at(&client, 21)?; + let mut chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + assert_eq!(chain.get(21).unwrap().median_time_past(), None); + + let update = client.fetch_headers_at_heights(chain.tip(), (11..=21).collect::>())?; + chain.apply_update(update)?; + assert!(chain.get(21).unwrap().median_time_past().is_some()); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_sparse_gap_bridge() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(10, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let blocks = [ + (0, header_at(&client, 0)?), + (1, header_at(&client, 1)?), + (5, header_at(&client, 5)?), + ]; + let mut chain = local_chain_from_headers(&blocks); + + let update = client.fetch_headers_at_heights(chain.tip(), [4])?; + assert!(update.get(4).is_some()); + assert!(update.get(5).is_some()); + + chain.apply_update(update)?; + assert_eq!(chain.get(4).map(|cp| cp.height()), Some(4)); + + Ok(()) +} + +#[test] +fn fetch_headers_at_heights_no_tip_suffix_padding() -> anyhow::Result<()> { + let env = TestEnv::new()?; + env.mine_blocks(50, None)?; + let env = env.reset_electrsd()?; + let client = esplora_client(&env)?; + + let genesis = header_at(&client, 0)?; + let h21 = header_at(&client, 21)?; + let chain = local_chain_from_headers(&[(0, genesis), (21, h21)]); + + let update = client.fetch_headers_at_heights(chain.tip(), [22])?; + let heights: BTreeMap = update.iter().map(|cp| (cp.height(), ())).collect(); + + // Must not include the electrs tip-suffix (~10 blocks) beyond what connects height 22. + assert!(heights.contains_key(&22)); + assert!(!heights.contains_key(&50)); + + Ok(()) +}