Return `AvailableBalances` in `get_channel_stats`
What changed, and why it matters
This commit refactors how Lightning channel balance information is computed so that the same code path calculates both commitment statistics and available balances. The key behavioral change is that available-balance calculations now always use the holder's perspective, even when evaluating the counterparty's commitment. This is intended to prevent inconsistent or incorrect balance reporting, but the commit itself is a refactor rather than a clear-cut fix for an active vulnerability.
Review the new unified balance computation for correctness, particularly that using holder perspective for `AvailableBalances` when `local=false` does not under- or over-report spendable amounts. Run the updated test and any related balance/reserve tests. Consider whether this change warrants a changelog note if user-visible balance estimates changed.
Security signals we found
Refactor of balance-limit computation to use unified code path
Available balances now consistently computed from holder's perspective regardless of `local` flag
Debug assertion added to sanity-check reserve compliance after adding max outbound HTLC
Test adjustment suggests previous calculation could produce limits exceeding counterparty constraints
No explicit security claim, CVE, or advisory in commit message or diff
Evidence from the diff
The change moves get_available_balances from a standalone public function into the TxBuilder::get_channel_stats trait implementation, returning it as part of ChannelStats. Previously, get_available_balances_for_scope called get_available_balances directly with local=false and passed self.counterparty_dust_limit_satoshis indirectly. Now it calls get_next_remote_commitment_stats, which invokes SpecTxBuilder::get_channel_stats with local=false. Inside get_channel_stats, the code now explicitly branches: for local=true it uses holder_dust_limit_satoshis; for local=false it uses counterparty_dust_limit_satoshis for commitment stats, but get_available_balances is always called with the holder’s is_outbound_from_holder and value_to_holder_msat. A debug assertion is added to verify that when an outbound HTLC at the reported limit is added, the holder’s balance remains above the counterparty’s reserve. A test is updated to set a higher counterparty_max_htlc_value_in_flight_msat.
Changed components
lightning/src/ln/channel.rslightning/src/sign/tx_builder.rsChannelContext::get_available_balances_for_scopeChannelContext::get_next_local_commitment_statsChannelContext::get_next_remote_commitment_statsSpecTxBuilder::get_channel_statsAvailableBalances calculationInspect captured patch +110 / −50
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index f52f3af..22ae13f 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -72,8 +72,7 @@ use crate::offers::static_invoice::StaticInvoice;
use crate::routing::gossip::NodeId;
use crate::sign::ecdsa::EcdsaChannelSigner;
use crate::sign::tx_builder::{
- get_available_balances, ChannelConstraints, ChannelStats, HTLCAmountDirection,
- SpecTxBuilder, TxBuilder,
+ ChannelConstraints, ChannelStats, HTLCAmountDirection, SpecTxBuilder, TxBuilder,
};
use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider};
use crate::types::features::{ChannelTypeFeatures, InitFeatures};
@@ -4836,6 +4835,22 @@ impl<SP: SignerProvider> ChannelContext<SP> {
.saturating_add(inbound_claimed_htlc_msat)
}
+ fn get_channel_constraints(&self, funding: &FundingScope) -> ChannelConstraints {
+ ChannelConstraints {
+ holder_dust_limit_satoshis: self.holder_dust_limit_satoshis,
+ counterparty_selected_channel_reserve_satoshis: funding
+ .counterparty_selected_channel_reserve_satoshis
+ .unwrap_or(0),
+ counterparty_dust_limit_satoshis: self.counterparty_dust_limit_satoshis,
+ holder_selected_channel_reserve_satoshis: funding
+ .holder_selected_channel_reserve_satoshis,
+ counterparty_htlc_minimum_msat: self.counterparty_htlc_minimum_msat,
+ counterparty_max_accepted_htlcs: self.counterparty_max_accepted_htlcs as u64,
+ counterparty_max_htlc_value_in_flight_msat: self
+ .counterparty_max_htlc_value_in_flight_msat,
+ }
+ }
+
fn get_next_local_commitment_stats(
&self, funding: &FundingScope, htlc_candidate: Option<HTLCAmountDirection>,
include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize,
@@ -4848,6 +4863,11 @@ impl<SP: SignerProvider> ChannelContext<SP> {
);
let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(true, funding);
+ let max_dust_htlc_exposure_msat =
+ self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate);
+
+ let channel_constraints = self.get_channel_constraints(funding);
+
let local_stats = SpecTxBuilder {}.get_channel_stats(
true,
funding.is_outbound(),
@@ -4857,7 +4877,8 @@ impl<SP: SignerProvider> ChannelContext<SP> {
addl_nondust_htlc_count,
feerate_per_kw,
dust_exposure_limiting_feerate,
- self.holder_dust_limit_satoshis,
+ max_dust_htlc_exposure_msat,
+ channel_constraints,
funding.get_channel_type(),
)?;
@@ -4880,7 +4901,8 @@ impl<SP: SignerProvider> ChannelContext<SP> {
0,
feerate_per_kw,
dust_exposure_limiting_feerate,
- self.holder_dust_limit_satoshis,
+ max_dust_htlc_exposure_msat,
+ channel_constraints,
funding.get_channel_type(),
)
.expect("Balance exhausted on local commitment")
@@ -4908,6 +4930,11 @@ impl<SP: SignerProvider> ChannelContext<SP> {
);
let next_value_to_self_msat = self.get_next_commitment_value_to_self_msat(false, funding);
+ let max_dust_htlc_exposure_msat =
+ self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate);
+
+ let channel_constraints = self.get_channel_constraints(funding);
+
let remote_stats = SpecTxBuilder {}.get_channel_stats(
false,
funding.is_outbound(),
@@ -4917,7 +4944,8 @@ impl<SP: SignerProvider> ChannelContext<SP> {
addl_nondust_htlc_count,
feerate_per_kw,
dust_exposure_limiting_feerate,
- self.counterparty_dust_limit_satoshis,
+ max_dust_htlc_exposure_msat,
+ channel_constraints,
funding.get_channel_type(),
)?;
@@ -4940,7 +4968,8 @@ impl<SP: SignerProvider> ChannelContext<SP> {
0,
feerate_per_kw,
dust_exposure_limiting_feerate,
- self.counterparty_dust_limit_satoshis,
+ max_dust_htlc_exposure_msat,
+ channel_constraints,
funding.get_channel_type(),
)
.expect("Balance exhausted on remote commitment")
@@ -5752,47 +5781,49 @@ impl<SP: SignerProvider> ChannelContext<SP> {
outbound_details
}
- fn get_channel_constraints(&self, funding: &FundingScope) -> ChannelConstraints {
- ChannelConstraints {
- holder_dust_limit_satoshis: self.holder_dust_limit_satoshis,
- counterparty_selected_channel_reserve_satoshis: funding
- .counterparty_selected_channel_reserve_satoshis
- .unwrap_or(0),
- counterparty_dust_limit_satoshis: self.counterparty_dust_limit_satoshis,
- holder_selected_channel_reserve_satoshis: funding
- .holder_selected_channel_reserve_satoshis,
- counterparty_htlc_minimum_msat: self.counterparty_htlc_minimum_msat,
- counterparty_max_accepted_htlcs: self.counterparty_max_accepted_htlcs as u64,
- counterparty_max_htlc_value_in_flight_msat: self
- .counterparty_max_htlc_value_in_flight_msat,
- }
- }
-
#[rustfmt::skip]
fn get_available_balances_for_scope<F: FeeEstimator>(
&self, funding: &FundingScope, fee_estimator: &LowerBoundedFeeEstimator<F>,
) -> AvailableBalances {
- 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 addl_nondust_htlc_count = 0;
let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate(
&fee_estimator, funding.get_channel_type(),
);
- let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate);
- get_available_balances(
- funding.is_outbound(),
- funding.get_value_satoshis(),
- funding.get_value_to_self_msat(),
- &pending_htlcs,
+ let balances = self.get_next_remote_commitment_stats(
+ funding,
+ htlc_candidate,
+ include_counterparty_unknown_htlcs,
+ addl_nondust_htlc_count,
self.feerate_per_kw,
- dust_exposure_limiting_feerate,
- max_dust_htlc_exposure_msat,
- self.get_channel_constraints(funding),
- funding.get_channel_type(),
- )
+ dust_exposure_limiting_feerate
+ ).map(|(remote_stats, _)| remote_stats.available_balances).unwrap();
+
+ #[cfg(debug_assertions)]
+ if balances.next_outbound_htlc_limit_msat >= balances.next_outbound_htlc_minimum_msat
+ && balances.next_outbound_htlc_limit_msat != 0
+ {
+ let (remote_stats, _remote_htlcs) = self.get_next_remote_commitment_stats(
+ funding,
+ Some(HTLCAmountDirection {
+ outbound: true,
+ // Note that this likely creates a non-dust HTLC, we could add a check for the
+ // biggest dust HTLC to make sure we still have a broadcastable commitment in
+ // that case.
+ amount_msat: balances.next_outbound_htlc_limit_msat,
+ }),
+ include_counterparty_unknown_htlcs,
+ addl_nondust_htlc_count,
+ self.feerate_per_kw,
+ dust_exposure_limiting_feerate
+ ).unwrap();
+ assert!(remote_stats.commitment_stats.holder_balance_msat
+ >= funding.counterparty_selected_channel_reserve_satoshis.unwrap_or(0) * 1000);
+ }
+
+ balances
}
#[rustfmt::skip]
@@ -16058,6 +16089,7 @@ mod tests {
let mut config = UserConfig::default();
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
let mut chan = OutboundV1Channel::<&TestKeysInterface>::new(&fee_est, &&keys_provider, &&keys_provider, node_id, &channelmanager::provided_init_features(&config), 10_000_000, 100_000_000, 42, &config, 0, 42, None, &logger).unwrap();
+ chan.context.counterparty_max_htlc_value_in_flight_msat = 1_000_000_000;
let commitment_tx_fee_0_htlcs = commit_tx_fee_sat(chan.context.feerate_per_kw, 0, chan.funding.get_channel_type()) * 1000;
let commitment_tx_fee_1_htlc = commit_tx_fee_sat(chan.context.feerate_per_kw, 1, chan.funding.get_channel_type()) * 1000;
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index 3840cc7..c0d6df0 100644
--- a/lightning/src/sign/tx_builder.rs
+++ b/lightning/src/sign/tx_builder.rs
@@ -44,6 +44,7 @@ pub(crate) struct NextCommitmentStats {
pub(crate) struct ChannelStats {
pub commitment_stats: NextCommitmentStats,
+ pub available_balances: crate::ln::channel::AvailableBalances,
}
fn commit_plus_htlc_tx_fees_msat(
@@ -294,7 +295,7 @@ fn get_next_commitment_stats(
})
}
-pub(crate) fn get_available_balances(
+fn get_available_balances(
is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64,
pending_htlcs: &[HTLCAmountDirection], feerate_per_kw: u32,
dust_exposure_limiting_feerate: Option<u32>, max_dust_htlc_exposure_msat: u64,
@@ -523,10 +524,10 @@ pub(crate) fn get_available_balances(
pub(crate) trait TxBuilder {
fn get_channel_stats(
&self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64,
- value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection],
+ value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection],
addl_nondust_htlc_count: usize, feerate_per_kw: u32,
- dust_exposure_limiting_feerate: Option<u32>, broadcaster_dust_limit_satoshis: u64,
- channel_type: &ChannelTypeFeatures,
+ dust_exposure_limiting_feerate: Option<u32>, max_dust_htlc_exposure_msat: u64,
+ channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures,
) -> Result<ChannelStats, ()>;
fn build_commitment_transaction<L: Logger>(
&self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey,
@@ -541,25 +542,52 @@ pub(crate) struct SpecTxBuilder {}
impl TxBuilder for SpecTxBuilder {
fn get_channel_stats(
&self, local: bool, is_outbound_from_holder: bool, channel_value_satoshis: u64,
- value_to_holder_msat: u64, next_commitment_htlcs: &[HTLCAmountDirection],
+ value_to_holder_msat: u64, pending_htlcs: &[HTLCAmountDirection],
addl_nondust_htlc_count: usize, feerate_per_kw: u32,
- dust_exposure_limiting_feerate: Option<u32>, broadcaster_dust_limit_satoshis: u64,
- channel_type: &ChannelTypeFeatures,
+ dust_exposure_limiting_feerate: Option<u32>, max_dust_htlc_exposure_msat: u64,
+ channel_constraints: ChannelConstraints, channel_type: &ChannelTypeFeatures,
) -> Result<ChannelStats, ()> {
- let commitment_stats = get_next_commitment_stats(
- local,
+ let commitment_stats = if local {
+ get_next_commitment_stats(
+ true,
+ is_outbound_from_holder,
+ channel_value_satoshis,
+ value_to_holder_msat,
+ pending_htlcs,
+ addl_nondust_htlc_count,
+ feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ channel_constraints.holder_dust_limit_satoshis,
+ channel_type,
+ )?
+ } else {
+ get_next_commitment_stats(
+ false,
+ is_outbound_from_holder,
+ channel_value_satoshis,
+ value_to_holder_msat,
+ pending_htlcs,
+ addl_nondust_htlc_count,
+ feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ channel_constraints.counterparty_dust_limit_satoshis,
+ channel_type,
+ )?
+ };
+
+ let available_balances = get_available_balances(
is_outbound_from_holder,
channel_value_satoshis,
value_to_holder_msat,
- next_commitment_htlcs,
- addl_nondust_htlc_count,
+ pending_htlcs,
feerate_per_kw,
dust_exposure_limiting_feerate,
- broadcaster_dust_limit_satoshis,
+ max_dust_htlc_exposure_msat,
+ channel_constraints,
channel_type,
- )?;
+ );
- Ok(ChannelStats { commitment_stats })
+ Ok(ChannelStats { commitment_stats, available_balances })
}
fn build_commitment_transaction<L: Logger>(
&self, local: bool, commitment_number: u64, per_commitment_point: &PublicKey,
Why this scored 28/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.