From a211df9566e332d9f2a362446d92989b3e4d2ad6 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 06:32:01 +0000 Subject: [PATCH 1/5] Add `ChannelContext::get_post_splice_holder_counterparty_balances` We now validate that both commitments retain at least one output after a splice, which is crucial for zero-reserve channels. --- lightning/src/ln/channel.rs | 153 +++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 32c0e94bdc8..98370a77b92 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -5259,6 +5259,104 @@ impl ChannelContext { } } + /// The balances returned here should only be used to check that both parties still hold + /// their respective reserves *after* a splice. This function also checks that both local + /// and remote commitments still have at least one output after the splice, which is + /// particularly relevant for zero-reserve channels. + /// + /// Do NOT use this to determine how much the holder can splice out of the channel. The balance + /// of the holder after a splice is not necessarily equal to the funds they can splice out + /// of the channel due to the reserve, and the zero-reserve-at-least-one-output + /// requirements. Note you cannot simply subtract out the reserve, as splicing funds out + /// of the channel changes the reserve the holder must keep in the channel. + fn get_post_splice_holder_counterparty_balances( + &self, funding: &FundingScope, our_contribution_candidate: SignedAmount, + their_contribution_candidate: SignedAmount, addl_nondust_htlc_count: usize, + feerate_per_kw: u32, + ) -> Result<(Amount, Amount), String> { + let htlc_candidate = None; + // There shouldn't be any pending HTLC adds at this point. We nonetheless choose to + // take the remote's view of the pending HTLCs to make this function consistent + // with `FundedChannel::get_next_splice_out_maximum`. In debug mode, we check that + // these two functions are consistent *before* clearing any HTLC updates from the + // channel, see `FundedChannel::funding_contributed`. + let include_counterparty_unknown_htlcs = true; + let post_splice_channel_value_satoshis = funding.compute_post_splice_value( + our_contribution_candidate.to_sat(), + their_contribution_candidate.to_sat(), + ); + let value_to_self_msat = self.get_next_commitment_value_to_self_msat( + false, // Take the remote's view of the pending HTLCs + funding, + ); + let our_contribution_candidate_msat = our_contribution_candidate.to_sat() * 1000; + let post_splice_value_to_self_msat = value_to_self_msat + .checked_add_signed(our_contribution_candidate_msat) + .ok_or(format!( + "Our contribution candidate {our_contribution_candidate_msat}msat is \ + greater than our total balance in the channel {value_to_self_msat}msat" + ))?; + + let commitment_htlcs = self.get_next_commitment_htlcs( + false, // Take the remote's view of the pending HTLCs + htlc_candidate, + include_counterparty_unknown_htlcs, + ); + + // We are not interested in dust exposure at this point, so use the 250sat/kw default + let max_dust_htlc_exposure_msat = self.get_max_dust_htlc_exposure_msat(None); + // Again, not interested in dust exposure, so no need to pass this feerate + let dust_exposure_limiting_feerate = None; + + let channel_constraints = self.get_channel_constraints(funding); + + let get_channel_stats = |local: bool| { + SpecTxBuilder {}.get_channel_stats( + local, + funding.is_outbound(), + post_splice_channel_value_satoshis, + post_splice_value_to_self_msat, + &commitment_htlcs, + addl_nondust_htlc_count, + feerate_per_kw, + dust_exposure_limiting_feerate, + max_dust_htlc_exposure_msat, + channel_constraints, + funding.get_channel_type(), + ) + }; + + // Different dust limits on the local and remote commitments cause the commitment + // transaction fee to be different depending on the commitment, so we grab the floor + // of both balances across both commitments here. + // + // `get_channel_stats` also checks for at least one output on the commitment given + // these parameters. This is particularly relevant for zero-reserve channels. + // + // This "at-least-one-output" check is why we still run both checks on + // zero-fee-commitment channels, even though those channels don't suffer from the + // commitment transaction fee asymmetry. + let local_stats = + get_channel_stats(true).map_err(|()| "Balance exhausted on local commitment")?; + let remote_stats = + get_channel_stats(false).map_err(|()| "Balance exhausted on remote commitment")?; + + let holder_balance_floor = Amount::from_sat( + cmp::min( + local_stats.commitment_stats.holder_balance_msat, + remote_stats.commitment_stats.holder_balance_msat, + ) / 1000, + ); + let counterparty_balance_floor = Amount::from_sat( + cmp::min( + local_stats.commitment_stats.counterparty_balance_msat, + remote_stats.commitment_stats.counterparty_balance_msat, + ) / 1000, + ); + + Ok((holder_balance_floor, counterparty_balance_floor)) + } + fn get_next_local_commitment_stats( &self, funding: &FundingScope, htlc_candidate: Option, include_counterparty_unknown_htlcs: bool, addl_nondust_htlc_count: usize, @@ -12772,11 +12870,6 @@ where )); } - let (holder_balance_remaining, counterparty_balance_remaining) = - self.get_holder_counterparty_balances_floor_incl_fee(&self.funding).map_err(|e| { - format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e) - })?; - let post_channel_value = self.funding.compute_post_splice_value( our_funding_contribution.to_sat(), their_funding_contribution.to_sat(), @@ -12796,20 +12889,35 @@ where self.funding.holder_selected_channel_reserve_satoshis == 0, )); - // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve + // At all times, require that the channel funder have the fees for an additional + // inbound HTLC on the channel. + let addl_nondust_htlc_count = 1; + // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop + let feerate_per_kw = if !self.funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() + { + // Similar to HTLC-add validation, require the funder to have enough funds + // reserved for fees such that the feerate can jump without rendering the + // channel useless. + self.context.feerate_per_kw * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else { + self.context.feerate_per_kw + }; - if our_funding_contribution != SignedAmount::ZERO { - let post_splice_holder_balance = Amount::from_sat( - holder_balance_remaining.to_sat() - .checked_add_signed(our_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; our remaining balance {} does not cover our negative funding contribution {}", - self.context.channel_id(), - holder_balance_remaining, - our_funding_contribution, - ))?, - ); + let (post_splice_holder_balance, post_splice_counterparty_balance) = self + .context + .get_post_splice_holder_counterparty_balances( + &self.funding, + our_funding_contribution, + their_funding_contribution, + addl_nondust_htlc_count, + feerate_per_kw, + ) + .map_err(|e| { + format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e) + })?; + // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve + if our_funding_contribution != SignedAmount::ZERO { post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; our post-splice channel balance {} is smaller than their selected v2 reserve {}", @@ -12821,17 +12929,6 @@ where } if their_funding_contribution != SignedAmount::ZERO { - let post_splice_counterparty_balance = Amount::from_sat( - counterparty_balance_remaining.to_sat() - .checked_add_signed(their_funding_contribution.to_sat()) - .ok_or(format!( - "Channel {} cannot be spliced out; their remaining balance {} does not cover their negative funding contribution {}", - self.context.channel_id(), - counterparty_balance_remaining, - their_funding_contribution, - ))?, - ); - post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve) .ok_or(format!( "Channel {} cannot be {}; their post-splice channel balance {} is smaller than our selected v2 reserve {}", From a0573b1f262cdd983149659489042c600a2fdc5e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 8 Apr 2026 20:53:03 +0000 Subject: [PATCH 2/5] Add `AvailableBalances::next_splice_out_maximum_sat` We previously determined this value by subtracting the htlcs, the anchors, and the commitment transaction fee. This ignored the reserve, as well as the at-least-one-output requirement in zero-reserve channels. This new field now accounts for both of these constraints. --- lightning/src/ln/channel.rs | 93 ++++++++++------------- lightning/src/ln/channel_state.rs | 1 + lightning/src/ln/channelmanager.rs | 1 + lightning/src/sign/tx_builder.rs | 118 ++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 54 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 98370a77b92..7494d7e5159 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -122,6 +122,8 @@ pub struct AvailableBalances { pub next_outbound_htlc_limit_msat: u64, /// The minimum value we can assign to the next outbound HTLC pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value of the next splice-out + pub next_splice_out_maximum_sat: u64, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -5269,6 +5271,9 @@ impl ChannelContext { /// of the channel due to the reserve, and the zero-reserve-at-least-one-output /// requirements. Note you cannot simply subtract out the reserve, as splicing funds out /// of the channel changes the reserve the holder must keep in the channel. + /// + /// See [`FundedChannel::get_next_splice_out_maximum`] for the maximum value of the next + /// splice out of the holder's balance. fn get_post_splice_holder_counterparty_balances( &self, funding: &FundingScope, our_contribution_candidate: SignedAmount, their_contribution_candidate: SignedAmount, addl_nondust_htlc_count: usize, @@ -6856,7 +6861,7 @@ pub(crate) fn get_legacy_default_holder_selected_channel_reserve_satoshis( /// /// This is used both for outbound and inbound channels and has lower bound /// of `dust_limit_satoshis`. -fn get_v2_channel_reserve_satoshis( +pub(crate) fn get_v2_channel_reserve_satoshis( channel_value_satoshis: u64, dust_limit_satoshis: u64, is_0reserve: bool, ) -> u64 { if is_0reserve { @@ -12452,10 +12457,7 @@ where "build_prior_contribution requires pending_splice" ); let prior = self.pending_splice.as_ref()?.contributions.last()?; - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(h, _)| h) - .ok(); + let holder_balance = self.get_next_splice_out_maximum(&self.funding).ok(); Some(PriorContribution::new(prior.clone(), holder_balance)) } @@ -12530,10 +12532,7 @@ where return contribution; } - let holder_balance = match self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) - { + let holder_balance = match self.get_next_splice_out_maximum(&self.funding) { Ok(balance) => balance, Err(_) => return contribution, }; @@ -12946,8 +12945,7 @@ where &self, feerate: FeeRate, logger: &L, ) -> Result<(Option, Option), ChannelError> { let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(holder, _)| holder) + .get_next_splice_out_maximum(&self.funding) .map_err(|e| { log_info!( logger, @@ -13379,63 +13377,49 @@ where )) } - fn get_holder_counterparty_balances_floor_incl_fee( - &self, funding: &FundingScope, - ) -> Result<(Amount, Amount), String> { + /// Determines the maximum value that the holder can splice out of the channel, accounting + /// for the updated reserves after said splice. This maximum also makes sure the local + /// commitment retains at least one output after the splice, which is particularly relevant + /// for zero-reserve channels. + fn get_next_splice_out_maximum(&self, funding: &FundingScope) -> Result { let include_counterparty_unknown_htlcs = true; - // Make sure that that the funder of the channel can pay the transaction fees for an additional - // nondust HTLC on the channel. - let addl_nondust_htlc_count = 1; // We are not interested in dust exposure let dust_exposure_limiting_feerate = None; - // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop - let feerate_per_kw = if !funding.get_channel_type().supports_anchors_zero_fee_htlc_tx() { - // Similar to HTLC additions, require the funder to have enough funds reserved for - // fees such that the feerate can jump without rendering the channel useless. - self.context.feerate_per_kw * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 - } else { - self.context.feerate_per_kw - }; - - let (local_stats, _local_htlcs) = self - .context - .get_next_local_commitment_stats( - funding, - None, // htlc_candidate - include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - feerate_per_kw, - dust_exposure_limiting_feerate, - ) - .map_err(|()| "Balance exhausted on local commitment")?; - + // When reading the available balances, we take the remote's view of the pending + // HTLCs, see `tx_builder` for further details let (remote_stats, _remote_htlcs) = self .context .get_next_remote_commitment_stats( funding, None, // htlc_candidate include_counterparty_unknown_htlcs, - addl_nondust_htlc_count, - feerate_per_kw, + 0, + self.context.feerate_per_kw, dust_exposure_limiting_feerate, ) .map_err(|()| "Balance exhausted on remote commitment")?; - let holder_balance_floor = Amount::from_sat( - cmp::min( - local_stats.commitment_stats.holder_balance_msat, - remote_stats.commitment_stats.holder_balance_msat, - ) / 1000, - ); - let counterparty_balance_floor = Amount::from_sat( - cmp::min( - local_stats.commitment_stats.counterparty_balance_msat, - remote_stats.commitment_stats.counterparty_balance_msat, - ) / 1000, - ); + let next_splice_out_maximum_sat = + remote_stats.available_balances.next_splice_out_maximum_sat; - Ok((holder_balance_floor, counterparty_balance_floor)) + #[cfg(debug_assertions)] + { + // After this max splice out, validation passes, accounting for the updated reserves + self.validate_splice_contributions( + SignedAmount::from_sat(-(next_splice_out_maximum_sat as i64)), + SignedAmount::ZERO, + ) + .unwrap(); + // Splice-out an additional satoshi, and validation fails! + self.validate_splice_contributions( + SignedAmount::from_sat(-((next_splice_out_maximum_sat + 1) as i64)), + SignedAmount::ZERO, + ) + .unwrap_err(); + } + + Ok(Amount::from_sat(next_splice_out_maximum_sat)) } pub fn splice_locked( @@ -13663,6 +13647,9 @@ where next_outbound_htlc_minimum_msat: acc .next_outbound_htlc_minimum_msat .max(e.next_outbound_htlc_minimum_msat), + next_splice_out_maximum_sat: acc + .next_splice_out_maximum_sat + .min(e.next_splice_out_maximum_sat), }) }) } diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 5547bee8f4c..720f8e7b789 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -533,6 +533,7 @@ impl ChannelDetails { outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + next_splice_out_maximum_sat: 0, } }); let (to_remote_reserve_satoshis, to_self_reserve_satoshis) = diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 660875400c7..283950eb216 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8026,6 +8026,7 @@ impl< outbound_capacity_msat: 0, next_outbound_htlc_limit_msat: 0, next_outbound_htlc_minimum_msat: u64::MAX, + next_splice_out_maximum_sat: 0, } }); let is_in_range = (balances.next_outbound_htlc_minimum_msat diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index a54f8f70f8d..ae98ed14a22 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -9,7 +9,9 @@ use crate::ln::chan_utils::{ second_stage_tx_fees_sat, ChannelTransactionParameters, CommitmentTransaction, HTLCOutputInCommitment, }; -use crate::ln::channel::{CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI}; +use crate::ln::channel::{ + get_v2_channel_reserve_satoshis, CommitmentStats, ANCHOR_OUTPUT_VALUE_SATOSHI, +}; use crate::prelude::*; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; @@ -315,6 +317,105 @@ fn get_next_commitment_stats( }) } +/// Determines the maximum value that the holder can splice out of the channel, accounting +/// for the updated reserves after said splice. This maximum also makes sure the local commitment +/// retains at least one output after the splice, which is particularly relevant for +/// zero-reserve channels. +// +// The equation to determine `percent_max_splice_out_sat` is: +// 1) floor((c - s) / 100) == h - s - p +// We want the maximum value of s that will satisfy this equation, therefore, we solve: +// 2) (c - s) / 100 < h - s - p + 1 +// where c: `channel_value_satoshis` +// s: `percent_max_splice_out_sat` +// h: `local_balance_before_fee_sat` +// p: `post_splice_min_balance_sat` +// This results in: +// 3) s < (100h + 100 - 100p - c) / 99 +fn get_next_splice_out_maximum_sat( + is_outbound_from_holder: bool, channel_value_satoshis: u64, local_balance_before_fee_msat: u64, + remote_balance_before_fee_msat: u64, spiked_feerate: u32, + spiked_feerate_nondust_htlc_count: usize, post_splice_min_balance_sat: u64, + channel_constraints: &ChannelConstraints, channel_type: &ChannelTypeFeatures, +) -> u64 { + let local_balance_before_fee_sat = local_balance_before_fee_msat / 1000; + let mut next_splice_out_maximum_sat = + if channel_constraints.counterparty_selected_channel_reserve_satoshis != 0 { + let dividend = local_balance_before_fee_sat + .saturating_mul(100) + .saturating_add(100) + .saturating_sub(post_splice_min_balance_sat.saturating_mul(100)) + .saturating_sub(channel_value_satoshis); + let percent_max_splice_out_sat = dividend.saturating_sub(1) / 99; + let dust_limit_max_splice_out_sat = local_balance_before_fee_sat + .saturating_sub(channel_constraints.holder_dust_limit_satoshis) + .saturating_sub(post_splice_min_balance_sat); + // Take whichever constraint you hit first as you increase `splice_out_max` + let max_splice_out_sat = + cmp::min(percent_max_splice_out_sat, dust_limit_max_splice_out_sat); + #[cfg(debug_assertions)] + if max_splice_out_sat == 0 { + let current_balance_sat = + local_balance_before_fee_sat.saturating_sub(post_splice_min_balance_sat); + let v2_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis, + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder cannot splice out anything, they must be at or + // below the v2 reserve + debug_assert!(current_balance_sat <= v2_reserve_sat); + } else { + let post_splice_reserve_sat = get_v2_channel_reserve_satoshis( + channel_value_satoshis.saturating_sub(max_splice_out_sat), + channel_constraints.holder_dust_limit_satoshis, + false, + ); + // If the holder can splice out some maximum, splicing out that + // maximum lands them at exactly the new v2 reserve + the + // `post_splice_min_balance_sat` + debug_assert_eq!( + local_balance_before_fee_sat.saturating_sub(max_splice_out_sat), + post_splice_reserve_sat.saturating_add(post_splice_min_balance_sat) + ); + } + max_splice_out_sat + } else { + // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_min_balance_sat` + local_balance_before_fee_sat.saturating_sub(post_splice_min_balance_sat) + }; + + // We only bother to check the local commitment here, the counterparty will check its own commitment. + // + // If the current `next_splice_out_maximum_sat` would produce a local commitment with no + // outputs, bump this maximum such that, after the splice, the holder's balance covers at + // least `dust_limit_satoshis` and, if they are the funder, `current_spiked_tx_fee_sat`. + // We don't include an additional non-dust inbound HTLC in the `current_spiked_tx_fee_sat`, + // because we don't mind if the holder dips below their dust limit to cover the fee for that + // inbound non-dust HTLC. + if !has_output( + is_outbound_from_holder, + local_balance_before_fee_msat.saturating_sub(next_splice_out_maximum_sat * 1000), + remote_balance_before_fee_msat, + spiked_feerate, + spiked_feerate_nondust_htlc_count, + channel_constraints.holder_dust_limit_satoshis, + channel_type, + ) { + let dust_limit_satoshis = channel_constraints.holder_dust_limit_satoshis; + let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type); + let min_balance_sat = if is_outbound_from_holder { + dust_limit_satoshis.saturating_add(current_spiked_tx_fee_sat) + } else { + dust_limit_satoshis + }; + next_splice_out_maximum_sat = + (local_balance_before_fee_msat / 1000).saturating_sub(min_balance_sat); + } + + next_splice_out_maximum_sat +} + 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, @@ -410,6 +511,20 @@ fn get_available_balances( total_anchors_sat.saturating_mul(1000), ); + let next_splice_out_maximum_sat = get_next_splice_out_maximum_sat( + is_outbound_from_holder, + channel_value_satoshis, + local_balance_before_fee_msat, + remote_balance_before_fee_msat, + spiked_feerate, + // The number of non-dust HTLCs on the local commitment at the spiked feerate + local_nondust_htlc_count, + // The post-splice minimum balance of the holder + if is_outbound_from_holder { local_min_commit_tx_fee_sat } else { 0 }, + &channel_constraints, + channel_type, + ); + let outbound_capacity_msat = local_balance_before_fee_msat .saturating_sub(channel_constraints.counterparty_selected_channel_reserve_satoshis * 1000); @@ -582,6 +697,7 @@ fn get_available_balances( outbound_capacity_msat, next_outbound_htlc_limit_msat: available_capacity_msat, next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat, } } From 2b380527c53cdc749dd4013b62da03f1d217804e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 00:22:21 +0000 Subject: [PATCH 3/5] Use `next_splice_out_maximum_sat` to validate `funding_contributed` This is equivalent to the previous commit, see the debug assertions added in the previous commit. We now also get to communicate the exact maximum back to the user, instead of some "balance is lower than our reserve" message, which is hard to react to. --- lightning/src/ln/channel.rs | 8 +++++++- lightning/src/ln/splicing_tests.rs | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7494d7e5159..76acfc5cc22 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12633,7 +12633,13 @@ where // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. let our_funding_contribution = contribution.net_value(); - self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) + let next_splice_out_maximum = self.get_next_splice_out_maximum(&self.funding)?; + if our_funding_contribution.is_negative() && our_funding_contribution.unsigned_abs() > next_splice_out_maximum { + let unsigned_contribution = our_funding_contribution.unsigned_abs(); + Err(format!("Our splice-out value of {unsigned_contribution} is greater than the maximum {next_splice_out_maximum}")) + } else { + Ok(()) + } }) { log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adccd17627..6fab9c94f0c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4155,8 +4155,8 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { format!("Channel {} cannot accept funding contribution", channel_id); assert_eq!(error, APIError::APIMisuseError { err: cannot_accept_contribution }); let cannot_be_funded = format!( - "Channel {} cannot be funded: Channel {} cannot be spliced out; our post-splice channel balance {} is smaller than their selected v2 reserve {}", - channel_id, channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + "Channel {} cannot be funded: Our splice-out value of {} is greater than the maximum {}", + channel_id, splice_out_incl_fees + Amount::ONE_SAT, splice_out_incl_fees, ); initiator.logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); From 6b14dabd9fa49e7bddd2b6d462867ed129c40bce Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 06:33:02 +0000 Subject: [PATCH 4/5] Add `test_0reserve_splice` --- lightning/src/ln/htlc_reserve_unit_tests.rs | 2 +- lightning/src/ln/splicing_tests.rs | 202 +++++++++++++++++++- 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/htlc_reserve_unit_tests.rs b/lightning/src/ln/htlc_reserve_unit_tests.rs index aaf81b87be7..45d3cf5950f 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -2581,7 +2581,7 @@ fn test_0reserve_no_outputs() { do_test_0reserve_no_outputs_p2a_anchor(); } -fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( +pub(crate) fn setup_0reserve_no_outputs_channels<'a, 'b, 'c, 'd>( nodes: &'a Vec>, channel_value_sat: u64, dust_limit_satoshis: u64, ) -> (ChannelId, Transaction) { let node_a_id = nodes[0].node.get_our_node_id(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 6fab9c94f0c..a654cfa6cd6 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -16,7 +16,8 @@ use crate::chain::ChannelMonitorUpdateStatus; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::{ - CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, + ANCHOR_OUTPUT_VALUE_SATOSHI, CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY, + FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, }; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; @@ -6647,3 +6648,202 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { other => panic!("Expected SpliceFailed, got {:?}", other), } } + +#[test] +fn test_0reserve_splice() { + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, config.clone()); + let b = do_test_0reserve_splice_holder_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_holder_validation(false, config.clone()); + let b = do_test_0reserve_splice_holder_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + let mut config = test_default_channel_config(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, config.clone()); + let b = do_test_0reserve_splice_counterparty_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::only_static_remote_key()); + + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = false; + let a = do_test_0reserve_splice_counterparty_validation(false, config.clone()); + let b = do_test_0reserve_splice_counterparty_validation(true, config.clone()); + assert_eq!(a, b); + assert_eq!(a, ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies()); + + // TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A + // output. We will be able to withdraw up to the dust limit of the funding script, which + // is checked in interactivetx. Still need to double check whether that's what we actually + // want. +} + +#[cfg(test)] +fn do_test_0reserve_splice_holder_validation( + splice_passes: bool, config: UserConfig, +) -> ChannelTypeFeatures { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_1 = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + 253 * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + 253 + } else { + panic!("Unexpected channel type"); + }; + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let estimated_fees = 183; + let splice_out_max_value = Amount::from_sat( + channel_value_sat - commit_tx_fee_sat - anchors_sat - estimated_fees - dust_limit_satoshis, + ); + let outputs = vec![TxOut { + value: splice_out_max_value + if splice_passes { Amount::ZERO } else { Amount::ONE_SAT }, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + + if splice_passes { + let contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + } else { + assert!(initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).is_err()); + let splice_out_value = + splice_out_max_value + Amount::from_sat(estimated_fees) + Amount::ONE_SAT; + let splice_out_max_value = splice_out_max_value + Amount::from_sat(estimated_fees); + let cannot_be_funded = format!( + "Channel {channel_id} cannot be funded: Our \ + splice-out value of {splice_out_value} is greater than the maximum \ + {splice_out_max_value}" + ); + nodes[0].logger.assert_log("lightning::ln::channel", cannot_be_funded, 1); + } + + channel_type +} + +#[cfg(test)] +fn do_test_0reserve_splice_counterparty_validation( + splice_passes: bool, config: UserConfig, +) -> ChannelTypeFeatures { + use crate::ln::htlc_reserve_unit_tests::setup_0reserve_no_outputs_channels; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let _node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_1 = nodes[1].node.get_our_node_id(); + + let channel_value_sat = 100_000; + // Some dust limit, does not matter + let dust_limit_satoshis = 546; + + let (channel_id, _tx) = + setup_0reserve_no_outputs_channels(&nodes, channel_value_sat, dust_limit_satoshis); + let details = &nodes[0].node.list_channels()[0]; + let channel_type = details.channel_type.clone().unwrap(); + + let feerate = if channel_type == ChannelTypeFeatures::only_static_remote_key() { + 253 * FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 + } else if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + 253 + } else { + panic!("Unexpected channel type"); + }; + let commit_tx_fee_sat = chan_utils::commit_tx_fee_sat(feerate, 0, &channel_type); + let anchors_sat = + if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + + let splice_out_value_incl_fees = + Amount::from_sat(channel_value_sat - commit_tx_fee_sat - anchors_sat - dust_limit_satoshis); + + let funding_contribution_sat = + -(splice_out_value_incl_fees.to_sat() as i64) - if splice_passes { 0 } else { 1 }; + let outputs = vec![TxOut { + // Splice out some dummy amount to get past the initiator's validation, + // we'll modify the message in-flight. + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]; + let _contribution = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap(); + + let initiator = &nodes[0]; + let acceptor = &nodes[1]; + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let mut splice_init = + get_event_msg!(initiator, MessageSendEvent::SendSpliceInit, node_id_acceptor); + // Make the modification here + splice_init.funding_contribution_satoshis = funding_contribution_sat; + + if splice_passes { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let _splice_ack = + get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); + } else { + acceptor.node.handle_splice_init(node_id_initiator, &splice_init); + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { + assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); + } else { + panic!("Expected MessageSendEvent::HandleError"); + } + let cannot_splice_out = format!( + "Got non-closing error: Channel {channel_id} cannot \ + be spliced; Balance exhausted on local commitment" + ); + acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + } + + channel_type +} From 3bd7a76c0f253ba974765d1465b3b335032a5b27 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 9 Apr 2026 07:06:07 +0000 Subject: [PATCH 5/5] Add `ChannelDetails::next_splice_out_maximum_sat` --- fuzz/src/router.rs | 1 + lightning/src/ln/channel_state.rs | 5 +++++ lightning/src/routing/router.rs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2e5b15fc7f4..5bf9650ebad 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -248,6 +248,7 @@ pub fn do_test(data: &[u8], out: Out) { outbound_capacity_msat: capacity.saturating_mul(1000), next_outbound_htlc_limit_msat: capacity.saturating_mul(1000), next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: capacity, inbound_htlc_minimum_msat: None, inbound_htlc_maximum_msat: None, config: None, diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 720f8e7b789..9fd0df4a1bf 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -399,6 +399,8 @@ pub struct ChannelDetails { /// an upper-bound. This is intended for use when routing, allowing us to ensure we pick a /// route which is valid. pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value of the next splice out from our channel balance. + pub next_splice_out_maximum_sat: u64, /// The available inbound capacity for the remote peer to send HTLCs to us. This does not /// include any pending HTLCs which are not yet fully resolved (and, thus, whose balance is not /// available for inclusion in new inbound HTLCs). @@ -583,6 +585,7 @@ impl ChannelDetails { outbound_capacity_msat: balance.outbound_capacity_msat, next_outbound_htlc_limit_msat: balance.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: balance.next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat: balance.next_splice_out_maximum_sat, user_channel_id: context.get_user_id(), confirmations_required: channel.minimum_depth(), confirmations: Some(funding.get_funding_tx_confirmations(best_block_height)), @@ -622,6 +625,7 @@ impl_writeable_tlv_based!(ChannelDetails, { (20, inbound_capacity_msat, required), (21, next_outbound_htlc_minimum_msat, (default_value, 0)), (22, confirmations_required, option), + (23, next_splice_out_maximum_sat, (default_value, u64::from(outbound_capacity_msat.0.unwrap()) / 1000)), (24, force_close_spend_delay, option), (26, is_outbound, required), (28, is_channel_ready, required), @@ -726,6 +730,7 @@ mod tests { outbound_capacity_msat: 24_300, next_outbound_htlc_limit_msat: 20_000, next_outbound_htlc_minimum_msat: 132, + next_splice_out_maximum_sat: 20, inbound_capacity_msat: 42, unspendable_punishment_reserve: Some(8273), confirmations_required: Some(5), diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 0c0d14b43fd..e2ec6228d8c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4150,6 +4150,7 @@ mod tests { outbound_capacity_msat, next_outbound_htlc_limit_msat: outbound_capacity_msat, next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: outbound_capacity_msat / 1000, inbound_capacity_msat: 42, unspendable_punishment_reserve: None, confirmations_required: None, @@ -9649,6 +9650,7 @@ pub(crate) mod bench_utils { outbound_capacity_msat: 10_000_000_000, next_outbound_htlc_minimum_msat: 0, next_outbound_htlc_limit_msat: 10_000_000_000, + next_splice_out_maximum_sat: 10_000_000, inbound_capacity_msat: 0, unspendable_punishment_reserve: None, confirmations_required: None,