@@ -19,11 +19,12 @@ use bip157::{
1919} ;
2020use bitcoin:: constants:: SUBSIDY_HALVING_INTERVAL ;
2121use bitcoin:: { Amount , FeeRate , Network , Script , ScriptBuf , Transaction , Txid } ;
22+ use electrum_client:: ElectrumApi ;
2223use lightning:: chain:: { Confirm , WatchedOutput } ;
2324use lightning:: util:: ser:: Writeable ;
2425use tokio:: sync:: { mpsc, oneshot} ;
2526
26- use super :: WalletSyncStatus ;
27+ use super :: { FeeSourceConfig , WalletSyncStatus } ;
2728use crate :: config:: { CbfSyncConfig , Config , BDK_CLIENT_STOP_GAP } ;
2829use crate :: error:: Error ;
2930use 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.
4344const 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+
4562pub ( 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
102121impl 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