From c5e84da1f11a6075a9a3eab97e09419d80244c70 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 27 Jul 2025 20:03:35 +0000 Subject: [PATCH 1/9] Add `TxBuilder::get_builder_stats` --- lightning/src/sign/tx_builder.rs | 177 ++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 6e623d1a7db..b62d4e0a87f 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -1,11 +1,13 @@ //! Defines the `TxBuilder` trait, and the `SpecTxBuilder` type +#![allow(dead_code)] use core::ops::Deref; +use core::cmp; use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; use crate::ln::chan_utils::{ - commit_tx_fee_sat, htlc_success_tx_weight, htlc_timeout_tx_weight, + commit_tx_fee_sat, htlc_success_tx_weight, htlc_timeout_tx_weight, htlc_tx_fees_sat, ChannelTransactionParameters, CommitmentTransaction, HTLCOutputInCommitment, }; use crate::ln::channel::{CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI}; @@ -13,7 +15,125 @@ use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; +pub(crate) struct HTLCAmountDirection { + pub outbound: bool, + pub amount_msat: u64, +} + +impl HTLCAmountDirection { + fn is_dust(&self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_sat: u64, channel_type: &ChannelTypeFeatures) -> bool { + let htlc_tx_fee_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { + 0 + } else { + let htlc_tx_weight = if self.outbound == local { + htlc_timeout_tx_weight(channel_type) + } else { + htlc_success_tx_weight(channel_type) + }; + // As required by the spec, round down + feerate_per_kw as u64 * htlc_tx_weight / 1000 + }; + self.amount_msat / 1000 < broadcaster_dust_limit_sat + htlc_tx_fee_sat + } +} + +pub(crate) struct BuilderStats { + pub holder_commit_tx_fee_sat: u64, + pub counterparty_commit_tx_fee_sat: u64, + pub on_counterparty_tx_dust_exposure_msat: u64, + pub extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat: Option, + pub on_holder_tx_dust_exposure_msat: u64, + pub holder_balance_msat: Option, + pub counterparty_balance_msat: Option, +} + +fn on_holder_tx_dust_exposure_msat(dust_buffer_feerate: u32, holder_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, htlcs: &[HTLCAmountDirection]) -> u64 { + htlcs + .iter() + .filter_map(|htlc| { htlc.is_dust(true, dust_buffer_feerate, holder_dust_limit_satoshis, channel_type).then_some(htlc.amount_msat) }) + .sum() +} + +fn on_counterparty_tx_dust_exposure_msat(dust_buffer_feerate: u32, excess_feerate_opt: Option, counterparty_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, on_remote_htlcs: &[HTLCAmountDirection]) -> (u64, Option) { + let mut on_counterparty_tx_accepted_nondust_htlcs = 0; + let mut on_counterparty_tx_offered_nondust_htlcs = 0; + let mut on_counterparty_tx_dust_exposure_msat: u64 = on_remote_htlcs + .iter() + .filter_map(|htlc| { + if htlc.is_dust(false, dust_buffer_feerate, counterparty_dust_limit_satoshis, channel_type) { + Some(htlc.amount_msat) + } else { + if !htlc.outbound { + on_counterparty_tx_offered_nondust_htlcs += 1; + } else { + on_counterparty_tx_accepted_nondust_htlcs += 1; + } + None + } + }) + .sum(); + + let extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat = + excess_feerate_opt.map(|excess_feerate| { + let extra_htlc_commit_tx_fee_sat = commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1 + on_counterparty_tx_offered_nondust_htlcs, channel_type); + let extra_htlc_htlc_tx_fees_sat = htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1, on_counterparty_tx_offered_nondust_htlcs, channel_type); + + let commit_tx_fee_sat = commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + on_counterparty_tx_offered_nondust_htlcs, channel_type); + let htlc_tx_fees_sat = htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs, on_counterparty_tx_offered_nondust_htlcs, channel_type); + + let extra_htlc_dust_exposure = on_counterparty_tx_dust_exposure_msat + (extra_htlc_commit_tx_fee_sat + extra_htlc_htlc_tx_fees_sat) * 1000; + on_counterparty_tx_dust_exposure_msat += (commit_tx_fee_sat + htlc_tx_fees_sat) * 1000; + extra_htlc_dust_exposure + }); + ( + on_counterparty_tx_dust_exposure_msat, + extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat, + ) +} + +fn subtract_addl_outputs( + is_outbound_from_holder: bool, value_to_self_after_htlcs: Option, + value_to_remote_after_htlcs: Option, channel_type: &ChannelTypeFeatures, +) -> (Option, Option) { + let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let mut local_balance_before_fee_msat = value_to_self_after_htlcs; + let mut remote_balance_before_fee_msat = value_to_remote_after_htlcs; + + // We MUST use saturating subs here, as the funder's balance is not guaranteed to be greater + // than or equal to `total_anchors_sat`. + // + // This is because when the remote party sends an `update_fee` message, we build the new + // commitment transaction *before* checking whether the remote party's balance is enough to + // cover the total anchor sum. + + if is_outbound_from_holder { + local_balance_before_fee_msat = + local_balance_before_fee_msat.and_then(|v| v.checked_sub(total_anchors_sat * 1000)); + } else { + remote_balance_before_fee_msat = + remote_balance_before_fee_msat.and_then(|v| v.checked_sub(total_anchors_sat * 1000)); + } + + (local_balance_before_fee_msat, remote_balance_before_fee_msat) +} + +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 + // more dust balance if the feerate increases when we have several HTLCs pending + // which are near the dust limit. + let feerate_plus_quarter = feerate_per_kw.checked_mul(1250).map(|v| v / 1000); + cmp::max(feerate_per_kw.saturating_add(2530), feerate_plus_quarter.unwrap_or(u32::MAX)) +} + pub(crate) trait TxBuilder { + fn get_builder_stats(&self, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, counterparty_dust_limit_satoshis: u64) -> BuilderStats; fn commit_tx_fee_sat( &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, ) -> u64; @@ -34,6 +154,61 @@ pub(crate) trait TxBuilder { pub(crate) struct SpecTxBuilder {} impl TxBuilder for SpecTxBuilder { + fn get_builder_stats(&self, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, counterparty_dust_limit_satoshis: u64) -> BuilderStats { + let excess_feerate_opt = feerate_per_kw.checked_sub(dust_exposure_limiting_feerate.unwrap_or(0)); + // Dust exposure is only decoupled from feerate for zero fee commitment channels. + let is_zero_fee_comm = channel_type.supports_anchor_zero_fee_commitments(); + debug_assert_eq!(is_zero_fee_comm, dust_exposure_limiting_feerate.is_none()); + if is_zero_fee_comm { + debug_assert_eq!(feerate_per_kw, 0); + debug_assert_eq!(excess_feerate_opt, Some(0)); + debug_assert_eq!(nondust_htlcs, 0); + } + + // Calculate balances after htlcs + let value_to_counterparty_msat = channel_value_satoshis * 1000 - value_to_holder_msat; + let outbound_htlcs_value_msat: u64 = htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); + let inbound_htlcs_value_msat: u64 = htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); + let value_to_holder_after_htlcs = value_to_holder_msat.checked_sub(outbound_htlcs_value_msat); + let value_to_counterparty_after_htlcs = value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat); + + // Increment the feerate by a buffer to calculate dust exposure + let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); + + // Calculate dust exposure on holder's commitment transaction + let on_holder_htlc_count = htlcs.iter().filter(|htlc| !htlc.is_dust(true, feerate_per_kw, holder_dust_limit_satoshis, channel_type)).count(); + let holder_commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, on_holder_htlc_count + nondust_htlcs, channel_type); + let on_holder_tx_dust_exposure_msat = on_holder_tx_dust_exposure_msat( + dust_buffer_feerate, + holder_dust_limit_satoshis, + channel_type, + &htlcs, + ); + + // Calculate dust exposure on counterparty's commitment transaction + let on_counterparty_htlc_count = htlcs.iter().filter(|htlc| !htlc.is_dust(false, feerate_per_kw, counterparty_dust_limit_satoshis, channel_type)).count(); + let counterparty_commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, on_counterparty_htlc_count + nondust_htlcs, channel_type); + let (on_counterparty_tx_dust_exposure_msat, extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat) = on_counterparty_tx_dust_exposure_msat( + dust_buffer_feerate, + excess_feerate_opt, + counterparty_dust_limit_satoshis, + channel_type, + &htlcs, + ); + + // Subtract the anchors from the channel funder + let (holder_balance_msat, counterparty_balance_msat) = subtract_addl_outputs(is_outbound_from_holder, value_to_holder_after_htlcs, value_to_counterparty_after_htlcs, channel_type); + + BuilderStats { + holder_commit_tx_fee_sat, + counterparty_commit_tx_fee_sat, + on_counterparty_tx_dust_exposure_msat, + extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat, + on_holder_tx_dust_exposure_msat, + holder_balance_msat, + counterparty_balance_msat, + } + } fn commit_tx_fee_sat( &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, ) -> u64 { From 3f496622f93118174b9b920001555bbf4a0176f3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 27 Jul 2025 22:40:42 +0000 Subject: [PATCH 2/9] validate_update_add_htlc --- lightning/src/ln/channel.rs | 93 ++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index b5b77972a6c..7d605fbc9a7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -70,7 +70,7 @@ use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder}; +use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder, HTLCAmountDirection}; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage}; @@ -1097,12 +1097,12 @@ pub enum AnnouncementSigsState { /// An enum indicating whether the local or remote side offered a given HTLC. enum HTLCInitiator { LocalOffered, + #[allow(dead_code)] RemoteOffered, } /// Current counts of various HTLCs, useful for calculating current balances available exactly. struct HTLCStats { - pending_inbound_htlcs: usize, pending_outbound_htlcs: usize, pending_inbound_htlcs_value_msat: u64, pending_outbound_htlcs_value_msat: u64, @@ -4104,6 +4104,39 @@ where ); } + fn pending_inbound_htlcs_value_msat(&self) -> u64 { + self.pending_inbound_htlcs.iter().map(|htlc| htlc.amount_msat).sum() + } + + fn next_commitment_htlcs(&self, htlc_candidate: Option) -> Vec { + let mut commitment_htlcs = Vec::with_capacity(1 + self.pending_inbound_htlcs.len() + self.pending_outbound_htlcs.len() + self.holding_cell_htlc_updates.len()); + let pending_inbound_htlcs = self.pending_inbound_htlcs.iter().map(|InboundHTLCOutput { amount_msat, .. }| HTLCAmountDirection { outbound: false, amount_msat: *amount_msat }); + use OutboundHTLCState::*; + let pending_outbound_htlcs = self.pending_outbound_htlcs.iter().filter_map(|OutboundHTLCOutput { ref state, amount_msat, .. }| + matches!(state, LocalAnnounced { .. } | Committed | RemoteRemoved { .. }).then_some(HTLCAmountDirection { outbound: true, amount_msat: *amount_msat })); + + // We do not include holding cell HTLCs, we will validate them upon freeing the holding cell... + //let holding_cell_htlcs = self.holding_cell_htlc_updates.iter().filter_map(|htlc| if let HTLCUpdateAwaitingACK::AddHTLC { amount_msat, ..} = htlc { Some(HTLCAmountDirection { outbound: true, amount_msat: *amount_msat }) } else { None }); + + commitment_htlcs.extend(htlc_candidate.into_iter().chain(pending_inbound_htlcs).chain(pending_outbound_htlcs)); + commitment_htlcs + } + + fn get_next_commitment_value_to_self_msat(&self, funding: &FundingScope) -> u64 { + let outbound_removed_htlc_msat: u64 = self.pending_outbound_htlcs + .iter() + .filter_map(|htlc| { + matches!( + htlc.state, + OutboundHTLCState::AwaitingRemoteRevokeToRemove(OutboundHTLCOutcome::Success(_, _)) + | OutboundHTLCState::AwaitingRemovedRemoteRevoke(OutboundHTLCOutcome::Success(_, _)) + ) + .then_some(htlc.amount_msat) + }) + .sum(); + funding.value_to_self_msat.saturating_sub(outbound_removed_htlc_msat) + } + #[rustfmt::skip] fn validate_update_add_htlc( &self, funding: &FundingScope, msg: &msgs::UpdateAddHTLC, @@ -4119,70 +4152,37 @@ where let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( &fee_estimator, funding.get_channel_type(), ); - let htlc_stats = self.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate); - if htlc_stats.pending_inbound_htlcs + 1 > self.holder_max_accepted_htlcs as usize { + + if self.pending_inbound_htlcs.len() + 1 > self.holder_max_accepted_htlcs as usize { return Err(ChannelError::close(format!("Remote tried to push more than our max accepted HTLCs ({})", self.holder_max_accepted_htlcs))); } - if htlc_stats.pending_inbound_htlcs_value_msat + msg.amount_msat > self.holder_max_htlc_value_in_flight_msat { + if self.pending_inbound_htlcs_value_msat() + msg.amount_msat > self.holder_max_htlc_value_in_flight_msat { return Err(ChannelError::close(format!("Remote HTLC add would put them over our max HTLC value ({})", self.holder_max_htlc_value_in_flight_msat))); } - // Check holder_selected_channel_reserve_satoshis (we're getting paid, so they have to at least meet - // the reserve_satoshis we told them to always have as direct payment so that they lose - // something if we punish them for broadcasting an old state). - // Note that we don't really care about having a small/no to_remote output in our local - // commitment transactions, as the purpose of the channel reserve is to ensure we can - // punish *them* if they misbehave, so we discount any outbound HTLCs which will not be - // present in the next commitment transaction we send them (at least for fulfilled ones, - // failed ones won't modify value_to_self). - // Note that we will send HTLCs which another instance of rust-lightning would think - // violate the reserve value if we do not do this (as we forget inbound HTLCs from the - // Channel state once they will not be present in the next received commitment - // transaction). - let (local_balance_before_fee_msat, remote_balance_before_fee_msat) = { - let removed_outbound_total_msat: u64 = self.pending_outbound_htlcs - .iter() - .filter_map(|htlc| { - matches!( - htlc.state, - OutboundHTLCState::AwaitingRemoteRevokeToRemove(OutboundHTLCOutcome::Success(_, _)) - | OutboundHTLCState::AwaitingRemovedRemoteRevoke(OutboundHTLCOutcome::Success(_, _)) - ) - .then_some(htlc.amount_msat) - }) - .sum(); - let pending_value_to_self_msat = - funding.value_to_self_msat + htlc_stats.pending_inbound_htlcs_value_msat - removed_outbound_total_msat; - let pending_remote_value_msat = - funding.get_value_satoshis() * 1000 - pending_value_to_self_msat; + let next_commitment_htlcs = self.next_commitment_htlcs(Some(HTLCAmountDirection { outbound: false, amount_msat: msg.amount_msat })); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat(funding); + let next_commitment_stats = SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, 0, self.feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis); - // Subtract any non-HTLC outputs from the local and remote balances - SpecTxBuilder {}.subtract_non_htlc_outputs(funding.is_outbound(), funding.value_to_self_msat, pending_remote_value_msat, funding.get_channel_type()) - }; - if remote_balance_before_fee_msat < msg.amount_msat { - return Err(ChannelError::close("Remote HTLC add would overdraw remaining funds".to_owned())); - } + let remote_balance_before_fee_msat = next_commitment_stats.counterparty_balance_msat.ok_or(ChannelError::close("Remote HTLC add would overdraw remaining funds".to_owned()))?; // Check that the remote can afford to pay for this HTLC on-chain at the current // feerate_per_kw, while maintaining their channel reserve (as required by the spec). { let remote_commit_tx_fee_msat = if funding.is_outbound() { 0 } else { - let htlc_candidate = HTLCCandidate::new(msg.amount_msat, HTLCInitiator::RemoteOffered); - self.next_remote_commit_tx_fee_msat(funding, Some(htlc_candidate), None) // Don't include the extra fee spike buffer HTLC in calculations + next_commitment_stats.counterparty_commit_tx_fee_sat * 1000 }; - if remote_balance_before_fee_msat.saturating_sub(msg.amount_msat) < remote_commit_tx_fee_msat { + if remote_balance_before_fee_msat < remote_commit_tx_fee_msat { return Err(ChannelError::close("Remote HTLC add would not leave enough to pay for fees".to_owned())); }; - if remote_balance_before_fee_msat.saturating_sub(msg.amount_msat).saturating_sub(remote_commit_tx_fee_msat) < funding.holder_selected_channel_reserve_satoshis * 1000 { + if remote_balance_before_fee_msat.saturating_sub(remote_commit_tx_fee_msat) < funding.holder_selected_channel_reserve_satoshis * 1000 { return Err(ChannelError::close("Remote HTLC add would put them under remote reserve value".to_owned())); } } if funding.is_outbound() { // Check that they won't violate our local required channel reserve by adding this HTLC. - let htlc_candidate = HTLCCandidate::new(msg.amount_msat, HTLCInitiator::RemoteOffered); - let local_commit_tx_fee_msat = self.next_local_commit_tx_fee_msat(funding, htlc_candidate, None); - if local_balance_before_fee_msat < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 + local_commit_tx_fee_msat { + if next_commitment_stats.holder_balance_msat.unwrap() < funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 + next_commitment_stats.holder_commit_tx_fee_sat * 1000 { return Err(ChannelError::close("Cannot accept HTLC that would put our balance under counterparty-announced channel reserve value".to_owned())); } } @@ -4795,7 +4795,6 @@ where }); HTLCStats { - pending_inbound_htlcs: self.pending_inbound_htlcs.len(), pending_outbound_htlcs, pending_inbound_htlcs_value_msat, pending_outbound_htlcs_value_msat, From d239e94714eb28c06c4bf2ff3485a52aacb6762a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 00:53:25 +0000 Subject: [PATCH 3/9] Add `PredictedNextFee` --- lightning/src/ln/channel.rs | 57 ++++++++++++++++++++++++++++++++ lightning/src/sign/tx_builder.rs | 8 +++++ 2 files changed, 65 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7d605fbc9a7..5f3e5deb612 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1986,6 +1986,10 @@ pub(super) struct FundingScope { next_local_commitment_tx_fee_info_cached: Mutex>, #[cfg(any(test, fuzzing))] next_remote_commitment_tx_fee_info_cached: Mutex>, + #[cfg(any(test, fuzzing))] + next_local_fee: Mutex, + #[cfg(any(test, fuzzing))] + next_remote_fee: Mutex, pub(super) channel_transaction_parameters: ChannelTransactionParameters, @@ -2062,6 +2066,10 @@ impl Readable for FundingScope { next_local_commitment_tx_fee_info_cached: Mutex::new(None), #[cfg(any(test, fuzzing))] next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_local_fee: Mutex::new(PredictedNextFee::default()), + #[cfg(any(test, fuzzing))] + next_remote_fee: Mutex::new(PredictedNextFee::default()), }) } } @@ -3208,6 +3216,10 @@ where next_local_commitment_tx_fee_info_cached: Mutex::new(None), #[cfg(any(test, fuzzing))] next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_local_fee: Mutex::new(PredictedNextFee::default()), + #[cfg(any(test, fuzzing))] + next_remote_fee: Mutex::new(PredictedNextFee::default()), channel_transaction_parameters: ChannelTransactionParameters { holder_pubkeys: pubkeys, @@ -3451,6 +3463,10 @@ where next_local_commitment_tx_fee_info_cached: Mutex::new(None), #[cfg(any(test, fuzzing))] next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_local_fee: Mutex::new(PredictedNextFee::default()), + #[cfg(any(test, fuzzing))] + next_remote_fee: Mutex::new(PredictedNextFee::default()), channel_transaction_parameters: ChannelTransactionParameters { holder_pubkeys: pubkeys, @@ -4187,6 +4203,23 @@ where } } + #[cfg(any(test, fuzzing))] + { + let mut predicted_htlcs = next_commitment_htlcs; + predicted_htlcs.sort_unstable(); + *funding.next_local_fee.lock().unwrap() = PredictedNextFee { + predicted_feerate: self.feerate_per_kw, + predicted_htlcs: predicted_htlcs.clone(), + predicted_fee_sat: next_commitment_stats.holder_commit_tx_fee_sat, + }; + + *funding.next_remote_fee.lock().unwrap() = PredictedNextFee { + predicted_feerate: self.feerate_per_kw, + predicted_htlcs, + predicted_fee_sat: next_commitment_stats.counterparty_commit_tx_fee_sat, + }; + } + Ok(()) } @@ -4271,6 +4304,12 @@ where } } } + let PredictedNextFee { predicted_feerate, predicted_htlcs, predicted_fee_sat } = funding.next_local_fee.lock().unwrap().clone(); + let mut actual_nondust_htlcs: Vec<_> = commitment_data.tx.nondust_htlcs().iter().map(|htlc| HTLCAmountDirection { outbound: htlc.offered, amount_msat: htlc.amount_msat }).collect(); + actual_nondust_htlcs.sort_unstable(); + if predicted_feerate == commitment_data.tx.feerate_per_kw() && predicted_htlcs == actual_nondust_htlcs { + assert_eq!(predicted_fee_sat, commitment_data.stats.commit_tx_fee_sat); + } } if msg.htlc_signatures.len() != commitment_data.tx.nondust_htlcs().len() { @@ -5921,6 +5960,14 @@ struct CommitmentTxInfoCached { feerate: u32, } +#[cfg(any(test, fuzzing))] +#[derive(Clone, Default)] +struct PredictedNextFee { + predicted_feerate: u32, + predicted_htlcs: Vec, + predicted_fee_sat: u64, +} + /// Contents of a wire message that fails an HTLC backwards. Useful for [`FundedChannel::fail_htlc`] to /// fail with either [`msgs::UpdateFailMalformedHTLC`] or [`msgs::UpdateFailHTLC`] as needed. trait FailHTLCContents { @@ -10885,6 +10932,12 @@ where } } } + let PredictedNextFee { predicted_feerate, predicted_htlcs, predicted_fee_sat } = funding.next_remote_fee.lock().unwrap().clone(); + let mut actual_nondust_htlcs: Vec<_> = counterparty_commitment_tx.nondust_htlcs().iter().map(|htlc| HTLCAmountDirection { outbound: !htlc.offered, amount_msat: htlc.amount_msat }).collect(); + actual_nondust_htlcs.sort_unstable(); + if predicted_feerate == counterparty_commitment_tx.feerate_per_kw() && predicted_htlcs == actual_nondust_htlcs { + assert_eq!(predicted_fee_sat, commitment_data.stats.commit_tx_fee_sat); + } } (commitment_data.htlcs_included, counterparty_commitment_tx) @@ -13510,6 +13563,10 @@ where next_local_commitment_tx_fee_info_cached: Mutex::new(None), #[cfg(any(test, fuzzing))] next_remote_commitment_tx_fee_info_cached: Mutex::new(None), + #[cfg(any(test, fuzzing))] + next_local_fee: Mutex::new(PredictedNextFee::default()), + #[cfg(any(test, fuzzing))] + next_remote_fee: Mutex::new(PredictedNextFee::default()), channel_transaction_parameters: channel_parameters, funding_transaction, diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index b62d4e0a87f..e9ca7cf05f2 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -15,6 +15,14 @@ use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; +#[cfg(any(test, fuzzing))] +#[derive(Clone, PartialEq, PartialOrd, Eq, Ord)] +pub(crate) struct HTLCAmountDirection { + pub outbound: bool, + pub amount_msat: u64, +} + +#[cfg(not(any(test, fuzzing)))] pub(crate) struct HTLCAmountDirection { pub outbound: bool, pub amount_msat: u64, From 697b41bc10bebe3581ea18466894a6ce547aa2ca Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 01:46:27 +0000 Subject: [PATCH 4/9] validate_update_fee --- lightning/src/ln/channel.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5f3e5deb612..2dacfb50619 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4235,16 +4235,31 @@ where let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( &fee_estimator, funding.get_channel_type(), ); - let htlc_stats = self.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate); + let next_commitment_htlcs = self.next_commitment_htlcs(None); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat(funding); + let next_commitment_stats = SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, 0, msg.feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis); + let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - if htlc_stats.on_holder_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { + if next_commitment_stats.on_holder_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { return Err(ChannelError::close(format!("Peer sent update_fee with a feerate ({}) which may over-expose us to dust-in-flight on our own transactions (totaling {} msat)", - msg.feerate_per_kw, htlc_stats.on_holder_tx_dust_exposure_msat))); + msg.feerate_per_kw, next_commitment_stats.on_holder_tx_dust_exposure_msat))); } - if htlc_stats.on_counterparty_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { + if next_commitment_stats.on_counterparty_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { return Err(ChannelError::close(format!("Peer sent update_fee with a feerate ({}) which may over-expose us to dust-in-flight on our counterparty's transactions (totaling {} msat)", - msg.feerate_per_kw, htlc_stats.on_counterparty_tx_dust_exposure_msat))); + msg.feerate_per_kw, next_commitment_stats.on_counterparty_tx_dust_exposure_msat))); + } + + #[cfg(any(test, fuzzing))] + { + let mut predicted_htlcs = next_commitment_htlcs; + predicted_htlcs.sort_unstable(); + *funding.next_local_fee.lock().unwrap() = PredictedNextFee { + predicted_feerate: msg.feerate_per_kw, + predicted_htlcs, + predicted_fee_sat: next_commitment_stats.holder_commit_tx_fee_sat, + }; } + Ok(()) } From 72fd3f5a42146c45c2543493b984c856797ee257 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 02:10:20 +0000 Subject: [PATCH 5/9] can_send_update_fee --- lightning/src/ln/channel.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2dacfb50619..97004ee6cd4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1112,8 +1112,6 @@ struct HTLCStats { // htlc on the counterparty's commitment transaction. extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat: Option, on_holder_tx_dust_exposure_msat: u64, - outbound_holding_cell_msat: u64, - on_holder_tx_outbound_holding_cell_htlcs_count: u32, // dust HTLCs *non*-included } /// A struct gathering data on a commitment, either local or remote. @@ -4375,11 +4373,11 @@ where let dust_exposure_limiting_feerate = self.get_dust_exposure_limiting_feerate( &fee_estimator, funding.get_channel_type(), ); - let htlc_stats = self.get_pending_htlc_stats(funding, Some(feerate_per_kw), dust_exposure_limiting_feerate); - let stats = self.build_commitment_stats(funding, true, true, Some(feerate_per_kw), Some(htlc_stats.on_holder_tx_outbound_holding_cell_htlcs_count as usize + CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize)); - let holder_balance_msat = stats.local_balance_before_fee_msat - htlc_stats.outbound_holding_cell_msat; + let next_commitment_htlcs = self.next_commitment_htlcs(None); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat(funding); + let next_commitment_stats = SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, CONCURRENT_INBOUND_HTLC_FEE_BUFFER as usize, feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis); // Note that `stats.commit_tx_fee_sat` accounts for any HTLCs that transition from non-dust to dust under a higher feerate (in the case where HTLC-transactions pay endogenous fees). - if holder_balance_msat < stats.commit_tx_fee_sat * 1000 + funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 { + if next_commitment_stats.holder_balance_msat.unwrap() < next_commitment_stats.holder_commit_tx_fee_sat * 1000 + funding.counterparty_selected_channel_reserve_satoshis.unwrap() * 1000 { //TODO: auto-close after a number of failures? log_debug!(logger, "Cannot afford to send new feerate at {}", feerate_per_kw); return false; @@ -4387,15 +4385,27 @@ where // Note, we evaluate pending htlc "preemptive" trimmed-to-dust threshold at the proposed `feerate_per_kw`. let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - if htlc_stats.on_holder_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { + if next_commitment_stats.on_holder_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { log_debug!(logger, "Cannot afford to send new feerate at {} without infringing max dust htlc exposure", feerate_per_kw); return false; } - if htlc_stats.on_counterparty_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { + if next_commitment_stats.on_counterparty_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { log_debug!(logger, "Cannot afford to send new feerate at {} without infringing max dust htlc exposure", feerate_per_kw); return false; } + #[cfg(any(test, fuzzing))] + { + let next_commitment_stats = SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, 0, feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis); + let mut predicted_htlcs = next_commitment_htlcs; + predicted_htlcs.sort_unstable(); + *funding.next_local_fee.lock().unwrap() = PredictedNextFee { + predicted_feerate: feerate_per_kw, + predicted_htlcs, + predicted_fee_sat: next_commitment_stats.holder_commit_tx_fee_sat, + }; + } + return true; } @@ -4786,8 +4796,6 @@ where } let mut pending_outbound_htlcs_value_msat = 0; - let mut outbound_holding_cell_msat = 0; - let mut on_holder_tx_outbound_holding_cell_htlcs_count = 0; let mut pending_outbound_htlcs = self.pending_outbound_htlcs.len(); { let counterparty_dust_limit_success_sat = htlc_success_tx_fee_sat + context.counterparty_dust_limit_satoshis; @@ -4808,7 +4816,6 @@ where if let &HTLCUpdateAwaitingACK::AddHTLC { ref amount_msat, .. } = update { pending_outbound_htlcs += 1; pending_outbound_htlcs_value_msat += amount_msat; - outbound_holding_cell_msat += amount_msat; if *amount_msat / 1000 < counterparty_dust_limit_success_sat { on_counterparty_tx_dust_exposure_msat += amount_msat; } else { @@ -4816,8 +4823,6 @@ where } if *amount_msat / 1000 < holder_dust_limit_timeout_sat { on_holder_tx_dust_exposure_msat += amount_msat; - } else { - on_holder_tx_outbound_holding_cell_htlcs_count += 1; } } } @@ -4855,8 +4860,6 @@ where on_counterparty_tx_dust_exposure_msat, extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat, on_holder_tx_dust_exposure_msat, - outbound_holding_cell_msat, - on_holder_tx_outbound_holding_cell_htlcs_count, } } From 74dd5fc4d9ecac573247f3dcb9c8962256c36b09 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 02:52:22 +0000 Subject: [PATCH 6/9] can_accept_incoming_htlc --- lightning/src/ln/channel.rs | 102 +++++++++++++---------------- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 45 insertions(+), 59 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 97004ee6cd4..787c437dcb7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4411,84 +4411,70 @@ where #[rustfmt::skip] fn can_accept_incoming_htlc( - &self, funding: &FundingScope, msg: &msgs::UpdateAddHTLC, + &self, funding: &FundingScope, dust_exposure_limiting_feerate: Option, logger: &L, ) -> Result<(), LocalHTLCFailureReason> where L::Target: Logger, { - let htlc_stats = self.get_pending_htlc_stats(funding, None, dust_exposure_limiting_feerate); + // `Some(())` is for the fee spike buffer we keep for the remote if the channel is not zero fee. This deviates from the spec because the fee spike buffer requirement + // doesn't exist on the receiver's side, only on the sender's. Note that with anchor + // outputs we are no longer as sensitive to fee spikes, so we need to account for them. + // + // A `None` `HTLCCandidate` is used as in this case because we're already accounting for + // the incoming HTLC as it has been fully committed by both sides. + let fee_spike_buffer_htlc = if funding.get_channel_type().supports_anchor_zero_fee_commitments() { + 0 + } else { + 1 + }; + let next_commitment_htlcs = self.next_commitment_htlcs(None); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat(funding); + let next_commitment_stats = SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, fee_spike_buffer_htlc, self.feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis); + let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(dust_exposure_limiting_feerate); - let on_counterparty_tx_dust_htlc_exposure_msat = htlc_stats.on_counterparty_tx_dust_exposure_msat; - if on_counterparty_tx_dust_htlc_exposure_msat > max_dust_htlc_exposure_msat { + if next_commitment_stats.on_counterparty_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { // Note that the total dust exposure includes both the dust HTLCs and the excess mining fees of the counterparty commitment transaction log_info!(logger, "Cannot accept value that would put our total dust exposure at {} over the limit {} on counterparty commitment tx", - on_counterparty_tx_dust_htlc_exposure_msat, max_dust_htlc_exposure_msat); + next_commitment_stats.on_counterparty_tx_dust_exposure_msat, max_dust_htlc_exposure_msat); return Err(LocalHTLCFailureReason::DustLimitCounterparty) } - let dust_buffer_feerate = self.get_dust_buffer_feerate(None); - let (htlc_success_tx_fee_sat, _) = second_stage_tx_fees_sat( - &funding.get_channel_type(), dust_buffer_feerate, - ); - let exposure_dust_limit_success_sats = htlc_success_tx_fee_sat + self.holder_dust_limit_satoshis; - if msg.amount_msat / 1000 < exposure_dust_limit_success_sats { - let on_holder_tx_dust_htlc_exposure_msat = htlc_stats.on_holder_tx_dust_exposure_msat; - if on_holder_tx_dust_htlc_exposure_msat > max_dust_htlc_exposure_msat { - log_info!(logger, "Cannot accept value that would put our exposure to dust HTLCs at {} over the limit {} on holder commitment tx", - on_holder_tx_dust_htlc_exposure_msat, max_dust_htlc_exposure_msat); - return Err(LocalHTLCFailureReason::DustLimitHolder) - } + if next_commitment_stats.on_holder_tx_dust_exposure_msat > max_dust_htlc_exposure_msat { + // Note: We now always check holder dust exposure, whereas we previously would only + // do it if the incoming HTLC was dust on our own commitment transaction + log_info!(logger, "Cannot accept value that would put our exposure to dust HTLCs at {} over the limit {} on holder commitment tx", + next_commitment_stats.on_holder_tx_dust_exposure_msat, max_dust_htlc_exposure_msat); + return Err(LocalHTLCFailureReason::DustLimitHolder) } if !funding.is_outbound() { - let removed_outbound_total_msat: u64 = self.pending_outbound_htlcs - .iter() - .filter_map(|htlc| { - matches!( - htlc.state, - OutboundHTLCState::AwaitingRemoteRevokeToRemove(OutboundHTLCOutcome::Success(_, _)) - | OutboundHTLCState::AwaitingRemovedRemoteRevoke(OutboundHTLCOutcome::Success(_, _)) - ) - .then_some(htlc.amount_msat) - }) - .sum(); - let pending_value_to_self_msat = - funding.value_to_self_msat + htlc_stats.pending_inbound_htlcs_value_msat - removed_outbound_total_msat; - let pending_remote_value_msat = - funding.get_value_satoshis() * 1000 - pending_value_to_self_msat; - // Subtract any non-HTLC outputs from the local and remote balances - let (_, remote_balance_before_fee_msat) = SpecTxBuilder {}.subtract_non_htlc_outputs( - funding.is_outbound(), - pending_value_to_self_msat, - pending_remote_value_msat, - funding.get_channel_type() - ); - - // `Some(())` is for the fee spike buffer we keep for the remote if the channel is - // not zero fee. This deviates from the spec because the fee spike buffer requirement - // doesn't exist on the receiver's side, only on the sender's. Note that with anchor - // outputs we are no longer as sensitive to fee spikes, so we need to account for them. - // - // A `None` `HTLCCandidate` is used as in this case because we're already accounting for - // the incoming HTLC as it has been fully committed by both sides. - let fee_spike_buffer_htlc = if funding.get_channel_type().supports_anchor_zero_fee_commitments() { - None - } else { - Some(()) - }; - - let mut remote_fee_cost_incl_stuck_buffer_msat = self.next_remote_commit_tx_fee_msat( - funding, None, fee_spike_buffer_htlc, - ); + let mut remote_fee_cost_incl_stuck_buffer_msat = next_commitment_stats.counterparty_commit_tx_fee_sat * 1000; if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { remote_fee_cost_incl_stuck_buffer_msat *= FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE; } + let remote_balance_before_fee_msat = next_commitment_stats.counterparty_balance_msat.unwrap_or(0); if remote_balance_before_fee_msat.saturating_sub(funding.holder_selected_channel_reserve_satoshis * 1000) < remote_fee_cost_incl_stuck_buffer_msat { log_info!(logger, "Attempting to fail HTLC due to fee spike buffer violation in channel {}. Rebalancing is required.", &self.channel_id()); return Err(LocalHTLCFailureReason::FeeSpikeBuffer); } } + #[cfg(any(test, fuzzing))] + { + let next_commitment_stats = if fee_spike_buffer_htlc == 1 { + SpecTxBuilder {}.get_builder_stats(funding.is_outbound(), funding.get_value_satoshis(), value_to_self_msat, &next_commitment_htlcs, 0, self.feerate_per_kw, dust_exposure_limiting_feerate, funding.get_channel_type(), self.holder_dust_limit_satoshis, self.counterparty_dust_limit_satoshis) + } else { + next_commitment_stats + }; + let mut predicted_htlcs = next_commitment_htlcs; + predicted_htlcs.sort_unstable(); + *funding.next_remote_fee.lock().unwrap() = PredictedNextFee { + predicted_feerate: self.feerate_per_kw, + predicted_htlcs, + predicted_fee_sat: next_commitment_stats.counterparty_commit_tx_fee_sat, + }; + } + Ok(()) } @@ -9451,7 +9437,7 @@ where /// this function determines whether to fail the HTLC, or forward / claim it. #[rustfmt::skip] pub fn can_accept_incoming_htlc( - &self, msg: &msgs::UpdateAddHTLC, fee_estimator: &LowerBoundedFeeEstimator, logger: L + &self, fee_estimator: &LowerBoundedFeeEstimator, logger: L ) -> Result<(), LocalHTLCFailureReason> where F::Target: FeeEstimator, @@ -9467,7 +9453,7 @@ where core::iter::once(&self.funding) .chain(self.pending_funding.iter()) - .try_for_each(|funding| self.context.can_accept_incoming_htlc(funding, msg, dust_exposure_limiting_feerate, &logger)) + .try_for_each(|funding| self.context.can_accept_incoming_htlc(funding, dust_exposure_limiting_feerate, &logger)) } pub fn get_cur_holder_commitment_transaction_number(&self) -> u64 { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8bac6c2fa3a..64ef1b531d1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6262,7 +6262,7 @@ where &chan.context, Some(update_add_htlc.payment_hash), ); - chan.can_accept_incoming_htlc(update_add_htlc, &self.fee_estimator, &logger) + chan.can_accept_incoming_htlc(&self.fee_estimator, &logger) }, ) { Some(Ok(_)) => {}, From 6834098b2df6a88c53cfdc7978c7deaeccbe686f Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 04:17:17 +0000 Subject: [PATCH 7/9] fmt --- lightning/src/ln/channel.rs | 55 ++++++++----- lightning/src/sign/tx_builder.rs | 131 +++++++++++++++++++++++++------ 2 files changed, 142 insertions(+), 44 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 787c437dcb7..4c5642c2846 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -70,7 +70,7 @@ use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::tx_builder::{SpecTxBuilder, TxBuilder, HTLCAmountDirection}; +use crate::sign::tx_builder::{HTLCAmountDirection, SpecTxBuilder, TxBuilder}; use crate::sign::{ChannelSigner, EntropySource, NodeSigner, Recipient, SignerProvider}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; use crate::types::payment::{PaymentHash, PaymentPreimage}; @@ -4122,32 +4122,51 @@ where self.pending_inbound_htlcs.iter().map(|htlc| htlc.amount_msat).sum() } - fn next_commitment_htlcs(&self, htlc_candidate: Option) -> Vec { - let mut commitment_htlcs = Vec::with_capacity(1 + self.pending_inbound_htlcs.len() + self.pending_outbound_htlcs.len() + self.holding_cell_htlc_updates.len()); - let pending_inbound_htlcs = self.pending_inbound_htlcs.iter().map(|InboundHTLCOutput { amount_msat, .. }| HTLCAmountDirection { outbound: false, amount_msat: *amount_msat }); + fn next_commitment_htlcs( + &self, htlc_candidate: Option, + ) -> Vec { + let mut commitment_htlcs = Vec::with_capacity( + 1 + self.pending_inbound_htlcs.len() + + self.pending_outbound_htlcs.len() + + self.holding_cell_htlc_updates.len(), + ); + let pending_inbound_htlcs = + self.pending_inbound_htlcs.iter().map(|InboundHTLCOutput { amount_msat, .. }| { + HTLCAmountDirection { outbound: false, amount_msat: *amount_msat } + }); use OutboundHTLCState::*; - let pending_outbound_htlcs = self.pending_outbound_htlcs.iter().filter_map(|OutboundHTLCOutput { ref state, amount_msat, .. }| - matches!(state, LocalAnnounced { .. } | Committed | RemoteRemoved { .. }).then_some(HTLCAmountDirection { outbound: true, amount_msat: *amount_msat })); + let pending_outbound_htlcs = self.pending_outbound_htlcs.iter().filter_map( + |OutboundHTLCOutput { ref state, amount_msat, .. }| { + matches!(state, LocalAnnounced { .. } | Committed | RemoteRemoved { .. }) + .then_some(HTLCAmountDirection { outbound: true, amount_msat: *amount_msat }) + }, + ); // We do not include holding cell HTLCs, we will validate them upon freeing the holding cell... //let holding_cell_htlcs = self.holding_cell_htlc_updates.iter().filter_map(|htlc| if let HTLCUpdateAwaitingACK::AddHTLC { amount_msat, ..} = htlc { Some(HTLCAmountDirection { outbound: true, amount_msat: *amount_msat }) } else { None }); - commitment_htlcs.extend(htlc_candidate.into_iter().chain(pending_inbound_htlcs).chain(pending_outbound_htlcs)); + commitment_htlcs.extend( + htlc_candidate.into_iter().chain(pending_inbound_htlcs).chain(pending_outbound_htlcs), + ); commitment_htlcs } fn get_next_commitment_value_to_self_msat(&self, funding: &FundingScope) -> u64 { - let outbound_removed_htlc_msat: u64 = self.pending_outbound_htlcs - .iter() - .filter_map(|htlc| { - matches!( - htlc.state, - OutboundHTLCState::AwaitingRemoteRevokeToRemove(OutboundHTLCOutcome::Success(_, _)) - | OutboundHTLCState::AwaitingRemovedRemoteRevoke(OutboundHTLCOutcome::Success(_, _)) - ) - .then_some(htlc.amount_msat) - }) - .sum(); + let outbound_removed_htlc_msat: u64 = + self.pending_outbound_htlcs + .iter() + .filter_map(|htlc| { + matches!( + htlc.state, + OutboundHTLCState::AwaitingRemoteRevokeToRemove( + OutboundHTLCOutcome::Success(_, _) + ) | OutboundHTLCState::AwaitingRemovedRemoteRevoke( + OutboundHTLCOutcome::Success(_, _) + ) + ) + .then_some(htlc.amount_msat) + }) + .sum(); funding.value_to_self_msat.saturating_sub(outbound_removed_htlc_msat) } diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index e9ca7cf05f2..e0a09ef368b 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -1,8 +1,8 @@ //! Defines the `TxBuilder` trait, and the `SpecTxBuilder` type #![allow(dead_code)] -use core::ops::Deref; use core::cmp; +use core::ops::Deref; use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; @@ -29,7 +29,10 @@ pub(crate) struct HTLCAmountDirection { } impl HTLCAmountDirection { - fn is_dust(&self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_sat: u64, channel_type: &ChannelTypeFeatures) -> bool { + fn is_dust( + &self, local: bool, feerate_per_kw: u32, broadcaster_dust_limit_sat: u64, + channel_type: &ChannelTypeFeatures, + ) -> bool { let htlc_tx_fee_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { 0 } else { @@ -55,20 +58,35 @@ pub(crate) struct BuilderStats { pub counterparty_balance_msat: Option, } -fn on_holder_tx_dust_exposure_msat(dust_buffer_feerate: u32, holder_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, htlcs: &[HTLCAmountDirection]) -> u64 { +fn on_holder_tx_dust_exposure_msat( + dust_buffer_feerate: u32, holder_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, + htlcs: &[HTLCAmountDirection], +) -> u64 { htlcs .iter() - .filter_map(|htlc| { htlc.is_dust(true, dust_buffer_feerate, holder_dust_limit_satoshis, channel_type).then_some(htlc.amount_msat) }) + .filter_map(|htlc| { + htlc.is_dust(true, dust_buffer_feerate, holder_dust_limit_satoshis, channel_type) + .then_some(htlc.amount_msat) + }) .sum() } -fn on_counterparty_tx_dust_exposure_msat(dust_buffer_feerate: u32, excess_feerate_opt: Option, counterparty_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, on_remote_htlcs: &[HTLCAmountDirection]) -> (u64, Option) { +fn on_counterparty_tx_dust_exposure_msat( + dust_buffer_feerate: u32, excess_feerate_opt: Option, + counterparty_dust_limit_satoshis: u64, channel_type: &ChannelTypeFeatures, + on_remote_htlcs: &[HTLCAmountDirection], +) -> (u64, Option) { let mut on_counterparty_tx_accepted_nondust_htlcs = 0; let mut on_counterparty_tx_offered_nondust_htlcs = 0; let mut on_counterparty_tx_dust_exposure_msat: u64 = on_remote_htlcs .iter() .filter_map(|htlc| { - if htlc.is_dust(false, dust_buffer_feerate, counterparty_dust_limit_satoshis, channel_type) { + if htlc.is_dust( + false, + dust_buffer_feerate, + counterparty_dust_limit_satoshis, + channel_type, + ) { Some(htlc.amount_msat) } else { if !htlc.outbound { @@ -83,13 +101,34 @@ fn on_counterparty_tx_dust_exposure_msat(dust_buffer_feerate: u32, excess_feerat let extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat = excess_feerate_opt.map(|excess_feerate| { - let extra_htlc_commit_tx_fee_sat = commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1 + on_counterparty_tx_offered_nondust_htlcs, channel_type); - let extra_htlc_htlc_tx_fees_sat = htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + 1, on_counterparty_tx_offered_nondust_htlcs, channel_type); - - let commit_tx_fee_sat = commit_tx_fee_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs + on_counterparty_tx_offered_nondust_htlcs, channel_type); - let htlc_tx_fees_sat = htlc_tx_fees_sat(excess_feerate, on_counterparty_tx_accepted_nondust_htlcs, on_counterparty_tx_offered_nondust_htlcs, channel_type); - - let extra_htlc_dust_exposure = on_counterparty_tx_dust_exposure_msat + (extra_htlc_commit_tx_fee_sat + extra_htlc_htlc_tx_fees_sat) * 1000; + let extra_htlc_commit_tx_fee_sat = commit_tx_fee_sat( + excess_feerate, + on_counterparty_tx_accepted_nondust_htlcs + + 1 + on_counterparty_tx_offered_nondust_htlcs, + channel_type, + ); + let extra_htlc_htlc_tx_fees_sat = htlc_tx_fees_sat( + excess_feerate, + on_counterparty_tx_accepted_nondust_htlcs + 1, + on_counterparty_tx_offered_nondust_htlcs, + channel_type, + ); + + let commit_tx_fee_sat = commit_tx_fee_sat( + excess_feerate, + on_counterparty_tx_accepted_nondust_htlcs + + on_counterparty_tx_offered_nondust_htlcs, + channel_type, + ); + let htlc_tx_fees_sat = htlc_tx_fees_sat( + excess_feerate, + on_counterparty_tx_accepted_nondust_htlcs, + on_counterparty_tx_offered_nondust_htlcs, + channel_type, + ); + + let extra_htlc_dust_exposure = on_counterparty_tx_dust_exposure_msat + + (extra_htlc_commit_tx_fee_sat + extra_htlc_htlc_tx_fees_sat) * 1000; on_counterparty_tx_dust_exposure_msat += (commit_tx_fee_sat + htlc_tx_fees_sat) * 1000; extra_htlc_dust_exposure }); @@ -141,7 +180,13 @@ fn get_dust_buffer_feerate(feerate_per_kw: u32) -> u32 { } pub(crate) trait TxBuilder { - fn get_builder_stats(&self, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, counterparty_dust_limit_satoshis: u64) -> BuilderStats; + fn get_builder_stats( + &self, is_outbound_from_holder: bool, channel_value_satoshis: u64, + value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, + feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, + channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, + counterparty_dust_limit_satoshis: u64, + ) -> BuilderStats; fn commit_tx_fee_sat( &self, feerate_per_kw: u32, nondust_htlc_count: usize, channel_type: &ChannelTypeFeatures, ) -> u64; @@ -162,8 +207,15 @@ pub(crate) trait TxBuilder { pub(crate) struct SpecTxBuilder {} impl TxBuilder for SpecTxBuilder { - fn get_builder_stats(&self, is_outbound_from_holder: bool, channel_value_satoshis: u64, value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, counterparty_dust_limit_satoshis: u64) -> BuilderStats { - let excess_feerate_opt = feerate_per_kw.checked_sub(dust_exposure_limiting_feerate.unwrap_or(0)); + fn get_builder_stats( + &self, is_outbound_from_holder: bool, channel_value_satoshis: u64, + value_to_holder_msat: u64, htlcs: &[HTLCAmountDirection], nondust_htlcs: usize, + feerate_per_kw: u32, dust_exposure_limiting_feerate: Option, + channel_type: &ChannelTypeFeatures, holder_dust_limit_satoshis: u64, + counterparty_dust_limit_satoshis: u64, + ) -> BuilderStats { + let excess_feerate_opt = + feerate_per_kw.checked_sub(dust_exposure_limiting_feerate.unwrap_or(0)); // Dust exposure is only decoupled from feerate for zero fee commitment channels. let is_zero_fee_comm = channel_type.supports_anchor_zero_fee_commitments(); debug_assert_eq!(is_zero_fee_comm, dust_exposure_limiting_feerate.is_none()); @@ -175,17 +227,27 @@ impl TxBuilder for SpecTxBuilder { // Calculate balances after htlcs let value_to_counterparty_msat = channel_value_satoshis * 1000 - value_to_holder_msat; - let outbound_htlcs_value_msat: u64 = htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); - let inbound_htlcs_value_msat: u64 = htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); - let value_to_holder_after_htlcs = value_to_holder_msat.checked_sub(outbound_htlcs_value_msat); - let value_to_counterparty_after_htlcs = value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat); + let outbound_htlcs_value_msat: u64 = + htlcs.iter().filter_map(|htlc| htlc.outbound.then_some(htlc.amount_msat)).sum(); + let inbound_htlcs_value_msat: u64 = + htlcs.iter().filter_map(|htlc| (!htlc.outbound).then_some(htlc.amount_msat)).sum(); + let value_to_holder_after_htlcs = + value_to_holder_msat.checked_sub(outbound_htlcs_value_msat); + let value_to_counterparty_after_htlcs = + value_to_counterparty_msat.checked_sub(inbound_htlcs_value_msat); // Increment the feerate by a buffer to calculate dust exposure let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); // Calculate dust exposure on holder's commitment transaction - let on_holder_htlc_count = htlcs.iter().filter(|htlc| !htlc.is_dust(true, feerate_per_kw, holder_dust_limit_satoshis, channel_type)).count(); - let holder_commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, on_holder_htlc_count + nondust_htlcs, channel_type); + let on_holder_htlc_count = htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust(true, feerate_per_kw, holder_dust_limit_satoshis, channel_type) + }) + .count(); + let holder_commit_tx_fee_sat = + commit_tx_fee_sat(feerate_per_kw, on_holder_htlc_count + nondust_htlcs, channel_type); let on_holder_tx_dust_exposure_msat = on_holder_tx_dust_exposure_msat( dust_buffer_feerate, holder_dust_limit_satoshis, @@ -194,9 +256,21 @@ impl TxBuilder for SpecTxBuilder { ); // Calculate dust exposure on counterparty's commitment transaction - let on_counterparty_htlc_count = htlcs.iter().filter(|htlc| !htlc.is_dust(false, feerate_per_kw, counterparty_dust_limit_satoshis, channel_type)).count(); - let counterparty_commit_tx_fee_sat = commit_tx_fee_sat(feerate_per_kw, on_counterparty_htlc_count + nondust_htlcs, channel_type); - let (on_counterparty_tx_dust_exposure_msat, extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat) = on_counterparty_tx_dust_exposure_msat( + let on_counterparty_htlc_count = htlcs + .iter() + .filter(|htlc| { + !htlc.is_dust(false, feerate_per_kw, counterparty_dust_limit_satoshis, channel_type) + }) + .count(); + let counterparty_commit_tx_fee_sat = commit_tx_fee_sat( + feerate_per_kw, + on_counterparty_htlc_count + nondust_htlcs, + channel_type, + ); + let ( + on_counterparty_tx_dust_exposure_msat, + extra_nondust_htlc_on_counterparty_tx_dust_exposure_msat, + ) = on_counterparty_tx_dust_exposure_msat( dust_buffer_feerate, excess_feerate_opt, counterparty_dust_limit_satoshis, @@ -205,7 +279,12 @@ impl TxBuilder for SpecTxBuilder { ); // Subtract the anchors from the channel funder - let (holder_balance_msat, counterparty_balance_msat) = subtract_addl_outputs(is_outbound_from_holder, value_to_holder_after_htlcs, value_to_counterparty_after_htlcs, channel_type); + let (holder_balance_msat, counterparty_balance_msat) = subtract_addl_outputs( + is_outbound_from_holder, + value_to_holder_after_htlcs, + value_to_counterparty_after_htlcs, + channel_type, + ); BuilderStats { holder_commit_tx_fee_sat, From 3696d994b941cba682fe0c5bd38ac6e91f416116 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 18:44:18 +0000 Subject: [PATCH 8/9] fixup: validate_update_add_htlc bring back useful comment --- lightning/src/ln/channel.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 4c5642c2846..c546732a6b6 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4201,6 +4201,19 @@ where // Check that the remote can afford to pay for this HTLC on-chain at the current // feerate_per_kw, while maintaining their channel reserve (as required by the spec). + // + // We check holder_selected_channel_reserve_satoshis (we're getting paid, so they have to at least meet + // the reserve_satoshis we told them to always have as direct payment so that they lose + // something if we punish them for broadcasting an old state). + // Note that we don't really care about having a small/no to_remote output in our local + // commitment transactions, as the purpose of the channel reserve is to ensure we can + // punish *them* if they misbehave, so we discount any outbound HTLCs which will not be + // present in the next commitment transaction we send them (at least for fulfilled ones, + // failed ones won't modify value_to_self). + // Note that we will send HTLCs which another instance of rust-lightning would think + // violate the reserve value if we do not do this (as we forget inbound HTLCs from the + // Channel state once they will not be present in the next received commitment + // transaction). { let remote_commit_tx_fee_msat = if funding.is_outbound() { 0 } else { next_commitment_stats.counterparty_commit_tx_fee_sat * 1000 From 2410913a7ede1cee2b878bec7f3d79cab66a440c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 28 Jul 2025 23:13:38 +0000 Subject: [PATCH 9/9] fixup: Add `TxBuilder::get_builder_stats` clarify comments --- lightning/src/sign/tx_builder.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index e0a09ef368b..008f325a33b 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -239,7 +239,7 @@ impl TxBuilder for SpecTxBuilder { // Increment the feerate by a buffer to calculate dust exposure let dust_buffer_feerate = get_dust_buffer_feerate(feerate_per_kw); - // Calculate dust exposure on holder's commitment transaction + // Calculate fees and dust exposure on holder's commitment transaction let on_holder_htlc_count = htlcs .iter() .filter(|htlc| { @@ -255,7 +255,7 @@ impl TxBuilder for SpecTxBuilder { &htlcs, ); - // Calculate dust exposure on counterparty's commitment transaction + // Calculate fees and dust exposure on counterparty's commitment transaction let on_counterparty_htlc_count = htlcs .iter() .filter(|htlc| {