From 1fa3b6e644e1832c5d426c54bcae3f5047d6323c Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 8 Sep 2026 17:02:24 +0300 Subject: [PATCH 01/19] 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/cbf.rs | 413 ++++++++++++++++++++++++++++++++ src/chain/mod.rs | 207 ++++++++++++++-- src/lib.rs | 18 +- src/wallet/mod.rs | 35 ++- tests/common/mod.rs | 114 ++++++--- tests/integration_tests_rust.rs | 153 +++++++++--- tests/reorg_test.rs | 1 + 9 files changed, 844 insertions(+), 102 deletions(-) create mode 100644 src/chain/cbf.rs diff --git a/Cargo.toml b/Cargo.toml index ba98aebdd6..3747032d11 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.2.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 21f15508b4..f217aed2b9 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1674,6 +1674,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/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 e1f7e2ad41..c75927bfce 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,13 +20,15 @@ use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; -#[cfg(feature = "chain-bitcoind")] -use lightning::chain::Listen; +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")] @@ -146,6 +150,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>, @@ -160,9 +179,12 @@ enum ChainSourceKind { Electrum(ElectrumChainSource), #[cfg(feature = "chain-bitcoind")] Bitcoind(BitcoindChainSource), + #[cfg(feature = "chain-cbf")] + Cbf(CbfChainSource), } -#[cfg(feature = "chain-bitcoind")] +#[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, @@ -170,7 +192,7 @@ pub(crate) struct ChainListener { pub(crate) output_sweeper: std::sync::Weak, } -#[cfg(feature = "chain-bitcoind")] +#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] impl ChainListener { fn upgrade( &self, @@ -184,7 +206,33 @@ impl ChainListener { } } -#[cfg(feature = "chain-bitcoind")] +#[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, @@ -193,20 +241,35 @@ impl Listen for ChainListener { 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); + // Components may have been persisted at different tips; only feed each the blocks + // it hasn't seen yet, as they assert on chain order. Monitors tolerate replays. + if !onchain_wallet.has_block(height, header.block_hash()) { + onchain_wallet.filtered_block_connected(header, txdata, height); + } + if channel_manager.current_best_block().height < 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); + if output_sweeper.current_best_block().height < 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); + // See `filtered_block_connected`. + if !onchain_wallet.has_block(height, block.header.block_hash()) { + onchain_wallet.block_connected(block, height); + } + if channel_manager.current_best_block().height < height { + channel_manager.block_connected(block, height); + } chain_monitor.block_connected(block, height); - output_sweeper.block_connected(block, height); + if output_sweeper.current_best_block().height < height { + output_sweeper.block_connected(block, height); + } } } @@ -214,10 +277,17 @@ impl Listen for ChainListener { 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); + // Components already at or below the fork point have nothing to disconnect. + if onchain_wallet.current_best_block().height > fork_point_block.height { + onchain_wallet.blocks_disconnected(fork_point_block); + } + if channel_manager.current_best_block().height > fork_point_block.height { + channel_manager.blocks_disconnected(fork_point_block); + } chain_monitor.blocks_disconnected(fork_point_block); - output_sweeper.blocks_disconnected(fork_point_block); + if output_sweeper.current_best_block().height > fork_point_block.height { + output_sweeper.blocks_disconnected(fork_point_block); + } } } } @@ -315,10 +385,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. }, @@ -357,6 +459,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() } @@ -369,6 +479,8 @@ impl ChainSource { ChainSourceKind::Electrum { .. } => true, #[cfg(feature = "chain-bitcoind")] ChainSourceKind::Bitcoind { .. } => false, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => false, } } @@ -410,6 +522,10 @@ impl ChainSource { ChainSourceKind::Electrum(source) => { source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -437,9 +553,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; } }, @@ -461,9 +577,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; } }, @@ -479,6 +595,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, + // ); + }, } } @@ -521,7 +648,7 @@ impl ChainSource { log_trace!( logger, "Stopping background syncing on-chain wallet.", - ); + ); return; } _ = onchain_wallet_sync_interval.tick() => { @@ -535,7 +662,7 @@ impl ChainSource { Arc::clone(&channel_manager), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), - ).await; + ).await; } } } @@ -561,6 +688,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") + }, } } @@ -589,6 +720,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") + }, } } @@ -623,6 +758,10 @@ impl ChainSource { ) .await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -640,6 +779,10 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.update_fee_rate_estimates().await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -663,6 +806,10 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.validate_zero_fee_commitments_support().await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -683,6 +830,10 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.process_transaction_broadcast(package).await }, + #[cfg(feature = "chain-cbf")] + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -722,6 +873,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) { @@ -734,6 +889,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 af19bf51e7..ec36f17390 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -331,11 +331,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 edc0004d58..d0de4b06de 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -39,7 +39,7 @@ use lightning::chain::chaininterface::{ }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint as LdkOutPoint; -use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; @@ -456,7 +456,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()); @@ -470,6 +474,12 @@ impl Wallet { BlockLocator { block_hash: checkpoint.hash(), height: checkpoint.height(), previous_blocks } } + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] + pub(crate) fn has_block(&self, height: u32, block_hash: bitcoin::BlockHash) -> bool { + let checkpoint = self.inner.lock().expect("lock").latest_checkpoint(); + checkpoint.get(height).map_or(false, |cp| cp.hash() == block_hash) + } + pub(crate) async fn apply_update(&self, update: impl Into) -> Result<(), Error> { let mut locked_persister = self.persister.lock().await; let events = { @@ -529,6 +539,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(()); @@ -1877,6 +1905,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 @@ -1953,6 +1982,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) } @@ -2549,6 +2579,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 2546b09db2..84addcbc17 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -534,9 +534,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 @@ -548,14 +548,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 @@ -567,10 +567,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( @@ -588,7 +588,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(); }, @@ -603,7 +605,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(); } @@ -612,8 +616,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(); @@ -628,7 +638,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(); } @@ -894,7 +906,7 @@ fn start_node(node: TestNode) -> TestNode { 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); @@ -903,9 +915,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) { @@ -934,14 +948,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) { @@ -953,7 +969,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) { @@ -970,7 +987,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` @@ -1004,7 +1022,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, { @@ -1012,14 +1040,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; } @@ -1027,11 +1057,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) { @@ -1247,13 +1277,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); @@ -1329,7 +1361,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(); @@ -1697,7 +1731,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(); @@ -1709,7 +1745,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(); @@ -1731,7 +1769,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(); @@ -1807,7 +1847,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(); @@ -1852,7 +1894,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(); @@ -1874,19 +1919,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 bb7c664f05..b2d63f5d25 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -32,9 +32,9 @@ 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_node_with_store, setup_two_nodes, - splice_in_with_all, wait_for_block, wait_for_channel_ready_to_send, wait_for_outpoint_spend, - wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, TestConfig, TestNode, - TestStoreType, TestSyncStore, + splice_in_with_all, wait_for_block, wait_for_channel_ready_to_send, wait_for_node_tip, + wait_for_outpoint_spend, wait_for_tx, InMemoryStore, NodePaymentExt, TestChainSource, + TestConfig, TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::electrum_client::ElectrumApi; @@ -1013,7 +1013,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); @@ -1932,6 +1932,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(); @@ -1976,10 +1981,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()); @@ -2047,9 +2053,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; @@ -2087,12 +2095,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; @@ -2110,11 +2118,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; @@ -2231,10 +2241,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)) @@ -2249,16 +2260,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()); @@ -2272,10 +2287,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); @@ -2320,9 +2337,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 @@ -2358,9 +2377,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 @@ -2968,10 +2989,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()); @@ -3069,7 +3092,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, @@ -3533,6 +3558,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); @@ -4873,10 +4903,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()); @@ -5266,12 +5298,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!( @@ -5468,10 +5510,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()); @@ -5607,10 +5651,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()); @@ -5705,11 +5751,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); @@ -5857,9 +5906,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()); @@ -6121,9 +6172,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()); @@ -6439,9 +6492,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() @@ -6534,9 +6590,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()); @@ -6569,9 +6627,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() @@ -6632,9 +6692,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()); @@ -6935,6 +6997,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 @@ -7035,9 +7102,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), @@ -7146,10 +7215,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()); @@ -7307,11 +7378,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()); @@ -7363,10 +7436,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()); @@ -7382,10 +7457,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 993b026378302912cb2482a89996cbf29a6faf87 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Mon, 8 Jun 2026 13:56:14 +0900 Subject: [PATCH 02/19] 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 c75927bfce..a9fe888f4a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -432,6 +432,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 8ce4bc9826e52f39bc664be48a0c70cddbdfc327 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 22 May 2026 13:05:22 +0900 Subject: [PATCH 03/19] 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 ec36f17390..50b866956c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -936,13 +936,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. match self.background_processor_stop_sender.send(()) { Ok(()) => log_trace!(self.logger, "Sent shutdown signal to background processor."), From 7f2e52513917c83aea9b13ee7c7c6a0d723c101c Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Tue, 9 Jun 2026 05:05:38 +0900 Subject: [PATCH 04/19] 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 7485ecdaddd2b0d152a86609f1972f2f67b56c9a Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 05:05:56 +0200 Subject: [PATCH 05/19] 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 7bee5bafd9e347bf4c02c315b4580b5b0d7056c0 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 03:46:21 +0200 Subject: [PATCH 06/19] 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/lib.rs | 2 + src/util.rs | 55 +++++++ src/wallet/mod.rs | 13 +- 9 files changed, 506 insertions(+), 153 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3747032d11..1771dae7c0 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 f217aed2b9..a0a07fc945 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -55,6 +55,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::{ @@ -139,6 +141,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)] @@ -440,6 +447,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 @@ -1674,8 +1695,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 00155771a9..914d263380 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -311,7 +311,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!( @@ -753,91 +760,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 a9fe888f4a..e52267ef17 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -387,7 +387,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, @@ -395,8 +395,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()); @@ -419,7 +423,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. @@ -461,14 +465,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() } @@ -599,14 +595,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 }, } } @@ -782,8 +773,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 d524eac58e..390b25a863 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/lib.rs b/src/lib.rs index 50b866956c..0e1106a980 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -132,6 +132,8 @@ pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; pub use builder::{BuildError, Builder}; use chain::ChainSource; +#[cfg(feature = "chain-cbf")] +pub use chain::CbfFeeSourceConfig; use config::{ default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config, LNURL_AUTH_TIMEOUT_SECS, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, 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 d0de4b06de..6f35c5f5db 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -39,7 +39,7 @@ use lightning::chain::chaininterface::{ }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint as LdkOutPoint; -use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; @@ -549,14 +549,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(()); @@ -1905,7 +1897,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 @@ -1982,7 +1973,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) } @@ -2579,7 +2569,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 b7d38ddcde1afc7b15fc863b67281e421e3f4ecd Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 15:54:35 +0200 Subject: [PATCH 07/19] cbf: implement package broadcasting Co-authored-by: febyeji --- src/chain/cbf.rs | 74 ++++++++++++++++++++++++++++++++++++++++-------- src/chain/mod.rs | 14 +++++---- 2 files changed, 71 insertions(+), 17 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 e52267ef17..58a5d06d20 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -41,7 +41,7 @@ use crate::config::ElectrumSyncConfig; use crate::config::EsploraSyncConfig; use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_SECS}; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::tx_broadcaster::BroadcastPackage; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -800,8 +800,12 @@ impl ChainSource { bitcoind_chain_source.validate_zero_fee_commitments_support().await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf { .. } => { - todo!(); + ChainSourceKind::Cbf(_) => { + log_error!( + self.logger, + "CBF chain sources cannot verify zero-fee commitment package relay support" + ); + Err(Error::ChainSourceNotSupported) }, } } @@ -824,8 +828,8 @@ 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 9a09744985e18415422092d84d3cca3094999cd1 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 00:35:52 +0300 Subject: [PATCH 08/19] 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 58a5d06d20..c2570dcc64 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; @@ -753,6 +753,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 6f35c5f5db..2373379f7c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -3904,13 +3904,14 @@ enum FundingStatusUpdate { 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 84addcbc17..f33f39c3e2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -350,6 +350,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") @@ -364,7 +368,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| { @@ -385,6 +389,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())]; @@ -409,6 +415,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}"), } } @@ -655,6 +666,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)] @@ -785,6 +798,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); + }, } } @@ -1745,6 +1764,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"); @@ -1769,6 +1790,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"); @@ -1824,8 +1846,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 840f41a9b1582ba06bdac9428ed6583ff752163b Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Wed, 8 Jul 2026 19:33:28 +0100 Subject: [PATCH 09/19] 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 c2570dcc64..a16578e114 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -752,10 +752,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 0e1106a980..1b81d085db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2292,6 +2292,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 7d92d20567614b76508528fc5ab96e9e885ace89 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 17 Jul 2026 06:47:22 +0900 Subject: [PATCH 10/19] 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 1771dae7c0..0b5eb47fee 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.2.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 2373379f7c..cc212f8541 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -539,14 +539,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 caf9693403505ae9bf7289a9ccb5cca08de8d3f7 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 21 Jul 2026 15:34:58 +0200 Subject: [PATCH 11/19] 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 0b5eb47fee..1eb86b6452 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.2.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 0612b08799896b0134bd5bd1517575c2d14ee28d Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 29 Sep 2026 19:49:08 +0200 Subject: [PATCH 12/19] 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 f33f39c3e2..92256ccafb 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -601,7 +601,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(); }, @@ -1938,27 +1938,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 f3d79a0709231387879194e89fd3d358c3906095 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 30 Sep 2026 00:38:55 +0200 Subject: [PATCH 13/19] 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 914d263380..e7f8fe881b 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -99,7 +99,6 @@ impl ElectrumChainSource { self.server_url.clone(), self.sync_config.clone(), Arc::clone(&runtime), - Arc::clone(&self.config), Arc::clone(&self.logger), ) } @@ -445,15 +444,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() { @@ -502,7 +500,6 @@ struct ElectrumRuntimeClient { bdk_electrum_client: Arc>>, tx_sync: Arc>>, runtime: Arc, - config: Arc, logger: Arc, confirm_gate: Arc, } @@ -510,7 +507,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) @@ -539,7 +536,6 @@ impl ElectrumRuntimeClient { bdk_electrum_client, tx_sync, runtime, - config, logger, confirm_gate: Arc::new(ConfirmGate::new()), }) From 763af4a0d362dfcd79e31ff1b910b98a497d0939 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Wed, 30 Sep 2026 11:14:19 +0200 Subject: [PATCH 14/19] cbf: add uniffi builder --- src/builder.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/builder.rs b/src/builder.rs index a0a07fc945..b677606c5c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1122,6 +1122,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 75464414368e65474c719e9bde9565987ae428b8 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Thu, 8 Oct 2026 15:43:34 +0200 Subject: [PATCH 15/19] cbf: add preprocessing step before kyoto runs Node might go offline on a reorgnaized block, which would make stall syncing after a restart. This commits adds a preprocessing step which runs once before the events from kyoto are processed. During the preprocessing step we check whether we have forked and disconnect blocks accordingly. Still there might be a problem if several of chain monitors diverged and live on different forks, but other chain sources (bitcoind) ignore that and that perhaps requires a fix upstream. --- src/chain/cbf.rs | 100 ++++++++++++++++++++++++++++++++++++++++---- src/chain/mod.rs | 50 +++++++++++++--------- src/wallet/mod.rs | 15 +++++++ tests/reorg_test.rs | 39 ++++++++++++++++- 4 files changed, 175 insertions(+), 29 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 27bf2ebf7b..de8d1182c4 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -6,8 +6,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent, - HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, - Warning, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, + Requester as KyotoRequester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; @@ -60,7 +60,7 @@ const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; /// Runtime status of the underlying kyoto node. enum CbfRuntimeStatus { - Started { requester: Requester }, + Started { requester: KyotoRequester }, Stopped, } @@ -182,9 +182,10 @@ impl BlockApplicator { }, ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); - self.next_height = fork_point.height + 1; + let best_block_height = self.chain_listener.get_best_block().height; + self.next_height = best_block_height + 1; self.sync_state_tx.send_replace(CbfSyncState::Active { - applied_tip: Some(fork_point.height), + applied_tip: Some(best_block_height), synced_to_tip: false, }); }, @@ -430,9 +431,19 @@ impl CbfChainSource { Arc::clone(&restart_logger), )); + let (preprocessed_event_tx, preprocessed_event_rx) = + mpsc::unbounded_channel::(); + let _ = tokio::spawn(Self::preprocess_kyoto_events( + current_event_rx, + preprocessed_event_tx, + restart_listener.clone(), + Arc::clone(&restart_cbf_runtime_status), + ops_tx.clone(), + )); + let event_handle = tokio::spawn(Self::process_kyoto_events( Arc::clone(&restart_logger), - current_event_rx, + preprocessed_event_rx, Arc::clone(&restart_registered_scripts), Arc::clone(&restart_cbf_runtime_status), ops_tx.clone(), @@ -583,6 +594,39 @@ impl CbfChainSource { } } + //this function is called once per start before we really process the events + //we need it to decide if we have been reorganized during the time we were offline + async fn preprocess_kyoto_events( + mut kyoto_events_rx: mpsc::UnboundedReceiver, + processed_events_tx: mpsc::UnboundedSender, chain_listener: ChainListener, + cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, + ) { + let mut checked_for_offline_reorg = false; + while let Some(event) = kyoto_events_rx.recv().await { + if !checked_for_offline_reorg { + match &event { + // if we received filter, we're in canonical chain and can check for forks occured + KyotoEvent::IndexedFilter(_) => { + checked_for_offline_reorg = true; + let requester = match &*cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return, + }; + if let Some(fork_point) = + startup_fork_point(&requester, &chain_listener).await + { + let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); + } + }, + _ => {}, + } + } + if processed_events_tx.send(event).is_err() { + return; + } + } + } + async fn process_kyoto_events( logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, @@ -930,7 +974,7 @@ impl CbfChainSource { /// 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>, + &self, requester: &KyotoRequester, cache: &Mutex>, ) -> Vec { let tip_height = match requester.chain_tip().await { Ok(tip) => tip.height, @@ -1003,6 +1047,48 @@ impl CbfChainSource { } } +///Fork point which might have occured when we were offline +async fn startup_fork_point( + requester: &KyotoRequester, chain_listener: &ChainListener, +) -> Option { + let canonical_tip_height = requester.chain_tip().await.ok()?.height; + + let mut fork_point: Option = None; + for best_block in chain_listener.best_blocks() { + // The consumer's tip followed by the hashes of the blocks right below it. + let mut known_blocks = vec![(best_block.height, best_block.block_hash)]; + for (i, previous_hash) in best_block.previous_blocks.iter().enumerate() { + match (best_block.height.checked_sub(i as u32 + 1), previous_hash) { + (Some(height), Some(hash)) => known_blocks.push((height, *hash)), + _ => break, + } + } + + for (height, hash) in known_blocks { + if height > canonical_tip_height { + continue; + } + let canonical_header = match requester.get_header(height).await.ok().flatten() { + Some(header) => header, + // Kyoto has no headers below its checkpoint. + None => break, + }; + if canonical_header.block_hash() != hash { + continue; + } + // The consumer's last canonical block. If it is its tip, nothing needs disconnecting. + if height < best_block.height { + match fork_point { + Some(lowest) if lowest.height <= height => {}, + _ => fork_point = Some(BlockLocator::new(hash, height)), + } + } + break; + } + } + fork_point +} + 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(); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index a16578e114..4c653c7e61 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -21,9 +21,9 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use bitcoin::{Script, Txid}; -use lightning::chain::{BlockLocator, Filter}; #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] use lightning::chain::Listen; +use lightning::chain::{BlockLocator, Filter}; #[cfg(feature = "chain-bitcoind")] use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -208,27 +208,26 @@ impl ChainListener { #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] impl ChainListener { - pub(crate) fn get_best_block(&self) -> BlockLocator { + pub(crate) fn best_blocks(&self) -> Vec { let (onchain_wallet, channel_manager, chain_monitor, output_sweeper) = self.upgrade().expect("chain listener components dropped"); - let candidates = [ + let mut best_blocks = vec![ 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 + best_blocks.extend( + chain_monitor + .list_monitors() + .iter() + .flat_map(|id| chain_monitor.get_monitor(*id)) + .map(|m| m.current_best_block()), + ); + best_blocks + } + + pub(crate) fn get_best_block(&self) -> BlockLocator { + self.best_blocks().into_iter().min_by_key(|b| b.height).expect("non-empty") } } @@ -284,7 +283,20 @@ impl Listen for ChainListener { if channel_manager.current_best_block().height > fork_point_block.height { channel_manager.blocks_disconnected(fork_point_block); } - chain_monitor.blocks_disconnected(fork_point_block); + // Monitors are persisted at different heights, and `ChainMonitor::blocks_disconnected` + // asserts every one of them is above the fork point, so disconnect them one by one. + for monitor in + chain_monitor.list_monitors().iter().flat_map(|id| chain_monitor.get_monitor(*id)) + { + if monitor.current_best_block().height > fork_point_block.height { + monitor.blocks_disconnected( + fork_point_block, + onchain_wallet.broadcaster(), + onchain_wallet.fee_estimator(), + onchain_wallet.logger(), + ); + } + } if output_sweeper.current_best_block().height > fork_point_block.height { output_sweeper.blocks_disconnected(fork_point_block); } @@ -771,9 +783,7 @@ impl ChainSource { bitcoind_chain_source.update_fee_rate_estimates().await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf(cbf_chain_source) => { - cbf_chain_source.update_fee_rate_estimates().await - }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.update_fee_rate_estimates().await, } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index cc212f8541..734e4fff46 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -460,6 +460,21 @@ impl Wallet { self.inner.lock().expect("lock").latest_checkpoint() } + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] + pub(crate) fn broadcaster(&self) -> &Arc { + &self.broadcaster + } + + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] + pub(crate) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] + pub(crate) fn logger(&self) -> &Arc { + &self.logger + } + #[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(); diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index b1e65abdb0..786fc2c440 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -12,8 +12,8 @@ use serde_json::json; use crate::common::{ expect_event, exponential_backoff_poll, generate_blocks_and_wait, invalidate_blocks, open_channel, premine_and_distribute_funds, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_node, wait_for_outpoint_spend, wait_for_tx, NodePaymentExt, - TestChainSource, + setup_bitcoind_and_electrsd, setup_node, wait_for_node_tip, wait_for_outpoint_spend, + wait_for_tx, NodePaymentExt, TestChainSource, TestStoreType, }; #[cfg(feature = "chain-bitcoind")] @@ -72,6 +72,41 @@ fn bitcoind_rest_follows_valid_reorg() { }) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn restart_follows_deep_reorg_that_happened_while_offline() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let mut config = random_config(); + config.store_type = TestStoreType::Sqlite; + let node = setup_node(&chain_source, config.clone()); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 50).await; + node.sync_wallets().unwrap(); + let old_tip = node.status().current_best_block; + + node.stop().unwrap(); + drop(node); + + let reorg_depth = 5; + invalidate_blocks(&bitcoind.client, reorg_depth); + let new_tip_height = + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, reorg_depth + 20).await; + let new_tip_hash = bitcoind.client.best_block_hash().expect("failed to get new tip hash"); + assert_ne!(old_tip.block_hash, new_tip_hash); + + let node = setup_node(&chain_source, config); + wait_for_node_tip(&node, new_tip_height) + .await + .expect("restarted node did not reach the new tip after an offline reorg"); + node.sync_wallets().unwrap(); + + let synced_tip = node.status().current_best_block; + assert_eq!(synced_tip.height as usize, new_tip_height); + assert_eq!(synced_tip.block_hash, new_tip_hash); + + node.stop().unwrap(); +} + async fn wait_for_pending_sweep_balance( node: &ldk_node::Node, mut matches_balance: F, ) -> PendingSweepBalance From 331b3cd700baa354a859d76dc936f7a5d2a30bc4 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Mon, 26 Oct 2026 21:10:56 +0100 Subject: [PATCH 16/19] cbf: discover a stable tip for a fresh wallet A CBF node has no trusted node to ask for the chain tip, so a wallet created for the first time started from genesis and had to download every filter on the way up. On a first start, run a short-lived headers-only kyoto node up to the tip, validate the best header through the same helper the bitcoind sources use, and walk back `REORG_SAFETY_BLOCKS` from it. The wallet and the channel manager begin from that block instead of genesis. The walk back is needed because kyoto cannot report a reorg at or below its start checkpoint, and on the first start `resume_checkpoint` has nothing lower to step to. `ChainSource::fetch_stable_tip` is only called by the builder when the CBF source is configured and no tip is known yet. Also move to bip157 0.8.0: `Client::warn_rx` is now a bounded receiver, and `data_dir` no longer exists, so the unused call is removed. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.toml | 4 +- src/builder.rs | 15 +++- src/chain/cbf.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++--- src/chain/mod.rs | 11 +++ 4 files changed, 206 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1eb86b6452..a9633d0039 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-esplora", "chain-electrum"] +chain-cbf = ["dep:bip157", "dep:lightning-block-sync", "chain-esplora", "chain-electrum"] storage-sqlite = ["dep:rusqlite"] storage-filesystem = ["dep:lightning-persister"] storage-vss = ["dep:vss-client", "dep:prost"] @@ -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.2.0", default-features = false, features = ["std", "keys-bip39"]} -bip157 = { version = "0.6.3", default-features = false, optional = true } +bip157 = { version = "0.8.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 b677606c5c..f5d6befa17 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1679,7 +1679,8 @@ fn build_with_store_internal( }, }; - let (chain_source, chain_tip_opt) = match chain_data_source_config { + #[allow(unused_mut)] + let (chain_source, mut chain_tip_opt) = match chain_data_source_config { #[cfg(feature = "chain-esplora")] Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => { let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default()); @@ -1867,6 +1868,18 @@ fn build_with_store_internal( wallet }, None => { + // A CBF node has no trusted node to ask for the tip, so a fresh wallet discovers it + // with a short headers-only sync and starts there, as everything below it is empty. + #[cfg(feature = "chain-cbf")] + if chain_tip_opt.is_none() + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Cbf { .. })) + { + let stable_tip = runtime + .block_on(chain_source.fetch_stable_tip()) + .map_err(|_| BuildError::ChainTipFetchFailed)?; + chain_tip_opt = Some(stable_tip); + } + // Guard against silently setting the wallet birthday to genesis on a fresh node: // if we are creating a new wallet but failed to learn the current chain tip from // a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index de8d1182c4..68e10b8355 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use std::future::Future; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -12,6 +13,8 @@ use bip157::{ 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 lightning_block_sync::init::validate_best_block_header; +use lightning_block_sync::{BlockData, BlockHeaderData, BlockSource, BlockSourceError}; use tokio::sync::{mpsc, watch}; @@ -43,6 +46,9 @@ 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; +/// How long a first-ever start waits for the headers-only sync that discovers the chain tip. +const STABLE_TIP_FETCH_TIMEOUT_SECS: u64 = 600; + /// Maximum consecutive `node.run()` failures before the restart loop gives up. const MAX_RESTART_RETRIES: u32 = 5; @@ -341,9 +347,6 @@ impl CbfChainSource { ) -> (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()); } @@ -366,6 +369,106 @@ impl CbfChainSource { kyoto_builder.build() } + /// Syncs kyoto on headers_only mode (fast sync) to get the current tip (and walk back reorg + /// safety window) to use it as a wallet birthday. + pub(crate) async fn fetch_stable_tip(&self) -> Result { + log_info!(self.logger, "CBF: finding a stable tip for the first start."); + + let mut kyoto_builder = KyotoBuilder::new(self.config.network).headers_only_sync(); + + if !self.trusted_peers.is_empty() { + kyoto_builder = kyoto_builder.add_peers(self.trusted_peers.to_vec()); + } + + kyoto_builder = kyoto_builder.required_peers(DEFAULT_REQUIRED_PEERS); + kyoto_builder = + kyoto_builder.response_timeout(Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)); + + // The mainnet headers before taproot activation tell us nothing we need, so skip them. + if self.config.network == Network::Bitcoin { + kyoto_builder = kyoto_builder + .chain_state(ChainState::Checkpoint(HashCheckpoint::taproot_activation())); + } + + let (node, client) = kyoto_builder.build(); + let Client { requester, info_rx, warn_rx, mut event_rx } = client; + tokio::spawn(Self::process_info_messages(info_rx, Arc::clone(&self.logger))); + tokio::spawn(Self::process_warn_messages(warn_rx, Arc::clone(&self.logger))); + let node_handle = tokio::spawn(node.run()); + + let source = KyotoBlockSource(requester.clone()); + let stable_tip_discovery = async { + loop { + match event_rx.recv().await { + Some(KyotoEvent::FiltersSynced(_)) => break, + Some(KyotoEvent::ChainUpdate(BlockHeaderChanges::Connected(header))) => { + if header.height % 50_000 == 0 { + log_info!( + self.logger, + "CBF: tip discovery at height {}.", + header.height + ); + } + }, + Some(_) => {}, + None => { + return Err(BlockSourceError::persistent( + "kyoto stopped before reaching the tip", + )) + }, + } + } + + // A chain no longer than the safety window has no stable tip other than genesis, and + // kyoto holds no header for genesis itself, so start from there. + let (_, tip_height) = source.get_best_block().await?; + if tip_height.map_or(false, |height| height <= REORG_SAFETY_BLOCKS) { + return Ok(BlockLocator::from_network(self.config.network)); + } + + let validated_tip = validate_best_block_header(&source).await?; + let mut header_data = *validated_tip; + // Kyoto cannot report reorgs below its start checkpoint, so start a few blocks back. + for _ in 0..REORG_SAFETY_BLOCKS { + if header_data.height == 0 { + break; + } + header_data = source + .get_header(&header_data.header.prev_blockhash, Some(header_data.height - 1)) + .await?; + } + Ok(BlockLocator::new(header_data.header.block_hash(), header_data.height)) + }; + let result = tokio::time::timeout( + Duration::from_secs(STABLE_TIP_FETCH_TIMEOUT_SECS), + stable_tip_discovery, + ) + .await; + + // Stop the temporary node before the real one starts. + let _ = requester.shutdown(); + let _ = node_handle.await; + + match result { + Ok(Ok(stable_tip)) => { + log_info!(self.logger, "CBF: first start at height {}.", stable_tip.height); + Ok(stable_tip) + }, + Ok(Err(e)) => { + log_error!(self.logger, "Failed to discover the chain tip: {:?}", e); + Err(Error::TxSyncFailed) + }, + Err(_) => { + log_error!( + self.logger, + "Timed out after {}s discovering the chain tip.", + STABLE_TIP_FETCH_TIMEOUT_SECS + ); + Err(Error::TxSyncTimeout) + }, + } + } + pub(crate) fn start(&self, chain_listener: ChainListener) { let (node, client) = Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); @@ -586,9 +689,7 @@ impl CbfChainSource { } } - async fn process_warn_messages( - mut warn_rx: mpsc::UnboundedReceiver, logger: Arc, - ) { + async fn process_warn_messages(mut warn_rx: mpsc::Receiver, logger: Arc) { while let Some(warning) = warn_rx.recv().await { log_debug!(logger, "CBF node warning: {}", warning); } @@ -1047,6 +1148,71 @@ impl CbfChainSource { } } +/// Exposes kyoto's [`KyotoRequester`] as a [`BlockSource`], so that the tip can be discovered with +/// the same code path as for the Bitcoin Core sources. +struct KyotoBlockSource(KyotoRequester); + +impl BlockSource for KyotoBlockSource { + fn get_header<'a>( + &'a self, header_hash: &'a BlockHash, height_hint: Option, + ) -> impl Future> + 'a { + async move { + // Kyoto looks headers up by height, so resolve the height first if we weren't given it. + let height = match height_hint { + Some(height) => height, + None => self + .0 + .height_of_hash(*header_hash) + .await + .map_err(|e| BlockSourceError::transient(format!("{:?}", e)))? + .ok_or_else(|| BlockSourceError::persistent("unknown block hash"))?, + }; + let indexed_header = self + .0 + .get_header(height) + .await + .map_err(|e| BlockSourceError::transient(format!("{:?}", e)))? + .ok_or_else(|| BlockSourceError::persistent("no header at height"))?; + if indexed_header.header.block_hash() != *header_hash { + return Err(BlockSourceError::persistent("header does not match requested hash")); + } + // Kyoto does not expose the accumulated chain work. Only the work of this block is + // filled in, which is enough for validating a single tip but not for comparing tips. + Ok(BlockHeaderData { + header: indexed_header.header, + height, + chainwork: indexed_header.header.work(), + }) + } + } + + fn get_block<'a>( + &'a self, header_hash: &'a BlockHash, + ) -> impl Future> + 'a { + async move { + let indexed_block = self + .0 + .get_block(*header_hash) + .await + .map_err(|e| BlockSourceError::transient(format!("{:?}", e)))?; + Ok(BlockData::FullBlock(indexed_block.block)) + } + } + + fn get_best_block<'a>( + &'a self, + ) -> impl Future), BlockSourceError>> + 'a { + async move { + let tip = self + .0 + .chain_tip() + .await + .map_err(|e| BlockSourceError::transient(format!("{:?}", e)))?; + Ok((tip.hash, Some(tip.height))) + } + } +} + ///Fork point which might have occured when we were offline async fn startup_fork_point( requester: &KyotoRequester, chain_listener: &ChainListener, @@ -1106,15 +1272,18 @@ fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option< } } - // Walk BDK's checkpoint chain back to the reorg-safe anchor height. + // Walk BDK's checkpoint chain back to the reorg-safe anchor height. Don't step onto genesis: + // a wallet created at a birthday has no checkpoints below it, so its lowest one is the start. 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, + Some(prev) if prev.height() > 0 => cursor = prev, + _ => break, } } - (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) + // Kyoto must not start above any listener, or that listener would miss the blocks in between. + (cursor.height() > 0 && cursor.height() <= min_best_block.height) + .then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 4c653c7e61..09fec26085 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -481,6 +481,17 @@ impl ChainSource { self.registered_txids.lock().expect("lock").clone() } + /// Discovers a stable (reorg-safe) tip for a first-ever start to begin from. Only the CBF + /// source needs this, as it has no trusted node to ask for the tip, so it must only be called + /// when the CBF source is configured. + #[cfg(feature = "chain-cbf")] + pub(crate) async fn fetch_stable_tip(&self) -> Result { + match &self.kind { + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.fetch_stable_tip().await, + _ => unreachable!("a stable tip is only fetched for the CBF chain source"), + } + } + pub(crate) fn is_transaction_based(&self) -> bool { match &self.kind { #[cfg(feature = "chain-esplora")] From 6c48fc58bdebccc6892ba612e794293bd255b872 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 27 Oct 2026 14:41:08 +0100 Subject: [PATCH 17/19] cbf: wait for the chain source to sync in `initialize_wallets` Upstream now syncs Lightning state before peers or event processing start, going through `ChainSource::initialize_wallets` for every backend. The CBF source has no separate Lightning sync: `ChainSource::start` already spawns the kyoto node, which feeds all listeners. Hold the node start until the CBF source has caught up to the network tip and applied its blocks, via `wait_until_synced`. This replaces the `todo!()` placeholder left while rebasing onto upstream. Co-Authored-By: Claude Sonnet 5.5 --- src/chain/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 09fec26085..f33662d494 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -544,8 +544,12 @@ impl ChainSource { source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await }, #[cfg(feature = "chain-cbf")] - ChainSourceKind::Cbf { .. } => { - todo!(); + ChainSourceKind::Cbf(source) => { + // The kyoto node was already started by `ChainSource::start` and feeds every + // listener, so there is nothing to sync here: just hold the start until it has + // caught up to the network tip, before peers or event processing can act on + // stale confirmations. + source.wait_until_synced().await }, } } From 343ae6f848c0ba1298b3796b32a20cf14494882e Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 27 Oct 2026 15:04:00 +0100 Subject: [PATCH 18/19] cbf: apply our own transactions to the wallet at broadcast CBF has no mempool, so the wallet never sees a transaction it has just broadcast until a block confirms it. Until then BDK's coin selection still treats the inputs as spendable, and a second send could double-spend them. On CBF, apply the transaction as unconfirmed ourselves when we send it (`send_to_address`), replace it (`bump_fee_rbf`, which also evicts the replaced transaction) or fund a channel with it. The funding case runs from the event handler after the channel facts are recorded, so the payment this creates is classified. This replaces the earlier approach that hooked into the now-removed broadcast-time classification; transactions produced by LDK itself (closes, sweeps, anchors) are not applied this way. CBF also has no mempool to tell us a transaction was evicted. Fall back to a stale-broadcast deadline of 336h, matching Bitcoin Core's mempool expiry, for outbound non-funding payments, and mark a dropped payment failed instead of re-creating it as pending. `update_payment_store` takes a reference time for this: the connecting block's own time rather than the wall clock, so replaying many blocks after being offline does not skew the comparison. Both behaviours apply to CBF only. `apply_mempool_txs` becomes available under `chain-cbf` as well as `chain-bitcoind`. Co-Authored-By: Claude Sonnet 5.5 --- src/chain/mod.rs | 21 +++- src/event.rs | 16 +++ src/lib.rs | 3 +- src/wallet/mod.rs | 258 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 244 insertions(+), 54 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f33662d494..9a26e6e7bc 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -481,6 +481,17 @@ impl ChainSource { self.registered_txids.lock().expect("lock").clone() } + pub(crate) fn is_cbf(&self) -> bool { + #[cfg(feature = "chain-cbf")] + { + matches!(self.kind, ChainSourceKind::Cbf(_)) + } + #[cfg(not(feature = "chain-cbf"))] + { + false + } + } + /// Discovers a stable (reorg-safe) tip for a first-ever start to begin from. Only the CBF /// source needs this, as it has no trusted node to ask for the tip, so it must only be called /// when the CBF source is configured. @@ -835,7 +846,9 @@ impl ChainSource { /// Hands the package to the configured chain source, parents before their child so a CPFP /// package a chain source submits one transaction at a time is still accepted. - async fn broadcast(&self, package: BroadcastPackage) { + async fn broadcast(&self, package: BroadcastPackage, onchain_wallet: &Wallet) { + #[cfg(not(feature = "chain-cbf"))] + let _ = onchain_wallet; let package = package.into_sorted_transactions(); match &self.kind { #[cfg(feature = "chain-esplora")] @@ -852,6 +865,9 @@ impl ChainSource { }, #[cfg(feature = "chain-cbf")] ChainSourceKind::Cbf(cbf_chain_source) => { + // CBF has no mempool to show the wallet what we broadcast, so apply it before + // handing it over, which may wait on a peer. + onchain_wallet.apply_broadcast_package(&package).await; cbf_chain_source.process_broadcast_package(package.into_inner()).await }, } @@ -859,6 +875,7 @@ impl ChainSource { pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, + onchain_wallet: Arc, ) { loop { let package = tokio::select! { @@ -874,7 +891,7 @@ impl ChainSource { } package = self.tx_broadcaster.next_package() => package, }; - self.broadcast(package).await; + self.broadcast(package, &onchain_wallet).await; } } } diff --git a/src/event.rs b/src/event.rs index 99b217fd52..994918f9bf 100644 --- a/src/event.rs +++ b/src/event.rs @@ -901,6 +901,22 @@ where ); } + // On CBF, apply the transaction to the wallet as unconfirmed before LDK can + // treat a 0conf channel as ready, which it may do synchronously as part of + // the funding-signed handshake, without waiting on our broadcast queue. + // After the facts above, so the payment this creates is classified. + #[cfg(feature = "chain-cbf")] + if let Err(e) = + self.wallet.apply_own_unconfirmed_tx(&final_tx, Vec::new()).await + { + log_error!( + self.logger, + "Failed to apply the funding transaction of channel {} to the wallet: {}", + temporary_channel_id, + e, + ); + } + let needs_manual_broadcast = self .liquidity_source .lsps2_service() diff --git a/src/lib.rs b/src/lib.rs index 1b81d085db..4e2973ddb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -694,8 +694,9 @@ impl Node { let stop_tx_bcast = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); + let onchain_wallet = Arc::clone(&self.wallet); self.runtime.spawn_cancellable_background_task(async move { - chain_source.continuously_process_broadcast_queue(stop_tx_bcast).await + chain_source.continuously_process_broadcast_queue(stop_tx_bcast, onchain_wallet).await }); let retention_secs = self.forwarded_payment_aggregation_retention_secs; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 734e4fff46..10625c30dd 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -10,6 +10,7 @@ use std::future::Future; use std::ops::Deref; use std::str::FromStr; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::ChainPosition; @@ -75,6 +76,8 @@ use crate::payment::{ PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; +#[cfg(feature = "chain-cbf")] +use crate::tx_broadcaster::SortedTransactions; use crate::types::{Broadcaster, ChannelTxFactsStore, PaymentStore, PendingPaymentStore}; use crate::wallet::provenance::{ ChannelLiveness, ChannelTxFacts, ChannelTxFactsRejection, FactsRecordOutcome, FactsRetention, @@ -100,6 +103,12 @@ 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 are exempt, as their lifecycle +/// follows the channel rather than this deadline. 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`]. /// @@ -507,7 +516,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 })?; @@ -520,7 +530,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. On CBF it is also how a + /// transaction we ourselves just built is applied as unconfirmed immediately, rather than + /// waiting on a chain-source sync that 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. + #[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))] pub(crate) async fn apply_mempool_txs( &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { @@ -540,7 +558,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 })?; @@ -554,6 +573,57 @@ impl Wallet { Ok(()) } + /// On CBF, applies a transaction we ourselves are about to broadcast to the wallet as + /// unconfirmed, evicting the ones it replaces. Other chain sources observe the broadcast + /// transaction in their mempool, so this does nothing there. + /// + /// CBF has no mempool to observe the broadcast from, so without this BDK's coin selection + /// would still see the transaction's inputs as spendable and a second send could double-spend + /// them. It must not be called with the persister or the payment stores locked, as it takes + /// both itself. + #[cfg(feature = "chain-cbf")] + pub(crate) async fn apply_own_unconfirmed_tx( + &self, tx: &Transaction, replaced: Vec, + ) -> Result<(), Error> { + if !self.chain_source.is_cbf() { + return Ok(()); + } + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let evicted = replaced.into_iter().map(|txid| (txid, now)).collect(); + self.apply_mempool_txs(vec![(tx.clone(), now)], evicted).await + } + + /// Applies the transactions of a package this node is about to broadcast, which the wallet + /// has a stake in, as unconfirmed: those it has recorded facts about, such as a splice LDK + /// negotiated and broadcasts without us ever holding it, and those spending or paying one of + /// its scripts. Transactions it has no stake in are left out of the wallet's graph. + /// + /// The transactions this node builds itself are applied as it builds them, which is earlier + /// than any broadcast; doing it again here changes nothing. It must not be called with the + /// persister or the payment stores locked, for the reason [`Self::apply_own_unconfirmed_tx`] + /// gives. + #[cfg(feature = "chain-cbf")] + pub(crate) async fn apply_broadcast_package(&self, package: &SortedTransactions) { + if !self.chain_source.is_cbf() { + return; + } + for tx in package.iter() { + let txid = tx.compute_txid(); + let is_relevant = self.inner.lock().expect("lock").spk_index().inner().is_relevant(tx); + if !is_relevant && self.channel_tx_facts(&txid).await.is_none() { + continue; + } + if let Err(e) = self.apply_own_unconfirmed_tx(tx, Vec::new()).await { + log_error!( + self.logger, + "Failed to apply the transaction {} we are broadcasting to the wallet: {}", + txid, + e, + ); + } + } + } + /// 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 @@ -562,7 +632,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(()); } @@ -698,6 +777,7 @@ impl Wallet { .await; let mut unconfirmed_outbound_txids: Vec = Vec::new(); + let mut stale_outbound_txids: Vec = Vec::new(); let mut unclassified_transactions: Vec<(PaymentId, Txid)> = Vec::new(); for payment in pending_payments { @@ -763,7 +843,7 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, - .. + ref tx_type, } => { if self .fail_funding_payment_lost_to_conflict(&payment, new_tip.height) @@ -772,7 +852,35 @@ impl Wallet { continue; } if details.direction == PaymentDirection::Outbound { - unconfirmed_outbound_txids.push(txid); + // Funding transactions are exempt: their lifecycle follows the + // channel rather than our stale-broadcast eviction. + let is_funding = matches!( + tx_type, + Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. } + ) + ); + let first_seen = + self.inner.lock().expect("lock").get_tx(txid).and_then( + |tx| match tx.chain_position { + ChainPosition::Unconfirmed { + first_seen, .. + } => first_seen, + _ => None, + }, + ); + let is_stale = + self.chain_source.is_cbf() + && !is_funding && first_seen.is_some_and(|first_seen| { + reference_time.saturating_sub(first_seen) + >= STALE_BROADCAST_EVICTION_SECS + }); + if is_stale { + stale_outbound_txids.push(txid); + } else { + unconfirmed_outbound_txids.push(txid); + } } }, _ => {}, @@ -785,6 +893,19 @@ impl Wallet { // records they could still classify have been classified. self.prune_channel_tx_facts(new_tip.height).await; + if !stale_outbound_txids.is_empty() { + let evicted_txids = stale_outbound_txids + .into_iter() + .map(|txid| (txid, reference_time)) + .collect::>(); + let eviction_events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + locked_wallet.apply_evicted_txs_events(evicted_txids) + }; + Box::pin(self.update_payment_store(eviction_events, reference_time)) + .await?; + } + if !unconfirmed_outbound_txids.is_empty() { let txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); @@ -955,6 +1076,26 @@ impl Wallet { }, } + // CBF has no mempool poll that could evict a transaction, so a drop there is + // our own stale-broadcast deadline (`ChainTipChanged`, above) giving up + // waiting on it, and nothing takes its place. Mark the payment failed rather + // than resurrecting it as still-pending, which would just leave it stuck + // forever. Other backends have a mempool that can bring it back, so they keep + // re-creating the payment below and rebroadcasting it. + if self.chain_source.is_cbf() { + stores + .mutate_payment(&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?; + stores.remove_pending_payment(&payment_id).await?; + continue; + } + // See `TxConfirmed`: a fact that cannot be read fails the pass. let provenance = self.read_tx_provenance(txid, &tx).await?; let payment = { @@ -2351,6 +2492,9 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + // `apply_own_unconfirmed_tx` below re-acquires this same (non-reentrant) lock itself. + #[cfg(feature = "chain-cbf")] + drop(locked_persister); let tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract transaction: {}", e); @@ -2358,6 +2502,8 @@ impl Wallet { })?; let txid = tx.compute_txid(); + #[cfg(feature = "chain-cbf")] + self.apply_own_unconfirmed_tx(&tx, Vec::new()).await?; self.broadcaster.broadcast(tx); match send_amount { @@ -3735,7 +3881,13 @@ impl Wallet { let stores = self.payment_stores.lock().await; stores.insert_or_update_payment(new_payment.clone()).await?; self.upsert_pending_payment(&stores, new_payment, Vec::new()).await?; - + // `apply_own_unconfirmed_tx` below takes both locks itself, in this same order. + #[cfg(feature = "chain-cbf")] + { + drop(stores); + drop(locked_persister); + self.apply_own_unconfirmed_tx(&fee_bumped_tx, vec![txid]).await?; + } self.broadcaster.broadcast(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); @@ -3977,7 +4129,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; } @@ -5560,7 +5713,7 @@ mod tests { tx: Arc::new(tx.clone()), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); } /// Records the payment wallet sync creates for `tx` without the wallet taking the transaction @@ -5572,7 +5725,7 @@ mod tests { tx: Arc::new(tx.clone()), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); } /// Signs a splice round and lets wallet sync observe its transaction, as the node does: the @@ -5806,7 +5959,7 @@ mod tests { block_time: confirmed_block_time(confirmation_height), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("record"); @@ -6369,7 +6522,7 @@ mod tests { block_time: confirmed_block_time(105), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().expect("payment"); assert!(matches!( @@ -6857,7 +7010,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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -6914,7 +7067,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_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7023,7 +7176,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_stores.payment_store().get(&payment_id).await.unwrap().is_none()); } @@ -7076,7 +7229,7 @@ mod tests { old_block_time: None, }, ]; - wallet.update_payment_store(events).await.unwrap(); + wallet.update_payment_store(events, 0).await.unwrap(); assert!( wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), @@ -7197,7 +7350,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(); let funding = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7332,7 +7485,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(); let funding = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7420,7 +7573,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7502,7 +7655,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7520,7 +7673,7 @@ mod tests { old_tip: block_id(5 + ANTI_REORG_DELAY - 1), new_tip: block_id(6 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7594,7 +7747,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7612,7 +7765,7 @@ mod tests { old_tip: block_id(5 + ANTI_REORG_DELAY - 1), new_tip: block_id(6 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7679,7 +7832,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); // The first round confirms. insert_confirmed_tx(&wallet, first_tx.clone(), 6); @@ -7689,7 +7842,7 @@ mod tests { block_time: confirmed_block_time(6), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7771,7 +7924,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7824,7 +7977,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 2), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7892,7 +8045,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -7943,7 +8096,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -8002,7 +8155,7 @@ mod tests { new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }, ]; - wallet.update_payment_store(events).await.unwrap(); + wallet.update_payment_store(events, 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -8027,7 +8180,7 @@ mod tests { let txid = tx.compute_txid(); let reads_before = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let reads = counting_store.reads(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE) - reads_before; let payment_id = PaymentId(txid.to_byte_array()); @@ -8058,7 +8211,7 @@ mod tests { // r1 reappears in the mempool after the failure... let event = WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -8080,7 +8233,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(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -8128,7 +8281,7 @@ mod tests { let event = WalletEvent::TxUnconfirmed { txid: r1, tx: Arc::new(dummy_tx()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&settled_id).await.unwrap().unwrap(); @@ -8190,7 +8343,7 @@ mod tests { old_tip: block_id(9), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().unwrap(); @@ -8706,7 +8859,7 @@ mod tests { tx: Arc::new(dummy_tx()), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) @@ -8753,7 +8906,7 @@ mod tests { tx: Arc::new(dummy_tx()), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment = wallet.payment_stores.payment_store().get(&id).await.unwrap().unwrap(); assert!( matches!(payment.kind, PaymentKind::Onchain { txid, .. } if txid == counterparty_txid) @@ -9407,7 +9560,7 @@ mod tests { let event = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(tx.clone()), old_block_time: None }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); // The reported share identifies the funding payment the transaction belongs to, so the // record is filed under that payment rather than under the transaction's own id. @@ -9506,7 +9659,7 @@ mod tests { fail_store.fail_reads.store(true, Ordering::Release); for event in [unconfirmed(), confirmed_at(tip_height), confirmed_at(confirmation_height)] { - let result = wallet.update_payment_store(vec![event]).await; + let result = wallet.update_payment_store(vec![event], 0).await; assert!(matches!(result, Err(Error::PersistenceFailed)), "{:?}", result); assert!( wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none(), @@ -9517,7 +9670,7 @@ mod tests { // The replay with the reads back records the transaction as the channel reported it, // under the funding payment the report identifies. fail_store.fail_reads.store(false, Ordering::Release); - wallet.update_payment_store(vec![confirmed_at(confirmation_height)]).await.unwrap(); + wallet.update_payment_store(vec![confirmed_at(confirmation_height)], 0).await.unwrap(); assert!(wallet.payment_stores.payment_store().get(&payment_id).await.unwrap().is_none()); let payment = wallet .payment_stores @@ -9564,14 +9717,14 @@ mod tests { block_time: confirmed_block_time(5), old_block_time: None, }; - wallet.update_payment_store(vec![confirmed]).await.unwrap(); + wallet.update_payment_store(vec![confirmed], 0).await.unwrap(); let block_id = |height| BlockId { height, hash: bitcoin::BlockHash::from_byte_array([7u8; 32]) }; let graduated = WalletEvent::ChainTipChanged { old_tip: block_id(5), new_tip: block_id(5 + ANTI_REORG_DELAY - 1), }; - wallet.update_payment_store(vec![graduated]).await.unwrap(); + wallet.update_payment_store(vec![graduated], 0).await.unwrap(); let payment_id = PaymentId(claim_txid.to_byte_array()); let unclassified = wallet @@ -9648,7 +9801,7 @@ mod tests { insert_unconfirmed_tx(wallet, spend.clone()); let seen = WalletEvent::TxUnconfirmed { txid, tx: Arc::new(spend.clone()), old_block_time: None }; - wallet.update_payment_store(vec![seen]).await.unwrap(); + wallet.update_payment_store(vec![seen], 0).await.unwrap(); let payment_id = PaymentId(txid.to_byte_array()); let recorded = @@ -9828,7 +9981,7 @@ mod tests { tx: Arc::new(sweep.clone()), old_block_time: None, }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let payment_id = PaymentId(sweep_txid.to_byte_array()); let unclassified = wallet @@ -9857,7 +10010,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(1), new_tip: block_id(2) }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); let classified = wallet .payment_stores @@ -9996,7 +10149,7 @@ mod tests { old_tip: block_id_at(height - 1), new_tip: block_id_at(height), }; - wallet.update_payment_store(vec![event]).await.unwrap(); + wallet.update_payment_store(vec![event], 0).await.unwrap(); } /// A chain tip far enough past both the age cap and the burial of the spend above. @@ -10454,11 +10607,14 @@ mod tests { let close_txid = close.compute_txid(); insert_unconfirmed_tx(&wallet, close.clone()); wallet - .update_payment_store(vec![WalletEvent::TxUnconfirmed { - txid: close_txid, - tx: Arc::new(close), - old_block_time: None, - }]) + .update_payment_store( + vec![WalletEvent::TxUnconfirmed { + txid: close_txid, + tx: Arc::new(close), + old_block_time: None, + }], + 0, + ) .await .unwrap(); let payment = wallet From ec136be23eba1955e75add04fb9ca9e0cbca7b73 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 27 Oct 2026 15:13:52 +0100 Subject: [PATCH 19/19] cbf: do not require both Esplora and Electrum `chain-cbf` pulled in both `chain-esplora` and `chain-electrum` only because the optional external fee sources need their clients. Make each source depend on its own feature instead, so CBF can be built with either one, both or neither (in which case fee rates are derived from recent blocks). `CbfFeeSourceConfig::Esplora` and `::Electrum`, the matching `FeeSource` variants and the code that uses them are gated on `chain-esplora` and `chain-electrum` respectively. The "at least one chain source feature" check now also counts `chain-cbf`. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.toml | 2 +- src/chain/cbf.rs | 16 +++++++++++++++- src/chain/mod.rs | 5 +++++ src/lib.rs | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a9633d0039..35cb0b98ee 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", "dep:lightning-block-sync", "chain-esplora", "chain-electrum"] +chain-cbf = ["dep:bip157", "dep:lightning-block-sync"] storage-sqlite = ["dep:rusqlite"] storage-filesystem = ["dep:lightning-persister"] storage-vss = ["dep:vss-client", "dep:prost"] diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 68e10b8355..6d09c7214f 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -11,6 +11,7 @@ use bip157::{ Requester as KyotoRequester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; +#[cfg(feature = "chain-electrum")] use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; use lightning_block_sync::init::validate_best_block_header; @@ -18,13 +19,16 @@ use lightning_block_sync::{BlockData, BlockHeaderData, BlockSource, BlockSourceE use tokio::sync::{mpsc, watch}; +#[cfg(feature = "chain-electrum")] use crate::chain::electrum::get_electrum_fee_rate_cache_update; use crate::chain::{CbfFeeSourceConfig, ChainListener}; use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; +#[cfg(feature = "chain-esplora")] +use crate::fee_estimator::get_num_block_defaults_for_target; 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, + ConfirmationTarget, OnchainFeeEstimator, }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -58,10 +62,13 @@ const INITIAL_BACKOFF_MS: u64 = 500; /// Retry matched block downloads before surfacing a CBF sync failure. const CBF_BLOCK_FETCH_RETRIES: u8 = 3; +#[cfg(feature = "chain-esplora")] const ESPLORA_TIMEOUT: u64 = 2; /// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. +#[cfg(feature = "chain-electrum")] const ELECTRUM_FEE_NUM_RETRIES: u8 = 3; +#[cfg(feature = "chain-electrum")] const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; /// Runtime status of the underlying kyoto node. @@ -289,10 +296,12 @@ enum FeeSource { /// 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. + #[cfg(feature = "chain-esplora")] Esplora { client: esplora_client::AsyncClient }, /// Delegate fee estimation to an Electrum server. /// /// A fresh connection is opened for each estimation cycle. + #[cfg(feature = "chain-electrum")] Electrum { server_url: String }, } @@ -310,6 +319,7 @@ impl CbfChainSource { .collect(); let fee_source = match fee_source_config { + #[cfg(feature = "chain-esplora")] Some(CbfFeeSourceConfig::Esplora(server_url)) => { let mut esplora_builder = esplora_client::Builder::new(&server_url); esplora_builder = esplora_builder.timeout(ESPLORA_TIMEOUT); @@ -319,6 +329,7 @@ impl CbfChainSource { })?; FeeSource::Esplora { client } }, + #[cfg(feature = "chain-electrum")] Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, None => FeeSource::Cbf { block_fee_cache: Arc::new(Mutex::new(BTreeMap::new())) }, }; @@ -486,6 +497,7 @@ 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)), + #[cfg(any(feature = "chain-esplora", feature = "chain-electrum"))] _ => None, }; let best_block_height = chain_listener.get_best_block().height; @@ -901,6 +913,7 @@ impl CbfChainSource { pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { let new_fee_rate_cache = match &self.fee_source { + #[cfg(feature = "chain-esplora")] FeeSource::Esplora { client } => { let estimates = client.get_fee_estimates().await.map_err(|e| { log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); @@ -930,6 +943,7 @@ impl CbfChainSource { } new_fee_rate_cache }, + #[cfg(feature = "chain-electrum")] FeeSource::Electrum { server_url } => { let electrum_config = ElectrumConfigBuilder::new() .retry(ELECTRUM_FEE_NUM_RETRIES) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 9a26e6e7bc..87569d864c 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -155,13 +155,18 @@ impl WalletSyncStatus { /// 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. +/// +/// Each source is available only with its own chain source feature (`chain-esplora`, +/// `chain-electrum`), so CBF can be built with either, both or neither. #[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. + #[cfg(feature = "chain-esplora")] Esplora(String), /// Use an Electrum server for fee rate estimation. + #[cfg(feature = "chain-electrum")] Electrum(String), } diff --git a/src/lib.rs b/src/lib.rs index 4e2973ddb4..7176c2a215 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,7 +84,8 @@ #[cfg(not(any( feature = "chain-esplora", feature = "chain-electrum", - feature = "chain-bitcoind" + feature = "chain-bitcoind", + feature = "chain-cbf" )))] compile_error!("at least one chain source feature must be enabled");