@@ -15,22 +15,30 @@ use bdk_wallet::Update;
1515use bip157:: {
1616 BlockHash , Builder , Client , Event , Info , Requester , SyncUpdate , TrustedPeer , Warning ,
1717} ;
18- use bitcoin:: { Script , ScriptBuf , Transaction , Txid } ;
18+ use bitcoin:: constants:: SUBSIDY_HALVING_INTERVAL ;
19+ use bitcoin:: { Amount , FeeRate , Network , Script , ScriptBuf , Transaction , Txid } ;
1920use lightning:: chain:: WatchedOutput ;
2021use lightning:: util:: ser:: Writeable ;
2122use tokio:: sync:: { mpsc, oneshot} ;
2223
2324use super :: WalletSyncStatus ;
2425use crate :: config:: { CbfSyncConfig , Config , BDK_CLIENT_STOP_GAP } ;
2526use crate :: fee_estimator:: {
26- apply_post_estimation_adjustments, get_all_conf_targets, OnchainFeeEstimator ,
27+ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
28+ OnchainFeeEstimator ,
2729} ;
2830use crate :: io:: utils:: write_node_metrics;
2931use crate :: logger:: { log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger , Logger } ;
3032use crate :: runtime:: Runtime ;
3133use crate :: types:: { ChainMonitor , ChannelManager , DynStore , Sweeper , Wallet } ;
3234use crate :: { Error , NodeMetrics } ;
3335
36+ /// Minimum fee rate: 1 sat/vB = 250 sat/kWU. Used as a floor for computed fee rates.
37+ const MIN_FEERATE_SAT_PER_KWU : u64 = 250 ;
38+
39+ /// Number of recent blocks to look back for per-target fee rate estimation.
40+ const FEE_RATE_LOOKBACK_BLOCKS : usize = 6 ;
41+
3442pub ( super ) struct CbfChainSource {
3543 /// Peer addresses for sourcing compact block filters via P2P.
3644 peers : Vec < String > ,
@@ -406,8 +414,6 @@ impl CbfChainSource {
406414 }
407415
408416 /// Estimate fee rates from recent block data.
409- // NOTE: This is a single-block fee estimation. A multi-block lookback with
410- // per-target percentile selection is added later.
411417 pub ( crate ) async fn update_fee_rate_estimates ( & self ) -> Result < ( ) , Error > {
412418 let requester = self . requester ( ) ?;
413419
@@ -421,26 +427,118 @@ impl CbfChainSource {
421427
422428 let now = Instant :: now ( ) ;
423429
424- let base_fee_rate = tokio:: time:: timeout (
425- Duration :: from_secs (
426- self . sync_config . timeouts_config . fee_rate_cache_update_timeout_secs ,
427- ) ,
428- requester. average_fee_rate ( tip_hash) ,
429- )
430- . await
431- . map_err ( |e| {
432- log_error ! ( self . logger, "Updating fee rate estimates timed out: {}" , e) ;
433- Error :: FeerateEstimationUpdateTimeout
434- } ) ?
435- . map_err ( |e| {
436- log_error ! ( self . logger, "Failed to retrieve fee rate estimate: {:?}" , e) ;
437- Error :: FeerateEstimationUpdateFailed
438- } ) ?;
430+ // Fetch fee rates from the last N blocks for per-target estimation.
431+ // We compute fee rates ourselves rather than using Requester::average_fee_rate,
432+ // so we can sample multiple blocks and select percentiles per confirmation target.
433+ let mut block_fee_rates: Vec < u64 > = Vec :: with_capacity ( FEE_RATE_LOOKBACK_BLOCKS ) ;
434+ let mut current_hash = tip_hash;
435+
436+ let timeout = Duration :: from_secs (
437+ self . sync_config . timeouts_config . fee_rate_cache_update_timeout_secs ,
438+ ) ;
439+ let fetch_start = Instant :: now ( ) ;
440+
441+ for idx in 0 ..FEE_RATE_LOOKBACK_BLOCKS {
442+ // Check if we've exceeded the overall timeout for fee estimation.
443+ let remaining_timeout = timeout. saturating_sub ( fetch_start. elapsed ( ) ) ;
444+ if remaining_timeout. is_zero ( ) {
445+ log_error ! ( self . logger, "Updating fee rate estimates timed out." ) ;
446+ return Err ( Error :: FeerateEstimationUpdateTimeout ) ;
447+ }
448+
449+ // Fetch the block via P2P. On the first iteration, a fetch failure
450+ // likely means the cached tip is stale (initial sync or reorg), so
451+ // we clear the tip and skip gracefully instead of returning an error.
452+ let indexed_block =
453+ match tokio:: time:: timeout ( remaining_timeout, requester. get_block ( current_hash) )
454+ . await
455+ {
456+ Ok ( Ok ( indexed_block) ) => indexed_block,
457+ Ok ( Err ( e) ) if idx == 0 => {
458+ log_debug ! (
459+ self . logger,
460+ "Cached CBF tip {} was unavailable during fee estimation, \
461+ likely due to initial sync or a reorg: {:?}",
462+ current_hash,
463+ e
464+ ) ;
465+ * self . latest_tip . lock ( ) . unwrap ( ) = None ;
466+ return Ok ( ( ) ) ;
467+ } ,
468+ Ok ( Err ( e) ) => {
469+ log_error ! (
470+ self . logger,
471+ "Failed to fetch block for fee estimation: {:?}" ,
472+ e
473+ ) ;
474+ return Err ( Error :: FeerateEstimationUpdateFailed ) ;
475+ } ,
476+ Err ( e) if idx == 0 => {
477+ log_debug ! (
478+ self . logger,
479+ "Timed out fetching cached CBF tip {} during fee estimation, \
480+ likely due to initial sync or a reorg: {}",
481+ current_hash,
482+ e
483+ ) ;
484+ * self . latest_tip . lock ( ) . unwrap ( ) = None ;
485+ return Ok ( ( ) ) ;
486+ } ,
487+ Err ( e) => {
488+ log_error ! ( self . logger, "Updating fee rate estimates timed out: {}" , e) ;
489+ return Err ( Error :: FeerateEstimationUpdateTimeout ) ;
490+ } ,
491+ } ;
492+
493+ let height = indexed_block. height ;
494+ let block = & indexed_block. block ;
495+ let weight_kwu = block. weight ( ) . to_kwu_floor ( ) ;
496+
497+ // Compute fee rate: (coinbase_output - subsidy) / weight.
498+ // For blocks with zero weight (e.g. coinbase-only in regtest), use the floor rate.
499+ let fee_rate_sat_per_kwu = if weight_kwu == 0 {
500+ MIN_FEERATE_SAT_PER_KWU
501+ } else {
502+ let subsidy = block_subsidy ( height) ;
503+ let revenue = block
504+ . txdata
505+ . first ( )
506+ . map ( |tx| tx. output . iter ( ) . map ( |o| o. value ) . sum ( ) )
507+ . unwrap_or ( Amount :: ZERO ) ;
508+ let block_fees = revenue. checked_sub ( subsidy) . unwrap_or ( Amount :: ZERO ) ;
509+
510+ if block_fees == Amount :: ZERO && self . config . network == Network :: Bitcoin {
511+ log_error ! (
512+ self . logger,
513+ "Failed to retrieve fee rate estimates: zero block fees are disallowed on Mainnet." ,
514+ ) ;
515+ return Err ( Error :: FeerateEstimationUpdateFailed ) ;
516+ }
517+
518+ ( block_fees. to_sat ( ) / weight_kwu) . max ( MIN_FEERATE_SAT_PER_KWU )
519+ } ;
520+
521+ block_fee_rates. push ( fee_rate_sat_per_kwu) ;
522+ // Walk backwards through the chain via prev_blockhash.
523+ if height == 0 {
524+ break ;
525+ }
526+ current_hash = block. header . prev_blockhash ;
527+ }
528+
529+ if block_fee_rates. is_empty ( ) {
530+ log_error ! ( self . logger, "No blocks available for fee rate estimation." ) ;
531+ return Err ( Error :: FeerateEstimationUpdateFailed ) ;
532+ }
533+
534+ block_fee_rates. sort ( ) ;
439535
440536 let confirmation_targets = get_all_conf_targets ( ) ;
441537 let mut new_fee_rate_cache = HashMap :: with_capacity ( confirmation_targets. len ( ) ) ;
442538
443539 for target in confirmation_targets {
540+ let num_blocks = get_num_block_defaults_for_target ( target) ;
541+ let base_fee_rate = select_fee_rate_for_target ( & block_fee_rates, num_blocks) ;
444542 let adjusted_fee_rate = apply_post_estimation_adjustments ( target, base_fee_rate) ;
445543 new_fee_rate_cache. insert ( target, adjusted_fee_rate) ;
446544
@@ -456,8 +554,9 @@ impl CbfChainSource {
456554
457555 log_debug ! (
458556 self . logger,
459- "Fee rate cache update finished in {}ms." ,
460- now. elapsed( ) . as_millis( )
557+ "Fee rate cache update finished in {}ms ({} blocks sampled)." ,
558+ now. elapsed( ) . as_millis( ) ,
559+ block_fee_rates. len( ) ,
461560 ) ;
462561
463562 update_node_metrics_timestamp (
@@ -546,3 +645,184 @@ fn update_node_metrics_timestamp(
546645 write_node_metrics ( & * locked, kv_store, logger) ?;
547646 Ok ( ( ) )
548647}
648+
649+ /// Compute the block subsidy (mining reward before fees) at the given block height.
650+ fn block_subsidy ( height : u32 ) -> Amount {
651+ let halvings = height / SUBSIDY_HALVING_INTERVAL ;
652+ if halvings >= 64 {
653+ return Amount :: ZERO ;
654+ }
655+ let base = Amount :: ONE_BTC . to_sat ( ) * 50 ;
656+ Amount :: from_sat ( base >> halvings)
657+ }
658+
659+ /// Select a fee rate from sorted block fee rates based on confirmation urgency.
660+ ///
661+ /// For urgent targets (1 block), uses the highest observed fee rate.
662+ /// For medium targets (2-6 blocks), uses the 75th percentile.
663+ /// For standard targets (7-12 blocks), uses the median.
664+ /// For low-urgency targets (13+ blocks), uses the 25th percentile.
665+ fn select_fee_rate_for_target ( sorted_rates : & [ u64 ] , num_blocks : usize ) -> FeeRate {
666+ if sorted_rates. is_empty ( ) {
667+ return FeeRate :: from_sat_per_kwu ( MIN_FEERATE_SAT_PER_KWU ) ;
668+ }
669+
670+ let len = sorted_rates. len ( ) ;
671+ let idx = if num_blocks <= 1 {
672+ len - 1
673+ } else if num_blocks <= 6 {
674+ ( len * 3 ) / 4
675+ } else if num_blocks <= 12 {
676+ len / 2
677+ } else {
678+ len / 4
679+ } ;
680+
681+ FeeRate :: from_sat_per_kwu ( sorted_rates[ idx. min ( len - 1 ) ] )
682+ }
683+
684+ #[ cfg( test) ]
685+ mod tests {
686+ use bitcoin:: constants:: SUBSIDY_HALVING_INTERVAL ;
687+ use bitcoin:: { Amount , FeeRate } ;
688+
689+ use super :: { block_subsidy, select_fee_rate_for_target, MIN_FEERATE_SAT_PER_KWU } ;
690+ use crate :: fee_estimator:: {
691+ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
692+ } ;
693+
694+ #[ test]
695+ fn select_fee_rate_empty_returns_floor ( ) {
696+ let rate = select_fee_rate_for_target ( & [ ] , 1 ) ;
697+ assert_eq ! ( rate, FeeRate :: from_sat_per_kwu( MIN_FEERATE_SAT_PER_KWU ) ) ;
698+ }
699+
700+ #[ test]
701+ fn select_fee_rate_single_element_returns_it_for_all_buckets ( ) {
702+ let rates = [ 5000u64 ] ;
703+ // Every urgency bucket should return the single available rate.
704+ for num_blocks in [ 1 , 3 , 6 , 12 , 144 , 1008 ] {
705+ let rate = select_fee_rate_for_target ( & rates, num_blocks) ;
706+ assert_eq ! (
707+ rate,
708+ FeeRate :: from_sat_per_kwu( 5000 ) ,
709+ "num_blocks={} should return the only available rate" ,
710+ num_blocks,
711+ ) ;
712+ }
713+ }
714+
715+ #[ test]
716+ fn select_fee_rate_picks_correct_percentile ( ) {
717+ // 6 sorted rates: indices 0..5
718+ let rates = [ 100 , 200 , 300 , 400 , 500 , 600 ] ;
719+ // 1-block (most urgent): highest → index 5 → 600
720+ assert_eq ! ( select_fee_rate_for_target( & rates, 1 ) , FeeRate :: from_sat_per_kwu( 600 ) ) ;
721+ // 6-block (medium): 75th percentile → (6*3)/4 = 4 → 500
722+ assert_eq ! ( select_fee_rate_for_target( & rates, 6 ) , FeeRate :: from_sat_per_kwu( 500 ) ) ;
723+ // 12-block (standard): median → 6/2 = 3 → 400
724+ assert_eq ! ( select_fee_rate_for_target( & rates, 12 ) , FeeRate :: from_sat_per_kwu( 400 ) ) ;
725+ // 144-block (low): 25th percentile → 6/4 = 1 → 200
726+ assert_eq ! ( select_fee_rate_for_target( & rates, 144 ) , FeeRate :: from_sat_per_kwu( 200 ) ) ;
727+ }
728+
729+ #[ test]
730+ fn select_fee_rate_monotonic_urgency ( ) {
731+ // More urgent targets should never produce lower rates than less urgent ones.
732+ let rates = [ 250 , 500 , 1000 , 2000 , 4000 , 8000 ] ;
733+ let urgent = select_fee_rate_for_target ( & rates, 1 ) ;
734+ let medium = select_fee_rate_for_target ( & rates, 6 ) ;
735+ let standard = select_fee_rate_for_target ( & rates, 12 ) ;
736+ let low = select_fee_rate_for_target ( & rates, 144 ) ;
737+
738+ assert ! (
739+ urgent >= medium,
740+ "urgent ({}) >= medium ({})" ,
741+ urgent. to_sat_per_kwu( ) ,
742+ medium. to_sat_per_kwu( )
743+ ) ;
744+ assert ! (
745+ medium >= standard,
746+ "medium ({}) >= standard ({})" ,
747+ medium. to_sat_per_kwu( ) ,
748+ standard. to_sat_per_kwu( )
749+ ) ;
750+ assert ! (
751+ standard >= low,
752+ "standard ({}) >= low ({})" ,
753+ standard. to_sat_per_kwu( ) ,
754+ low. to_sat_per_kwu( )
755+ ) ;
756+ }
757+
758+ #[ test]
759+ fn uniform_rates_match_naive_single_rate ( ) {
760+ // When all blocks have the same fee rate (like the old single-block
761+ // approach), every target should select that same base rate. This
762+ // proves the optimized multi-block approach is backwards-compatible.
763+
764+ let uniform_rate = 3000u64 ;
765+ let rates = [ uniform_rate; 6 ] ;
766+ for target in get_all_conf_targets ( ) {
767+ let num_blocks = get_num_block_defaults_for_target ( target) ;
768+ let optimized = select_fee_rate_for_target ( & rates, num_blocks) ;
769+ let naive = FeeRate :: from_sat_per_kwu ( uniform_rate) ;
770+ assert_eq ! (
771+ optimized, naive,
772+ "For target {:?} (num_blocks={}), optimized rate should match naive single-rate" ,
773+ target, num_blocks,
774+ ) ;
775+
776+ // Also verify the post-estimation adjustments produce the same
777+ // result for both approaches.
778+ let adjusted_optimized = apply_post_estimation_adjustments ( target, optimized) ;
779+ let adjusted_naive = apply_post_estimation_adjustments ( target, naive) ;
780+ assert_eq ! ( adjusted_optimized, adjusted_naive) ;
781+ }
782+ }
783+
784+ #[ test]
785+ fn block_subsidy_genesis ( ) {
786+ assert_eq ! ( block_subsidy( 0 ) , Amount :: from_sat( 50 * 100_000_000 ) ) ;
787+ }
788+
789+ #[ test]
790+ fn block_subsidy_first_halving ( ) {
791+ assert_eq ! ( block_subsidy( SUBSIDY_HALVING_INTERVAL ) , Amount :: from_sat( 25 * 100_000_000 ) ) ;
792+ }
793+
794+ #[ test]
795+ fn block_subsidy_second_halving ( ) {
796+ assert_eq ! ( block_subsidy( SUBSIDY_HALVING_INTERVAL * 2 ) , Amount :: from_sat( 1_250_000_000 ) ) ;
797+ }
798+
799+ #[ test]
800+ fn block_subsidy_exhausted_after_64_halvings ( ) {
801+ assert_eq ! ( block_subsidy( SUBSIDY_HALVING_INTERVAL * 64 ) , Amount :: ZERO ) ;
802+ assert_eq ! ( block_subsidy( SUBSIDY_HALVING_INTERVAL * 100 ) , Amount :: ZERO ) ;
803+ }
804+
805+ #[ test]
806+ fn select_fee_rate_two_elements ( ) {
807+ let rates = [ 1000 , 5000 ] ;
808+ // 1-block: index 1 (highest) → 5000
809+ assert_eq ! ( select_fee_rate_for_target( & rates, 1 ) , FeeRate :: from_sat_per_kwu( 5000 ) ) ;
810+ // 6-block: (2*3)/4 = 1 → 5000
811+ assert_eq ! ( select_fee_rate_for_target( & rates, 6 ) , FeeRate :: from_sat_per_kwu( 5000 ) ) ;
812+ // 12-block: 2/2 = 1 → 5000
813+ assert_eq ! ( select_fee_rate_for_target( & rates, 12 ) , FeeRate :: from_sat_per_kwu( 5000 ) ) ;
814+ // 144-block: 2/4 = 0 → 1000
815+ assert_eq ! ( select_fee_rate_for_target( & rates, 144 ) , FeeRate :: from_sat_per_kwu( 1000 ) ) ;
816+ }
817+
818+ #[ test]
819+ fn select_fee_rate_all_targets_use_valid_indices ( ) {
820+ for size in 1 ..=6 {
821+ let rates: Vec < u64 > = ( 1 ..=size) . map ( |i| i as u64 * 1000 ) . collect ( ) ;
822+ for target in get_all_conf_targets ( ) {
823+ let num_blocks = get_num_block_defaults_for_target ( target) ;
824+ let _ = select_fee_rate_for_target ( & rates, num_blocks) ;
825+ }
826+ }
827+ }
828+ }
0 commit comments