Skip to content

Commit 2bcc7c0

Browse files
randomloginfebyeji
authored andcommitted
Add fee rate cache for cbf fee source (#21)
Previously we had to refetch the blocks from peers on each fee rate update, which is a huge bandwith overhead. Now we store cache of 12 previously caculated fee rates and download blocks only one by one.
1 parent 0181500 commit 2bcc7c0

1 file changed

Lines changed: 82 additions & 23 deletions

File tree

src/chain/cbf.rs

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
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};
99
use std::net::SocketAddr;
1010
use std::sync::atomic::{AtomicU32, Ordering};
1111
use 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.
4747
const 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

Comments
 (0)