55// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66// accordance with one or both of these licenses.
77
8- use std:: collections:: { HashMap , HashSet } ;
8+ use std:: collections:: { HashMap , HashSet , VecDeque } ;
99use std:: net:: SocketAddr ;
1010use std:: sync:: atomic:: { AtomicU32 , Ordering } ;
1111use std:: sync:: { Arc , Mutex , RwLock } ;
@@ -46,6 +46,11 @@ const MIN_FEERATE_SAT_PER_KWU: u64 = 250;
4646/// Number of recent blocks to look back for per-target fee rate estimation.
4747const FEE_RATE_LOOKBACK_BLOCKS : usize = 6 ;
4848
49+ /// Capacity of the per-block fee-rate cache. Sized at 2× the lookback so a tip
50+ /// advance of up to `FEE_RATE_LOOKBACK_BLOCKS` only ever incurs that many
51+ /// fresh `get_block` round trips, regardless of how many cycles ran before.
52+ const BLOCK_FEE_CACHE_CAPACITY : usize = FEE_RATE_LOOKBACK_BLOCKS * 2 ;
53+
4954/// Number of blocks to walk back from a component's persisted best block height
5055/// for reorg safety when computing the incremental scan skip height.
5156/// Matches bdk-kyoto's `IMPOSSIBLE_REORG_DEPTH`.
@@ -100,6 +105,11 @@ pub(super) struct CbfChainSource {
100105 lightning_wallet_sync_status : Mutex < WalletSyncStatus > ,
101106 /// Shared fee rate estimator, updated by this chain source.
102107 fee_estimator : Arc < OnchainFeeEstimator > ,
108+ /// Cache of per-block fee rates so that, when the tip advances by N blocks,
109+ /// the next refresh fetches only N new blocks rather than the full lookback.
110+ /// FIFO bounded by [`BLOCK_FEE_CACHE_CAPACITY`]; eviction follows insertion order,
111+ /// which matches the tip-backward walk pattern.
112+ block_fee_cache : Mutex < VecDeque < ( BlockHash , FeeRate ) > > ,
103113 /// Persistent key-value store for node metrics.
104114 kv_store : Arc < DynStore > ,
105115 /// Node configuration (network, storage path, etc.).
@@ -156,6 +166,7 @@ impl CbfChainSource {
156166 let onchain_wallet_sync_status = Mutex :: new ( WalletSyncStatus :: Completed ) ;
157167 let lightning_wallet_sync_status = Mutex :: new ( WalletSyncStatus :: Completed ) ;
158168 let onchain_wallet = Mutex :: new ( None ) ;
169+ let block_fee_cache = Mutex :: new ( VecDeque :: with_capacity ( BLOCK_FEE_CACHE_CAPACITY ) ) ;
159170 Ok ( Self {
160171 peers,
161172 sync_config,
@@ -170,6 +181,7 @@ impl CbfChainSource {
170181 onchain_wallet_sync_status,
171182 lightning_wallet_sync_status,
172183 fee_estimator,
184+ block_fee_cache,
173185 onchain_wallet,
174186 kv_store,
175187 config,
@@ -929,14 +941,54 @@ impl CbfChainSource {
929941
930942 let now = Instant :: now ( ) ;
931943
932- // Fetch fee rates from the last N blocks for per-target estimation.
933- // We compute fee rates ourselves rather than using Requester::average_fee_rate,
934- // so we can sample multiple blocks and select percentiles per confirmation target.
935- let mut block_fee_rates: Vec < u64 > = Vec :: with_capacity ( FEE_RATE_LOOKBACK_BLOCKS ) ;
936- let mut current_hash = tip. hash ;
944+ // Sample the last N blocks for per-target estimation. We walk by height
945+ // (decrementing a counter) rather than by `prev_blockhash` so that cache
946+ // hits don't have to read any data out of the cached block — the hash for
947+ // the next height comes from kyoto's local header chain via `get_header`.
948+ let mut block_fee_rates: Vec < FeeRate > = Vec :: with_capacity ( FEE_RATE_LOOKBACK_BLOCKS ) ;
949+ let mut cache_hits = 0usize ;
950+
951+ for offset in 0 ..FEE_RATE_LOOKBACK_BLOCKS {
952+ let Some ( height) = tip. height . checked_sub ( offset as u32 ) else { break } ;
953+
954+ // Map height → hash via kyoto's local header chain (no P2P round trip).
955+ let current_hash = match requester. get_header ( height) . await {
956+ Ok ( Some ( indexed_header) ) => indexed_header. header . block_hash ( ) ,
957+ Ok ( None ) => {
958+ log_debug ! (
959+ self . logger,
960+ "CBF header at height {} not yet in local chain; \
961+ skipping fee estimation cycle.",
962+ height,
963+ ) ;
964+ return Ok ( None ) ;
965+ } ,
966+ Err ( e) => {
967+ log_error ! (
968+ self . logger,
969+ "Failed to look up header at height {}: {:?}" ,
970+ height,
971+ e
972+ ) ;
973+ return Err ( Error :: FeerateEstimationUpdateFailed ) ;
974+ } ,
975+ } ;
976+
977+ // Cache lookup: linear scan over a small ring buffer (≤ BLOCK_FEE_CACHE_CAPACITY).
978+ let cached = self . block_fee_cache . lock ( ) . expect ( "lock" ) . iter ( ) . find_map ( |( h, r) | {
979+ if * h == current_hash {
980+ Some ( * r)
981+ } else {
982+ None
983+ }
984+ } ) ;
985+ if let Some ( fee_rate) = cached {
986+ cache_hits += 1 ;
987+ block_fee_rates. push ( fee_rate) ;
988+ continue ;
989+ }
937990
938- for _ in 0 ..FEE_RATE_LOOKBACK_BLOCKS {
939- // Check if we've exceeded the overall timeout for fee estimation.
991+ // Cache miss: fetch the full block over P2P and compute the fee rate.
940992 let remaining_timeout = timeout. saturating_sub ( fetch_start. elapsed ( ) ) ;
941993 if remaining_timeout. is_zero ( ) {
942994 log_error ! ( self . logger, "Updating fee rate estimates timed out." ) ;
@@ -974,7 +1026,6 @@ impl CbfChainSource {
9741026 } ,
9751027 } ;
9761028
977- let height = indexed_block. height ;
9781029 let block = & indexed_block. block ;
9791030 let weight_kwu = block. weight ( ) . to_kwu_floor ( ) ;
9801031
@@ -1002,12 +1053,18 @@ impl CbfChainSource {
10021053 ( block_fees. to_sat ( ) / weight_kwu) . max ( MIN_FEERATE_SAT_PER_KWU )
10031054 } ;
10041055
1005- block_fee_rates. push ( fee_rate_sat_per_kwu) ;
1006- // Walk backwards through the chain via prev_blockhash.
1007- if height == 0 {
1008- break ;
1056+ let fee_rate = FeeRate :: from_sat_per_kwu ( fee_rate_sat_per_kwu) ;
1057+
1058+ // Insert into the cache, evicting the oldest entry if at capacity.
1059+ {
1060+ let mut cache = self . block_fee_cache . lock ( ) . expect ( "lock" ) ;
1061+ if cache. len ( ) == BLOCK_FEE_CACHE_CAPACITY {
1062+ cache. pop_front ( ) ;
1063+ }
1064+ cache. push_back ( ( current_hash, fee_rate) ) ;
10091065 }
1010- current_hash = block. header . prev_blockhash ;
1066+
1067+ block_fee_rates. push ( fee_rate) ;
10111068 }
10121069
10131070 if block_fee_rates. is_empty ( ) {
@@ -1036,9 +1093,10 @@ impl CbfChainSource {
10361093
10371094 log_debug ! (
10381095 self . logger,
1039- "CBF fee rate estimation finished in {}ms ({} blocks sampled)." ,
1096+ "CBF fee rate estimation finished in {}ms ({} blocks sampled, {} cache hits )." ,
10401097 now. elapsed( ) . as_millis( ) ,
10411098 block_fee_rates. len( ) ,
1099+ cache_hits,
10421100 ) ;
10431101
10441102 Ok ( Some ( new_fee_rate_cache) )
@@ -1281,7 +1339,7 @@ fn block_subsidy(height: u32) -> Amount {
12811339/// For medium targets (2-6 blocks), uses the 75th percentile.
12821340/// For standard targets (7-12 blocks), uses the median.
12831341/// For low-urgency targets (13+ blocks), uses the 25th percentile.
1284- fn select_fee_rate_for_target ( sorted_rates : & [ u64 ] , num_blocks : usize ) -> FeeRate {
1342+ fn select_fee_rate_for_target ( sorted_rates : & [ FeeRate ] , num_blocks : usize ) -> FeeRate {
12851343 if sorted_rates. is_empty ( ) {
12861344 return FeeRate :: from_sat_per_kwu ( MIN_FEERATE_SAT_PER_KWU ) ;
12871345 }
@@ -1297,7 +1355,7 @@ fn select_fee_rate_for_target(sorted_rates: &[u64], num_blocks: usize) -> FeeRat
12971355 len / 4
12981356 } ;
12991357
1300- FeeRate :: from_sat_per_kwu ( sorted_rates[ idx. min ( len - 1 ) ] )
1358+ sorted_rates[ idx. min ( len - 1 ) ]
13011359}
13021360
13031361#[ cfg( test) ]
@@ -1318,7 +1376,7 @@ mod tests {
13181376
13191377 #[ test]
13201378 fn select_fee_rate_single_element_returns_it_for_all_buckets ( ) {
1321- let rates = [ 5000u64 ] ;
1379+ let rates = [ FeeRate :: from_sat_per_kwu ( 5000 ) ] ;
13221380 // Every urgency bucket should return the single available rate.
13231381 for num_blocks in [ 1 , 3 , 6 , 12 , 144 , 1008 ] {
13241382 let rate = select_fee_rate_for_target ( & rates, num_blocks) ;
@@ -1334,7 +1392,7 @@ mod tests {
13341392 #[ test]
13351393 fn select_fee_rate_picks_correct_percentile ( ) {
13361394 // 6 sorted rates: indices 0..5
1337- let rates = [ 100 , 200 , 300 , 400 , 500 , 600 ] ;
1395+ let rates: [ FeeRate ; 6 ] = [ 100 , 200 , 300 , 400 , 500 , 600 ] . map ( FeeRate :: from_sat_per_kwu ) ;
13381396 // 1-block (most urgent): highest → index 5 → 600
13391397 assert_eq ! ( select_fee_rate_for_target( & rates, 1 ) , FeeRate :: from_sat_per_kwu( 600 ) ) ;
13401398 // 6-block (medium): 75th percentile → (6*3)/4 = 4 → 500
@@ -1348,7 +1406,7 @@ mod tests {
13481406 #[ test]
13491407 fn select_fee_rate_monotonic_urgency ( ) {
13501408 // More urgent targets should never produce lower rates than less urgent ones.
1351- let rates = [ 250 , 500 , 1000 , 2000 , 4000 , 8000 ] ;
1409+ let rates: [ FeeRate ; 6 ] = [ 250 , 500 , 1000 , 2000 , 4000 , 8000 ] . map ( FeeRate :: from_sat_per_kwu ) ;
13521410 let urgent = select_fee_rate_for_target ( & rates, 1 ) ;
13531411 let medium = select_fee_rate_for_target ( & rates, 6 ) ;
13541412 let standard = select_fee_rate_for_target ( & rates, 12 ) ;
@@ -1381,7 +1439,7 @@ mod tests {
13811439 // proves the optimized multi-block approach is backwards-compatible.
13821440
13831441 let uniform_rate = 3000u64 ;
1384- let rates = [ uniform_rate; 6 ] ;
1442+ let rates = [ FeeRate :: from_sat_per_kwu ( uniform_rate) ; 6 ] ;
13851443 for target in get_all_conf_targets ( ) {
13861444 let num_blocks = get_num_block_defaults_for_target ( target) ;
13871445 let optimized = select_fee_rate_for_target ( & rates, num_blocks) ;
@@ -1423,7 +1481,7 @@ mod tests {
14231481
14241482 #[ test]
14251483 fn select_fee_rate_two_elements ( ) {
1426- let rates = [ 1000 , 5000 ] ;
1484+ let rates: [ FeeRate ; 2 ] = [ 1000 , 5000 ] . map ( FeeRate :: from_sat_per_kwu ) ;
14271485 // 1-block: index 1 (highest) → 5000
14281486 assert_eq ! ( select_fee_rate_for_target( & rates, 1 ) , FeeRate :: from_sat_per_kwu( 5000 ) ) ;
14291487 // 6-block: (2*3)/4 = 1 → 5000
@@ -1437,7 +1495,8 @@ mod tests {
14371495 #[ test]
14381496 fn select_fee_rate_all_targets_use_valid_indices ( ) {
14391497 for size in 1 ..=6 {
1440- let rates: Vec < u64 > = ( 1 ..=size) . map ( |i| i as u64 * 1000 ) . collect ( ) ;
1498+ let rates: Vec < FeeRate > =
1499+ ( 1 ..=size) . map ( |i| FeeRate :: from_sat_per_kwu ( i as u64 * 1000 ) ) . collect ( ) ;
14411500 for target in get_all_conf_targets ( ) {
14421501 let num_blocks = get_num_block_defaults_for_target ( target) ;
14431502 let _ = select_fee_rate_for_target ( & rates, num_blocks) ;
0 commit comments