Skip to content

Commit aec7c8a

Browse files
randomloginfebyeji
authored andcommitted
Add optional fee source from esplora/electrum
Fixes the problem when the funding tx is not registered for CBF node, for example during splicing. Added macros to skip incompatible with CBF backend tests (which require mempool).
1 parent 5f0aa28 commit aec7c8a

File tree

7 files changed

+314
-46
lines changed

7 files changed

+314
-46
lines changed

src/builder.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use lightning::util::sweep::OutputSweeper;
4242
use lightning_persister::fs_store::v1::FilesystemStore;
4343
use vss_client::headers::VssHeaderProvider;
4444

45-
use crate::chain::ChainSource;
45+
use crate::chain::{ChainSource, FeeSourceConfig};
4646
use crate::config::{
4747
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
4848
BitcoindRestClientConfig, CbfSyncConfig, Config, ElectrumSyncConfig, EsploraSyncConfig,
@@ -108,6 +108,7 @@ enum ChainDataSourceConfig {
108108
Cbf {
109109
peers: Vec<String>,
110110
sync_config: Option<CbfSyncConfig>,
111+
fee_source_config: Option<FeeSourceConfig>,
111112
},
112113
}
113114

@@ -384,8 +385,10 @@ impl NodeBuilder {
384385
/// target selection partially mitigates this.
385386
pub fn set_chain_source_cbf(
386387
&mut self, peers: Vec<String>, sync_config: Option<CbfSyncConfig>,
388+
fee_source_config: Option<FeeSourceConfig>,
387389
) -> &mut Self {
388-
self.chain_data_source_config = Some(ChainDataSourceConfig::Cbf { peers, sync_config });
390+
self.chain_data_source_config =
391+
Some(ChainDataSourceConfig::Cbf { peers, sync_config, fee_source_config });
389392
self
390393
}
391394

@@ -929,8 +932,11 @@ impl ArcedNodeBuilder {
929932
/// divided by block weight) rather than per-transaction fee rates. This can underestimate
930933
/// next-block inclusion rates during periods of high mempool congestion. Percentile-based
931934
/// target selection partially mitigates this.
932-
pub fn set_chain_source_cbf(&self, peers: Vec<String>, sync_config: Option<CbfSyncConfig>) {
933-
self.inner.write().unwrap().set_chain_source_cbf(peers, sync_config);
935+
pub fn set_chain_source_cbf(
936+
&self, peers: Vec<String>, sync_config: Option<CbfSyncConfig>,
937+
fee_source_config: Option<FeeSourceConfig>,
938+
) {
939+
self.inner.write().unwrap().set_chain_source_cbf(peers, sync_config, fee_source_config);
934940
}
935941

936942
/// Configures the [`Node`] instance to connect to a Bitcoin Core node via RPC.
@@ -1405,11 +1411,12 @@ fn build_with_store_internal(
14051411
}),
14061412
},
14071413

1408-
Some(ChainDataSourceConfig::Cbf { peers, sync_config }) => {
1414+
Some(ChainDataSourceConfig::Cbf { peers, sync_config, fee_source_config }) => {
14091415
let sync_config = sync_config.clone().unwrap_or(CbfSyncConfig::default());
14101416
ChainSource::new_cbf(
14111417
peers.clone(),
14121418
sync_config,
1419+
fee_source_config.clone(),
14131420
Arc::clone(&fee_estimator),
14141421
Arc::clone(&tx_broadcaster),
14151422
Arc::clone(&kv_store),
@@ -2182,7 +2189,7 @@ mod tests {
21822189
let sync_config = CbfSyncConfig::default();
21832190

21842191
let peers = vec!["127.0.0.1:8333".to_string()];
2185-
builder.set_chain_source_cbf(peers.clone(), Some(sync_config.clone()));
2192+
builder.set_chain_source_cbf(peers.clone(), Some(sync_config.clone()), None);
21862193

21872194
let guard = builder.inner.read().unwrap();
21882195
assert!(matches!(

src/chain/cbf.rs

Lines changed: 206 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ use bip157::{
1919
};
2020
use bitcoin::constants::SUBSIDY_HALVING_INTERVAL;
2121
use bitcoin::{Amount, FeeRate, Network, Script, ScriptBuf, Transaction, Txid};
22+
use electrum_client::ElectrumApi;
2223
use lightning::chain::{Confirm, WatchedOutput};
2324
use lightning::util::ser::Writeable;
2425
use tokio::sync::{mpsc, oneshot};
2526

26-
use super::WalletSyncStatus;
27+
use super::{FeeSourceConfig, WalletSyncStatus};
2728
use crate::config::{CbfSyncConfig, Config, BDK_CLIENT_STOP_GAP};
2829
use crate::error::Error;
2930
use crate::fee_estimator::{
@@ -42,11 +43,29 @@ const MIN_FEERATE_SAT_PER_KWU: u64 = 250;
4243
/// Number of recent blocks to look back for per-target fee rate estimation.
4344
const FEE_RATE_LOOKBACK_BLOCKS: usize = 6;
4445

46+
/// The fee estimation back-end used by the CBF chain source.
47+
enum FeeSource {
48+
/// Derive fee rates from the coinbase reward of recent blocks.
49+
///
50+
/// Provides a per-target rate using percentile selection across multiple blocks.
51+
/// Less accurate than a mempool-aware source but requires no extra connectivity.
52+
Cbf,
53+
/// Delegate fee estimation to an Esplora HTTP server.
54+
Esplora { client: esplora_client::AsyncClient },
55+
/// Delegate fee estimation to an Electrum server.
56+
///
57+
/// A fresh connection is opened for each estimation cycle because `ElectrumClient`
58+
/// is not `Sync`.
59+
Electrum { server_url: String },
60+
}
61+
4562
pub(super) struct CbfChainSource {
4663
/// Peer addresses for sourcing compact block filters via P2P.
4764
peers: Vec<String>,
4865
/// User-provided sync configuration (timeouts, background sync intervals).
4966
pub(super) sync_config: CbfSyncConfig,
67+
/// Fee estimation back-end.
68+
fee_source: FeeSource,
5069
/// Tracks whether the bip157 node is running and holds the command handle.
5170
cbf_runtime_status: Mutex<CbfRuntimeStatus>,
5271
/// Latest chain tip hash, updated by the background event processing task.
@@ -101,10 +120,22 @@ struct CbfEventState {
101120

102121
impl CbfChainSource {
103122
pub(crate) fn new(
104-
peers: Vec<String>, sync_config: CbfSyncConfig, fee_estimator: Arc<OnchainFeeEstimator>,
105-
kv_store: Arc<DynStore>, config: Arc<Config>, logger: Arc<Logger>,
106-
node_metrics: Arc<RwLock<NodeMetrics>>,
123+
peers: Vec<String>, sync_config: CbfSyncConfig, fee_source_config: Option<FeeSourceConfig>,
124+
fee_estimator: Arc<OnchainFeeEstimator>, kv_store: Arc<DynStore>, config: Arc<Config>,
125+
logger: Arc<Logger>, node_metrics: Arc<RwLock<NodeMetrics>>,
107126
) -> Self {
127+
let fee_source = match fee_source_config {
128+
Some(FeeSourceConfig::Esplora(server_url)) => {
129+
let timeout = sync_config.timeouts_config.per_request_timeout_secs;
130+
let mut builder = esplora_client::Builder::new(&server_url);
131+
builder = builder.timeout(timeout as u64);
132+
let client = builder.build_async().unwrap();
133+
FeeSource::Esplora { client }
134+
},
135+
Some(FeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url },
136+
None => FeeSource::Cbf,
137+
};
138+
108139
let cbf_runtime_status = Mutex::new(CbfRuntimeStatus::Stopped);
109140
let latest_tip = Arc::new(Mutex::new(None));
110141
let watched_scripts = Arc::new(RwLock::new(Vec::new()));
@@ -121,6 +152,7 @@ impl CbfChainSource {
121152
Self {
122153
peers,
123154
sync_config,
155+
fee_source,
124156
cbf_runtime_status,
125157
latest_tip,
126158
watched_scripts,
@@ -451,8 +483,24 @@ impl CbfChainSource {
451483
async fn sync_onchain_wallet_op(
452484
&self, requester: Requester, scripts: Vec<ScriptBuf>,
453485
) -> Result<(TxUpdate<ConfirmationBlockTime>, SyncUpdate), Error> {
454-
let skip_height = *self.last_onchain_synced_height.lock().unwrap();
455-
let (sync_update, matched) = self.run_filter_scan(scripts, skip_height).await?;
486+
// Always do a full scan (skip_height=None) for the on-chain wallet.
487+
// Unlike the Lightning wallet which can rely on reorg_queue events,
488+
// the on-chain wallet needs to see all blocks to correctly detect
489+
// reorgs via checkpoint comparison in the caller.
490+
//
491+
// We include LDK-registered scripts (e.g., channel funding output
492+
// scripts) alongside the wallet scripts. This ensures the on-chain
493+
// wallet scan also fetches blocks containing channel funding
494+
// transactions, whose outputs are needed by BDK's TxGraph to
495+
// calculate fees for subsequent spends such as splice transactions.
496+
// Without these, BDK's `calculate_fee` would fail with
497+
// `MissingTxOut` because the parent transaction's outputs are
498+
// unknown. This mirrors what the Bitcoind chain source does in
499+
// `Wallet::block_connected` by inserting registered tx outputs.
500+
let mut all_scripts = scripts;
501+
// we query all registered scripts, not only BDK-related
502+
all_scripts.extend(self.registered_scripts.lock().unwrap().iter().cloned());
503+
let (sync_update, matched) = self.run_filter_scan(all_scripts, None).await?;
456504

457505
log_debug!(
458506
self.logger,
@@ -608,13 +656,45 @@ impl CbfChainSource {
608656
}
609657

610658
pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> {
659+
let new_fee_rate_cache = match &self.fee_source {
660+
FeeSource::Cbf => self.fee_rate_cache_from_cbf().await?,
661+
FeeSource::Esplora { client } => Some(self.fee_rate_cache_from_esplora(client).await?),
662+
FeeSource::Electrum { server_url } => {
663+
Some(self.fee_rate_cache_from_electrum(server_url).await?)
664+
},
665+
};
666+
667+
let Some(new_fee_rate_cache) = new_fee_rate_cache else {
668+
return Ok(());
669+
};
670+
671+
self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache);
672+
673+
update_node_metrics_timestamp(
674+
&self.node_metrics,
675+
&*self.kv_store,
676+
&*self.logger,
677+
|m, t| {
678+
m.latest_fee_rate_cache_update_timestamp = t;
679+
},
680+
)?;
681+
682+
Ok(())
683+
}
684+
685+
/// Derive per-target fee rates from recent blocks' coinbase outputs.
686+
///
687+
/// Returns `Ok(None)` when no chain tip is available yet (first startup before sync).
688+
async fn fee_rate_cache_from_cbf(
689+
&self,
690+
) -> Result<Option<HashMap<crate::fee_estimator::ConfirmationTarget, FeeRate>>, Error> {
611691
let requester = self.requester()?;
612692

613693
let tip_hash = match *self.latest_tip.lock().unwrap() {
614694
Some(hash) => hash,
615695
None => {
616696
log_debug!(self.logger, "No tip available yet for fee rate estimation, skipping.");
617-
return Ok(());
697+
return Ok(None);
618698
},
619699
};
620700

@@ -656,7 +736,7 @@ impl CbfChainSource {
656736
e
657737
);
658738
*self.latest_tip.lock().unwrap() = None;
659-
return Ok(());
739+
return Ok(None);
660740
},
661741
Ok(Err(e)) => {
662742
log_error!(
@@ -675,7 +755,7 @@ impl CbfChainSource {
675755
e
676756
);
677757
*self.latest_tip.lock().unwrap() = None;
678-
return Ok(());
758+
return Ok(None);
679759
},
680760
Err(e) => {
681761
log_error!(self.logger, "Updating fee rate estimates timed out: {}", e);
@@ -743,25 +823,130 @@ impl CbfChainSource {
743823
);
744824
}
745825

746-
self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache);
747-
748826
log_debug!(
749827
self.logger,
750-
"Fee rate cache update finished in {}ms ({} blocks sampled).",
828+
"CBF fee rate estimation finished in {}ms ({} blocks sampled).",
751829
now.elapsed().as_millis(),
752830
block_fee_rates.len(),
753831
);
754832

755-
update_node_metrics_timestamp(
756-
&self.node_metrics,
757-
&*self.kv_store,
758-
&*self.logger,
759-
|m, t| {
760-
m.latest_fee_rate_cache_update_timestamp = t;
761-
},
762-
)?;
833+
Ok(Some(new_fee_rate_cache))
834+
}
763835

764-
Ok(())
836+
/// Fetch per-target fee rates from an Esplora server.
837+
async fn fee_rate_cache_from_esplora(
838+
&self, client: &esplora_client::AsyncClient,
839+
) -> Result<HashMap<crate::fee_estimator::ConfirmationTarget, FeeRate>, Error> {
840+
let timeout = Duration::from_secs(
841+
self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs,
842+
);
843+
let estimates = tokio::time::timeout(timeout, client.get_fee_estimates())
844+
.await
845+
.map_err(|e| {
846+
log_error!(self.logger, "Updating fee rate estimates timed out: {}", e);
847+
Error::FeerateEstimationUpdateTimeout
848+
})?
849+
.map_err(|e| {
850+
log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e);
851+
Error::FeerateEstimationUpdateFailed
852+
})?;
853+
854+
if estimates.is_empty() && self.config.network == Network::Bitcoin {
855+
log_error!(
856+
self.logger,
857+
"Failed to retrieve fee rate estimates: empty estimates are disallowed on Mainnet.",
858+
);
859+
return Err(Error::FeerateEstimationUpdateFailed);
860+
}
861+
862+
let confirmation_targets = get_all_conf_targets();
863+
let mut new_fee_rate_cache = HashMap::with_capacity(confirmation_targets.len());
864+
for target in confirmation_targets {
865+
let num_blocks = get_num_block_defaults_for_target(target);
866+
let converted_estimate_sat_vb =
867+
esplora_client::convert_fee_rate(num_blocks, estimates.clone())
868+
.map_or(1.0, |converted| converted.max(1.0));
869+
let fee_rate = FeeRate::from_sat_per_kwu((converted_estimate_sat_vb * 250.0) as u64);
870+
let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate);
871+
new_fee_rate_cache.insert(target, adjusted_fee_rate);
872+
873+
log_trace!(
874+
self.logger,
875+
"Fee rate estimation updated for {:?}: {} sats/kwu",
876+
target,
877+
adjusted_fee_rate.to_sat_per_kwu(),
878+
);
879+
}
880+
Ok(new_fee_rate_cache)
881+
}
882+
883+
/// Fetch per-target fee rates from an Electrum server.
884+
///
885+
/// Opens a fresh connection for each call because `ElectrumClient` is not `Sync`.
886+
async fn fee_rate_cache_from_electrum(
887+
&self, server_url: &str,
888+
) -> Result<HashMap<crate::fee_estimator::ConfirmationTarget, FeeRate>, Error> {
889+
let server_url = server_url.to_owned();
890+
let confirmation_targets = get_all_conf_targets();
891+
let per_request_timeout = self.sync_config.timeouts_config.per_request_timeout_secs;
892+
893+
let raw_estimates: Vec<serde_json::Value> = tokio::time::timeout(
894+
Duration::from_secs(
895+
self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs,
896+
),
897+
tokio::task::spawn_blocking(move || {
898+
let electrum_config = electrum_client::ConfigBuilder::new()
899+
.retry(3)
900+
.timeout(Some(per_request_timeout))
901+
.build();
902+
let client = electrum_client::Client::from_config(&server_url, electrum_config)
903+
.map_err(|_| Error::FeerateEstimationUpdateFailed)?;
904+
let mut batch = electrum_client::Batch::default();
905+
for target in confirmation_targets {
906+
batch.estimate_fee(get_num_block_defaults_for_target(target));
907+
}
908+
client.batch_call(&batch).map_err(|_| Error::FeerateEstimationUpdateFailed)
909+
}),
910+
)
911+
.await
912+
.map_err(|e| {
913+
log_error!(self.logger, "Updating fee rate estimates timed out: {}", e);
914+
Error::FeerateEstimationUpdateTimeout
915+
})?
916+
.map_err(|_| Error::FeerateEstimationUpdateFailed)? // JoinError
917+
?; // inner Result
918+
919+
let confirmation_targets = get_all_conf_targets();
920+
921+
if raw_estimates.len() != confirmation_targets.len()
922+
&& self.config.network == Network::Bitcoin
923+
{
924+
log_error!(
925+
self.logger,
926+
"Failed to retrieve fee rate estimates: Electrum server didn't return all expected results.",
927+
);
928+
return Err(Error::FeerateEstimationUpdateFailed);
929+
}
930+
931+
let mut new_fee_rate_cache = HashMap::with_capacity(confirmation_targets.len());
932+
for (target, raw_rate) in confirmation_targets.into_iter().zip(raw_estimates.into_iter()) {
933+
// Electrum returns BTC/KvB; fall back to 1 sat/vb (= 0.00001 BTC/KvB) on failure.
934+
let fee_rate_btc_per_kvb =
935+
raw_rate.as_f64().map_or(0.00001_f64, |v: f64| v.max(0.00001));
936+
// Convert BTC/KvB → sat/kwu: multiply by 25_000_000 (= 10^8 / 4).
937+
let fee_rate =
938+
FeeRate::from_sat_per_kwu((fee_rate_btc_per_kvb * 25_000_000.0).round() as u64);
939+
let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate);
940+
new_fee_rate_cache.insert(target, adjusted_fee_rate);
941+
942+
log_trace!(
943+
self.logger,
944+
"Fee rate estimation updated for {:?}: {} sats/kwu",
945+
target,
946+
adjusted_fee_rate.to_sat_per_kwu(),
947+
);
948+
}
949+
Ok(new_fee_rate_cache)
765950
}
766951

767952
/// Broadcast a package of transactions via the P2P network.

0 commit comments

Comments
 (0)