From 619391499573247c39fd989cacd54a27b00b55b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 24 Jan 2026 10:20:23 +0000 Subject: [PATCH 1/4] feat(testenv): add `mine_block` with custom timestamp and coinbase address Refactor block mining in `TestEnv` to use `getblocktemplate` RPC properly: - Add `MineParams` struct to configure mining (empty blocks, custom timestamp, custom coinbase address) - Add `mine_block()` method that builds blocks from the template with proper BIP34 coinbase scriptSig, witness commitment, and merkle root - Add `min_time_for_next_block()` and `get_block_template()` helpers - Refactor `mine_empty_block()` to use the new `mine_block()` API - Include mempool transactions when `empty: false` --- crates/testenv/src/lib.rs | 264 ++++++++++++++++++++++++++++++++------ 1 file changed, 227 insertions(+), 37 deletions(-) diff --git a/crates/testenv/src/lib.rs b/crates/testenv/src/lib.rs index 914200e99..f819b2a6a 100644 --- a/crates/testenv/src/lib.rs +++ b/crates/testenv/src/lib.rs @@ -3,9 +3,18 @@ pub mod utils; use anyhow::Context; +use bdk_chain::bitcoin::{ + block::Header, hash_types::TxMerkleNode, hex::FromHex, script::PushBytesBuf, transaction, + Address, Amount, Block, BlockHash, CompactTarget, ScriptBuf, Transaction, TxIn, TxOut, Txid, +}; use bdk_chain::CheckPoint; -use bitcoin::{address::NetworkChecked, Address, Amount, BlockHash, Txid}; -use std::time::Duration; +use bitcoin::address::NetworkChecked; +use bitcoin::consensus::Decodable; +use bitcoin::hex::HexToBytesError; +use core::str::FromStr; +use core::time::Duration; +use electrsd::corepc_node::vtype::GetBlockTemplate; +use electrsd::corepc_node::{TemplateRequest, TemplateRules}; pub use electrsd; pub use electrsd::corepc_client; @@ -45,6 +54,27 @@ impl Default for Config<'_> { } } +/// Parameters for [`TestEnv::mine_block`]. +#[non_exhaustive] +#[derive(Default)] +pub struct MineParams { + /// If `true`, the block will be empty (no mempool transactions). + pub empty: bool, + /// Set a custom block timestamp. Defaults to `max(min_time, now)`. + pub time: Option, + /// Set a custom coinbase output script. Defaults to `OP_TRUE`. + pub coinbase_address: Option, +} + +impl MineParams { + fn address_or_anyone_can_spend(&self) -> ScriptBuf { + self.coinbase_address + .clone() + // OP_TRUE (anyone can spend) + .unwrap_or(ScriptBuf::from_bytes(vec![0x51])) + } +} + impl TestEnv { /// Construct a new [`TestEnv`] instance with the default configuration used by BDK. pub fn new() -> anyhow::Result { @@ -119,53 +149,138 @@ impl TestEnv { Ok(block_hashes) } + /// Get a block template from the node. + pub fn get_block_template(&self) -> anyhow::Result { + use corepc_node::TemplateRequest; + Ok(self.bitcoind.client.get_block_template(&TemplateRequest { + rules: vec![ + TemplateRules::Segwit, + TemplateRules::Taproot, + TemplateRules::Csv, + ], + })?) + } + /// Mine a block that is guaranteed to be empty even with transactions in the mempool. #[cfg(feature = "std")] pub fn mine_empty_block(&self) -> anyhow::Result<(usize, BlockHash)> { - use bitcoin::secp256k1::rand::random; - use bitcoin::{ - block::Header, hashes::Hash, transaction, Block, ScriptBuf, ScriptHash, Transaction, - TxIn, TxMerkleNode, TxOut, - }; - use corepc_node::{TemplateRequest, TemplateRules}; - let request = TemplateRequest { - rules: vec![TemplateRules::Segwit], - }; - let bt = self + self.mine_block(MineParams { + empty: true, + ..Default::default() + }) + } + + /// Get the minimum valid timestamp for the next block. + pub fn min_time_for_next_block(&self) -> anyhow::Result { + Ok(self .bitcoind .client - .get_block_template(&request)? - .into_model()?; + .get_block_template(&TemplateRequest { + rules: vec![ + TemplateRules::Segwit, + TemplateRules::Taproot, + TemplateRules::Csv, + ], + })? + .min_time) + } + + /// Mine a single block with the given [`MineParams`]. + pub fn mine_block(&self, params: MineParams) -> anyhow::Result<(usize, BlockHash)> { + let bt = self.bitcoind.client.get_block_template(&TemplateRequest { + rules: vec![ + TemplateRules::Segwit, + TemplateRules::Taproot, + TemplateRules::Csv, + ], + })?; + let coinbase_scriptsig = { + // BIP34 requires the height to be the first item in coinbase scriptSig. + // Bitcoin Core validates by checking if scriptSig STARTS with the expected + // encoding (using minimal opcodes like OP_1 for height 1). + // The scriptSig must also be 2-100 bytes total. + let mut builder = bdk_chain::bitcoin::script::Builder::new().push_int(bt.height); + for v in bt.coinbase_aux.values() { + let bytes = Vec::::from_hex(v).expect("must be valid hex"); + let bytes_buf = PushBytesBuf::try_from(bytes).expect("must be valid bytes"); + builder = builder.push_slice(bytes_buf); + } + let script = builder.into_script(); + // Ensure scriptSig is at least 2 bytes (pad with OP_0 if needed) + if script.len() < 2 { + bdk_chain::bitcoin::script::Builder::new() + .push_int(bt.height) + .push_opcode(bdk_chain::bitcoin::opcodes::OP_0) + .into_script() + } else { + script + } + }; - let txdata = vec![Transaction { + let coinbase_outputs = if params.empty { + let value: Amount = Amount::from_sat( + (bt.coinbase_value - bt.transactions.iter().map(|tx| tx.fee).sum::()) as u64, + ); + vec![TxOut { + value, + script_pubkey: params.address_or_anyone_can_spend(), + }] + } else { + core::iter::once(TxOut { + value: Amount::from_sat(bt.coinbase_value.try_into().expect("must fit into u64")), + script_pubkey: params.address_or_anyone_can_spend(), + }) + .chain( + bt.default_witness_commitment + .map(|s| -> Result<_, HexToBytesError> { + Ok(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_hex(&s)?, + }) + }) + .transpose()?, + ) + .collect() + }; + + let coinbase_tx = Transaction { version: transaction::Version::ONE, lock_time: bdk_chain::bitcoin::absolute::LockTime::from_height(0)?, input: vec![TxIn { previous_output: bdk_chain::bitcoin::OutPoint::default(), - script_sig: ScriptBuf::builder() - .push_int(bt.height as _) - // random number so that re-mining creates unique block - .push_int(random()) - .into_script(), + script_sig: coinbase_scriptsig, sequence: bdk_chain::bitcoin::Sequence::default(), witness: bdk_chain::bitcoin::Witness::new(), }], - output: vec![TxOut { - value: Amount::ZERO, - script_pubkey: ScriptBuf::new_p2sh(&ScriptHash::all_zeros()), - }], - }]; + output: coinbase_outputs, + }; + + let txdata = if params.empty { + vec![coinbase_tx] + } else { + core::iter::once(coinbase_tx) + .chain(bt.transactions.iter().map(|tx| { + let raw = Vec::::from_hex(&tx.data).expect("must be valid hex"); + Transaction::consensus_decode(&mut raw.as_slice()).expect("must decode tx") + })) + .collect() + }; let mut block = Block { header: Header { - version: bt.version, - prev_blockhash: bt.previous_block_hash, - merkle_root: TxMerkleNode::all_zeros(), - time: Ord::max( + version: bdk_chain::bitcoin::blockdata::block::Version::from_consensus(bt.version), + prev_blockhash: BlockHash::from_str(&bt.previous_block_hash)?, + merkle_root: TxMerkleNode::from_raw_hash( + bdk_chain::bitcoin::merkle_tree::calculate_root( + txdata.iter().map(|tx| tx.compute_txid().to_raw_hash()), + ) + .expect("must have atleast one tx"), + ), + time: params.time.unwrap_or(Ord::max( bt.min_time, std::time::UNIX_EPOCH.elapsed()?.as_secs() as u32, - ), - bits: bt.bits, + )), + bits: CompactTarget::from_unprefixed_hex(&bt.bits)?, nonce: 0, }, txdata, @@ -173,16 +288,18 @@ impl TestEnv { block.header.merkle_root = block.compute_merkle_root().expect("must compute"); + // Mine! + let target = block.header.target(); for nonce in 0..=u32::MAX { block.header.nonce = nonce; - if block.header.target().is_met_by(block.block_hash()) { - break; + let blockhash = block.block_hash(); + if target.is_met_by(blockhash) { + self.rpc_client().submit_block(&block)?; + return Ok((bt.height as usize, blockhash)); } } - self.bitcoind.client.submit_block(&block)?; - - Ok((bt.height as usize, block.block_hash())) + Err(anyhow::anyhow!("Cannot find nonce that meets the target")) } /// This method waits for the Electrum notification indicating that a new block has been mined. @@ -318,9 +435,11 @@ impl TestEnv { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { - use crate::TestEnv; + use crate::{MineParams, TestEnv}; + use bdk_chain::bitcoin::{Amount, ScriptBuf}; use core::time::Duration; use electrsd::corepc_node::anyhow::Result; + use std::collections::BTreeSet; /// This checks that reorgs initiated by `bitcoind` is detected by our `electrsd` instance. #[test] @@ -355,4 +474,75 @@ mod test { Ok(()) } + + #[test] + fn test_mine_block() -> Result<()> { + let anyone_can_spend = ScriptBuf::from_bytes(vec![0x51]); + + let env = TestEnv::new()?; + + // So we can spend. + let addr = env + .rpc_client() + .get_new_address(None, None)? + .address()? + .assume_checked(); + env.mine_blocks(100, Some(addr.clone()))?; + + // Try mining a block with custom time. + let custom_time = env.min_time_for_next_block()? + 100; + let (_a_height, a_hash) = env.mine_block(MineParams { + empty: false, + time: Some(custom_time), + coinbase_address: None, + })?; + let a_block = env.rpc_client().get_block(a_hash)?; + assert_eq!(a_block.header.time, custom_time); + assert_eq!( + a_block.txdata[0].output[0].script_pubkey, anyone_can_spend, + "Subsidy address must be anyone_can_spend" + ); + + // Now try mining with min time & some txs. + let txid1 = env.send(&addr, Amount::from_sat(100_000))?; + let txid2 = env.send(&addr, Amount::from_sat(200_000))?; + let txid3 = env.send(&addr, Amount::from_sat(300_000))?; + let min_time = env.min_time_for_next_block()?; + let (_b_height, b_hash) = env.mine_block(MineParams { + empty: false, + time: Some(min_time), + coinbase_address: None, + })?; + let b_block = env.rpc_client().get_block(b_hash)?; + assert_eq!(b_block.header.time, min_time); + assert_eq!( + a_block.txdata[0].output[0].script_pubkey, anyone_can_spend, + "Subsidy address must be anyone_can_spend" + ); + assert_eq!( + b_block + .txdata + .iter() + .skip(1) // ignore coinbase + .map(|tx| tx.compute_txid()) + .collect::>(), + [txid1, txid2, txid3].into_iter().collect(), + "Must have all txs" + ); + + // Custom subsidy address. + let (_c_height, c_hash) = env.mine_block(MineParams { + empty: false, + time: None, + coinbase_address: Some(addr.script_pubkey()), + })?; + let c_block = env.rpc_client().get_block(c_hash)?; + assert_eq!( + c_block.txdata[0].output[0].script_pubkey, + addr.script_pubkey(), + "Custom address works" + ); + + Ok(()) + } } From 47d7284ab0470708577ffe9df6a4afbe94bc9751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 26 Jan 2026 08:12:49 +0000 Subject: [PATCH 2/4] feat(core): Introduce `FromBlockHeader` trait This allows us to use any subset of a `Header` to construct checkpoint data. Currently, only `BlockHash` and `Header` implement this. Chain sources can bound the checkpoint data generic to this trait, so all checkpoint data types that implement `FromBlockHeader` is supported by the chain source. --- crates/core/src/checkpoint.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/core/src/checkpoint.rs b/crates/core/src/checkpoint.rs index 0a394122a..36bd8cbf9 100644 --- a/crates/core/src/checkpoint.rs +++ b/crates/core/src/checkpoint.rs @@ -55,6 +55,24 @@ impl Drop for CPInner { } } +/// Trait that converts [`Header`] to subset data. +pub trait FromBlockHeader { + /// Returns the subset data from a block `header`. + fn from_blockheader(header: Header) -> Self; +} + +impl FromBlockHeader for BlockHash { + fn from_blockheader(header: Header) -> Self { + header.block_hash() + } +} + +impl FromBlockHeader for Header { + fn from_blockheader(header: Header) -> Self { + header + } +} + /// Trait that converts [`CheckPoint`] `data` to [`BlockHash`]. /// /// Implementations of [`ToBlockHash`] must always return the block's consensus-defined hash. If @@ -204,7 +222,7 @@ impl CheckPoint { // Methods where `D: ToBlockHash` impl CheckPoint where - D: ToBlockHash + fmt::Debug + Copy, + D: ToBlockHash + fmt::Debug + Clone, { const MTP_BLOCK_COUNT: u32 = 11; From 96086c6f47ecd54cdccab89b63b2e726707ab5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 26 Jan 2026 11:52:31 +0000 Subject: [PATCH 3/4] feat(bitcoind_rpc)!: Emitters support different checkpoint data types --- crates/bitcoind_rpc/src/bip158.rs | 141 +++++++++++------- crates/bitcoind_rpc/src/lib.rs | 71 ++++++--- crates/bitcoind_rpc/tests/test_filter_iter.rs | 4 +- 3 files changed, 132 insertions(+), 84 deletions(-) diff --git a/crates/bitcoind_rpc/src/bip158.rs b/crates/bitcoind_rpc/src/bip158.rs index 81963def6..15387b68b 100644 --- a/crates/bitcoind_rpc/src/bip158.rs +++ b/crates/bitcoind_rpc/src/bip158.rs @@ -6,8 +6,13 @@ //! [0]: https://github.com/bitcoin/bips/blob/master/bip-0157.mediawiki //! [1]: https://github.com/bitcoin/bips/blob/master/bip-0158.mediawiki +use core::fmt::Debug; + use bdk_core::bitcoin; use bdk_core::CheckPoint; +use bdk_core::FromBlockHeader; +use bdk_core::ToBlockHash; +use bitcoin::block::Header; use bitcoin::BlockHash; use bitcoin::{bip158::BlockFilter, Block, ScriptBuf}; use bitcoincore_rpc; @@ -29,22 +34,25 @@ use bitcoincore_rpc::{json::GetBlockHeaderResult, RpcApi}; /// Events contain the updated checkpoint `cp` which may be incorporated into the local chain /// state to stay in sync with the tip. #[derive(Debug)] -pub struct FilterIter<'a> { +pub struct FilterIter<'a, D = BlockHash> { /// RPC client client: &'a bitcoincore_rpc::Client, /// SPK inventory spks: Vec, /// checkpoint - cp: CheckPoint, + cp: CheckPoint, /// Header info, contains the prev and next hashes for each header. header: Option, } -impl<'a> FilterIter<'a> { +impl<'a, D> FilterIter<'a, D> +where + D: ToBlockHash + Clone + Debug, +{ /// Construct [`FilterIter`] with checkpoint, RPC client and SPKs. pub fn new( client: &'a bitcoincore_rpc::Client, - cp: CheckPoint, + cp: CheckPoint, spks: impl IntoIterator, ) -> Self { Self { @@ -69,13 +77,76 @@ impl<'a> FilterIter<'a> { } Err(Error::ReorgDepthExceeded) } + + fn try_next_with(&mut self, to_data: F) -> Result>, Error> + where + F: Fn(Header) -> D, + { + 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()?, + }; + + let mut next_hash = match header.next_block_hash { + Some(hash) => hash, + None => return Ok(None), + }; + + 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; + } + + next_hash = next_header.hash; + let next_height: u32 = next_header.height.try_into()?; + + cp = cp.insert( + next_height, + to_data(self.client.get_block_header(&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)?); + } + + // Store the next header + self.header = Some(next_header); + // Update self.cp + self.cp = cp.clone(); + + Ok(Some(Event { cp, block })) + } + + /// Get the next event with a custom checkpoint data type. + pub fn next_with(&mut self, to_data: F) -> Option, Error>> + where + F: Fn(Header) -> D, + { + self.try_next_with(to_data).transpose() + } } /// Event returned by [`FilterIter`]. #[derive(Debug, Clone)] -pub struct Event { +pub struct Event { /// Checkpoint - pub cp: CheckPoint, + pub cp: CheckPoint, /// Block, will be `Some(..)` for matching blocks pub block: Option, } @@ -92,60 +163,14 @@ impl Event { } } -impl Iterator for FilterIter<'_> { - type Item = Result; +impl Iterator for FilterIter<'_, D> +where + D: ToBlockHash + FromBlockHeader + Clone + Debug, +{ + type Item = Result, Error>; fn next(&mut self) -> Option { - (|| -> Result, 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()?, - }; - - let mut next_hash = match header.next_block_hash { - Some(hash) => hash, - None => return Ok(None), - }; - - 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; - } - - next_hash = next_header.hash; - let next_height: u32 = next_header.height.try_into()?; - - cp = cp.insert(next_height, 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)?); - } - - // Store the next header - self.header = Some(next_header); - // Update self.cp - self.cp = cp.clone(); - - Ok(Some(Event { cp, block })) - })() - .transpose() + self.try_next_with(D::from_blockheader).transpose() } } diff --git a/crates/bitcoind_rpc/src/lib.rs b/crates/bitcoind_rpc/src/lib.rs index 06c4fe0aa..de9a90fa8 100644 --- a/crates/bitcoind_rpc/src/lib.rs +++ b/crates/bitcoind_rpc/src/lib.rs @@ -16,10 +16,11 @@ extern crate alloc; use alloc::sync::Arc; use bdk_core::collections::{HashMap, HashSet}; -use bdk_core::{BlockId, CheckPoint}; +use bdk_core::{BlockId, CheckPoint, FromBlockHeader, ToBlockHash}; use bitcoin::{Block, BlockHash, Transaction, Txid}; use bitcoincore_rpc::{bitcoincore_rpc_json, RpcApi}; use core::ops::Deref; +use std::fmt::Debug; pub mod bip158; @@ -30,13 +31,13 @@ pub use bitcoincore_rpc; /// Refer to [module-level documentation] for more. /// /// [module-level documentation]: crate -pub struct Emitter { +pub struct Emitter { client: C, start_height: u32, /// The checkpoint of the last-emitted block that is in the best chain. If it is later found /// that the block is no longer in the best chain, it will be popped off from here. - last_cp: CheckPoint, + last_cp: CheckPoint, /// The block result returned from rpc of the last-emitted block. As this result contains the /// next block's block hash (which we use to fetch the next block), we set this to `None` @@ -62,10 +63,11 @@ pub struct Emitter { /// to start empty (i.e. with no unconfirmed transactions). pub const NO_EXPECTED_MEMPOOL_TXS: core::iter::Empty> = core::iter::empty(); -impl Emitter +impl Emitter where C: Deref, C::Target: RpcApi, + D: ToBlockHash + Clone + Debug, { /// Construct a new [`Emitter`]. /// @@ -80,7 +82,7 @@ where /// If it is known that the wallet is empty, [`NO_EXPECTED_MEMPOOL_TXS`] can be used. pub fn new( client: C, - last_cp: CheckPoint, + last_cp: CheckPoint, start_height: u32, expected_mempool_txs: impl IntoIterator>>, ) -> Self { @@ -198,9 +200,18 @@ where Ok(mempool_event) } - /// Emit the next block height and block (if any). - pub fn next_block(&mut self) -> Result>, bitcoincore_rpc::Error> { - if let Some((checkpoint, block)) = poll(self, move |hash, client| client.get_block(hash))? { + /// Emit the next block, using `to_data` to construct checkpoint data from the block. + /// + /// This is the alternative to [`next_block`](Self::next_block) when [`FromBlockHeader`] isn't + /// implemented for `D`. + pub fn next_block_with( + &mut self, + to_data: F, + ) -> Result>, bitcoincore_rpc::Error> + where + F: Fn(&Block) -> D, + { + if let Some((checkpoint, block)) = poll(self, to_data)? { // Stop tracking unconfirmed transactions that have been confirmed in this block. for tx in &block.txdata { self.mempool_snapshot.remove(&tx.compute_txid()); @@ -209,6 +220,14 @@ where } Ok(None) } + + /// Emit the next block height and block (if any). + pub fn next_block(&mut self) -> Result>, bitcoincore_rpc::Error> + where + D: FromBlockHeader, + { + self.next_block_with(|block| D::from_blockheader(block.header)) + } } /// A new emission from mempool. @@ -223,7 +242,7 @@ pub struct MempoolEvent { /// A newly emitted block from [`Emitter`]. #[derive(Debug)] -pub struct BlockEvent { +pub struct BlockEvent { /// The block. pub block: B, @@ -235,10 +254,10 @@ pub struct BlockEvent { /// /// This is important as BDK structures require block-to-apply to be connected with another /// block in the original chain. - pub checkpoint: CheckPoint, + pub checkpoint: CheckPoint, } -impl BlockEvent { +impl BlockEvent { /// The block height of this new block. pub fn block_height(&self) -> u32 { self.checkpoint.height() @@ -264,17 +283,17 @@ impl BlockEvent { } } -enum PollResponse { +enum PollResponse { Block(bitcoincore_rpc_json::GetBlockResult), NoMoreBlocks, /// Fetched block is not in the best chain. BlockNotInBestChain, - AgreementFound(bitcoincore_rpc_json::GetBlockResult, CheckPoint), + AgreementFound(bitcoincore_rpc_json::GetBlockResult, CheckPoint), /// Force the genesis checkpoint down the receiver's throat. AgreementPointNotFound(BlockHash), } -fn poll_once(emitter: &Emitter) -> Result +fn poll_once(emitter: &Emitter) -> Result, bitcoincore_rpc::Error> where C: Deref, C::Target: RpcApi, @@ -328,30 +347,32 @@ where Ok(PollResponse::AgreementPointNotFound(genesis_hash)) } -fn poll( - emitter: &mut Emitter, - get_item: F, -) -> Result, V)>, bitcoincore_rpc::Error> +fn poll( + emitter: &mut Emitter, + to_cp_data: F, +) -> Result, Block)>, bitcoincore_rpc::Error> where C: Deref, C::Target: RpcApi, - F: Fn(&BlockHash, &C::Target) -> Result, + D: ToBlockHash + Clone + Debug, + F: Fn(&Block) -> D, { + let client = &emitter.client; loop { match poll_once(emitter)? { PollResponse::Block(res) => { let height = res.height as u32; - let hash = res.hash; - let item = get_item(&hash, &emitter.client)?; + let block = client.get_block(&res.hash)?; + let cp_data = to_cp_data(&block); let new_cp = emitter .last_cp .clone() - .push(height, hash) + .push(height, cp_data) .expect("must push"); emitter.last_cp = new_cp.clone(); emitter.last_block = Some(res); - return Ok(Some((new_cp, item))); + return Ok(Some((new_cp, block))); } PollResponse::NoMoreBlocks => { emitter.last_block = None; @@ -368,7 +389,9 @@ where continue; } PollResponse::AgreementPointNotFound(genesis_hash) => { - emitter.last_cp = CheckPoint::new(0, genesis_hash); + let block = client.get_block(&genesis_hash)?; + let cp_data = to_cp_data(&block); + emitter.last_cp = CheckPoint::new(0, cp_data); emitter.last_block = None; continue; } diff --git a/crates/bitcoind_rpc/tests/test_filter_iter.rs b/crates/bitcoind_rpc/tests/test_filter_iter.rs index e45e53311..8c9c895c2 100644 --- a/crates/bitcoind_rpc/tests/test_filter_iter.rs +++ b/crates/bitcoind_rpc/tests/test_filter_iter.rs @@ -1,7 +1,7 @@ use bdk_bitcoind_rpc::bip158::{Error, FilterIter}; use bdk_core::CheckPoint; use bdk_testenv::{anyhow, corepc_node, TestEnv}; -use bitcoin::{Address, Amount, Network, ScriptBuf}; +use bitcoin::{Address, Amount, BlockHash, Network, ScriptBuf}; use bitcoincore_rpc::RpcApi; use crate::common::ClientExt; @@ -63,7 +63,7 @@ fn filter_iter_error_wrong_network() -> anyhow::Result<()> { let _ = env.mine_blocks(10, None)?; // Try to initialize FilterIter with a CP on the wrong network - let cp = CheckPoint::new(0, bitcoin::hashes::Hash::hash(b"wrong-hash")); + let cp = CheckPoint::::new(0, bitcoin::hashes::Hash::hash(b"wrong-hash")); let client = ClientExt::get_rpc_client(&env)?; let mut iter = FilterIter::new(&client, cp, [ScriptBuf::new()]); assert!(matches!(iter.next(), Some(Err(Error::ReorgDepthExceeded)))); From 4755d8ceb654a01b29eb46a1b81070ee6aa015f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 3 Mar 2026 06:53:13 +0000 Subject: [PATCH 4/4] refactor(bitcoind_rpc)!: store conversion closure in `FilterIter` and `Emitter` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store `Fn(Header) -> D` in `FilterIter` and `Fn(&Block) -> D` in `Emitter`, enabling `Iterator` for any `D` without a `FromBlockHeader` bound. Add `new_with` constructors for custom `D` types; `new` delegates to `new_with` for the common `FromBlockHeader` case. Remove `FilterIter::next_with` and `Emitter::next_block_with` — callers should use `new_with` at construction time instead. Co-Authored-By: Claude Sonnet 4.6 --- crates/bitcoind_rpc/src/bip158.rs | 84 +++++++++++++++++++-------- crates/bitcoind_rpc/src/lib.rs | 96 ++++++++++++++++++------------- 2 files changed, 116 insertions(+), 64 deletions(-) diff --git a/crates/bitcoind_rpc/src/bip158.rs b/crates/bitcoind_rpc/src/bip158.rs index 15387b68b..9b6fe719b 100644 --- a/crates/bitcoind_rpc/src/bip158.rs +++ b/crates/bitcoind_rpc/src/bip158.rs @@ -13,6 +13,8 @@ use bdk_core::CheckPoint; use bdk_core::FromBlockHeader; use bdk_core::ToBlockHash; use bitcoin::block::Header; +use bitcoin::hashes::Hash; +use bitcoin::pow::CompactTarget; use bitcoin::BlockHash; use bitcoin::{bip158::BlockFilter, Block, ScriptBuf}; use bitcoincore_rpc; @@ -33,7 +35,6 @@ use bitcoincore_rpc::{json::GetBlockHeaderResult, RpcApi}; /// occur. `FilterIter` will continue to yield events until it reaches the latest chain tip. /// Events contain the updated checkpoint `cp` which may be incorporated into the local chain /// state to stay in sync with the tip. -#[derive(Debug)] pub struct FilterIter<'a, D = BlockHash> { /// RPC client client: &'a bitcoincore_rpc::Client, @@ -43,26 +44,56 @@ pub struct FilterIter<'a, D = BlockHash> { cp: CheckPoint, /// Header info, contains the prev and next hashes for each header. header: Option, + /// Closure to convert a block header into checkpoint data `D`. + to_data: Box D>, } -impl<'a, D> FilterIter<'a, D> -where - D: ToBlockHash + Clone + Debug, -{ - /// Construct [`FilterIter`] with checkpoint, RPC client and SPKs. - pub fn new( +impl core::fmt::Debug for FilterIter<'_, D> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("FilterIter") + .field("spks", &self.spks) + .field("cp", &self.cp) + .field("header", &self.header) + .finish_non_exhaustive() + } +} + +impl<'a, D: 'static> FilterIter<'a, D> { + /// Construct [`FilterIter`] with a custom closure to convert block headers into checkpoint + /// data. + /// + /// Use [`new`](Self::new) for the common case where `D` implements [`FromBlockHeader`]. + pub fn new_with( client: &'a bitcoincore_rpc::Client, cp: CheckPoint, spks: impl IntoIterator, + to_data: impl Fn(Header) -> D + 'static, ) -> Self { Self { client, spks: spks.into_iter().collect(), cp, header: None, + to_data: Box::new(to_data), } } +} +impl<'a, D: FromBlockHeader + 'static> FilterIter<'a, D> { + /// Construct [`FilterIter`] with checkpoint, RPC client and SPKs. + pub fn new( + client: &'a bitcoincore_rpc::Client, + cp: CheckPoint, + spks: impl IntoIterator, + ) -> Self { + Self::new_with(client, cp, spks, D::from_blockheader) + } +} + +impl<'a, D> FilterIter<'a, D> +where + D: ToBlockHash + Clone + Debug, +{ /// Return the agreement header with the remote node. /// /// Error if no agreement header is found. @@ -78,10 +109,7 @@ where Err(Error::ReorgDepthExceeded) } - fn try_next_with(&mut self, to_data: F) -> Result>, Error> - where - F: Fn(Header) -> D, - { + fn try_next(&mut self) -> Result>, Error> { let mut cp = self.cp.clone(); let header = match self.header.take() { @@ -111,10 +139,21 @@ where next_hash = next_header.hash; let next_height: u32 = next_header.height.try_into()?; - cp = cp.insert( - next_height, - to_data(self.client.get_block_header(&next_hash)?), - ); + // Reconstruct the block header from the already-fetched GetBlockHeaderResult, + // avoiding an extra `get_block_header` RPC call. + let block_header = Header { + version: next_header.version, + prev_blockhash: next_header + .previous_block_hash + .unwrap_or_else(BlockHash::all_zeros), + merkle_root: next_header.merkle_root, + time: next_header.time as u32, + bits: CompactTarget::from_unprefixed_hex(&next_header.bits) + .map_err(|_| Error::InvalidBits(next_header.bits.clone()))?, + nonce: next_header.nonce, + }; + + cp = cp.insert(next_height, (self.to_data)(block_header)); let mut block = None; let filter = BlockFilter::new(self.client.get_block_filter(&next_hash)?.filter.as_slice()); @@ -132,14 +171,6 @@ where Ok(Some(Event { cp, block })) } - - /// Get the next event with a custom checkpoint data type. - pub fn next_with(&mut self, to_data: F) -> Option, Error>> - where - F: Fn(Header) -> D, - { - self.try_next_with(to_data).transpose() - } } /// Event returned by [`FilterIter`]. @@ -165,12 +196,12 @@ impl Event { impl Iterator for FilterIter<'_, D> where - D: ToBlockHash + FromBlockHeader + Clone + Debug, + D: ToBlockHash + Clone + Debug, { type Item = Result, Error>; fn next(&mut self) -> Option { - self.try_next_with(D::from_blockheader).transpose() + self.try_next().transpose() } } @@ -185,6 +216,8 @@ pub enum Error { ReorgDepthExceeded, /// Error converting an integer TryFromInt(core::num::TryFromIntError), + /// Invalid bits string from RPC + InvalidBits(String), } impl core::fmt::Display for Error { @@ -194,6 +227,7 @@ impl core::fmt::Display for Error { Self::Bip158(e) => write!(f, "{e}"), Self::ReorgDepthExceeded => write!(f, "maximum reorg depth exceeded"), Self::TryFromInt(e) => write!(f, "{e}"), + Self::InvalidBits(s) => write!(f, "invalid bits string: {s}"), } } } diff --git a/crates/bitcoind_rpc/src/lib.rs b/crates/bitcoind_rpc/src/lib.rs index de9a90fa8..001f1c5ca 100644 --- a/crates/bitcoind_rpc/src/lib.rs +++ b/crates/bitcoind_rpc/src/lib.rs @@ -55,6 +55,9 @@ pub struct Emitter { /// sure the tip block is already emitted. When a block is emitted, the transactions in the /// block are removed from this field. mempool_snapshot: HashMap>, + + /// Closure to convert a block into checkpoint data `D`. + to_cp_data: Box D>, } /// Indicates that there are no initially-expected mempool transactions. @@ -67,24 +70,17 @@ impl Emitter where C: Deref, C::Target: RpcApi, - D: ToBlockHash + Clone + Debug, + D: ToBlockHash + Clone + Debug + 'static, { - /// Construct a new [`Emitter`]. + /// Construct a new [`Emitter`] with a custom closure to convert blocks into checkpoint data. /// - /// `last_cp` informs the emitter of the chain we are starting off with. This way, the emitter - /// can start emission from a block that connects to the original chain. - /// - /// `start_height` starts emission from a given height (if there are no conflicts with the - /// original chain). - /// - /// `expected_mempool_txs` is the initial set of unconfirmed transactions provided by the - /// wallet. This allows the [`Emitter`] to inform the wallet about relevant mempool evictions. - /// If it is known that the wallet is empty, [`NO_EXPECTED_MEMPOOL_TXS`] can be used. - pub fn new( + /// Use [`new`](Self::new) for the common case where `D` implements [`FromBlockHeader`]. + pub fn new_with( client: C, last_cp: CheckPoint, start_height: u32, expected_mempool_txs: impl IntoIterator>>, + to_cp_data: impl Fn(&Block) -> D + 'static, ) -> Self { Self { client, @@ -98,15 +94,56 @@ where (tx.compute_txid(), tx) }) .collect(), + to_cp_data: Box::new(to_cp_data), } } +} +impl Emitter +where + C: Deref, + C::Target: RpcApi, + D: ToBlockHash + FromBlockHeader + Clone + Debug + 'static, +{ + /// Construct a new [`Emitter`]. + /// + /// `last_cp` informs the emitter of the chain we are starting off with. This way, the emitter + /// can start emission from a block that connects to the original chain. + /// + /// `start_height` starts emission from a given height (if there are no conflicts with the + /// original chain). + /// + /// `expected_mempool_txs` is the initial set of unconfirmed transactions provided by the + /// wallet. This allows the [`Emitter`] to inform the wallet about relevant mempool evictions. + /// If it is known that the wallet is empty, [`NO_EXPECTED_MEMPOOL_TXS`] can be used. + pub fn new( + client: C, + last_cp: CheckPoint, + start_height: u32, + expected_mempool_txs: impl IntoIterator>>, + ) -> Self { + Self::new_with( + client, + last_cp, + start_height, + expected_mempool_txs, + |block| D::from_blockheader(block.header), + ) + } +} + +impl Emitter +where + C: Deref, + C::Target: RpcApi, + D: ToBlockHash + Clone + Debug + 'static, +{ /// Emit mempool transactions and any evicted [`Txid`]s. /// /// This method returns a [`MempoolEvent`] containing the full transactions (with their /// first-seen unix timestamps) that were emitted, and [`MempoolEvent::evicted`] which are /// any [`Txid`]s which were previously seen in the mempool and are now missing. Evicted txids - /// are only reported once the emitter’s checkpoint matches the RPC’s best block in both height + /// are only reported once the emitter's checkpoint matches the RPC's best block in both height /// and hash. Until `next_block()` advances the checkpoint to tip, `mempool()` will always /// return an empty `evicted` set. #[cfg(feature = "std")] @@ -200,18 +237,9 @@ where Ok(mempool_event) } - /// Emit the next block, using `to_data` to construct checkpoint data from the block. - /// - /// This is the alternative to [`next_block`](Self::next_block) when [`FromBlockHeader`] isn't - /// implemented for `D`. - pub fn next_block_with( - &mut self, - to_data: F, - ) -> Result>, bitcoincore_rpc::Error> - where - F: Fn(&Block) -> D, - { - if let Some((checkpoint, block)) = poll(self, to_data)? { + /// Emit the next block height and block (if any). + pub fn next_block(&mut self) -> Result>, bitcoincore_rpc::Error> { + if let Some((checkpoint, block)) = poll(self)? { // Stop tracking unconfirmed transactions that have been confirmed in this block. for tx in &block.txdata { self.mempool_snapshot.remove(&tx.compute_txid()); @@ -220,14 +248,6 @@ where } Ok(None) } - - /// Emit the next block height and block (if any). - pub fn next_block(&mut self) -> Result>, bitcoincore_rpc::Error> - where - D: FromBlockHeader, - { - self.next_block_with(|block| D::from_blockheader(block.header)) - } } /// A new emission from mempool. @@ -347,15 +367,13 @@ where Ok(PollResponse::AgreementPointNotFound(genesis_hash)) } -fn poll( +fn poll( emitter: &mut Emitter, - to_cp_data: F, ) -> Result, Block)>, bitcoincore_rpc::Error> where C: Deref, C::Target: RpcApi, - D: ToBlockHash + Clone + Debug, - F: Fn(&Block) -> D, + D: ToBlockHash + Clone + Debug + 'static, { let client = &emitter.client; loop { @@ -363,7 +381,7 @@ where PollResponse::Block(res) => { let height = res.height as u32; let block = client.get_block(&res.hash)?; - let cp_data = to_cp_data(&block); + let cp_data = (emitter.to_cp_data)(&block); let new_cp = emitter .last_cp @@ -390,7 +408,7 @@ where } PollResponse::AgreementPointNotFound(genesis_hash) => { let block = client.get_block(&genesis_hash)?; - let cp_data = to_cp_data(&block); + let cp_data = (emitter.to_cp_data)(&block); emitter.last_cp = CheckPoint::new(0, cp_data); emitter.last_block = None; continue;