Skip to content

Commit ae80301

Browse files
committed
Add multi-block fee estimation and unit tests
1 parent 637add7 commit ae80301

File tree

2 files changed

+330
-22
lines changed

2 files changed

+330
-22
lines changed

src/chain/cbf.rs

Lines changed: 301 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,30 @@ use bdk_wallet::Update;
1515
use 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};
1920
use lightning::chain::WatchedOutput;
2021
use lightning::util::ser::Writeable;
2122
use tokio::sync::{mpsc, oneshot};
2223

2324
use super::WalletSyncStatus;
2425
use crate::config::{CbfSyncConfig, Config, BDK_CLIENT_STOP_GAP};
2526
use 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
};
2830
use crate::io::utils::write_node_metrics;
2931
use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
3032
use crate::runtime::Runtime;
3133
use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
3234
use 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+
3442
pub(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

Comments
 (0)