Rewrite `get_available_balances_for_scope` using `tx_builder` functions
What changed, and why it matters
This commit rewrites how LDK calculates how much money is available to send in a Lightning channel. It changes which pending payments are counted, how fees are estimated, and how 'dust' (very small) payments are handled. The commit message explicitly notes one risky side effect: under a specific timing of fee updates and HTLCs, the local node may force-close a channel that older LDK versions would have kept open. The change is a refactor with security-relevant behavioral differences, not a clear-cut fix for a known exploit.
Treat as a security-relevant refactor requiring careful review and targeted testing. Verify that the new `tx_builder`-based calculations preserve invariants around channel reserve, dust exposure limits, and fee spikes across all channel states (outbound/inbound, anchor/non-anchor). Specifically test the inbound feerate-update scenario described in the commit message to confirm the force-close is acceptable and does not introduce a denial-of-service vector. Monitor LDK release notes and upstream discussions for any follow-up fixes or advisories.
Security signals we found
Behavioral change in fee/dust exposure calculation with explicit force-close risk described in commit message
Refactor removes direct use of pending fee updates for dust safety checks
Changes which HTLC states contribute to pending value and dust exposure
Changes reserved commit-tx fee computation, generally increasing reserved amounts and reducing HTLC additions
Changes dust exposure to account for HTLCs transitioning to dust at higher feerates, increasing allowed HTLC additions
No CVE, advisory, or researcher attribution present in commit or supplied references
Evidence from the diff
The patch refactors ChannelContext::get_available_balances_for_scope to use tx_builder helpers (commit_tx_fee_sat, get_dust_exposure_stats, get_dust_buffer_feerate) instead of the old channel module methods get_pending_htlc_stats, next_local_commit_tx_fee_msat, and next_remote_commit_tx_fee_msat. Key behavioral changes include: (1) HTLCStats for outbound/inbound pending values are now computed inline and exclude HTLCs in AwaitingRemoteRevokeToRemove, AwaitingRemovedRemoteRevoke, and LocalRemoved states; (2) dust exposure and dust-buffer feerate calculations ignore pending fee updates and use only the current feerate_per_kw; (3) for outbound channels, reserved commit-tx fees are derived from commit_tx_fee_sat at a fee-spike-buffer-multiplied feerate rather than multiplying the final fee, producing higher reserved values; (4) inbound-channel reserved fees now include outbound HTLCs in the holding cell and exclude LocalRemoved inbound HTLCs; (5) dust exposure now accounts for HTLCs becoming dust at higher feerates. The commit message states this can cause a force-close when an inbound feerate update exceeds the dust-exposure-limiting feerate while non-dust HTLCs exhaust max dust exposure at the new feerate, whereas prior LDK versions would fail only the single HTLC.
Changed components
lightning/src/ln/channel.rslightning/src/sign/tx_builder.rsLDK channel balance and HTLC availability logicLDK dust exposure and fee-reserve calculationsInspect captured patch +112 / −52
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index fac0c38..88cb399 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -39,11 +39,12 @@ use crate::chain::BestBlock;
use crate::events::{ClosureReason, FundingInfo};
use crate::ln::chan_utils;
use crate::ln::chan_utils::{
- get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat,
- selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters,
- ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters,
- CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction,
- EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT,
+ commit_tx_fee_sat, get_commitment_transaction_number_obscure_factor, max_htlcs,
+ second_stage_tx_fees_sat, selected_commitment_sat_per_1000_weight, ChannelPublicKeys,
+ ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction,
+ CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets,
+ HTLCOutputInCommitment, HolderCommitmentTransaction, EMPTY_SCRIPT_SIG_WEIGHT,
+ FUNDING_TRANSACTION_WITNESS_WEIGHT,
};
use crate::ln::channel_state::{
ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails,
@@ -72,8 +73,8 @@ use crate::offers::static_invoice::StaticInvoice;
use crate::routing::gossip::NodeId;
use crate::sign::ecdsa::EcdsaChannelSigner;
use crate::sign::tx_builder::{
- saturating_sub_anchor_outputs, ChannelConstraints, ChannelStats, HTLCAmountDirection,
- SpecTxBuilder, TxBuilder,
+ get_dust_buffer_feerate, get_dust_exposure_stats, saturating_sub_anchor_outputs,
+ ChannelConstraints, ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder,
};
use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider};
use crate::types::features::{ChannelTypeFeatures, InitFeatures};
@@ -5924,34 +5925,106 @@ impl<SP: SignerProvider> ChannelContext<SP> {
fn get_available_balances_for_scope<F: FeeEstimator>(
&self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator<F>,
) -> AvailableBalances {
- let context = &self;
- let channel_constraints = self.get_channel_constraints(funding);
- // Note that we have to handle overflow due to the case mentioned in the docs in general
- // here.
+ let local = false;
+ let htlc_candidate = None;
+ let include_counterparty_unknown_htlcs = true;
+ let pending_htlcs = self.get_next_commitment_htlcs(local, htlc_candidate, include_counterparty_unknown_htlcs);
let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate(
&fee_estimator, funding.get_channel_type(),
);
- let htlc_stats = context.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate);
+ let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate);
+
+ let is_outbound_from_holder = funding.is_outbound();
+ let channel_value_satoshis = funding.get_value_satoshis();
+ let value_to_holder_msat = funding.get_value_to_self_msat();
+ let pending_htlcs = &pending_htlcs;
+ let feerate_per_kw = self.feerate_per_kw;
+ let channel_constraints = self.get_channel_constraints(funding);
+ let channel_type = funding.get_channel_type();
+
+ let fee_spike_buffer_htlc =
+ if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 };
+
+ let local_feerate = feerate_per_kw
+ * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() {
+ crate::ln::channel::FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32
+ } else {
+ 1
+ };
+
+ let local_nondust_htlc_count = pending_htlcs
+ .iter()
+ .filter(|htlc| {
+ !htlc.is_dust(
+ true,
+ local_feerate,
+ channel_constraints.holder_dust_limit_satoshis,
+ channel_type,
+ )
+ })
+ .count();
+ let local_max_commit_tx_fee_sat = commit_tx_fee_sat(
+ local_feerate,
+ local_nondust_htlc_count + fee_spike_buffer_htlc + 1,
+ channel_type,
+ );
+ let local_min_commit_tx_fee_sat = commit_tx_fee_sat(
+ local_feerate,
+ local_nondust_htlc_count + fee_spike_buffer_htlc,
+ channel_type,
+ );
+ let (local_dust_exposure_msat, _) = get_dust_exposure_stats(
+ true,
+ pending_htlcs,
+ feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ channel_constraints.holder_dust_limit_satoshis,
+ channel_type,
+ );
+ let remote_nondust_htlc_count = pending_htlcs
+ .iter()
+ .filter(|htlc| {
+ !htlc.is_dust(
+ false,
+ feerate_per_kw,
+ channel_constraints.counterparty_dust_limit_satoshis,
+ channel_type,
+ )
+ })
+ .count();
+ let remote_commit_tx_fee_sat =
+ commit_tx_fee_sat(feerate_per_kw, remote_nondust_htlc_count + 1, channel_type);
+ let (remote_dust_exposure_msat, extra_htlc_remote_dust_exposure_msat) = get_dust_exposure_stats(
+ false,
+ pending_htlcs,
+ feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ channel_constraints.counterparty_dust_limit_satoshis,
+ channel_type,
+ );
- // Subtract anchor outputs from the local and remote balances
+ let outbound_htlcs_value_msat: u64 =
+ pending_htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum();
+ let inbound_htlcs_value_msat: u64 =
+ pending_htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum();
let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = saturating_sub_anchor_outputs(
- funding.is_outbound(),
- funding.value_to_self_msat.saturating_sub(htlc_stats.pending_outbound_htlcs_value_msat),
- (funding.get_value_satoshis() * 1000).checked_sub(funding.value_to_self_msat).unwrap().saturating_sub(htlc_stats.pending_inbound_htlcs_value_msat),
- funding.get_channel_type(),
+ is_outbound_from_holder,
+ value_to_holder_msat.saturating_sub(outbound_htlcs_value_msat),
+ (channel_value_satoshis * 1000).checked_sub(value_to_holder_msat).unwrap().saturating_sub(inbound_htlcs_value_msat),
+ &channel_type,
);
let outbound_capacity_msat = local_balance_before_fee_msat
- .saturating_sub(
- channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000);
+ .saturating_sub(
+ channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000);
let mut available_capacity_msat = outbound_capacity_msat;
let (real_htlc_success_tx_fee_sat, real_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(
- funding.get_channel_type(), context.feerate_per_kw,
+ channel_type, feerate_per_kw
);
- if funding.is_outbound() {
+ if is_outbound_from_holder {
// We should mind channel commit tx fee when computing how much of the available capacity
// can be used in the next htlc. Mirrors the logic in send_htlc.
//
@@ -5959,22 +6032,10 @@ impl<SP: SignerProvider> ChannelContext<SP> {
// and the answer will in turn change the amount itself — making it a circular
// dependency.
// This complicates the computation around dust-values, up to the one-htlc-value.
- let fee_spike_buffer_htlc = if funding.get_channel_type().supports_anchor_zero_fee_commitments() {
- None
- } else {
- Some(())
- };
let real_dust_limit_timeout_sat = real_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis;
- let htlc_above_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000, HTLCInitiator::LocalOffered);
- let mut max_reserved_commit_tx_fee_msat = context.next_local_commit_tx_fee_msat(&funding, htlc_above_dust, fee_spike_buffer_htlc);
- let htlc_dust = HTLCCandidate::new(real_dust_limit_timeout_sat * 1000 - 1, HTLCInitiator::LocalOffered);
- let mut min_reserved_commit_tx_fee_msat = context.next_local_commit_tx_fee_msat(&funding, htlc_dust, fee_spike_buffer_htlc);
-
- if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() {
- max_reserved_commit_tx_fee_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE;
- min_reserved_commit_tx_fee_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE;
- }
+ let max_reserved_commit_tx_fee_msat = local_max_commit_tx_fee_sat * 1000;
+ let min_reserved_commit_tx_fee_msat = local_min_commit_tx_fee_sat * 1000;
// We will first subtract the fee as if we were above-dust. Then, if the resulting
// value ends up being below dust, we have this fee available again. In that case,
@@ -5990,8 +6051,7 @@ impl<SP: SignerProvider> ChannelContext<SP> {
// If the channel is inbound (i.e. counterparty pays the fee), we need to make sure
// sending a new HTLC won't reduce their balance below our reserve threshold.
let real_dust_limit_success_sat = real_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis;
- let htlc_above_dust = HTLCCandidate::new(real_dust_limit_success_sat * 1000, HTLCInitiator::LocalOffered);
- let max_reserved_commit_tx_fee_msat = context.next_remote_commit_tx_fee_msat(funding, Some(htlc_above_dust), None);
+ let max_reserved_commit_tx_fee_msat = remote_commit_tx_fee_sat * 1000;
let holder_selected_chan_reserve_msat = channel_constraints.holder_selected_channel_reserve_satoshis * 1000;
if remote_balance_before_fee_msat < max_reserved_commit_tx_fee_msat + holder_selected_chan_reserve_msat {
@@ -6009,35 +6069,35 @@ impl<SP: SignerProvider> ChannelContext<SP> {
// send above the dust limit (as the router can always overpay to meet the dust limit).
let mut remaining_msat_below_dust_exposure_limit = None;
let mut dust_exposure_dust_limit_msat = 0;
- let max_dust_htlc_exposure_msat = context.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate);
- let dust_buffer_feerate = self.get_dust_buffer_feerate(None);
+ let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw);
let (buffer_htlc_success_tx_fee_sat, buffer_htlc_timeout_tx_fee_sat) = second_stage_tx_fees_sat(
- funding.get_channel_type(), dust_buffer_feerate,
+ channel_type, dust_buffer_feerate
);
let buffer_dust_limit_success_sat = buffer_htlc_success_tx_fee_sat + channel_constraints.counterparty_dust_limit_satoshis;
let buffer_dust_limit_timeout_sat = buffer_htlc_timeout_tx_fee_sat + channel_constraints.holder_dust_limit_satoshis;
- if let Some(extra_htlc_dust_exposure) = htlc_stats.extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat {
- if extra_htlc_dust_exposure > max_dust_htlc_exposure_msat {
+ if let Some(extra_htlc_remote_dust_exposure) = extra_htlc_remote_dust_exposure_msat {
+ if extra_htlc_remote_dust_exposure > max_dust_htlc_exposure_msat {
// If adding an extra HTLC would put us over the dust limit in total fees, we cannot
// send any non-dust HTLCs.
available_capacity_msat = cmp::min(available_capacity_msat, buffer_dust_limit_success_sat * 1000);
}
}
- if htlc_stats.on_counterparty_tx_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) {
+ if remote_dust_exposure_msat.saturating_add(buffer_dust_limit_success_sat * 1000) > max_dust_htlc_exposure_msat.saturating_add(1) {
// Note that we don't use the `counterparty_tx_dust_exposure` (with
// `htlc_dust_exposure_msat`) here as it only applies to non-dust HTLCs.
remaining_msat_below_dust_exposure_limit =
- Some(max_dust_htlc_exposure_msat.saturating_sub(htlc_stats.on_counterparty_tx_dust_exposure_msat));
+ Some(max_dust_htlc_exposure_msat.saturating_sub(remote_dust_exposure_msat));
dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_success_sat * 1000);
}
- if htlc_stats.on_holder_tx_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) {
+ if local_dust_exposure_msat as i64 + buffer_dust_limit_timeout_sat as i64 * 1000 - 1 > max_dust_htlc_exposure_msat.try_into().unwrap_or(i64::max_value()) {
remaining_msat_below_dust_exposure_limit = Some(cmp::min(
remaining_msat_below_dust_exposure_limit.unwrap_or(u64::max_value()),
- max_dust_htlc_exposure_msat.saturating_sub(htlc_stats.on_holder_tx_dust_exposure_msat)));
+ max_dust_htlc_exposure_msat.saturating_sub(local_dust_exposure_msat),
+ ));
dust_exposure_dust_limit_msat = cmp::max(dust_exposure_dust_limit_msat, buffer_dust_limit_timeout_sat * 1000);
}
@@ -6050,14 +6110,14 @@ impl<SP: SignerProvider> ChannelContext<SP> {
}
available_capacity_msat = cmp::min(available_capacity_msat,
- channel_constraints.counterparty_max_htlc_value_in_flight_msat - htlc_stats.pending_outbound_htlcs_value_msat);
+ channel_constraints.counterparty_max_htlc_value_in_flight_msat - outbound_htlcs_value_msat);
- if htlc_stats.pending_outbound_htlcs + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize {
+ if pending_htlcs.iter().filter(|htlc| htlc.outbound).count() + 1 > channel_constraints.counterparty_max_accepted_htlcs as usize {
available_capacity_msat = 0;
}
- #[allow(deprecated)] // TODO: Remove once balance_msat is removed.
- AvailableBalances {
+ #[allow(deprecated)] // TODO: Remove once balance_msat is removed
+ crate::ln::channel::AvailableBalances {
inbound_capacity_msat: remote_balance_before_fee_msat.saturating_sub(channel_constraints.holder_selected_channel_reserve_satoshis * 1000),
outbound_capacity_msat,
next_outbound_htlc_limit_msat: available_capacity_msat,
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index cf06383..8282eb8 100644
--- a/lightning/src/sign/tx_builder.rs
+++ b/lightning/src/sign/tx_builder.rs
@@ -21,7 +21,7 @@ pub(crate) struct HTLCAmountDirection {
}
impl HTLCAmountDirection {
- fn is_dust(
+ pub(crate) fn is_dust(
&self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_satoshis: u64,
channel_type: &ChannelTypeFeatures,
) -> bool {
@@ -162,7 +162,7 @@ pub(crate) fn saturating_sub_anchor_outputs(
}
}
-fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 {
+pub(crate) fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 {
// When calculating our exposure to dust HTLCs, we assume that the channel feerate
// may, at any point, increase by at least 10 sat/vB (i.e 2530 sat/kWU) or 25%,
// whichever is higher. This ensures that we aren't suddenly exposed to significantly
Why this scored 45/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.