From 5674ea94619b63e63195adde3e4edb9bb9d67acd Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 8 Sep 2026 17:02:24 +0300 Subject: [PATCH 01/16] Cbf chain source (#25) * Add CBF chain source stubs for starting Add stub methods/functions, add basic build and start of the CBF chain source as well as basic struct containing the fields which undoubtedtly are needed. * Add waiting for gossip propagation in tests Previously tests assumed that the chain source of the lightning node and is node which mines. This is not the case with CBF chain source which needs to wait until after mining a new block a new tips propagates to it. `wait_for_block` is made to return a new height and a new function `wait_for_node_tip` is added which waits until the given height is processed (returned via `status.best_block` ) on a given node. * Populate revealed spks for CBF Ask wallet for revealed spks, register them. Implement `Listen` trait ans add register_script method as well as implementation of registered scripts/outputs. --------- Co-authored-by: Yeji Han --- Cargo.toml | 3 + src/builder.rs | 2 + src/chain/bitcoind.rs | 59 +---- src/chain/cbf.rs | 413 ++++++++++++++++++++++++++++++++ src/chain/mod.rs | 211 +++++++++++++++- src/lib.rs | 18 +- src/wallet/mod.rs | 29 ++- tests/common/mod.rs | 114 ++++++--- tests/integration_tests_rust.rs | 151 +++++++++--- tests/reorg_test.rs | 1 + 10 files changed, 856 insertions(+), 145 deletions(-) create mode 100644 src/chain/cbf.rs diff --git a/Cargo.toml b/Cargo.toml index 9f8a729655..f2116a7853 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ default = [ "chain-esplora", "chain-electrum", "chain-bitcoind", + "chain-cbf", "storage-sqlite", "storage-filesystem", "storage-vss", @@ -52,6 +53,7 @@ chain-electrum = [ "lightning-transaction-sync/electrum-rustls-ring", ] chain-bitcoind = ["dep:lightning-block-sync"] +chain-cbf = ["dep:bip157"] storage-sqlite = ["dep:rusqlite"] storage-filesystem = ["dep:lightning-persister"] storage-vss = ["dep:vss-client", "dep:prost"] @@ -103,6 +105,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"], optional = true } bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"], optional = true } bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} +bip157 = { version = "0.6.0", default-features = false, optional = true } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/builder.rs b/src/builder.rs index 1158044e47..a3988f92d7 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1649,6 +1649,8 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, + //TODO add here an arm + // Some(ChainDataSoucrConfig::Cbf) #[cfg(feature = "chain-bitcoind")] Some(ChainDataSourceConfig::Bitcoind { rpc_host, diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 9ed38c2128..c5dcb6e446 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -31,7 +31,7 @@ use lightning_block_sync::{ }; use serde::Serialize; -use super::{WalletSyncGuard, WalletSyncStatus}; +use super::{ChainListener, WalletSyncGuard, WalletSyncStatus}; use crate::config::{ BitcoindRestClientConfig, Config, DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS, DEFAULT_TX_BROADCAST_TIMEOUT_SECS, @@ -1534,63 +1534,6 @@ pub(crate) enum FeeRateEstimationMode { Conservative, } -pub(crate) struct ChainListener { - pub(crate) onchain_wallet: std::sync::Weak, - pub(crate) channel_manager: std::sync::Weak, - pub(crate) chain_monitor: std::sync::Weak, - pub(crate) output_sweeper: std::sync::Weak, -} - -impl ChainListener { - fn upgrade( - &self, - ) -> Option<(Arc, Arc, Arc, Arc)> { - Some(( - self.onchain_wallet.upgrade()?, - self.channel_manager.upgrade()?, - self.chain_monitor.upgrade()?, - self.output_sweeper.upgrade()?, - )) - } -} - -impl Listen for ChainListener { - fn filtered_block_connected( - &self, header: &bitcoin::block::Header, - txdata: &lightning::chain::transaction::TransactionData, height: u32, - ) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() - { - onchain_wallet.filtered_block_connected(header, txdata, height); - channel_manager.filtered_block_connected(header, txdata, height); - chain_monitor.filtered_block_connected(header, txdata, height); - output_sweeper.filtered_block_connected(header, txdata, height); - } - } - fn block_connected(&self, block: &bitcoin::Block, height: u32) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() - { - onchain_wallet.block_connected(block, height); - channel_manager.block_connected(block, height); - chain_monitor.block_connected(block, height); - output_sweeper.block_connected(block, height); - } - } - - fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() - { - onchain_wallet.blocks_disconnected(fork_point_block); - channel_manager.blocks_disconnected(fork_point_block); - chain_monitor.blocks_disconnected(fork_point_block); - output_sweeper.blocks_disconnected(fork_point_block); - } - } -} - pub(crate) fn rpc_credentials(rpc_user: String, rpc_password: String) -> String { BASE64_STANDARD.encode(format!("{}:{}", rpc_user, rpc_password)) } diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs new file mode 100644 index 0000000000..bcaa0aadbc --- /dev/null +++ b/src/chain/cbf.rs @@ -0,0 +1,413 @@ +use std::collections::{HashSet, VecDeque}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bip157::chain::ChainState; +use bip157::{ + chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, +}; +use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; +use lightning::chain::{Listen, WatchedOutput}; + +use tokio::sync::{mpsc, oneshot}; + +use crate::chain::{CbfFeeSourceConfig, ChainListener}; +use crate::config::Config; +use crate::error::Error; +use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChainMonitor, ChannelManager, Sweeper, Wallet}; + +/// Walk back this many blocks from the wallet's persisted tip when deriving +/// the kyoto resume checkpoint, so a recent reorg cannot strand the node +/// above the new best chain. +const REORG_SAFETY_BLOCKS: u32 = 7; +const BLOCK_FEE_CACHE_CAPACITY: usize = REORG_SAFETY_BLOCKS as usize * 2; + +/// Peer response timeout passed to kyoto's `Builder::response_timeout`. +const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 5; + +/// Number of peers that must agree on filter headers before they're accepted. +const DEFAULT_REQUIRED_PEERS: u8 = 1; + +/// Maximum consecutive `node.run()` failures before the restart loop gives up. +const MAX_RESTART_RETRIES: u32 = 5; + +/// Initial backoff delay between restart attempts; doubles each failure. +const INITIAL_BACKOFF_MS: u64 = 500; + +const ESPLORA_TIMEOUT: u64 = 2; + +/// Runtime status of the underlying kyoto node. +enum CbfRuntimeStatus { + Started { requester: Requester }, + Stopped, +} + +/// Struct for holding cbf chain source +pub struct CbfChainSource { + /// Trusted peer addresses for kyoto's `Builder::add_peers`. + trusted_peers: Vec, + registered_scripts: Arc>>, + fee_source: FeeSource, + /// Tracks whether the kyoto node is running and holds the live requester. + cbf_runtime_status: Arc>, + /// Node configuration (network, storage path). + config: Arc, + logger: Arc, +} + +enum ChainOp { + ConnectFull { block_rx: oneshot::Receiver> }, + ConnectFiltered { header: Header, height: u32 }, + //Reorg { /* accepted / reorganized from BlockHeaderChanges */ }, +} + +struct BlockApplicator { + chain_listener: ChainListener, + ops_rx: mpsc::UnboundedReceiver, + logger: Arc, +} + +impl BlockApplicator { + async fn run(mut self) { + while let Some(op) = self.ops_rx.recv().await { + match op { + ChainOp::ConnectFull { block_rx } => match block_rx.await { + Ok(Ok(ib)) => self.chain_listener.block_connected(&ib.block, ib.height), + Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), + Err(_) => log_error!(self.logger, "block oneshot dropped"), + }, + ChainOp::ConnectFiltered { header, height } => { + self.chain_listener.filtered_block_connected(&header, &[], height) + }, + //ChainOp::Reorg { .. } => {}, + } + } + } +} + +enum FeeSource { + /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. + Cbf { block_fee_cache: Mutex> }, + /// Delegate fee estimation to an Esplora HTTP server. + Esplora { client: esplora_client::AsyncClient }, + /// Delegate fee estimation to an Electrum server. + /// + /// A fresh connection is opened for each estimation cycle. + Electrum { server_url: String }, +} + +impl FeeSource { + fn insert_cached_block(&self, block_hash: BlockHash, fee_rate: FeeRate) { + match &self { + Self::Cbf { block_fee_cache } => { + block_fee_cache.lock().expect("lock").push_back((block_hash, fee_rate)); + }, + _ => {}, + } + } +} + +impl CbfChainSource { + pub(crate) fn new( + peers: Vec, fee_source_config: Option, config: Arc, + logger: Arc, + ) -> Result { + let trusted_peers: Vec = peers + .iter() + .filter_map(|peer_str| { + peer_str.parse::().ok().map(TrustedPeer::from_socket_addr) + }) + .collect(); + + let fee_source = match fee_source_config { + Some(CbfFeeSourceConfig::Esplora(server_url)) => { + let mut esplora_builder = esplora_client::Builder::new(&server_url); + esplora_builder = esplora_builder.timeout(ESPLORA_TIMEOUT); + let client = esplora_builder.build_async().map_err(|e| { + log_error!(logger, "Failed to build esplora client: {}", e); + Error::ConnectionFailed + })?; + FeeSource::Esplora { client } + }, + Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, + None => FeeSource::Cbf { + block_fee_cache: Mutex::new(VecDeque::with_capacity(BLOCK_FEE_CACHE_CAPACITY)), + }, + }; + let registered_scripts = Arc::new(Mutex::new(HashSet::new())); + let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); + Ok(Self { + trusted_peers, + fee_source, + registered_scripts, + cbf_runtime_status, + config, + logger, + }) + } + + fn build_kyoto( + trusted_peers: &[TrustedPeer], config: &Config, logger: &Logger, + chain_listener: &ChainListener, + ) -> (KyotoNode, Client) { + let mut kyoto_builder = KyotoBuilder::new(config.network); + + let data_dir = std::path::PathBuf::from(&config.storage_dir_path).join("bip157_data"); + kyoto_builder = kyoto_builder.data_dir(data_dir); + + if !trusted_peers.is_empty() { + kyoto_builder = kyoto_builder.add_peers(trusted_peers.to_vec()); + } + + kyoto_builder = kyoto_builder.required_peers(DEFAULT_REQUIRED_PEERS); + kyoto_builder = kyoto_builder.fetch_witness_data(); + kyoto_builder = + kyoto_builder.response_timeout(Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)); + + if let Some(header_cp) = resume_checkpoint(logger, chain_listener) { + log_debug!( + logger, + "CBF builder: resuming from checkpoint height={}, hash={}", + header_cp.height, + header_cp.hash, + ); + kyoto_builder = kyoto_builder.chain_state(ChainState::Checkpoint(header_cp)); + } + + kyoto_builder.build() + } + + pub(crate) fn start(&self, runtime: Arc, chain_listener: ChainListener) { + //we populate registered scripts with all the scripts from the onchain wallet + if let Some(onchain_wallet) = chain_listener.onchain_wallet.upgrade() { + for script in onchain_wallet.list_revealed_scripts() { + self.register_script(script); + } + } + + let (node, client) = + Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); + let Client { requester, info_rx, warn_rx, event_rx } = client; + + { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Started { .. }) { + debug_assert!(false, "start() called while CBF chain source is already running"); + return; + } + *status = CbfRuntimeStatus::Started { requester }; + } + + let (ops_tx, ops_rx) = mpsc::unbounded_channel(); + let block_applicator = BlockApplicator { + chain_listener: chain_listener.clone(), + ops_rx, + logger: Arc::clone(&self.logger), + }; + runtime.spawn_background_task(block_applicator.run()); + + log_info!(self.logger, "CBF chain source started."); + + let restart_status = Arc::clone(&self.cbf_runtime_status); + let restart_logger = Arc::clone(&self.logger); + let restart_peers = self.trusted_peers.clone(); + let restart_config = Arc::clone(&self.config); + let restart_listener = chain_listener; + let restart_registered_scripts = Arc::clone(&self.registered_scripts); + let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); + // let restart_block_applicator = + + runtime.spawn_background_task(async move { + let mut current_node = node; + let mut current_info_rx = info_rx; + let mut current_warn_rx = warn_rx; + let mut current_event_rx = event_rx; + let mut retries = 0u32; + let mut backoff_ms = INITIAL_BACKOFF_MS; + + loop { + let info_handle = tokio::spawn(Self::process_info_messages( + current_info_rx, + Arc::clone(&restart_logger), + )); + let warn_handle = tokio::spawn(Self::process_warn_messages( + current_warn_rx, + Arc::clone(&restart_logger), + )); + + let event_handle = tokio::spawn(Self::process_kyoto_events( + current_event_rx, + Arc::clone(&restart_registered_scripts), + Arc::clone(&restart_cbf_runtime_status), + ops_tx.clone(), + )); + + match current_node.run().await { + Ok(()) => { + log_info!(restart_logger, "CBF node shut down cleanly."); + break; + }, + Err(e) => { + retries += 1; + if retries > MAX_RESTART_RETRIES { + log_error!( + restart_logger, + "CBF node failed {} times, giving up: {:?}", + retries, + e, + ); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + break; + } + log_error!( + restart_logger, + "CBF node exited with error (attempt {}/{}): {:?}. Restarting in {}ms.", + retries, + MAX_RESTART_RETRIES, + e, + backoff_ms, + ); + + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = backoff_ms.saturating_mul(2); + + // Abort the old log consumers before rebuilding. + info_handle.abort(); + warn_handle.abort(); + event_handle.abort(); + + let (new_node, new_client) = Self::build_kyoto( + &restart_peers, + &restart_config, + &restart_logger, + &restart_listener, + ); + let Client { + requester: new_requester, + info_rx: new_info_rx, + warn_rx: new_warn_rx, + event_rx: new_event_rx, + } = new_client; + + *restart_status.lock().expect("lock") = + CbfRuntimeStatus::Started { requester: new_requester }; + + current_node = new_node; + current_info_rx = new_info_rx; + current_warn_rx = new_warn_rx; + current_event_rx = new_event_rx; + }, + } + } + }); + } + + pub(crate) fn stop(&self) { + todo!(); + } + + async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { + while let Some(info) = info_rx.recv().await { + log_debug!(logger, "CBF node info: {}", info); + } + } + + async fn process_warn_messages( + mut warn_rx: mpsc::UnboundedReceiver, logger: Arc, + ) { + while let Some(warning) = warn_rx.recv().await { + log_debug!(logger, "CBF node warning: {}", warning); + } + } + + async fn process_kyoto_events( + mut event_rx: mpsc::UnboundedReceiver, + registered_scripts: Arc>>, + cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, + ) { + while let Some(event) = event_rx.recv().await { + match event { + // match download + Event::IndexedFilter(indexed_filter) => { + let matched = indexed_filter + .contains_any(registered_scripts.lock().expect("lock").iter()); + if matched { + let rtm = &*cbf_runtime_status.lock().expect("lock"); + let requestor = match rtm { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => { + //panic + // todo!(); + continue; + }, + }; + let block_rx = requestor + .request_block(indexed_filter.block_hash()) + .expect("cannot request block"); + let chop = ChainOp::ConnectFull { block_rx }; + //here we feed evets to the driver + ops_tx.send(chop); + } + }, + Event::FiltersSynced(sync_update) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::Connected(connected_blocks)) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted }) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { + todo!(); + }, + } + } + } + + pub(crate) fn register_tx(&self, _txid: &Txid, script_pubkey: &Script) { + self.registered_scripts.lock().expect("lock").insert(script_pubkey.into()); + } + + pub(crate) fn register_output(&self, output: WatchedOutput) { + self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); + } + + pub(crate) fn register_script(&self, script: ScriptBuf) { + self.registered_scripts.lock().expect("lock").insert(script); + } +} + +fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option { + let min_best_block = chain_listener.get_best_block(); + let bdk_cp = chain_listener.onchain_wallet.upgrade()?.latest_checkpoint(); + + if let Some(bdk_at_height) = bdk_cp.get(min_best_block.height) { + if bdk_at_height.hash() != min_best_block.block_hash { + log_error!( + logger, + "CBF resume: listener best block at height {} has hash {} but BDK has {}; \ + a component may be on a stale fork. Anchoring on BDK's chain.", + min_best_block.height, + min_best_block.block_hash, + bdk_at_height.hash(), + ); + } + } + + // Walk BDK's checkpoint chain back to the reorg-safe anchor height. + let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); + let mut cursor = bdk_cp; + while cursor.height() > target_height { + match cursor.prev() { + Some(prev) => cursor = prev, + None => break, + } + } + + (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) +} diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f01c1c8cb8..f07c4d38c5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -7,6 +7,8 @@ #[cfg(feature = "chain-bitcoind")] pub(crate) mod bitcoind; +#[cfg(feature = "chain-cbf")] +mod cbf; #[cfg(feature = "chain-electrum")] mod electrum; #[cfg(feature = "chain-esplora")] @@ -18,11 +20,15 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, ScriptBuf, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] +use lightning::chain::Listen; #[cfg(feature = "chain-bitcoind")] use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; +#[cfg(feature = "chain-cbf")] +use crate::chain::cbf::CbfChainSource; #[cfg(feature = "chain-electrum")] use crate::chain::electrum::ElectrumChainSource; #[cfg(feature = "chain-esplora")] @@ -143,6 +149,21 @@ impl WalletSyncStatus { } } +/// Optional external fee estimation backend for the CBF chain source. +/// +/// By default CBF derives fee rates from recent blocks' coinbase outputs. +/// Setting an external source provides more accurate, per-target estimates +/// from a mempool-aware server. +#[cfg(feature = "chain-cbf")] +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum CbfFeeSourceConfig { + /// Use an Esplora HTTP server for fee rate estimation. + Esplora(String), + /// Use an Electrum server for fee rate estimation. + Electrum(String), +} + pub(crate) struct ChainSource { kind: ChainSourceKind, registered_txids: Mutex>, @@ -157,6 +178,95 @@ enum ChainSourceKind { Electrum(ElectrumChainSource), #[cfg(feature = "chain-bitcoind")] Bitcoind(BitcoindChainSource), + #[cfg(feature = "chain-cbf")] + Cbf(CbfChainSource), +} + +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] +#[derive(Clone)] +pub(crate) struct ChainListener { + pub(crate) onchain_wallet: std::sync::Weak, + pub(crate) channel_manager: std::sync::Weak, + pub(crate) chain_monitor: std::sync::Weak, + pub(crate) output_sweeper: std::sync::Weak, +} + +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] +impl ChainListener { + fn upgrade( + &self, + ) -> Option<(Arc, Arc, Arc, Arc)> { + Some(( + self.onchain_wallet.upgrade()?, + self.channel_manager.upgrade()?, + self.chain_monitor.upgrade()?, + self.output_sweeper.upgrade()?, + )) + } +} + +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] +impl ChainListener { + pub(crate) fn get_best_block(&self) -> BlockLocator { + let (onchain_wallet, channel_manager, chain_monitor, output_sweeper) = + self.upgrade().expect("chain listener components dropped"); + let candidates = [ + onchain_wallet.current_best_block(), + channel_manager.current_best_block(), + output_sweeper.current_best_block(), + ]; + let mut min = candidates.into_iter().min_by_key(|b| b.height).expect("non-empty"); + if let Some(worst_monitor) = chain_monitor + .list_monitors() + .iter() + .flat_map(|id| chain_monitor.get_monitor(*id)) + .map(|m| m.current_best_block()) + .min_by_key(|b| b.height) + { + if worst_monitor.height < min.height { + min = worst_monitor; + } + } + min + } +} + +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] +impl Listen for ChainListener { + fn filtered_block_connected( + &self, header: &bitcoin::block::Header, + txdata: &lightning::chain::transaction::TransactionData, height: u32, + ) { + if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = + self.upgrade() + { + onchain_wallet.filtered_block_connected(header, txdata, height); + channel_manager.filtered_block_connected(header, txdata, height); + chain_monitor.filtered_block_connected(header, txdata, height); + output_sweeper.filtered_block_connected(header, txdata, height); + } + } + fn block_connected(&self, block: &bitcoin::Block, height: u32) { + if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = + self.upgrade() + { + onchain_wallet.block_connected(block, height); + channel_manager.block_connected(block, height); + chain_monitor.block_connected(block, height); + output_sweeper.block_connected(block, height); + } + } + + fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { + if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = + self.upgrade() + { + onchain_wallet.blocks_disconnected(fork_point_block); + channel_manager.blocks_disconnected(fork_point_block); + chain_monitor.blocks_disconnected(fork_point_block); + output_sweeper.blocks_disconnected(fork_point_block); + } + } } impl ChainSource { @@ -252,10 +362,42 @@ impl ChainSource { (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } - pub(crate) fn start(&self, runtime: Arc) -> Result<(), Error> { + #[cfg(feature = "chain-cbf")] + pub(crate) fn new_cbf( + peers: Vec, fee_source_config: Option, + fee_estimator: Arc, tx_broadcaster: Arc, + kv_store: Arc, config: Arc, logger: Arc, + node_metrics: Arc, + ) -> Result<(Self, Option), Error> { + let cbf_chain_source = CbfChainSource::new( + peers, + fee_source_config, + Arc::clone(&config), + Arc::clone(&logger), + )?; + let kind = ChainSourceKind::Cbf(cbf_chain_source); + let registered_txids = Mutex::new(HashSet::new()); + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) + } + + pub(crate) fn start( + &self, runtime: Arc, onchain_wallet: Arc, + channel_manager: Arc, chain_monitor: Arc, + output_sweeper: Arc, + ) -> Result<(), Error> { match &self.kind { #[cfg(feature = "chain-electrum")] ChainSourceKind::Electrum(electrum_chain_source) => electrum_chain_source.start(runtime)?, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf_chain_source) => { + let chain_listener = ChainListener { + onchain_wallet: Arc::downgrade(&onchain_wallet), + channel_manager: Arc::downgrade(&channel_manager), + chain_monitor: Arc::downgrade(&chain_monitor), + output_sweeper: Arc::downgrade(&output_sweeper), + }; + cbf_chain_source.start(runtime, chain_listener); + }, _ => { // Nothing to do for other chain sources. }, @@ -294,6 +436,14 @@ impl ChainSource { } } + pub(crate) fn register_script(&self, script: ScriptBuf) { + match &self.kind { + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf) => cbf.register_script(script), + _ => {}, // no-op: Esplora/Electrum/bitcoind don't need a watch set + } + } + pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -306,6 +456,8 @@ impl ChainSource { ChainSourceKind::Electrum { .. } => true, #[cfg(feature = "chain-bitcoind")] ChainSourceKind::Bitcoind { .. } => false, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => false, } } @@ -333,9 +485,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -357,9 +509,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -375,6 +527,17 @@ impl ChainSource { ) .await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf_chain_source) => { + todo!(); + // cbf_chain_source.process_kyoto_events( + // stop_sync_receiver, + // onchain_wallet, + // channel_manager, + // chain_monitor, + // output_sweeper, + // ); + }, } } @@ -417,7 +580,7 @@ impl ChainSource { log_trace!( logger, "Stopping background syncing on-chain wallet.", - ); + ); return; } _ = onchain_wallet_sync_interval.tick() => { @@ -431,7 +594,7 @@ impl ChainSource { Arc::clone(&channel_manager), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), - ).await; + ).await; } } } @@ -457,6 +620,10 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Onchain wallet will be synced via chain polling") }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + unreachable!("Onchain wallet synchronizes in background") + }, } } @@ -485,6 +652,10 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Lightning wallet will be synced via chain polling") }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + unreachable!("Lightning wallet synchronizes in background") + }, } } @@ -519,6 +690,10 @@ impl ChainSource { ) .await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -536,6 +711,10 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.update_fee_rate_estimates().await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -605,6 +784,10 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.process_transaction_broadcast(package).await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { ..} => { + todo!(); + } } } } @@ -626,6 +809,10 @@ impl Filter for ChainSource { }, #[cfg(feature = "chain-bitcoind")] ChainSourceKind::Bitcoind { .. } => (), + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_tx(txid, script_pubkey); + }, } } fn register_output(&self, output: lightning::chain::WatchedOutput) { @@ -638,6 +825,10 @@ impl Filter for ChainSource { }, #[cfg(feature = "chain-bitcoind")] ChainSourceKind::Bitcoind { .. } => (), + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_output(output); + }, } } } diff --git a/src/lib.rs b/src/lib.rs index 9b0700b967..4e6af48911 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -322,11 +322,19 @@ impl Node { self.runtime.allow_cancellable_background_task_spawns(); - // Start up any runtime-dependant chain sources (e.g. Electrum) - self.chain_source.start(Arc::clone(&self.runtime)).map_err(|e| { - log_error!(self.logger, "Failed to start chain syncing: {}", e); - e - })?; + // Start up any runtime-dependant chain sources (e.g. Electrum, CBF) + self.chain_source + .start( + Arc::clone(&self.runtime), + Arc::clone(&self.wallet), + Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), + Arc::clone(&self.output_sweeper), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to start chain syncing: {}", e); + e + })?; let manager_owns_any_0fc_channels = self.channel_manager.list_channels().into_iter().any(|channel| { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13a8ef4e00..f6d56b82f6 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -36,7 +36,7 @@ use lightning::chain::chaininterface::{ INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; -use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -224,7 +224,11 @@ impl Wallet { .collect() } - #[cfg(feature = "chain-bitcoind")] + pub(crate) fn latest_checkpoint(&self) -> bdk_chain::local_chain::CheckPoint { + self.inner.lock().expect("lock").latest_checkpoint() + } + + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] pub(crate) fn current_best_block(&self) -> BlockLocator { let checkpoint = self.inner.lock().expect("lock").latest_checkpoint(); let mut current_block = Some(checkpoint.clone()); @@ -297,6 +301,24 @@ impl Wallet { Ok(()) } + pub(crate) fn list_revealed_scripts(&self) -> Vec { + self.inner + .lock() + .expect("lock") + .spk_index() + .revealed_spks(..) + .map(|((_keychain, _index), spk)| spk) + .collect() + } + + /// Register scripts that BDK revealed at index time (e.g. change outputs, which `create_tx` + /// only peeks) with the chain source's watch set. No-op for non-CBF backends. + fn register_revealed_scripts(&self, _locked_wallet: &PersistedWallet) { + // TODO(cbf): diff `last_revealed_index(keychain)` against a per-keychain cursor and + // `chain_source.register_script(spk)` the delta for both keychains. + todo!() + } + async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); @@ -771,6 +793,7 @@ impl Wallet { .saturating_sub(locked_pool.available.len() + locked_pool.unpublished.len()); for _ in 0..needed { let address_info = locked_wallet.reveal_next_address(KeychainKind::External); + self.chain_source.register_script(address_info.script_pubkey()); locked_pool.unpublished.push((address_info.index, address_info.address)); } // Hand the reveals straight to the persister: this refill may run as a task the @@ -847,6 +870,7 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -1441,6 +1465,7 @@ impl Wallet { locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); })?; + self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address.script_pubkey()) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 91e23ca168..810f1da942 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -538,9 +538,9 @@ fn has_onchain_tx_type bool>(node: &TestNode, predica fn assert_any_node_has_onchain_tx_type bool + Copy>( nodes: &[(&str, &TestNode)], tx_type_name: &str, predicate: F, -) { +) -> Result<(), String> { if nodes.iter().any(|(_, node)| has_onchain_tx_type(node, predicate)) { - return; + return Ok(()); } let observed: Vec = nodes @@ -552,14 +552,14 @@ fn assert_any_node_has_onchain_tx_type bool + Copy>( }) }) .collect(); - panic!("Expected on-chain payment with tx_type {}; observed {:?}", tx_type_name, observed); + Err(format!("Expected on-chain payment with tx_type {}; observed {:?}", tx_type_name, observed)) } fn assert_all_nodes_have_onchain_tx_type bool + Copy>( nodes: &[(&str, &TestNode)], panic_msg: &str, tx_type_name: &str, predicate: F, -) { +) -> Result<(), String> { if nodes.iter().all(|(_, node)| has_onchain_tx_type(node, predicate)) { - return; + return Ok(()); } let observed: Vec = nodes @@ -571,10 +571,10 @@ fn assert_all_nodes_have_onchain_tx_type bool + Copy> }) }) .collect(); - panic!( + return Err(format!( "Expected {}nodes to have on-chain payment with tx_type {}; observed {:?}", panic_msg, tx_type_name, observed - ); + )); } async fn settle_force_close_balance( @@ -592,7 +592,9 @@ async fn settle_force_close_balance( assert_eq!(actual_counterparty_node_id, counterparty_node_id); let cur_height = node.status().current_best_block.height; let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + wait_for_node_tip(node, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(peer_node, new_height) .await .expect("node hasn't synced to the tip"); node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); }, @@ -607,7 +609,9 @@ async fn settle_force_close_balance( if node.list_balances().lightning_balances.is_empty() { break; } - generate_blocks_and_wait(bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(peer_node, new_height).await.expect("node hasn't synced to the tip"); node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); } @@ -616,8 +620,14 @@ async fn settle_force_close_balance( assert!(balances.lightning_balances.is_empty(), "Unexpected balance state: {:?}", balances); assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); match balances.pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => { - generate_blocks_and_wait(bitcoind, electrsd, 1).await; + PendingSweepBalance::BroadcastAwaitingConfirmation { latest_spending_txid, .. } => { + // The balance flips as soon as the sweeper generates the transaction, before it has + // been relayed to bitcoind. Make sure it is in the mempool so the next block + // includes it. + wait_for_tx(electrsd, latest_spending_txid).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(peer_node, new_height).await.expect("node hasn't synced to the tip"); node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); @@ -632,7 +642,9 @@ async fn settle_force_close_balance( _ => panic!("Unexpected balance state!"), } - generate_blocks_and_wait(bitcoind, electrsd, 5).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 5).await; + wait_for_node_tip(node, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(peer_node, new_height).await.expect("node hasn't synced to the tip"); node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); } @@ -878,7 +890,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> pub(crate) async fn generate_blocks_and_wait( bitcoind: &BitcoindClient, electrs: &E, num: usize, -) { +) -> usize { let _ = bitcoind.create_wallet("ldk_node_test"); let _ = bitcoind.load_wallet("ldk_node_test"); print!("Generating {} blocks...", num); @@ -887,9 +899,11 @@ pub(crate) async fn generate_blocks_and_wait( let address = bitcoind.new_address().expect("failed to get new address"); // TODO: expect this Result once the WouldBlock issue is resolved upstream. let _block_hashes_res = bitcoind.generate_to_address(num, &address); - wait_for_block(bitcoind, electrs, cur_height as usize + num).await; + let new_height = cur_height as usize + num; + wait_for_block(bitcoind, electrs, new_height).await; print!(" Done!"); println!("\n"); + return new_height; } pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { @@ -918,14 +932,16 @@ pub(crate) async fn wait_for_block( } bitcoind.get_block_hash(min_height as u64).ok()?.block_hash().ok() }) - .await; + .await + .expect("reached max tries"); // A height-only wait can return the old header during a same-height reorg. Require the // replacement hash so callers cannot sync against the stale chain by mistake. exponential_backoff_poll(|| { let header = electrs.block_header(min_height).ok()?; (header.block_hash() == expected_block_hash).then_some(()) }) - .await; + .await + .expect("reached max tries"); } pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { @@ -937,7 +953,8 @@ pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { electrs.ping().unwrap(); electrs.transaction_get(&txid).ok() }) - .await; + .await + .expect("reached max tries"); } pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoint: OutPoint) { @@ -954,7 +971,8 @@ pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoin }); is_spent.then_some(()) }) - .await; + .await + .expect("reached max tries"); } /// Polls the channel from `source_node` to `counterparty_node` until it reports `is_usable` @@ -988,7 +1006,17 @@ pub(crate) async fn wait_for_channel_ready_to_send( ); } -pub(crate) async fn exponential_backoff_poll(mut poll: F) -> T +pub(crate) async fn wait_for_node_tip(node: &Node, height: usize) -> Option<()> { + // Pull-based chain sources (Esplora, Electrum) only advance the tip when a sync runs, so + // trigger one on every try instead of relying on the slow background interval. + exponential_backoff_poll(|| { + node.sync_wallets().unwrap(); + (node.status().current_best_block.height as usize >= height).then_some(()) + }) + .await +} + +pub(crate) async fn exponential_backoff_poll(mut poll: F) -> Option where F: FnMut() -> Option, { @@ -996,14 +1024,16 @@ where let mut tries = 0; loop { match poll() { - Some(data) => break data, + Some(data) => return Some(data), None if delay.as_millis() < 512 => { delay = delay.mul_f32(2.0); }, None => {}, } - assert!(tries < 20, "Reached max tries."); + if tries >= 20 { + return None; + } tries += 1; tokio::time::sleep(delay).await; } @@ -1011,11 +1041,11 @@ where pub(crate) async fn premine_and_distribute_funds( bitcoind: &BitcoindClient, electrs: &E, addrs: Vec
, amount: Amount, -) { +) -> usize { premine_blocks(bitcoind, electrs).await; distribute_funds_unconfirmed(bitcoind, electrs, addrs, amount).await; - generate_blocks_and_wait(bitcoind, electrs, 1).await; + generate_blocks_and_wait(bitcoind, electrs, 1).await } pub(crate) async fn premine_blocks(bitcoind: &BitcoindClient, electrs: &E) { @@ -1227,13 +1257,15 @@ pub(crate) async fn do_channel_full_cycle( let premine_amount_sat = if expect_anchor_channel { 2_125_000 } else { 2_100_000 }; - premine_and_distribute_funds( + let new_height = premine_and_distribute_funds( &bitcoind, electrsd, vec![addr_a, addr_b], Amount::from_sat(premine_amount_sat), ) .await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); @@ -1307,7 +1339,9 @@ pub(crate) async fn do_channel_full_cycle( wait_for_tx(electrsd, funding_txo_a.txid).await; if !allow_0conf { - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); } node_a.sync_wallets().unwrap(); @@ -1648,7 +1682,9 @@ pub(crate) async fn do_channel_full_cycle( ); // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); println!("\nB splices out to pay A"); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -1660,7 +1696,9 @@ pub(crate) async fn do_channel_full_cycle( let splice_out_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(electrsd, splice_out_txo.txid).await; - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1682,7 +1720,9 @@ pub(crate) async fn do_channel_full_cycle( let splice_in_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_tx(electrsd, splice_in_txo.txid).await; - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1758,7 +1798,9 @@ pub(crate) async fn do_channel_full_cycle( wait_for_outpoint_spend(electrsd, splice_in_txo).await; - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1803,7 +1845,10 @@ pub(crate) async fn do_channel_full_cycle( assert_eq!(node_a_blocks_to_go, node_b_blocks_to_go); - generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + let new_height = + generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1825,19 +1870,22 @@ pub(crate) async fn do_channel_full_cycle( "no ", "UnilateralClose", |tx_type| !matches!(tx_type, TransactionType::UnilateralClose { .. }), - ); + ) + .unwrap(); assert_any_node_has_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], "Sweep", |tx_type| matches!(tx_type, TransactionType::Sweep { .. }), - ); + ) + .unwrap(); } else { assert_all_nodes_have_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], "all ", "CooperativeClose", |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), - ); + ) + .unwrap(); // Peer removed after cooperative close — no further reason to reconnect. assert!( !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3da60800c6..5c3697550b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -30,8 +30,8 @@ use common::{ open_channel_no_wait, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_block, wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, TestConfig, - TestNode, TestStoreType, TestSyncStore, + wait_for_block, wait_for_node_tip, wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, + TestConfig, TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -564,7 +564,7 @@ async fn channel_full_cycle_0conf() { .await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn channel_full_cycle_0reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); @@ -1188,6 +1188,11 @@ async fn electrum_registrations_survive_chain_source_restart() { async fn onchain_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); + #[cfg(feature = "chain-cbf")] + if matches!(chain_source, TestChainSource::Cbf(_)) { + println!("Skipping: not CBF-compatible (asserts live-mempool state CBF cannot observe)"); + return; + } let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -1232,10 +1237,11 @@ async fn onchain_send_receive() { let channel_amount_sat = 1_000_000; let reserve_amount_sat = 25_000; open_channel(&node_b, &node_a, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1303,9 +1309,11 @@ async fn onchain_send_receive() { assert_eq!(payment_a.amount_msat, payment_b.amount_msat); assert_eq!(payment_a.fee_paid_msat, payment_b.fee_paid_msat); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); let expected_node_a_balance = expected_node_a_balance + amount_to_send_sats; let expected_node_b_balance_lower = expected_node_b_balance_lower - amount_to_send_sats; @@ -1343,12 +1351,12 @@ async fn onchain_send_receive() { let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; - node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); let expected_node_b_balance_lower = expected_node_b_balance_lower + expected_node_a_balance; let expected_node_b_balance_upper = expected_node_b_balance_upper + expected_node_a_balance; let expected_node_a_balance = 0; @@ -1366,11 +1374,13 @@ async fn onchain_send_receive() { let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, false, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); let expected_node_b_balance_lower = expected_node_b_balance_lower + reserve_amount_sat; let expected_node_b_balance_upper = expected_node_b_balance_upper + reserve_amount_sat; @@ -1487,10 +1497,11 @@ async fn onchain_send_all_retains_reserve() { let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); // Check node a sent all and node b received it assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, 0); assert!(((premine_amount_sat * 2 - onchain_fee_buffer_sat)..=(premine_amount_sat * 2)) @@ -1505,16 +1516,20 @@ async fn onchain_send_all_retains_reserve() { .parse() .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the node tip"); assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, reserve_amount_sat); // Open a channel. open_channel(&node_b, &node_a, premine_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the node tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1528,10 +1543,12 @@ async fn onchain_send_all_retains_reserve() { let txid = node_b.onchain_payment().send_all_to_address(&addr_a, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the node tip"); // Check node b sent all and node a received it assert_eq!(node_b.list_balances().total_onchain_balance_sats, reserve_amount_sat); @@ -1576,9 +1593,11 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; original_node.sync_wallets().unwrap(); + wait_for_node_tip(&original_node, new_height) + .await + .expect("node hasn't synced to the node tip"); assert_eq!( original_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 2 @@ -1614,9 +1633,11 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; recovered_node.sync_wallets().unwrap(); + wait_for_node_tip(&recovered_node, new_height) + .await + .expect("node hasn't synced to the node tip"); assert_eq!( recovered_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 3 @@ -2223,10 +2244,12 @@ async fn splice_channel() { open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; // Open a channel with Node A contributing the funding - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the node tip"); let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2325,7 +2348,9 @@ async fn splice_channel() { expect_payment_received_event!(node_a, amount_msat); // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the node tip"); assert_eq!( node_a.list_balances().total_lightning_balance_sats, @@ -2608,6 +2633,11 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { electrsd_conf.network = "regtest"; let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); let chain_source = random_chain_source(&bitcoind, &electrsd); + #[cfg(feature = "chain-cbf")] + if matches!(chain_source, TestChainSource::Cbf(_)) { + println!("Skipping: not CBF-compatible (asserts live-mempool state CBF cannot observe)"); + return; + } let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); @@ -3000,10 +3030,12 @@ async fn simple_bolt12_send_receive() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3311,12 +3343,22 @@ async fn async_payment() { ) .await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_sender.sync_wallets().unwrap(); node_sender_lsp.sync_wallets().unwrap(); node_receiver_lsp.sync_wallets().unwrap(); node_receiver.sync_wallets().unwrap(); + wait_for_node_tip(&node_sender, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_sender_lsp, new_height) + .await + .expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_receiver_lsp, new_height) + .await + .expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_receiver, new_height) + .await + .expect("node hasn't synced to the node tip"); expect_channel_ready_event!(node_sender, node_sender_lsp.node_id()); expect_channel_ready_events!( @@ -3513,10 +3555,12 @@ async fn generate_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the node tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3652,10 +3696,12 @@ async fn unified_send_receive_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3750,11 +3796,14 @@ async fn unified_send_receive_bip21_uri() { }, }; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + // Make sure the transaction reached the mempool before mining, so the blocks include it. wait_for_tx(&electrsd.client, txid).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); assert_eq!(node_b.list_balances().total_onchain_balance_sats, 800_000); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000); @@ -3865,9 +3914,11 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { println!("Opening channel payer_node -> service_node!"); open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await.expect("node hasn't synced to the tip"); + wait_for_node_tip(&payer_node, new_height).await.expect("node hasn't sycned to the tip"); expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); @@ -4064,9 +4115,11 @@ async fn spontaneous_send_with_custom_preimage() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 500_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -4276,9 +4329,12 @@ async fn lsps2_client_trusts_lsp() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&payer_node, new_height).await.expect("node hasn't sycned to the tip"); assert_eq!( client_node .list_channels() @@ -4371,9 +4427,11 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Open a channel payer -> service that will allow paying the JIT invoice open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&payer_node, new_height).await.expect("node hasn't sycned to the tip"); expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); @@ -4406,9 +4464,11 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&client_node, new_height).await.expect("node hasn't sycned to the tip"); assert_eq!( client_node .list_channels() @@ -4469,9 +4529,11 @@ async fn payment_persistence_after_restart() { // Open a large channel from node_a to node_b let channel_amount_sat = 5_000_000; open_channel(&node_a, &node_b, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -4734,6 +4796,11 @@ async fn fs_store_persistence_backwards_compatibility() { async fn onchain_fee_bump_rbf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); + #[cfg(feature = "chain-cbf")] + if matches!(chain_source, TestChainSource::Cbf(_)) { + println!("Skipping: not CBF-compatible (asserts live-mempool state CBF cannot observe)"); + return; + } let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Fund both nodes @@ -4834,9 +4901,11 @@ async fn onchain_fee_bump_rbf() { } // Confirm the transaction and try to bump again (should fail) - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); assert_eq!( Err(NodeError::InvalidPaymentId), @@ -4945,10 +5014,12 @@ async fn open_channel_with_all_with_anchors() { let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); @@ -5099,11 +5170,13 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { opened_with_all_cases.push((variant, node_a, node_b, funding_txo_a)); } - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; for (variant, node_a, node_b, funding_txo) in opened_with_all_cases { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); @@ -5155,10 +5228,12 @@ async fn splice_in_with_all_balance() { // Open a channel with a fixed amount first let funding_txo = open_channel(&node_a, &node_b, channel_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); @@ -5174,10 +5249,12 @@ async fn splice_in_with_all_balance() { // Splice in with all remaining on-chain funds splice_in_with_all(&node_a, &node_b, &user_channel_id_a, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await.expect("node hasn't sycned to the tip"); + wait_for_node_tip(&node_b, new_height).await.expect("node hasn't sycned to the tip"); let _user_channel_id_a2 = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b2 = expect_channel_ready_event!(node_b, node_a.node_id()); diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index aa17f072c3..b1e65abdb0 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -86,6 +86,7 @@ where .find(|balance| matches_balance(balance)) }) .await + .expect("reached max tries") } proptest! { From 899139a8d4c1a42f8a09947a28b53f838e5d8709 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Mon, 8 Jun 2026 13:56:14 +0900 Subject: [PATCH 02/16] fix(cbf): shut down chain source cleanly - Implement CBF requester shutdown and mark runtime status stopped on clean exit. - Route generic chain source stop calls to the CBF backend. AI-assisted-by: OpenAI Codex --- src/chain/cbf.rs | 19 ++++++++++++++++++- src/chain/mod.rs | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index bcaa0aadbc..6a8d29b7ec 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -249,6 +249,7 @@ impl CbfChainSource { match current_node.run().await { Ok(()) => { log_info!(restart_logger, "CBF node shut down cleanly."); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; break; }, Err(e) => { @@ -307,7 +308,23 @@ impl CbfChainSource { } pub(crate) fn stop(&self) { - todo!(); + let requester = { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + match &*status { + CbfRuntimeStatus::Started { requester } => { + let requester = requester.clone(); + *status = CbfRuntimeStatus::Stopped; + Some(requester) + }, + CbfRuntimeStatus::Stopped => None, + } + }; + + if let Some(requester) = requester { + if let Err(e) = requester.shutdown() { + log_error!(self.logger, "Failed to shut down CBF node: {:?}", e); + } + } } async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f07c4d38c5..6e5e9dd7dd 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -409,6 +409,8 @@ impl ChainSource { match &self.kind { #[cfg(feature = "chain-electrum")] ChainSourceKind::Electrum(electrum_chain_source) => electrum_chain_source.stop(), + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.stop(), _ => { // Nothing to do for other chain sources. }, From 9ac13801f64a5b5a84ff792c6bd052e7fd15d9fc Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 22 May 2026 13:05:22 +0900 Subject: [PATCH 03/16] fix(cbf): stop chain source before waiting on tasks - Stop runtime-dependent chain sources before waiting for non-cancellable background tasks. - Allow the CBF requester shutdown to unblock the node run loop during Node::stop. --- src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4e6af48911..014e8fb0f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -899,13 +899,15 @@ impl Node { self.peer_manager.disconnect_all_peers(); log_debug!(self.logger, "Disconnected all network peers."); - // Wait until non-cancellable background tasks (mod LDK's background processor) are done. - self.runtime.wait_on_background_tasks(); - - // Stop any runtime-dependant chain sources. + // Stop any runtime-dependant chain sources before waiting on non-cancellable + // background tasks. Some chain sources own background tasks that only exit + // after their client/requester is shut down. self.chain_source.stop(); log_debug!(self.logger, "Stopped chain sources."); + // Wait until non-cancellable background tasks (mod LDK's background processor) are done. + self.runtime.wait_on_background_tasks(); + // Stop the background processor. self.background_processor_stop_sender .send(()) From abf8c35278b9341afbede209aa8c8eab89dbdca1 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Tue, 9 Jun 2026 05:05:38 +0900 Subject: [PATCH 04/16] fix(cbf): abort restart when stopped during backoff - Check CBF runtime status before publishing a rebuilt requester after restart backoff. - Shut down the newly built requester and exit the restart loop if stop() ran during the backoff. --- src/chain/cbf.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 6a8d29b7ec..34324e30bd 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -294,8 +294,18 @@ impl CbfChainSource { event_rx: new_event_rx, } = new_client; - *restart_status.lock().expect("lock") = - CbfRuntimeStatus::Started { requester: new_requester }; + { + let mut status = restart_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Stopped) { + let _ = new_requester.shutdown(); + log_info!( + restart_logger, + "CBF restart aborted: stop() called during backoff." + ); + break; + } + *status = CbfRuntimeStatus::Started { requester: new_requester }; + } current_node = new_node; current_info_rx = new_info_rx; From 85160a8297ab24fa6d7051623e582303a3dd0a03 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 05:05:56 +0200 Subject: [PATCH 05/16] Implement `process_kyoto_events` (#28) * Add CBF chain source stubs for starting * Implement `process_kyoto_events` and `ChainOp` Co-authored-by: febyeji --- src/chain/cbf.rs | 128 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 34324e30bd..d1ee7f930d 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -9,7 +9,7 @@ use bip157::{ HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; -use lightning::chain::{Listen, WatchedOutput}; +use lightning::chain::{BlockLocator, Listen, WatchedOutput}; use tokio::sync::{mpsc, oneshot}; @@ -60,9 +60,20 @@ pub struct CbfChainSource { } enum ChainOp { - ConnectFull { block_rx: oneshot::Receiver> }, - ConnectFiltered { header: Header, height: u32 }, - //Reorg { /* accepted / reorganized from BlockHeaderChanges */ }, + ConnectFull { + block_rx: oneshot::Receiver>, + }, + ConnectFiltered { + header: Header, + height: u32, + }, + Disconnect { + fork_point: BlockLocator, + }, + /// Marks reaching the chain tip. + Synced { + tip_height: u32, + }, } struct BlockApplicator { @@ -81,9 +92,16 @@ impl BlockApplicator { Err(_) => log_error!(self.logger, "block oneshot dropped"), }, ChainOp::ConnectFiltered { header, height } => { - self.chain_listener.filtered_block_connected(&header, &[], height) + self.chain_listener.filtered_block_connected(&header, &[], height); + }, + ChainOp::Disconnect { fork_point } => { + self.chain_listener.blocks_disconnected(fork_point); + }, + ChainOp::Synced { tip_height } => { + log_info!(self.logger, "CBF caught up to tip {}", tip_height); + // TODO: notify sync-completion waiters (start()/sync_wallets()/tests) once + // a notification primitive is plumbed through. }, - //ChainOp::Reorg { .. } => {}, } } } @@ -240,6 +258,7 @@ impl CbfChainSource { )); let event_handle = tokio::spawn(Self::process_kyoto_events( + Arc::clone(&restart_logger), current_event_rx, Arc::clone(&restart_registered_scripts), Arc::clone(&restart_cbf_runtime_status), @@ -352,45 +371,98 @@ impl CbfChainSource { } async fn process_kyoto_events( - mut event_rx: mpsc::UnboundedReceiver, + logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, ) { while let Some(event) = event_rx.recv().await { match event { - // match download Event::IndexedFilter(indexed_filter) => { + let requester = match &*cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => { + //TODO should we panic here? what do we do if we have no requester? + continue; + }, + }; + let block_hash = indexed_filter.block_hash(); let matched = indexed_filter .contains_any(registered_scripts.lock().expect("lock").iter()); - if matched { - let rtm = &*cbf_runtime_status.lock().expect("lock"); - let requestor = match rtm { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => { - //panic - // todo!(); + + let chop: ChainOp = if matched { + let block_rx = + requester.request_block(block_hash).expect("cannot request block"); + ChainOp::ConnectFull { block_rx } + } else { + let height = indexed_filter.height(); + //TODO we need to recheck that a particular height has not been + //reorganized, and we retrieve indeed the same block header that we + //received `IndexedFilter` event of. right now this would block + //the further sync, as we cannot apply blocks in order. + //Future solution would use something like `get_header_by_hash`. + match requester.get_header(height).await { + Ok(Some(indexed_header)) => { + if indexed_header.block_hash() != block_hash { + log_debug!( + logger, + "Filter for {} reorged; skipping", + block_hash + ); + continue; + } + ChainOp::ConnectFiltered { + header: indexed_header.header, + height: indexed_header.height, + } + }, + Ok(None) => { + log_error!(logger, "No header at height {}", height,); continue; }, - }; - let block_rx = requestor - .request_block(indexed_filter.block_hash()) - .expect("cannot request block"); - let chop = ChainOp::ConnectFull { block_rx }; - //here we feed evets to the driver - ops_tx.send(chop); + Err(e) => { + log_error!( + logger, + "Failed to fetch header at height {}: {:?}", + height, + e, + ); + continue; + }, + } + }; + if let Err(e) = ops_tx.send(chop) { + log_debug!(logger, "ops_rx gone: {}", e); } }, Event::FiltersSynced(sync_update) => { - todo!(); + //Because application of blocks is async, the fact that kyoto synced up to the + //tip does NOT mean that we caught everything up, that's why we send a ChainOp, + //only processing of which means we processed all blocks up to the tip. + log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); + let _ = ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }); }, - Event::ChainUpdate(BlockHeaderChanges::Connected(connected_blocks)) => { - todo!(); + Event::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { + log_debug!( + logger, + "Kyoto connected header at height {}", + indexed_header.height + ); }, - Event::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted }) => { - todo!(); + Event::ChainUpdate(BlockHeaderChanges::Reorganized { + reorganized, + accepted: _, + }) => { + // Rewind to the fork point; kyoto will re-deliver the new chain's filters. + if let Some(lowest) = reorganized.first() { + let fork_point = BlockLocator::new( + lowest.prev_blockhash(), + lowest.height.saturating_sub(1), + ); + let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); + } }, Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { - todo!(); + log_debug!(logger, "Kyoto added fork header at height {}", fork.height); }, } } From d31830ebdca0169b8f60c08b437c2a9c7c3e8f01 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 03:46:21 +0200 Subject: [PATCH 06/16] Implement fee source (#29) * Add CBF chain source stubs for starting * Add waiting for gossip propagation in tests * Populate revealed spks for CBF * Implement `process_kyoto_events` and `ChainOp` Co-authored-by: febyeji --- Cargo.toml | 2 +- src/builder.rs | 36 ++++- src/chain/cbf.rs | 362 +++++++++++++++++++++++++++++++++++++----- src/chain/electrum.rs | 156 +++++++++--------- src/chain/mod.rs | 31 ++-- src/config.rs | 2 +- src/util.rs | 55 +++++++ src/wallet/mod.rs | 13 +- 8 files changed, 504 insertions(+), 153 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f2116a7853..3f713de445 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ chain-electrum = [ "lightning-transaction-sync/electrum-rustls-ring", ] chain-bitcoind = ["dep:lightning-block-sync"] -chain-cbf = ["dep:bip157"] +chain-cbf = ["dep:bip157", "chain-esplora", "chain-electrum"] storage-sqlite = ["dep:rusqlite"] storage-filesystem = ["dep:lightning-persister"] storage-vss = ["dep:vss-client", "dep:prost"] diff --git a/src/builder.rs b/src/builder.rs index a3988f92d7..8686e58b8c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -53,6 +53,8 @@ use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; use crate::chain::ChainSource; +#[cfg(feature = "chain-cbf")] +use crate::chain::CbfFeeSourceConfig; #[cfg(feature = "chain-bitcoind")] use crate::config::BitcoindRestClientConfig; use crate::config::{ @@ -134,6 +136,11 @@ enum ChainDataSourceConfig { rest_client_config: Option, wallet_rescan_from_height: Option, }, + #[cfg(feature = "chain-cbf")] + Cbf { + peers: Vec, + fee_source_config: Option, + }, } #[derive(Debug, Clone)] @@ -432,6 +439,20 @@ impl NodeBuilder { self } + /// Configures the [`Node`] instance to source chain data via compact block filters + /// (BIP157/BIP158), connecting to the given peers (`ip:port`). + /// + /// `fee_source_config` optionally delegates fee estimation to an Esplora or Electrum server; + /// if `None`, fee rates are derived from recent blocks. + #[cfg(feature = "chain-cbf")] + pub fn set_chain_source_cbf( + &mut self, peers: Vec, fee_source_config: Option, + ) -> &mut Self { + self.chain_data_source_config = + Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }); + self + } + /// Configures the [`Node`] instance to connect to a Bitcoin Core node via RPC. /// /// This method establishes an RPC connection that enables all essential chain operations including @@ -1649,8 +1670,19 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, - //TODO add here an arm - // Some(ChainDataSoucrConfig::Cbf) + #[cfg(feature = "chain-cbf")] + Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }) => ChainSource::new_cbf( + peers.clone(), + fee_source_config.clone(), + Arc::clone(&runtime), + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .map_err(|_| BuildError::ChainSourceSetupFailed)?, #[cfg(feature = "chain-bitcoind")] Some(ChainDataSourceConfig::Bitcoind { rpc_host, diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index d1ee7f930d..789ece0c58 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -1,24 +1,34 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, }; -use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; +use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Txid}; +use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; +use crate::chain::electrum::get_electrum_fee_rate_cache_update; use crate::chain::{CbfFeeSourceConfig, ChainListener}; -use crate::config::Config; +use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; -use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::fee_estimator::{ + apply_post_estimation_adjustments, get_all_conf_targets, get_fallback_rate_for_target, + get_num_block_defaults_for_target, ConfirmationTarget, OnchainFeeEstimator, +}; +use crate::io::utils::update_and_persist_node_metrics; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::types::{ChainMonitor, ChannelManager, Sweeper, Wallet}; +use crate::types::DynStore; +use crate::util::{cbf_percentile_for_target, coinbase_fee_rate, percentile_of_sorted}; +use crate::wallet::Wallet; +use crate::PersistedNodeMetrics; /// Walk back this many blocks from the wallet's persisted tip when deriving /// the kyoto resume checkpoint, so a recent reorg cannot strand the node @@ -40,6 +50,10 @@ const INITIAL_BACKOFF_MS: u64 = 500; const ESPLORA_TIMEOUT: u64 = 2; +/// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. +const ELECTRUM_FEE_NUM_RETRIES: u8 = 3; +const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; + /// Runtime status of the underlying kyoto node. enum CbfRuntimeStatus { Started { requester: Requester }, @@ -50,12 +64,18 @@ enum CbfRuntimeStatus { pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. trusted_peers: Vec, + /// Scripts tracked by LDK, onchain wallet's scripts are pulled from the onchain wallet registered_scripts: Arc>>, fee_source: FeeSource, /// Tracks whether the kyoto node is running and holds the live requester. cbf_runtime_status: Arc>, + /// Handle used to spawn the background tasks and offload blocking work. + runtime: Arc, /// Node configuration (network, storage path). config: Arc, + fee_estimator: Arc, + kv_store: Arc, + node_metrics: Arc, logger: Arc, } @@ -79,6 +99,9 @@ enum ChainOp { struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, + /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download + /// here, so the fee estimator doesn't have to re-download them. + block_fee_cache: Option, logger: Arc, } @@ -87,7 +110,16 @@ impl BlockApplicator { while let Some(op) = self.ops_rx.recv().await { match op { ChainOp::ConnectFull { block_rx } => match block_rx.await { - Ok(Ok(ib)) => self.chain_listener.block_connected(&ib.block, ib.height), + Ok(Ok(ib)) => { + self.chain_listener.block_connected(&ib.block, ib.height); + if let Some(cache) = &self.block_fee_cache { + let fee_rate = coinbase_fee_rate(&ib.block, ib.height); + cache + .lock() + .expect("lock") + .insert(ib.height, (ib.block.block_hash(), fee_rate)); + } + }, Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), Err(_) => log_error!(self.logger, "block oneshot dropped"), }, @@ -107,9 +139,28 @@ impl BlockApplicator { } } +/// Number of most recent blocks whose coinbase-derived fee rates feed the native CBF estimator. +const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32; + +/// Lower bound for native CBF fee estimates (1 sat/vB), matching the floor used by the Esplora and +/// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet. +const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; + +/// Per-block timeout when downloading a block to derive its coinbase fee rate. Kept short so a +/// slow peer only delays a single sample rather than the whole fee update. +const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; + +/// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict +/// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the +/// canonical chain). Shared via `Arc` between the fee estimator and the [`BlockApplicator`]. +type BlockFeeCache = Arc>>; + enum FeeSource { /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. - Cbf { block_fee_cache: Mutex> }, + /// + /// The [`BlockApplicator`] also opportunistically inserts the fee rate of any block it already + /// downloads on a filter match, saving a re-download in the reconciliation loop. + Cbf { block_fee_cache: BlockFeeCache }, /// Delegate fee estimation to an Esplora HTTP server. Esplora { client: esplora_client::AsyncClient }, /// Delegate fee estimation to an Electrum server. @@ -118,21 +169,11 @@ enum FeeSource { Electrum { server_url: String }, } -impl FeeSource { - fn insert_cached_block(&self, block_hash: BlockHash, fee_rate: FeeRate) { - match &self { - Self::Cbf { block_fee_cache } => { - block_fee_cache.lock().expect("lock").push_back((block_hash, fee_rate)); - }, - _ => {}, - } - } -} - impl CbfChainSource { pub(crate) fn new( - peers: Vec, fee_source_config: Option, config: Arc, - logger: Arc, + peers: Vec, fee_source_config: Option, runtime: Arc, + fee_estimator: Arc, kv_store: Arc, config: Arc, + logger: Arc, node_metrics: Arc, ) -> Result { let trusted_peers: Vec = peers .iter() @@ -152,9 +193,7 @@ impl CbfChainSource { FeeSource::Esplora { client } }, Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, - None => FeeSource::Cbf { - block_fee_cache: Mutex::new(VecDeque::with_capacity(BLOCK_FEE_CACHE_CAPACITY)), - }, + None => FeeSource::Cbf { block_fee_cache: Arc::new(Mutex::new(BTreeMap::new())) }, }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); @@ -163,7 +202,11 @@ impl CbfChainSource { fee_source, registered_scripts, cbf_runtime_status, + runtime, config, + fee_estimator, + kv_store, + node_metrics, logger, }) } @@ -199,14 +242,7 @@ impl CbfChainSource { kyoto_builder.build() } - pub(crate) fn start(&self, runtime: Arc, chain_listener: ChainListener) { - //we populate registered scripts with all the scripts from the onchain wallet - if let Some(onchain_wallet) = chain_listener.onchain_wallet.upgrade() { - for script in onchain_wallet.list_revealed_scripts() { - self.register_script(script); - } - } - + pub(crate) fn start(&self, chain_listener: ChainListener) { let (node, client) = Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); let Client { requester, info_rx, warn_rx, event_rx } = client; @@ -221,12 +257,17 @@ impl CbfChainSource { } let (ops_tx, ops_rx) = mpsc::unbounded_channel(); + let block_fee_cache = match &self.fee_source { + FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), + _ => None, + }; let block_applicator = BlockApplicator { chain_listener: chain_listener.clone(), ops_rx, + block_fee_cache, logger: Arc::clone(&self.logger), }; - runtime.spawn_background_task(block_applicator.run()); + self.runtime.spawn_background_task(block_applicator.run()); log_info!(self.logger, "CBF chain source started."); @@ -239,7 +280,7 @@ impl CbfChainSource { let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); // let restart_block_applicator = - runtime.spawn_background_task(async move { + self.runtime.spawn_background_task(async move { let mut current_node = node; let mut current_info_rx = info_rx; let mut current_warn_rx = warn_rx; @@ -263,6 +304,7 @@ impl CbfChainSource { Arc::clone(&restart_registered_scripts), Arc::clone(&restart_cbf_runtime_status), ops_tx.clone(), + restart_listener.onchain_wallet.clone(), )); match current_node.run().await { @@ -374,10 +416,15 @@ impl CbfChainSource { logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, + onchain_wallet: std::sync::Weak, ) { while let Some(event) = event_rx.recv().await { match event { Event::IndexedFilter(indexed_filter) => { + let Some(onchain_wallet) = onchain_wallet.upgrade() else { + log_debug!(logger, "Onchain wallet dropped; stopping CBF event processing"); + break; + }; let requester = match &*cbf_runtime_status.lock().expect("lock") { CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { @@ -385,9 +432,16 @@ impl CbfChainSource { continue; }, }; + //registered_scripts contains only LDK scripts, not onchain wallet's scripts, + //as don't want to track them twice: once in bdk, once in CbfChainSource, thus + //each time we receive an IndexedFilter event, we ask bdk to give us all + //revealed scripts. We create all_scripts starting from onchain wallet's + //scripts and extend them with LDK's ones + let mut all_scripts = onchain_wallet.list_revealed_scripts(); + all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned()); + let block_hash = indexed_filter.block_hash(); - let matched = indexed_filter - .contains_any(registered_scripts.lock().expect("lock").iter()); + let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { let block_rx = @@ -476,8 +530,238 @@ impl CbfChainSource { self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); } - pub(crate) fn register_script(&self, script: ScriptBuf) { - self.registered_scripts.lock().expect("lock").insert(script); + // pub(crate) fn register_script(&self, script: ScriptBuf) { + // self.registered_scripts.lock().expect("lock").insert(script); + // } + + pub(crate) async fn continuously_update_fee_rate_estimates( + &self, mut stop_sync_receiver: watch::Receiver<()>, + ) { + let mut fee_rate_update_interval = + tokio::time::interval(Duration::from_secs(DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS)); + // We primed the cache once on startup, so skip the immediate first tick. + fee_rate_update_interval.reset(); + fee_rate_update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = stop_sync_receiver.changed() => { + log_trace!(self.logger, "Stopping CBF fee-rate update loop."); + return; + } + _ = fee_rate_update_interval.tick() => { + if let Err(e) = self.update_fee_rate_estimates().await { + log_error!(self.logger, "Failed to update fee rate estimates: {:?}", e); + } + } + } + } + } + + pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { + let new_fee_rate_cache = match &self.fee_source { + FeeSource::Esplora { client } => { + let estimates = client.get_fee_estimates().await.map_err(|e| { + log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if estimates.is_empty() && self.config.network == Network::Bitcoin { + log_error!( + self.logger, + "Failed to retrieve fee rate: empty fee estimates are disallowed on Mainnet." + ); + return Err(Error::FeerateEstimationUpdateFailed); + } + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target); + // Fall back to 1 sat/vb if we fail or it yields less than that, mostly to keep + // going on signet/regtest where estimates may be missing or bogus. + let converted_estimate_sat_vb = + esplora_client::convert_fee_rate(num_blocks, estimates.clone()) + .map_or(1.0, |converted| converted.max(1.0)); + let fee_rate = + FeeRate::from_sat_per_kwu((converted_estimate_sat_vb * 250.0) as u64); + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + FeeSource::Electrum { server_url } => { + let electrum_config = ElectrumConfigBuilder::new() + .retry(ELECTRUM_FEE_NUM_RETRIES) + .timeout(Some(Duration::from_secs(ELECTRUM_FEE_TIMEOUT_SECS))) + .build(); + + let server_url = server_url.clone(); + let electrum_client = self + .runtime + .spawn_blocking(move || { + ElectrumClient::from_config(&server_url, electrum_config) + }) + .await + .map_err(|e| { + log_error!(self.logger, "Fee rate estimation task panicked: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to connect to electrum server: {}", e); + Error::ConnectionFailed + })?; + + get_electrum_fee_rate_cache_update( + Arc::clone(&self.runtime), + Arc::new(electrum_client), + self.config.network, + ELECTRUM_FEE_TIMEOUT_SECS, + Arc::clone(&self.logger), + ) + .await? + }, + FeeSource::Cbf { block_fee_cache } => { + let requester = self.requester()?; + let mut samples_sat_per_kwu: Vec = self + .refresh_block_fee_window(&requester, block_fee_cache) + .await + .iter() + .map(|rate| rate.to_sat_per_kwu()) + .collect(); + samples_sat_per_kwu.sort_unstable(); + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let fee_rate = if samples_sat_per_kwu.is_empty() { + FeeRate::from_sat_per_kwu(get_fallback_rate_for_target(target) as u64) + } else { + let percentile = cbf_percentile_for_target(target); + let sat_per_kwu = percentile_of_sorted(&samples_sat_per_kwu, percentile) + .max(CBF_MIN_FEERATE_SAT_PER_KWU); + FeeRate::from_sat_per_kwu(sat_per_kwu) + }; + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + }; + + self.commit_fee_rate_cache(new_fee_rate_cache).await + } + + /// Writes a freshly computed per-target fee-rate map into the estimator cache and records the + /// update timestamp in the node metrics. + async fn commit_fee_rate_cache( + &self, new_fee_rate_cache: HashMap, + ) -> Result<(), Error> { + self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + update_and_persist_node_metrics(&self.node_metrics, &*self.kv_store, &*self.logger, |m| { + m.latest_fee_rate_cache_update_timestamp = unix_time_secs_opt + }) + .await?; + Ok(()) + } + + /// Returns a clone of the live kyoto requester, or an error if the node isn't running. + fn requester(&self) -> Result { + match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => Ok(requester.clone()), + CbfRuntimeStatus::Stopped => { + debug_assert!( + false, + "We should have started the chain source before updating fees" + ); + Err(Error::FeerateEstimationUpdateFailed) + }, + } + } + + /// Reconciles the block-fee cache against the canonical chain and returns the per-block fee + /// rates for the most recent [`FEE_WINDOW_BLOCKS`] blocks. + /// + /// For each height in the window we fetch the canonical block hash; if the cached entry still + /// matches we reuse its rate, otherwise (new block, or a block that was reorged out) we download + /// it via [`Requester::average_fee_rate`]. Heights outside the window are evicted by replacing + /// the cache with the freshly built window. + /// + /// This is best-effort: a height we can't fetch a header or block for is simply skipped (so a + /// slow or unresponsive peer can't stall or void the whole update), and an empty result just + /// means we have no recent data yet. The window therefore fills incrementally over successive + /// updates rather than requiring all [`FEE_WINDOW_BLOCKS`] downloads to succeed at once. + async fn refresh_block_fee_window( + &self, requester: &Requester, cache: &Mutex>, + ) -> Vec { + let tip_height = match requester.chain_tip().await { + Ok(tip) => tip.height, + Err(e) => { + log_error!(self.logger, "CBF fee update: failed to fetch chain tip: {:?}", e); + return Vec::new(); + }, + }; + let lo = tip_height.saturating_sub(FEE_WINDOW_BLOCKS - 1); + + // Snapshot the cache so we never hold the std `Mutex` across an `.await`. + let cached = cache.lock().expect("lock").clone(); + + let mut window = BTreeMap::new(); + for height in lo..=tip_height { + let canonical_hash = match requester.get_header(height).await { + // Height not available (yet); skip it. + Ok(None) => continue, + Ok(Some(header)) => header.block_hash(), + Err(e) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch header at height {}, skipping: {:?}", + height, + e + ); + continue; + }, + }; + + // Reuse the cached rate while the block is still canonical; otherwise download it. + if let Some((hash, fee_rate)) = cached.get(&height) { + if *hash == canonical_hash { + window.insert(height, (canonical_hash, *fee_rate)); + continue; + } + } + + match tokio::time::timeout( + Duration::from_secs(CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS), + requester.average_fee_rate(canonical_hash), + ) + .await + { + Ok(Ok(fee_rate)) => { + window.insert(height, (canonical_hash, fee_rate)); + }, + Ok(Err(e)) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch fee rate for block {}, skipping: {:?}", + canonical_hash, + e + ); + }, + Err(_) => { + log_debug!( + self.logger, + "CBF fee update: timed out fetching block {} for fee estimation, skipping.", + canonical_hash, + ); + }, + } + } + + let samples = window.values().map(|(_, fee_rate)| *fee_rate).collect(); + // Replacing the cache wholesale also evicts any entries that fell out of the window. + *cache.lock().expect("lock") = window; + samples } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 86025998e8..7ba927c54a 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -301,7 +301,14 @@ impl ElectrumChainSource { let now = Instant::now(); - let new_fee_rate_cache = electrum_client.get_fee_rate_cache_update().await?; + let new_fee_rate_cache = get_electrum_fee_rate_cache_update( + Arc::clone(&electrum_client.runtime), + Arc::clone(&electrum_client.electrum_client), + self.config.network, + self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, + Arc::clone(&self.logger), + ) + .await?; self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); log_debug!( @@ -738,91 +745,84 @@ impl ElectrumRuntimeClient { Err(e) => self.log_broadcast_error(e, &txids, &package), } } +} - async fn get_fee_rate_cache_update( - &self, - ) -> Result, Error> { - let electrum_client = Arc::clone(&self.electrum_client); - - let mut batch = Batch::default(); - let confirmation_targets = get_all_conf_targets(); - for target in confirmation_targets { - let num_blocks = get_num_block_defaults_for_target(target); - batch.estimate_fee(num_blocks, None); - } - - let spawn_fut = self.runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); +pub(crate) async fn get_electrum_fee_rate_cache_update( + runtime: Arc, electrum_client: Arc, network: Network, + fee_rate_cache_update_timeout_secs: u64, logger: Arc, +) -> Result, Error> { + let mut batch = Batch::default(); + let confirmation_targets = get_all_conf_targets(); + for target in confirmation_targets { + let num_blocks = get_num_block_defaults_for_target(target); + batch.estimate_fee(num_blocks, None); + } - let timeout_fut = tokio::time::timeout( - Duration::from_secs( - self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, - ), - spawn_fut, + let spawn_fut = runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); + + let timeout_fut = + tokio::time::timeout(Duration::from_secs(fee_rate_cache_update_timeout_secs), spawn_fut); + + let raw_estimates_btc_kvb = timeout_fut + .await + .map_err(|e| { + log_error!(logger, "Updating fee rate estimates timed out: {}", e); + Error::FeerateEstimationUpdateTimeout + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if raw_estimates_btc_kvb.len() != confirmation_targets.len() && network == Network::Bitcoin { + // Ensure we fail if we didn't receive all estimates. + debug_assert!( + false, + "Electrum server didn't return all expected results. This is disallowed on Mainnet." ); - - let raw_estimates_btc_kvb = timeout_fut - .await - .map_err(|e| { - log_error!(self.logger, "Updating fee rate estimates timed out: {}", e); - Error::FeerateEstimationUpdateTimeout - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })?; - - if raw_estimates_btc_kvb.len() != confirmation_targets.len() - && self.config.network == Network::Bitcoin - { - // Ensure we fail if we didn't receive all estimates. - debug_assert!(false, - "Electrum server didn't return all expected results. This is disallowed on Mainnet." - ); - log_error!(self.logger, + log_error!(logger, "Failed to retrieve fee rate estimates: Electrum server didn't return all expected results. This is disallowed on Mainnet." ); - return Err(Error::FeerateEstimationUpdateFailed); - } + return Err(Error::FeerateEstimationUpdateFailed); + } - let mut new_fee_rate_cache = HashMap::with_capacity(10); - for (target, raw_fee_rate_btc_per_kvb) in - confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) - { - // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 - // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary - // to continue on `signet`/`regtest` where we might not get estimates (or bogus - // values). - let fee_rate_btc_per_kvb = raw_fee_rate_btc_per_kvb - .as_f64() - .map_or(0.00001, |converted| converted.max(0.00001)); - - // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. - // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. - let fee_rate = { - let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; - FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) - }; - - // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that - // require some post-estimation adjustments to the fee rates, which we do here. - let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - - new_fee_rate_cache.insert(target, adjusted_fee_rate); - - log_trace!( - self.logger, - "Fee rate estimation updated for {:?}: {} sats/kwu", - target, - adjusted_fee_rate.to_sat_per_kwu(), - ); - } + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for (target, raw_fee_rate_btc_per_kvb) in + confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) + { + // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 + // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary + // to continue on `signet`/`regtest` where we might not get estimates (or bogus + // values). + let fee_rate_btc_per_kvb = + raw_fee_rate_btc_per_kvb.as_f64().map_or(0.00001, |converted| converted.max(0.00001)); + + // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. + // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. + let fee_rate = { + let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) + }; + + // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that + // require some post-estimation adjustments to the fee rates, which we do here. + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - Ok(new_fee_rate_cache) + new_fee_rate_cache.insert(target, adjusted_fee_rate); + + log_trace!( + logger, + "Fee rate estimation updated for {:?}: {} sats/kwu", + target, + adjusted_fee_rate.to_sat_per_kwu(), + ); } + + Ok(new_fee_rate_cache) } struct ConfirmGate { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 6e5e9dd7dd..13ef0ada45 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -364,7 +364,7 @@ impl ChainSource { #[cfg(feature = "chain-cbf")] pub(crate) fn new_cbf( - peers: Vec, fee_source_config: Option, + peers: Vec, fee_source_config: Option, runtime: Arc, fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc, @@ -372,8 +372,12 @@ impl ChainSource { let cbf_chain_source = CbfChainSource::new( peers, fee_source_config, + runtime, + Arc::clone(&fee_estimator), + Arc::clone(&kv_store), Arc::clone(&config), Arc::clone(&logger), + Arc::clone(&node_metrics), )?; let kind = ChainSourceKind::Cbf(cbf_chain_source); let registered_txids = Mutex::new(HashSet::new()); @@ -396,7 +400,7 @@ impl ChainSource { chain_monitor: Arc::downgrade(&chain_monitor), output_sweeper: Arc::downgrade(&output_sweeper), }; - cbf_chain_source.start(runtime, chain_listener); + cbf_chain_source.start(chain_listener); }, _ => { // Nothing to do for other chain sources. @@ -438,14 +442,6 @@ impl ChainSource { } } - pub(crate) fn register_script(&self, script: ScriptBuf) { - match &self.kind { - #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf(cbf) => cbf.register_script(script), - _ => {}, // no-op: Esplora/Electrum/bitcoind don't need a watch set - } - } - pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -531,14 +527,9 @@ impl ChainSource { }, #[cfg(feature = "chain-cbf")] ChainSourceKind::Cbf(cbf_chain_source) => { - todo!(); - // cbf_chain_source.process_kyoto_events( - // stop_sync_receiver, - // onchain_wallet, - // channel_manager, - // chain_monitor, - // output_sweeper, - // ); + //CBF cannot run without background syncing, when the chain source is running, it + //syncs. Thus we don't have anything similar to other chain sources. + cbf_chain_source.continuously_update_fee_rate_estimates(stop_sync_receiver).await }, } } @@ -714,8 +705,8 @@ impl ChainSource { bitcoind_chain_source.update_fee_rate_estimates().await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf { .. } => { - todo!(); + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.update_fee_rate_estimates().await }, } } diff --git a/src/config.rs b/src/config.rs index cb74b55c80..b7176e056f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,7 @@ use crate::logger::LogLevel; const DEFAULT_NETWORK: Network = Network::Bitcoin; const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80; const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; -const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; +pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; pub(crate) const MIN_PROBING_INTERVAL: Duration = Duration::from_millis(100); diff --git a/src/util.rs b/src/util.rs index 3350ad2c70..8cd86665a2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,61 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +//! Miscellaneous pure helper functions. + +use bitcoin::constants::SUBSIDY_HALVING_INTERVAL; +use bitcoin::{Amount, Block, FeeRate}; + +use crate::fee_estimator::{get_num_block_defaults_for_target, ConfirmationTarget}; + +/// Block subsidy at the given height (approximate on regtest). +pub(crate) fn block_subsidy(height: u32) -> Amount { + let halvings = height / SUBSIDY_HALVING_INTERVAL; + if halvings >= 64 { + return Amount::ZERO; + } + Amount::from_sat((Amount::ONE_BTC.to_sat() * 50) >> halvings) +} + +/// Average fee rate of a block, derived from its coinbase: `(coinbase output total - subsidy) / +/// weight`. Lets us compute the fee rate of a block we already hold without a re-download. +pub(crate) fn coinbase_fee_rate(block: &Block, height: u32) -> FeeRate { + let revenue: Amount = block + .txdata + .first() + .map(|coinbase| coinbase.output.iter().map(|txout| txout.value).sum()) + .unwrap_or(Amount::ZERO); + let block_fees = revenue.checked_sub(block_subsidy(height)).unwrap_or(Amount::ZERO); + let fee_rate = block_fees.to_sat().checked_div(block.weight().to_kwu_floor()).unwrap_or(0); + FeeRate::from_sat_per_kwu(fee_rate) +} + +/// Maps a confirmation target to the percentile of the recent-block fee-rate window we read for it. +/// +/// More urgent targets (shorter confirmation horizon) read a higher percentile; relaxed targets +/// read a lower one. This is a coarse stand-in for the per-horizon estimates a mempool-aware +/// backend would provide. +pub(crate) fn cbf_percentile_for_target(target: ConfirmationTarget) -> f64 { + match get_num_block_defaults_for_target(target) { + 0..=2 => 90.0, + 3..=6 => 75.0, + 7..=12 => 50.0, + 13..=144 => 25.0, + _ => 10.0, + } +} + +/// Returns the value at the given percentile of an ascending-sorted slice using nearest-rank. +/// Returns `0` for an empty slice. +pub(crate) fn percentile_of_sorted(sorted: &[u64], percentile: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let rank = ((percentile / 100.0) * sorted.len() as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[idx] +} + /// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). pub(crate) fn random_range(min: u64, max: u64) -> u64 { debug_assert!(min <= max); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f6d56b82f6..f38769e388 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -36,7 +36,7 @@ use lightning::chain::chaininterface::{ INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; -use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -311,14 +311,6 @@ impl Wallet { .collect() } - /// Register scripts that BDK revealed at index time (e.g. change outputs, which `create_tx` - /// only peeks) with the chain source's watch set. No-op for non-CBF backends. - fn register_revealed_scripts(&self, _locked_wallet: &PersistedWallet) { - // TODO(cbf): diff `last_revealed_index(keychain)` against a per-keychain cursor and - // `chain_source.register_script(spk)` the delta for both keychains. - todo!() - } - async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); @@ -793,7 +785,6 @@ impl Wallet { .saturating_sub(locked_pool.available.len() + locked_pool.unpublished.len()); for _ in 0..needed { let address_info = locked_wallet.reveal_next_address(KeychainKind::External); - self.chain_source.register_script(address_info.script_pubkey()); locked_pool.unpublished.push((address_info.index, address_info.address)); } // Hand the reveals straight to the persister: this refill may run as a task the @@ -870,7 +861,6 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; - self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -1465,7 +1455,6 @@ impl Wallet { locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); })?; - self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address.script_pubkey()) } From ae5a376eb83efb089021af9bad67ea82a79ab65d Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 15:54:35 +0200 Subject: [PATCH 07/16] cbf: implement package broadcasting Co-authored-by: febyeji --- src/chain/cbf.rs | 74 ++++++++++++++++++++++++++++++++++++++++-------- src/chain/mod.rs | 16 +++++++++-- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 789ece0c58..6ea88d8057 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -6,9 +6,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, - HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, + Warning, }; -use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Txid}; +use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; @@ -150,6 +151,13 @@ const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; /// slow peer only delays a single sample rather than the whole fee update. const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; +/// Upper bound on how long we wait for a peer to request a broadcast transaction. Kyoto resolves +/// `submit_package` only once a peer asks for the transaction, so it never resolves if peer +/// already has it. LDK re-broadcasts unconfirmed funding transactions on every monitor update, and +/// the broadcast queue is processed serially, so this must stay short or those repeats back up +/// the queue and delay unrelated broadcasts. +const CBF_BROADCAST_TIMEOUT_SECS: u64 = 1; + /// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict /// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the /// canonical chain). Shared via `Arc` between the fee estimator and the [`BlockApplicator`]. @@ -621,7 +629,10 @@ impl CbfChainSource { .await? }, FeeSource::Cbf { block_fee_cache } => { - let requester = self.requester()?; + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return Err(Error::FeerateEstimationUpdateFailed), + }; let mut samples_sat_per_kwu: Vec = self .refresh_block_fee_window(&requester, block_fee_cache) .await @@ -665,16 +676,55 @@ impl CbfChainSource { Ok(()) } - /// Returns a clone of the live kyoto requester, or an error if the node isn't running. - fn requester(&self) -> Result { - match &*self.cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => Ok(requester.clone()), + pub(crate) async fn process_broadcast_package(&self, package: Vec) { + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { - debug_assert!( - false, - "We should have started the chain source before updating fees" - ); - Err(Error::FeerateEstimationUpdateFailed) + debug_assert!(false, "We should have started the chain source before broadcasting"); + return; + }, + }; + + let timeout = Duration::from_secs(CBF_BROADCAST_TIMEOUT_SECS); + match Package::from_vec(package.clone()) { + Ok(package) => { + match tokio::time::timeout(timeout, requester.submit_package(package)).await { + Ok(Err(e)) => { + log_error!(self.logger, "Failed to broadcast transaction package: {:?}", e); + }, + Err(_) => { + log_debug!( + self.logger, + "No peer requested the transaction package within {}s, it may already be known", + CBF_BROADCAST_TIMEOUT_SECS + ); + }, + Ok(Ok(_)) => {}, + } + }, + Err(_) => { + for tx in package { + let txid = tx.compute_txid(); + match tokio::time::timeout(timeout, requester.submit_package(tx)).await { + Ok(Err(e)) => { + log_error!( + self.logger, + "Failed to broadcast transaction {}: {:?}", + txid, + e + ); + }, + Err(_) => { + log_debug!( + self.logger, + "No peer requested transaction {} within {}s, it may already be known", + txid, + CBF_BROADCAST_TIMEOUT_SECS + ); + }, + Ok(Ok(_)) => {}, + } + } }, } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 13ef0ada45..f942173f90 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -731,6 +731,14 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.validate_zero_fee_commitments_support().await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf(_) => { + log_error!( + self.logger, + "CBF chain sources cannot verify zero-fee commitment package relay support" + ); + Err(Error::ChainSourceNotSupported) + }, } } @@ -778,9 +786,11 @@ impl ChainSource { bitcoind_chain_source.process_transaction_broadcast(package).await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf { ..} => { - todo!(); - } + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source + .process_broadcast_package(package.into_inner()) + .await + }, } } } From 8999982393b0372a8945de7cd1c1d3366e0dd4b7 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 00:35:52 +0300 Subject: [PATCH 08/16] Add `next_height` to the block applicator Also added env var for the CBF tests, also waiting for tx gossip for broadcast in some of the tests. --- src/chain/cbf.rs | 41 ++++++++++++++++++++++++++++++++++------- src/chain/mod.rs | 3 ++- src/wallet/mod.rs | 13 +++++++------ tests/common/mod.rs | 26 +++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 6ea88d8057..8b0b87c654 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -100,6 +100,7 @@ enum ChainOp { struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, + next_height: u32, /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download /// here, so the fee estimator doesn't have to re-download them. block_fee_cache: Option, @@ -112,7 +113,17 @@ impl BlockApplicator { match op { ChainOp::ConnectFull { block_rx } => match block_rx.await { Ok(Ok(ib)) => { + if ib.height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + ib.height, + self.next_height + ); + continue; + } self.chain_listener.block_connected(&ib.block, ib.height); + self.next_height += 1; if let Some(cache) = &self.block_fee_cache { let fee_rate = coinbase_fee_rate(&ib.block, ib.height); cache @@ -125,10 +136,21 @@ impl BlockApplicator { Err(_) => log_error!(self.logger, "block oneshot dropped"), }, ChainOp::ConnectFiltered { header, height } => { + if height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + height, + self.next_height + ); + continue; + } self.chain_listener.filtered_block_connected(&header, &[], height); + self.next_height += 1; }, ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); + self.next_height = fork_point.height + 1; }, ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); @@ -270,6 +292,7 @@ impl CbfChainSource { _ => None, }; let block_applicator = BlockApplicator { + next_height: chain_listener.get_best_block().height + 1, chain_listener: chain_listener.clone(), ops_rx, block_fee_cache, @@ -342,14 +365,13 @@ impl CbfChainSource { backoff_ms, ); - tokio::time::sleep(Duration::from_millis(backoff_ms)).await; - backoff_ms = backoff_ms.saturating_mul(2); - // Abort the old log consumers before rebuilding. info_handle.abort(); warn_handle.abort(); event_handle.abort(); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = backoff_ms.saturating_mul(2); let (new_node, new_client) = Self::build_kyoto( &restart_peers, &restart_config, @@ -452,9 +474,11 @@ impl CbfChainSource { let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { - let block_rx = - requester.request_block(block_hash).expect("cannot request block"); - ChainOp::ConnectFull { block_rx } + if let Ok(handle) = requester.request_block(block_hash) { + ChainOp::ConnectFull { block_rx: handle } + } else { + break; + } } else { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been @@ -478,6 +502,8 @@ impl CbfChainSource { } }, Ok(None) => { + //TODO what do we do? + todo!(); log_error!(logger, "No header at height {}", height,); continue; }, @@ -488,7 +514,8 @@ impl CbfChainSource { height, e, ); - continue; + break; + // continue; }, } }; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f942173f90..11b2a5a16a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -20,7 +20,7 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, ScriptBuf, Transaction, Txid}; +use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, Filter}; #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] use lightning::chain::Listen; @@ -685,6 +685,7 @@ impl ChainSource { }, #[cfg(feature = "chain-cbf")] ChainSourceKind::Cbf { .. } => { + return Ok(()); todo!(); }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f38769e388..d74c6c0316 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2338,13 +2338,14 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { impl Listen for Wallet { fn filtered_block_connected( - &self, _header: &bitcoin::block::Header, - _txdata: &lightning::chain::transaction::TransactionData, _height: u32, + &self, header: &bitcoin::block::Header, + _txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - debug_assert!(false, "Syncing filtered blocks is currently not supported"); - // As far as we can tell this would be a no-op anyways as we don't have to tell BDK about - // the header chain of intermediate blocks. According to the BDK team, it's sufficient to - // only connect full blocks starting from the last point of disagreement. + // A non-matching filter means none of this block's transactions are relevant to us, so there + // is nothing but the header to apply. We still connect an empty block built from the header + // to keep the on-chain wallet's chain contiguous with the listeners. + let block = bitcoin::Block { header: *header, txdata: Vec::new() }; + self.block_connected(&block, height); } fn block_connected(&self, block: &bitcoin::Block, height: u32) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 810f1da942..a06eb157f9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -352,6 +352,10 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { let mut bitcoind_conf = corepc_node::Conf::default(); bitcoind_conf.network = "regtest"; bitcoind_conf.args.push("-rest"); + // Enable P2P and compact block filters so the CBF (BIP157) chain source can connect and sync. + bitcoind_conf.p2p = corepc_node::P2P::Yes; + bitcoind_conf.args.push("-blockfilterindex=1"); + bitcoind_conf.args.push("-peerblockfilters=1"); let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); let electrs_exe = env::var("ELECTRS_EXE") @@ -368,7 +372,7 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { pub(crate) fn random_chain_source<'a>( bitcoind: &'a BitcoinD, electrsd: &'a ElectrsD, ) -> TestChainSource<'a> { - #[cfg(not(feature = "chain-bitcoind"))] + #[cfg(not(any(feature = "chain-bitcoind", feature = "chain-cbf")))] let _ = bitcoind; let configured_sources = env::var("LDK_NODE_TEST_CHAIN_SOURCES").ok().map(|value| { @@ -389,6 +393,8 @@ pub(crate) fn random_chain_source<'a>( sources.push("BITCOIND_RPC".to_string()); sources.push("BITCOIND_REST".to_string()); } + #[cfg(feature = "chain-cbf")] + sources.push("CBF".to_string()); sources }); let source = &sources[rand::random_range(0..sources.len())]; @@ -413,6 +419,11 @@ pub(crate) fn random_chain_source<'a>( println!("Randomly setting up Bitcoind REST chain syncing..."); TestChainSource::BitcoindRestSync(bitcoind) }, + #[cfg(feature = "chain-cbf")] + "CBF" => { + println!("Randomly setting up CBF compact block filter syncing..."); + TestChainSource::Cbf(bitcoind) + }, _ => panic!("Unknown test chain source: {source}"), } } @@ -659,6 +670,8 @@ pub(crate) enum TestChainSource<'a> { BitcoindRpcSync(&'a BitcoinD), #[cfg(feature = "chain-bitcoind")] BitcoindRestSync(&'a BitcoinD), + #[cfg(feature = "chain-cbf")] + Cbf(&'a BitcoinD), } #[derive(Clone, Copy)] @@ -789,6 +802,12 @@ pub(crate) fn configure_chain_source( config.wallet_rescan_from_height, ); }, + #[cfg(feature = "chain-cbf")] + TestChainSource::Cbf(bitcoind) => { + let p2p_socket = bitcoind.params.p2p_socket.expect("P2P must be enabled for CBF"); + let peer_addr = format!("{}", p2p_socket); + builder.set_chain_source_cbf(vec![peer_addr], None); + }, } } @@ -1696,6 +1715,8 @@ pub(crate) async fn do_channel_full_cycle( let splice_out_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(electrsd, splice_out_txo.txid).await; + tokio::time::sleep(Duration::from_secs(2)).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); @@ -1720,6 +1741,7 @@ pub(crate) async fn do_channel_full_cycle( let splice_in_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_tx(electrsd, splice_in_txo.txid).await; + tokio::time::sleep(Duration::from_secs(5)).await; let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; wait_for_node_tip(&node_a, new_height).await.expect("node hasn't synced to the tip"); wait_for_node_tip(&node_b, new_height).await.expect("node hasn't synced to the tip"); @@ -1775,8 +1797,10 @@ pub(crate) async fn do_channel_full_cycle( tokio::time::sleep(Duration::from_secs(1)).await; if force_close { node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; } else { node_a.close_channel(&user_channel_id_a, node_b.node_id()).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; // The cooperative shutdown may complete before we get to check, but if the channel // is still visible it must already be in a shutdown state. if let Some(channel) = From cf49c744d7c16b072dfa6c67ec061733c94e9914 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Wed, 8 Jul 2026 19:33:28 +0100 Subject: [PATCH 09/16] cbf: make sync_wallets wait for applied tip --- src/chain/cbf.rs | 244 ++++++++++++++++++++++++++++++++++++++--------- src/chain/mod.rs | 5 +- src/lib.rs | 3 + 3 files changed, 205 insertions(+), 47 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 8b0b87c654..ae70251d57 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -5,15 +5,14 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ - chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, - HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, - Warning, + chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, + IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; -use tokio::sync::{mpsc, oneshot, watch}; +use tokio::sync::{mpsc, watch}; use crate::chain::electrum::get_electrum_fee_rate_cache_update; use crate::chain::{CbfFeeSourceConfig, ChainListener}; @@ -49,6 +48,9 @@ const MAX_RESTART_RETRIES: u32 = 5; /// Initial backoff delay between restart attempts; doubles each failure. const INITIAL_BACKOFF_MS: u64 = 500; +/// Retry matched block downloads before surfacing a CBF sync failure. +const CBF_BLOCK_FETCH_RETRIES: u8 = 3; + const ESPLORA_TIMEOUT: u64 = 2; /// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. @@ -61,6 +63,12 @@ enum CbfRuntimeStatus { Stopped, } +#[derive(Clone, Copy)] +enum CbfSyncState { + Active { applied_tip: Option }, + Failed(Error), +} + /// Struct for holding cbf chain source pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. @@ -70,6 +78,8 @@ pub struct CbfChainSource { fee_source: FeeSource, /// Tracks whether the kyoto node is running and holds the live requester. cbf_runtime_status: Arc>, + /// Highest CBF sync tip whose preceding chain updates have been applied to all listeners. + sync_state_tx: watch::Sender, /// Handle used to spawn the background tasks and offload blocking work. runtime: Arc, /// Node configuration (network, storage path). @@ -82,7 +92,7 @@ pub struct CbfChainSource { enum ChainOp { ConnectFull { - block_rx: oneshot::Receiver>, + block: IndexedBlock, }, ConnectFiltered { header: Header, @@ -95,15 +105,21 @@ enum ChainOp { Synced { tip_height: u32, }, + Failed { + error: Error, + }, } struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, next_height: u32, + sync_state_tx: watch::Sender, /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download /// here, so the fee estimator doesn't have to re-download them. block_fee_cache: Option, + kv_store: Arc, + node_metrics: Arc, logger: Arc, } @@ -111,29 +127,25 @@ impl BlockApplicator { async fn run(mut self) { while let Some(op) = self.ops_rx.recv().await { match op { - ChainOp::ConnectFull { block_rx } => match block_rx.await { - Ok(Ok(ib)) => { - if ib.height != self.next_height { - log_debug!( - self.logger, - "CBF skipping out-of-sequence block at height {} (expected {})", - ib.height, - self.next_height - ); - continue; - } - self.chain_listener.block_connected(&ib.block, ib.height); - self.next_height += 1; - if let Some(cache) = &self.block_fee_cache { - let fee_rate = coinbase_fee_rate(&ib.block, ib.height); - cache - .lock() - .expect("lock") - .insert(ib.height, (ib.block.block_hash(), fee_rate)); - } - }, - Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), - Err(_) => log_error!(self.logger, "block oneshot dropped"), + ChainOp::ConnectFull { block: ib } => { + if ib.height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + ib.height, + self.next_height + ); + continue; + } + self.chain_listener.block_connected(&ib.block, ib.height); + self.next_height += 1; + if let Some(cache) = &self.block_fee_cache { + let fee_rate = coinbase_fee_rate(&ib.block, ib.height); + cache + .lock() + .expect("lock") + .insert(ib.height, (ib.block.block_hash(), fee_rate)); + } }, ChainOp::ConnectFiltered { header, height } => { if height != self.next_height { @@ -151,15 +163,58 @@ impl BlockApplicator { ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); self.next_height = fork_point.height + 1; + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(fork_point.height), + }); }, ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); - // TODO: notify sync-completion waiters (start()/sync_wallets()/tests) once - // a notification primitive is plumbed through. + if self.next_height > tip_height { + self.publish_synced_tip(tip_height).await; + } else { + log_debug!( + self.logger, + "CBF waiting to apply blocks through tip {} (next height {})", + tip_height, + self.next_height + ); + } + }, + ChainOp::Failed { error } => { + self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); }, } } } + + async fn publish_synced_tip(&self, tip_height: u32) { + let already_published = { + let sync_state = *self.sync_state_tx.borrow(); + match sync_state { + CbfSyncState::Active { applied_tip } => applied_tip, + CbfSyncState::Failed(_) => None, + } + }; + if already_published.map_or(false, |published_height| published_height >= tip_height) { + return; + } + self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(tip_height) }); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + if let Err(e) = update_and_persist_node_metrics( + &self.node_metrics, + &*self.kv_store, + &*self.logger, + |m| { + m.latest_lightning_wallet_sync_timestamp = unix_time_secs_opt; + m.latest_onchain_wallet_sync_timestamp = unix_time_secs_opt; + }, + ) + .await + { + log_error!(self.logger, "Failed to persist CBF sync metrics: {:?}", e); + } + } } /// Number of most recent blocks whose coinbase-derived fee rates feed the native CBF estimator. @@ -227,11 +282,13 @@ impl CbfChainSource { }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); + let (sync_state_tx, _) = watch::channel(CbfSyncState::Active { applied_tip: None }); Ok(Self { trusted_peers, fee_source, registered_scripts, cbf_runtime_status, + sync_state_tx, runtime, config, fee_estimator, @@ -291,11 +348,17 @@ impl CbfChainSource { FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), _ => None, }; + let best_block_height = chain_listener.get_best_block().height; + self.sync_state_tx + .send_replace(CbfSyncState::Active { applied_tip: Some(best_block_height) }); let block_applicator = BlockApplicator { - next_height: chain_listener.get_best_block().height + 1, + next_height: best_block_height + 1, + sync_state_tx: self.sync_state_tx.clone(), chain_listener: chain_listener.clone(), ops_rx, block_fee_cache, + kv_store: Arc::clone(&self.kv_store), + node_metrics: Arc::clone(&self.node_metrics), logger: Arc::clone(&self.logger), }; self.runtime.spawn_background_task(block_applicator.run()); @@ -309,7 +372,7 @@ impl CbfChainSource { let restart_listener = chain_listener; let restart_registered_scripts = Arc::clone(&self.registered_scripts); let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); - // let restart_block_applicator = + let restart_sync_state_tx = self.sync_state_tx.clone(); self.runtime.spawn_background_task(async move { let mut current_node = node; @@ -342,6 +405,7 @@ impl CbfChainSource { Ok(()) => { log_info!(restart_logger, "CBF node shut down cleanly."); *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); break; }, Err(e) => { @@ -354,6 +418,8 @@ impl CbfChainSource { e, ); *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::TxSyncFailed)); break; } log_error!( @@ -389,6 +455,8 @@ impl CbfChainSource { let mut status = restart_status.lock().expect("lock"); if matches!(*status, CbfRuntimeStatus::Stopped) { let _ = new_requester.shutdown(); + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::NotRunning)); log_info!( restart_logger, "CBF restart aborted: stop() called during backoff." @@ -396,6 +464,9 @@ impl CbfChainSource { break; } *status = CbfRuntimeStatus::Started { requester: new_requester }; + restart_sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(restart_listener.get_best_block().height), + }); } current_node = new_node; @@ -426,6 +497,37 @@ impl CbfChainSource { log_error!(self.logger, "Failed to shut down CBF node: {:?}", e); } } + self.sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); + } + + pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> { + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return Err(Error::NotRunning), + }; + let target_tip = requester.chain_tip().await.map_err(|e| { + log_error!(self.logger, "Failed to fetch CBF chain tip before syncing: {:?}", e); + Error::TxSyncFailed + })?; + let target_height = target_tip.height; + let mut sync_state_rx = self.sync_state_tx.subscribe(); + + loop { + match *sync_state_rx.borrow() { + CbfSyncState::Active { applied_tip } => { + if applied_tip.map_or(false, |applied_height| applied_height >= target_height) { + return Ok(()); + } + }, + CbfSyncState::Failed(error) => return Err(error), + } + + if let Err(e) = sync_state_rx.changed().await { + debug_assert!(false, "Failed to receive CBF sync result: {:?}", e); + log_error!(self.logger, "Failed to receive CBF sync result: {:?}", e); + return Err(Error::TxSyncFailed); + } + } } async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { @@ -458,8 +560,8 @@ impl CbfChainSource { let requester = match &*cbf_runtime_status.lock().expect("lock") { CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { - //TODO should we panic here? what do we do if we have no requester? - continue; + let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + return; }, }; //registered_scripts contains only LDK scripts, not onchain wallet's scripts, @@ -474,11 +576,68 @@ impl CbfChainSource { let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { - if let Ok(handle) = requester.request_block(block_hash) { - ChainOp::ConnectFull { block_rx: handle } - } else { - break; - } + let mut attempt = 0; + let block = loop { + attempt += 1; + let handle = match requester.request_block(block_hash) { + Ok(handle) => handle, + Err(_) => { + log_error!( + logger, + "Failed to obtain receiver for matched CBF block {}; node is stopped", + block_hash + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + return; + }, + }; + + match handle.await { + Ok(Ok(block)) => break block, + Ok(Err(e)) if attempt < CBF_BLOCK_FETCH_RETRIES => { + log_debug!( + logger, + "CBF block fetch for {} failed on attempt {}: {:?}; retrying", + block_hash, + attempt, + e + ); + }, + Ok(Err(e)) => { + log_error!( + logger, + "CBF block fetch for {} failed after {} attempts: {:?}", + block_hash, + CBF_BLOCK_FETCH_RETRIES, + e + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + return; + }, + Err(_) if attempt < CBF_BLOCK_FETCH_RETRIES => { + log_debug!( + logger, + "CBF block receiver for {} dropped on attempt {}; retrying", + block_hash, + attempt + ); + }, + Err(_) => { + log_error!( + logger, + "CBF block receiver for {} dropped after {} attempts", + block_hash, + CBF_BLOCK_FETCH_RETRIES + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + return; + }, + } + }; + ChainOp::ConnectFull { block } } else { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been @@ -502,10 +661,9 @@ impl CbfChainSource { } }, Ok(None) => { - //TODO what do we do? - todo!(); log_error!(logger, "No header at height {}", height,); - continue; + let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + break; }, Err(e) => { log_error!( @@ -514,8 +672,8 @@ impl CbfChainSource { height, e, ); + let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); break; - // continue; }, } }; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 11b2a5a16a..2db50d6c3f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -684,10 +684,7 @@ impl ChainSource { .await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf { .. } => { - return Ok(()); - todo!(); - }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.wait_until_synced().await, } } diff --git a/src/lib.rs b/src/lib.rs index 014e8fb0f3..2e052dbfef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2064,6 +2064,9 @@ impl Node { /// However, if background syncing is disabled (i.e., `background_sync_config` is set to `None`), /// this method must be called manually to keep wallets in sync with the chain state. /// + /// When using the CBF chain source, syncing always runs in the background. In that mode this + /// method waits until the background sync has applied chain updates through the current tip. + /// /// [`EsploraSyncConfig::background_sync_config`]: crate::config::EsploraSyncConfig::background_sync_config pub fn sync_wallets(&self) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { From dc37ec1461f659df3c4c90fdf0610096e796fa61 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 17 Jul 2026 06:47:22 +0900 Subject: [PATCH 10/16] Cbf fix block fetch (#35) * bump kyoto version * Add `synced_to_tip` to CbfSyncState Previously we did not track the `FiltersSynced` kyoto event, so we could not tell when we had applied all blocks up to the tip. For example, when we stop and restart the node, kyoto's tip is 0 at the instant of start (it does not persist its chain), so our applied height trivially matches kyoto's tip and we would falsely conclude we had reached it. That is only actually true once we have received `FiltersSynced`. * Add lookahead addresses to `list_revealed_scripts`. Now the function is called `list_watched_scripts`. * Add timeout to block fetch attempts. Previously stalled fetch would hang indefinitely. --------- Co-authored-by: Alexander Shevtsov --- Cargo.toml | 2 +- src/chain/cbf.rs | 181 +++++++++++++++++++++++++++------------------- src/wallet/mod.rs | 14 ++-- 3 files changed, 114 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3f713de445..1e61c9a408 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,7 +105,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"], optional = true } bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"], optional = true } bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} -bip157 = { version = "0.6.0", default-features = false, optional = true } +bip157 = { version = "0.6.1", default-features = false, optional = true } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index ae70251d57..cfdd123221 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -65,10 +65,38 @@ enum CbfRuntimeStatus { #[derive(Clone, Copy)] enum CbfSyncState { - Active { applied_tip: Option }, + Active { + /// Highest tip whose preceding chain updates have been applied to all listeners. + applied_tip: Option, + /// Whether kyoto has reported catching up to the network tip (via `FiltersSynced`) and + /// the resulting blocks have been applied. `wait_until_synced` blocks until this is set. + /// + /// This must not be derived from a locally-sampled chain tip: kyoto does not persist, so a + /// freshly (re)started node's local header chain sits at genesis until it syncs from peers. + /// Comparing against that would make `wait_until_synced` return before any sync happens. + synced_to_tip: bool, + }, Failed(Error), } +/// Marks that we are applying a block past the last `FiltersSynced` tip, so a `sync_wallets` call +/// issued after new blocks are mined waits for the next `FiltersSynced` rather than returning on a +/// stale `synced_to_tip`. Only flips (and notifies waiters) when currently set. +/// +/// Called both when a new block's filter is received (before it is fetched and applied) and after +/// it is applied, so `synced_to_tip` reflects "behind by an unapplied block" as soon as we learn +/// that block exists, not only once we've finished catching up to it. +fn mark_syncing(sync_state_tx: &watch::Sender) { + // Copy the current state out and drop the `watch` read guard before calling `send_replace`: + // `borrow()` holds a read lock for the lifetime of its temporary, and `send_replace` takes + // the write lock, so holding the borrow across it deadlocks. `CbfSyncState` is `Copy`, so the + // deref copies and the guard is released at the end of this statement. + let current = *sync_state_tx.borrow(); + if let CbfSyncState::Active { applied_tip, synced_to_tip: true } = current { + sync_state_tx.send_replace(CbfSyncState::Active { applied_tip, synced_to_tip: false }); + } +} + /// Struct for holding cbf chain source pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. @@ -90,24 +118,13 @@ pub struct CbfChainSource { logger: Arc, } +#[derive(Debug)] enum ChainOp { - ConnectFull { - block: IndexedBlock, - }, - ConnectFiltered { - header: Header, - height: u32, - }, - Disconnect { - fork_point: BlockLocator, - }, - /// Marks reaching the chain tip. - Synced { - tip_height: u32, - }, - Failed { - error: Error, - }, + ConnectFull { block: IndexedBlock }, + ConnectFiltered { header: Header, height: u32 }, + Disconnect { fork_point: BlockLocator }, + Synced { tip_height: u32 }, + Failed { error: Error }, } struct BlockApplicator { @@ -139,6 +156,7 @@ impl BlockApplicator { } self.chain_listener.block_connected(&ib.block, ib.height); self.next_height += 1; + mark_syncing(&self.sync_state_tx); if let Some(cache) = &self.block_fee_cache { let fee_rate = coinbase_fee_rate(&ib.block, ib.height); cache @@ -159,12 +177,14 @@ impl BlockApplicator { } self.chain_listener.filtered_block_connected(&header, &[], height); self.next_height += 1; + mark_syncing(&self.sync_state_tx); }, ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); self.next_height = fork_point.height + 1; self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(fork_point.height), + synced_to_tip: false, }); }, ChainOp::Synced { tip_height } => { @@ -179,8 +199,10 @@ impl BlockApplicator { self.next_height ); } + log_info!(self.logger, "we set new tip and published at {}", tip_height); }, ChainOp::Failed { error } => { + log_info!(self.logger, "we received error chain op {}", error); self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); }, } @@ -191,14 +213,23 @@ impl BlockApplicator { let already_published = { let sync_state = *self.sync_state_tx.borrow(); match sync_state { - CbfSyncState::Active { applied_tip } => applied_tip, + CbfSyncState::Active { applied_tip, .. } => applied_tip, CbfSyncState::Failed(_) => None, } }; if already_published.map_or(false, |published_height| published_height >= tip_height) { + // Even if the applied tip is unchanged, we have now confirmed we are caught up to the + // network tip, so ensure the synced flag is set for any `wait_until_synced` waiter. + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: already_published, + synced_to_tip: true, + }); return; } - self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(tip_height) }); + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(tip_height), + synced_to_tip: true, + }); let unix_time_secs_opt = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); if let Err(e) = update_and_persist_node_metrics( @@ -224,9 +255,12 @@ const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32; /// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet. const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; -/// Per-block timeout when downloading a block to derive its coinbase fee rate. Kept short so a -/// slow peer only delays a single sample rather than the whole fee update. -const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; +/// Per-attempt timeout when downloading a block from a peer — used both for matched blocks we apply +/// to the listeners and for the coinbase-fee-rate samples. Kyoto queues the request and awaits a +/// peer response with no timeout of its own, so a slow or unresponsive peer would otherwise park the +/// fetch forever. Kept short so a single request is bounded and can be retried (or, for fees, only +/// delays one sample) rather than stalling. +const CBF_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; /// Upper bound on how long we wait for a peer to request a broadcast transaction. Kyoto resolves /// `submit_package` only once a peer asks for the transaction, so it never resolves if peer @@ -282,7 +316,8 @@ impl CbfChainSource { }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); - let (sync_state_tx, _) = watch::channel(CbfSyncState::Active { applied_tip: None }); + let (sync_state_tx, _) = + watch::channel(CbfSyncState::Active { applied_tip: None, synced_to_tip: false }); Ok(Self { trusted_peers, fee_source, @@ -349,8 +384,10 @@ impl CbfChainSource { _ => None, }; let best_block_height = chain_listener.get_best_block().height; - self.sync_state_tx - .send_replace(CbfSyncState::Active { applied_tip: Some(best_block_height) }); + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(best_block_height), + synced_to_tip: false, + }); let block_applicator = BlockApplicator { next_height: best_block_height + 1, sync_state_tx: self.sync_state_tx.clone(), @@ -399,6 +436,7 @@ impl CbfChainSource { Arc::clone(&restart_cbf_runtime_status), ops_tx.clone(), restart_listener.onchain_wallet.clone(), + restart_sync_state_tx.clone(), )); match current_node.run().await { @@ -466,6 +504,7 @@ impl CbfChainSource { *status = CbfRuntimeStatus::Started { requester: new_requester }; restart_sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(restart_listener.get_best_block().height), + synced_to_tip: false, }); } @@ -501,21 +540,20 @@ impl CbfChainSource { } pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> { - let requester = match &*self.cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => return Err(Error::NotRunning), - }; - let target_tip = requester.chain_tip().await.map_err(|e| { - log_error!(self.logger, "Failed to fetch CBF chain tip before syncing: {:?}", e); - Error::TxSyncFailed - })?; - let target_height = target_tip.height; + if matches!(&*self.cbf_runtime_status.lock().expect("lock"), CbfRuntimeStatus::Stopped) { + return Err(Error::NotRunning); + } let mut sync_state_rx = self.sync_state_tx.subscribe(); + // Wait for kyoto to report catching up to the network tip (a `FiltersSynced`-driven + // `synced_to_tip`) and for the resulting blocks to be applied. We must not target a + // locally-sampled `chain_tip()`: kyoto does not persist, so a freshly (re)started node's + // local header chain sits at genesis until it syncs from peers, which would let this return + // before any sync happens. loop { match *sync_state_rx.borrow() { - CbfSyncState::Active { applied_tip } => { - if applied_tip.map_or(false, |applied_height| applied_height >= target_height) { + CbfSyncState::Active { synced_to_tip, .. } => { + if synced_to_tip { return Ok(()); } }, @@ -548,7 +586,7 @@ impl CbfChainSource { logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, - onchain_wallet: std::sync::Weak, + onchain_wallet: std::sync::Weak, sync_state_tx: watch::Sender, ) { while let Some(event) = event_rx.recv().await { match event { @@ -557,6 +595,12 @@ impl CbfChainSource { log_debug!(logger, "Onchain wallet dropped; stopping CBF event processing"); break; }; + // A new block's filter arrived, so we're behind by at least this block until it + // is fetched (if matched) and applied. Flip this before the fetch, not after, + // so a `sync_wallets` call issued in between doesn't return on a stale + // `synced_to_tip` that predates this block. + mark_syncing(&sync_state_tx); + let requester = match &*cbf_runtime_status.lock().expect("lock") { CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { @@ -569,7 +613,7 @@ impl CbfChainSource { //each time we receive an IndexedFilter event, we ask bdk to give us all //revealed scripts. We create all_scripts starting from onchain wallet's //scripts and extend them with LDK's ones - let mut all_scripts = onchain_wallet.list_revealed_scripts(); + let mut all_scripts = onchain_wallet.list_watched_scripts(); all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned()); let block_hash = indexed_filter.block_hash(); @@ -593,42 +637,37 @@ impl CbfChainSource { }, }; - match handle.await { - Ok(Ok(block)) => break block, - Ok(Err(e)) if attempt < CBF_BLOCK_FETCH_RETRIES => { + // Bound the download so an unresponsive peer can't park the fetch forever, + // then flatten the three error layers (timeout / receiver dropped / fetch + // error) into a single reason so the retry-or-fail decision is written once. + let fetched = tokio::time::timeout( + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), + handle, + ) + .await + .map_err(|_| { + format!("timed out after {}s", CBF_BLOCK_FETCH_TIMEOUT_SECS) + }) + .and_then(|recv| recv.map_err(|_| "receiver was dropped".to_string())) + .and_then(|fetch| fetch.map_err(|e| format!("failed: {:?}", e))); + + match fetched { + Ok(block) => break block, + Err(reason) if attempt < CBF_BLOCK_FETCH_RETRIES => { log_debug!( logger, - "CBF block fetch for {} failed on attempt {}: {:?}; retrying", - block_hash, - attempt, - e - ); - }, - Ok(Err(e)) => { - log_error!( - logger, - "CBF block fetch for {} failed after {} attempts: {:?}", - block_hash, - CBF_BLOCK_FETCH_RETRIES, - e - ); - let _ = - ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - return; - }, - Err(_) if attempt < CBF_BLOCK_FETCH_RETRIES => { - log_debug!( - logger, - "CBF block receiver for {} dropped on attempt {}; retrying", + "CBF block fetch for {} {} on attempt {}; retrying", block_hash, + reason, attempt ); }, - Err(_) => { + Err(reason) => { log_error!( logger, - "CBF block receiver for {} dropped after {} attempts", + "CBF block fetch for {} {} after {} attempts; giving up", block_hash, + reason, CBF_BLOCK_FETCH_RETRIES ); let _ = @@ -642,9 +681,7 @@ impl CbfChainSource { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been //reorganized, and we retrieve indeed the same block header that we - //received `IndexedFilter` event of. right now this would block - //the further sync, as we cannot apply blocks in order. - //Future solution would use something like `get_header_by_hash`. + //received `IndexedFilter` event of. match requester.get_header(height).await { Ok(Some(indexed_header)) => { if indexed_header.block_hash() != block_hash { @@ -723,10 +760,6 @@ impl CbfChainSource { self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); } - // pub(crate) fn register_script(&self, script: ScriptBuf) { - // self.registered_scripts.lock().expect("lock").insert(script); - // } - pub(crate) async fn continuously_update_fee_rate_estimates( &self, mut stop_sync_receiver: watch::Receiver<()>, ) { @@ -967,7 +1000,7 @@ impl CbfChainSource { } match tokio::time::timeout( - Duration::from_secs(CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS), + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), requester.average_fee_rate(canonical_hash), ) .await diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d74c6c0316..2ad124f556 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -301,14 +301,12 @@ impl Wallet { Ok(()) } - pub(crate) fn list_revealed_scripts(&self) -> Vec { - self.inner - .lock() - .expect("lock") - .spk_index() - .revealed_spks(..) - .map(|((_keychain, _index), spk)| spk) - .collect() + /// Returns every script pubkey the wallet is watching for on-chain activity: all revealed + /// SPKs plus the lookahead window BDK derives beyond the last revealed index on each keychain. + /// A block may pay an address we have not explicitly revealed yet (e.g. on recovery, where a fresh + /// wallet has revealed nothing) but which is still within the gap limit. + pub(crate) fn list_watched_scripts(&self) -> Vec { + self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect() } async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { From 2f609d80dba5a44b229391aeae1618dceae1b524 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 21 Jul 2026 15:34:58 +0200 Subject: [PATCH 11/16] Use block header from IndexedFilter event After the new kyoto release (v0.6.3) `Indexedfilter` event has a `header` field which is used directly (previously we fetched header as an additional action). Also renamed import of kyoto `Event` into `KyotoEvent` for readability. --- Cargo.toml | 2 +- src/chain/cbf.rs | 54 +++++++++++------------------------------------- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e61c9a408..e0ec64eb66 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,7 +105,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"], optional = true } bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"], optional = true } bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} -bip157 = { version = "0.6.1", default-features = false, optional = true } +bip157 = { version = "0.6.3", default-features = false, optional = true } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index cfdd123221..27bf2ebf7b 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -5,8 +5,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ - chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, - IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, Warning, + chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, + Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; @@ -583,14 +584,14 @@ impl CbfChainSource { } async fn process_kyoto_events( - logger: Arc, mut event_rx: mpsc::UnboundedReceiver, + logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, onchain_wallet: std::sync::Weak, sync_state_tx: watch::Sender, ) { while let Some(event) = event_rx.recv().await { match event { - Event::IndexedFilter(indexed_filter) => { + KyotoEvent::IndexedFilter(indexed_filter) => { let Some(onchain_wallet) = onchain_wallet.upgrade() else { log_debug!(logger, "Onchain wallet dropped; stopping CBF event processing"); break; @@ -678,61 +679,30 @@ impl CbfChainSource { }; ChainOp::ConnectFull { block } } else { - let height = indexed_filter.height(); - //TODO we need to recheck that a particular height has not been - //reorganized, and we retrieve indeed the same block header that we - //received `IndexedFilter` event of. - match requester.get_header(height).await { - Ok(Some(indexed_header)) => { - if indexed_header.block_hash() != block_hash { - log_debug!( - logger, - "Filter for {} reorged; skipping", - block_hash - ); - continue; - } - ChainOp::ConnectFiltered { - header: indexed_header.header, - height: indexed_header.height, - } - }, - Ok(None) => { - log_error!(logger, "No header at height {}", height,); - let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - break; - }, - Err(e) => { - log_error!( - logger, - "Failed to fetch header at height {}: {:?}", - height, - e, - ); - let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - break; - }, + ChainOp::ConnectFiltered { + header: indexed_filter.header(), + height: indexed_filter.height(), } }; if let Err(e) = ops_tx.send(chop) { log_debug!(logger, "ops_rx gone: {}", e); } }, - Event::FiltersSynced(sync_update) => { + KyotoEvent::FiltersSynced(sync_update) => { //Because application of blocks is async, the fact that kyoto synced up to the //tip does NOT mean that we caught everything up, that's why we send a ChainOp, //only processing of which means we processed all blocks up to the tip. log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); let _ = ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }); }, - Event::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { + KyotoEvent::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { log_debug!( logger, "Kyoto connected header at height {}", indexed_header.height ); }, - Event::ChainUpdate(BlockHeaderChanges::Reorganized { + KyotoEvent::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted: _, }) => { @@ -745,7 +715,7 @@ impl CbfChainSource { let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); } }, - Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { + KyotoEvent::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { log_debug!(logger, "Kyoto added fork header at height {}", fork.height); }, } From 8e3d9d2aea0be0a23bded747c9a77a1f7a4a81cf Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 29 Sep 2026 19:30:23 +0200 Subject: [PATCH 12/16] wallet: mark utxo as spend at tx broadcast Previously the source of truth was the chain source: we can understand whether the tx has been broadcast by querying its mempool. This does not work with compact block filter node which does not have a mempool. With CBF we can only preemptively mark tx as broadcast and mark corresponding tx outputs as spent (to prevent double spend attempt or allow 0conf channels). This commit implements it via calling `apply_mempool_txs` with a tx to be broadcast (in `send_to_address`, `bump_fee_rbf` and `create_fudning_transaction`). Stale transaction which is not confirmed during next 2 weeks (2016 blocks, same value as for bitcoin core) is evicted. To properly apply `WalletEvent`s we need to use the block time, not the current time. Bugfixes: remove persister lock Used Claude to write code --- src/wallet/mod.rs | 151 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 36 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 2ad124f556..2301c006da 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -10,8 +10,10 @@ use std::future::Future; use std::ops::Deref; use std::str::FromStr; use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_chain::ChainPosition; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] @@ -85,6 +87,11 @@ pub(crate) mod ser; const DUST_LIMIT_SATS: u64 = 546; +/// Fallback deadline for CBF, which has no mempool to check eviction against directly: matches +/// Core's default mempool expiry (336h). Funding-typed payments use LDK's own deadline instead +/// (`ClosureReason::FundingTimedOut`). Compared against block time, not wall-clock time. +const STALE_BROADCAST_EVICTION_SECS: u64 = 336 * 60 * 60; + /// The number of external addresses kept revealed, persisted, and ready for handout via /// [`Wallet::pop_pooled_address`] and [`Wallet::get_new_address`]. /// @@ -254,7 +261,8 @@ impl Wallet { }, } }; - self.update_payment_store(events).await.map_err(|e| { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.update_payment_store(events, now).await.map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; @@ -267,7 +275,15 @@ impl Wallet { Ok(()) } - #[cfg(feature = "chain-bitcoind")] + /// Applies a set of unconfirmed transactions (and evicts a set of now-superseded ones) to the + /// on-chain wallet's own graph, reconciling the payment store accordingly. + /// + /// Bitcoind's mempool poll calls this with a live mempool snapshot. It's also the mechanism + /// [`Self::classify_broadcast`], [`Self::send_to_address`] and [`Self::bump_fee_rbf`] use to + /// apply a transaction we ourselves just built and broadcast, as unconfirmed, immediately — + /// rather than waiting on a chain-source sync that, on CBF, has no mempool to observe it from + /// at all. Without this, BDK's own coin selection would still see the just-spent inputs as + /// spendable until the transaction is mined, risking a second send double-spending the first. pub(crate) async fn apply_mempool_txs( &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { @@ -287,7 +303,8 @@ impl Wallet { .expect("applying mempool updates cannot fail") }; - self.update_payment_store(events).await.map_err(|e| { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.update_payment_store(events, now).await.map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; @@ -309,7 +326,16 @@ impl Wallet { self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect() } - async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { + /// `reference_time` is what the `ChainTipChanged` arm's staleness check compares a pending + /// payment's `first_seen` against. Pass the connecting block's own header time where one + /// exists (`block_connected`); otherwise the current wall-clock time. Using the block's own + /// time matters during catch-up (e.g. CBF replaying many blocks after being offline): each + /// replayed block calls this once, and `SystemTime::now()` would compare a historic + /// `first_seen` against today's clock on every one of them, evicting (or not) based on how + /// long the catch-up took rather than how long the transaction has actually been unconfirmed. + async fn update_payment_store( + &self, mut events: Vec, reference_time: u64, + ) -> Result<(), Error> { if events.is_empty() { return Ok(()); } @@ -411,6 +437,7 @@ impl Wallet { .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); + let mut stale_outbound_txids: Vec = Vec::new(); for payment in pending_payments { match payment.details.kind { @@ -435,12 +462,8 @@ impl Wallet { let current = existing?; match current.kind { PaymentKind::Onchain { - status: - ConfirmationStatus::Confirmed { height, .. }, - .. - } if new_tip.height - >= height + ANTI_REORG_DELAY - 1 => - { + status: ConfirmationStatus::Confirmed { height, .. }, .. + } if new_tip.height >= height + ANTI_REORG_DELAY - 1 => { graduated = true; let mut update = PaymentDetailsUpdate::new(payment_id); @@ -460,14 +483,42 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, - .. + tx_type, } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + // Funding transactions are exempt: LDK's own `FundingTimedOut` + // deadline covers those instead of our stale-broadcast eviction. + let is_funding = matches!( + tx_type, + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ) + ); + let is_stale = !is_funding + && matches!( + self.inner.lock().expect("lock").get_tx(txid).map(|tx| tx.chain_position), + Some(ChainPosition::Unconfirmed { first_seen: Some(first_seen), .. }) + if reference_time.saturating_sub(first_seen) + >= STALE_BROADCAST_EVICTION_SECS + ); + if is_stale { + stale_outbound_txids.push(txid); + } else { + unconfirmed_outbound_txids.push(txid); + } }, _ => {}, } } + if !stale_outbound_txids.is_empty() { + let evicted_txids = stale_outbound_txids + .into_iter() + .map(|txid| (txid, reference_time)) + .collect(); + Box::pin(self.apply_mempool_txs(Vec::new(), evicted_txids)).await?; + } + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -570,7 +621,7 @@ impl Wallet { self.pending_payment_store.insert_or_update(pending_payment_details).await?; }, - WalletEvent::TxDropped { txid, tx } => { + WalletEvent::TxDropped { txid, tx: _ } => { // See `TxConfirmed`: id resolution and the writes below must not interleave // with classification. let guard = self.funding_payment_update_lock.lock().await; @@ -592,21 +643,22 @@ impl Wallet { continue; } - let payment = { - let locked_wallet = self.inner.lock().expect("lock"); - self.create_payment_from_tx( - &locked_wallet, - txid, - payment_id, - &tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ) - }; - let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.payment_store.insert_or_update(payment).await?; - self.pending_payment_store.insert_or_update(pending_payment).await?; + // Not a funding-typed payment we mirror confirmation status for: BDK no longer + // considers this transaction canonical — either a mempool poll no longer sees + // it, or our own stale-broadcast deadline (`ChainTipChanged`, below) gave up + // waiting on it — and nothing takes its place, so mark the payment failed + // rather than resurrecting it as still-pending, which would just leave it + // stuck forever. + self.payment_store + .mutate(&payment_id, |existing| { + let current = existing?; + let mut update = PaymentDetailsUpdate::new(payment_id); + update.status = Some(PaymentStatus::Failed); + let mut updated = current.clone(); + updated.update(update).then_some(updated) + }) + .await?; + self.pending_payment_store.remove(&payment_id).await?; }, _ => { continue; @@ -658,12 +710,22 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + // `apply_mempool_txs` below re-acquires this same (non-reentrant) lock itself. + drop(locked_persister); let tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract transaction: {}", e); e })?; + // Apply as unconfirmed before handing the tx back to LDK for broadcasting: LDK may + // consider a 0conf channel ready synchronously, as part of the funding-signed handshake + // itself, with no dependency on our own async broadcast-queue classification having run + // yet (see `classify_broadcast`). Without this, a 0conf channel's `ChannelReady` can race + // ahead of the funding tx ever being reflected in the wallet's own spendable balance. + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.apply_mempool_txs(vec![(tx.clone(), now)], Vec::new()).await?; + Ok(tx) } @@ -1221,6 +1283,8 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + // `apply_mempool_txs` below re-acquires this same (non-reentrant) lock itself. + drop(locked_persister); let tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract transaction: {}", e); @@ -1228,6 +1292,8 @@ impl Wallet { })?; let txid = tx.compute_txid(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.apply_mempool_txs(vec![(tx.clone(), now)], Vec::new()).await?; self.broadcaster.broadcast_unclassified_transaction(tx); match send_amount { @@ -1532,6 +1598,9 @@ impl Wallet { pub(crate) async fn classify_broadcast( &self, tx: &Transaction, tx_type: &LdkTransactionType, ) -> Result<(), Error> { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.apply_mempool_txs(vec![(tx.clone(), now)], Vec::new()).await?; + match tx_type { LdkTransactionType::Funding { channels } => { self.classify_funding(tx, channels, tx_type.clone().into()).await @@ -1741,7 +1810,12 @@ impl Wallet { let txid = tx.compute_txid(); let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); - if amount_msat == Some(0) && fee_paid_msat == Some(0) { + // Keep cooperative closes in payment history for both peers, even when this + // wallet has no net on-chain amount to report for the close transaction. + if amount_msat == Some(0) + && fee_paid_msat == Some(0) + && !matches!(&tx_type, TransactionType::CooperativeClose { .. }) + { log_trace!( self.logger, "Not recording classified broadcast {} as a payment: no wallet-level activity", @@ -2280,10 +2354,14 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); Error::PersistenceFailed })?; + // `apply_mempool_txs` below re-acquires this same (non-reentrant) lock itself. + drop(locked_persister); self.payment_store.insert_or_update(new_payment).await?; self.pending_payment_store.insert_or_update(pending_payment_store).await?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + self.apply_mempool_txs(vec![(fee_bumped_tx.clone(), now)], vec![(txid, now)]).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); @@ -2396,7 +2474,8 @@ impl Listen for Wallet { } }; - if let Err(e) = self.update_payment_store(events).await { + // Use the connecting block's own time, not the wall clock: see `update_payment_store`. + if let Err(e) = self.update_payment_store(events, block.header.time as u64).await { log_error!(self.logger, "Failed to update payment store: {}", e); return; } @@ -3877,7 +3956,7 @@ mod tests { let block_id = |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); assert_eq!(payment.status, PaymentStatus::Succeeded); @@ -3927,7 +4006,7 @@ mod tests { let block_id = |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; let event = WalletEvent::ChainTipChanged { old_tip: block_id(9), new_tip: block_id(10) }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap(); assert_eq!( @@ -4026,7 +4105,7 @@ mod tests { tx: Arc::new(dummy_tx()), conflicts: vec![(0, conflicting_txid)], }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none()); } @@ -4198,7 +4277,7 @@ mod tests { block_time: confirmed_block_time(5), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); wallet.classify_funding(&tx, &channels, tx_type).await.unwrap(); assert_unchanged(&wallet, payment_id, true).await; } @@ -4252,7 +4331,7 @@ mod tests { }; let sync = tokio::spawn({ let wallet = Arc::clone(&wallet); - async move { wallet.update_payment_store(vec![event]).await } + async move { wallet.update_payment_store(vec![event], 0).await } }); // Liveness sanity only (both pre- and post-fix stall here): while classification is @@ -4326,7 +4405,7 @@ mod tests { let wallet = Arc::clone(&wallet); let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(dummy_tx()), old_block_time: None }; - async move { wallet.update_payment_store(vec![event]).await } + async move { wallet.update_payment_store(vec![event], 0).await } }); tokio::time::sleep(Duration::from_millis(250)).await; From 7ef2646e0d71d3b2ac936a52e9f94e150014c9ee Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 29 Sep 2026 19:49:08 +0200 Subject: [PATCH 13/16] tests: add waiting time for CBF tx propagation --- tests/common/mod.rs | 57 ++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a06eb157f9..108b0ef785 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -605,7 +605,7 @@ async fn settle_force_close_balance( let blocks_to_go = confirmation_height - cur_height; let new_height = generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; wait_for_node_tip(node, new_height).await.expect("node hasn't synced to the tip"); - wait_for_node_tip(peer_node, new_height) .await .expect("node hasn't synced to the tip"); + wait_for_node_tip(peer_node, new_height).await.expect("node hasn't synced to the tip"); node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); }, @@ -1889,27 +1889,42 @@ pub(crate) async fn do_channel_full_cycle( !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), "node_b should be removed from node_a peer store after the recovery reconnect" ); - assert_all_nodes_have_onchain_tx_type( - &[("node_a", &node_a), ("node_b", &node_b)], - "no ", - "UnilateralClose", - |tx_type| !matches!(tx_type, TransactionType::UnilateralClose { .. }), - ) - .unwrap(); - assert_any_node_has_onchain_tx_type( - &[("node_a", &node_a), ("node_b", &node_b)], - "Sweep", - |tx_type| matches!(tx_type, TransactionType::Sweep { .. }), - ) - .unwrap(); + // A node's own broadcast-queue processing (e.g. an earlier CBF broadcast that hit the + // "no peer requested it" timeout) can lag behind what electrs/bitcoind already see, so + // retry rather than asserting once immediately. + exponential_backoff_poll(|| { + assert_all_nodes_have_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "no ", + "UnilateralClose", + |tx_type| !matches!(tx_type, TransactionType::UnilateralClose { .. }), + ) + .ok() + }) + .await + .expect("Expected no nodes to have on-chain payment with tx_type UnilateralClose"); + exponential_backoff_poll(|| { + assert_any_node_has_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "Sweep", + |tx_type| matches!(tx_type, TransactionType::Sweep { .. }), + ) + .ok() + }) + .await + .expect("Expected on-chain payment with tx_type Sweep"); } else { - assert_all_nodes_have_onchain_tx_type( - &[("node_a", &node_a), ("node_b", &node_b)], - "all ", - "CooperativeClose", - |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), - ) - .unwrap(); + exponential_backoff_poll(|| { + assert_all_nodes_have_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "all ", + "CooperativeClose", + |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), + ) + .ok() + }) + .await + .expect("Expected all nodes to have on-chain payment with tx_type CooperativeClose"); // Peer removed after cooperative close — no further reason to reconnect. assert!( !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), From c3c4bf2cbf9ffd1dc2d9921ffeff48d9f019d0ff Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 30 Sep 2026 00:38:55 +0200 Subject: [PATCH 14/16] electrum: drop unused `config` from `ElectrumRuntimeClient` The fee-rate cache update moved into a free function that takes `network` directly, so the client no longer reads its `config` field. This tripped `dead_code` under `-D warnings` in CI. Remove the field and the argument threaded through `start`/`new`. Co-Authored-By: Claude Sonnet 5.5 --- src/chain/electrum.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7ba927c54a..d87d644b77 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -89,7 +89,6 @@ impl ElectrumChainSource { self.server_url.clone(), self.sync_config.clone(), Arc::clone(&runtime), - Arc::clone(&self.config), Arc::clone(&self.logger), ) } @@ -430,15 +429,14 @@ impl ElectrumRuntimeStatus { pub(super) fn start( &mut self, server_url: String, sync_config: ElectrumSyncConfig, runtime: Arc, - config: Arc, logger: Arc, + logger: Arc, ) -> Result<(), Error> { if self.client.is_some() { debug_assert!(false, "We shouldn't call start if we're already started"); return Ok(()); } - let client = - Arc::new(ElectrumRuntimeClient::new(server_url, sync_config, runtime, config, logger)?); + let client = Arc::new(ElectrumRuntimeClient::new(server_url, sync_config, runtime, logger)?); // (Re-)apply all known `Filter` entries to the fresh client. for (txid, script_pubkey) in self.registered_txs.iter() { @@ -487,7 +485,6 @@ struct ElectrumRuntimeClient { bdk_electrum_client: Arc>>, tx_sync: Arc>>, runtime: Arc, - config: Arc, logger: Arc, confirm_gate: Arc, } @@ -495,7 +492,7 @@ struct ElectrumRuntimeClient { impl ElectrumRuntimeClient { fn new( server_url: String, sync_config: ElectrumSyncConfig, runtime: Arc, - config: Arc, logger: Arc, + logger: Arc, ) -> Result { let electrum_config = ElectrumConfigBuilder::new() .retry(ELECTRUM_CLIENT_NUM_RETRIES) @@ -524,7 +521,6 @@ impl ElectrumRuntimeClient { bdk_electrum_client, tx_sync, runtime, - config, logger, confirm_gate: Arc::new(ConfirmGate::new()), }) From 1babe04815bfa6eadb03643293968722fb9c2bf1 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 30 Sep 2026 11:14:19 +0200 Subject: [PATCH 15/16] cbf: add uniffi builder --- src/builder.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/builder.rs b/src/builder.rs index 8686e58b8c..46b644f03b 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1108,6 +1108,21 @@ impl Builder { } } +#[cfg(all(feature = "uniffi", feature = "chain-cbf"))] +#[uniffi::export] +impl Builder { + /// Configures the [`Node`] instance to source chain data via compact block filters + /// (BIP157/BIP158), connecting to the given peers (`ip:port`). + /// + /// `fee_source_config` optionally delegates fee estimation to an Esplora or Electrum server; + /// if `None`, fee rates are derived from recent blocks. + pub fn set_chain_source_cbf( + &self, peers: Vec, fee_source_config: Option, + ) { + self.inner.write().expect("lock").set_chain_source_cbf(peers, fee_source_config); + } +} + #[cfg(all(feature = "uniffi", feature = "chain-bitcoind"))] #[uniffi::export] impl Builder { From 0591c917abd1ddf466125dc4349e117bf17fd3d0 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 30 Sep 2026 12:39:30 +0200 Subject: [PATCH 16/16] ci: retrigger