Add `AvailableBalances::next_splice_out_maximum_sat`
What changed, and why it matters
This commit fixes how rust-lightning calculates the maximum amount a user can splice out of a Lightning channel. Previously the calculation ignored channel reserve rules and a requirement to keep at least one output, which could have allowed a user to request an invalid splice-out that the protocol or counterparty would reject. The new field properly accounts for both constraints, making splice-out operations safer and more likely to succeed.
Review the new `get_next_splice_out_maximum_sat` arithmetic and debug assertions for off-by-one errors, especially around the percentage reserve formula and the zero-reserve/minimum-output branches. Ensure test coverage includes zero-reserve channels and channels near the reserve boundary.
Security signals we found
Incorrect maximum splice-out calculation could lead to protocol-rejected or unbroadcastable splice transactions
Zero-reserve channel edge case could produce a commitment with no outputs
Reserve requirement was not recomputed after reducing channel value
New calculation includes post-splice reserve and at-least-one-output constraints
Evidence from the diff
The change introduces AvailableBalances::next_splice_out_maximum_sat and a helper get_next_splice_out_maximum_sat in tx_builder.rs. The old code estimated the spliceable balance by subtracting HTLCs, anchors, and commitment fee from the holder balance. The new code additionally enforces the v2 channel reserve (which changes after the splice because the channel value drops) and ensures the post-splice local commitment still has at least one output—important for zero-reserve channels. The value is then propagated through FundingContribution fee-rate adjustment logic, replacing the previously used raw holder balance floor. Debug assertions verify that splicing exactly the maximum passes validation and splicing one satoshi more fails.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channel_state.rslightning/src/ln/channelmanager.rslightning/src/ln/funding.rslightning/src/sign/tx_builder.rsInspect captured patch +227 / −51
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 365e82a..e850d83 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -131,6 +131,8 @@ pub struct AvailableBalances {
///
/// See [`ChannelConfig::max_dust_htlc_exposure`] for more information on the dust calculation and to configure a limit.
pub dust_exposure_msat: u64,
+ /// The maximum value of the next splice-out
+ pub next_splice_out_maximum_sat: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -6765,7 +6767,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 {
@@ -12400,9 +12402,8 @@ where
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
- let holder_balance = self
- .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
- .map(|(h, _)| h)
+ let spliceable_balance = self
+ .get_next_splice_out_maximum(&self.funding)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
@@ -12410,7 +12411,7 @@ where
e
),
})?;
- Some(PriorContribution::new(prior.clone(), holder_balance))
+ Some(PriorContribution::new(prior.clone(), spliceable_balance))
} else {
None
}
@@ -12506,16 +12507,13 @@ where
return contribution;
}
- let holder_balance = match self
- .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
- .map(|(holder, _)| holder)
- {
+ let spliceable_balance = match self.get_next_splice_out_maximum(&self.funding) {
Ok(balance) => balance,
Err(_) => return contribution,
};
if let Err(e) =
- contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+ contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, spliceable_balance)
{
log_info!(
logger,
@@ -12536,7 +12534,7 @@ where
min_rbf_feerate,
);
contribution
- .for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+ .for_initiator_at_feerate(min_rbf_feerate, spliceable_balance)
.expect("feerate compatibility already checked")
}
@@ -12881,9 +12879,8 @@ where
fn resolve_queued_contribution<L: Logger>(
&self, feerate: FeeRate, logger: &L,
) -> Result<(Option<SignedAmount>, Option<Amount>), ChannelError> {
- let holder_balance = self
- .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
- .map(|(holder, _)| holder)
+ let spliceable_balance = self
+ .get_next_splice_out_maximum(&self.funding)
.map_err(|e| {
log_info!(
logger,
@@ -12895,9 +12892,9 @@ where
})
.ok();
- let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) {
+ let net_value = match spliceable_balance.and_then(|_| self.queued_funding_contribution()) {
Some(c) => {
- match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) {
+ match c.net_value_for_acceptor_at_feerate(feerate, spliceable_balance.unwrap()) {
Ok(net_value) => Some(net_value),
Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => {
return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh));
@@ -12917,7 +12914,7 @@ where
None => None,
};
- Ok((net_value, holder_balance))
+ Ok((net_value, spliceable_balance))
}
pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
@@ -13348,6 +13345,9 @@ where
/// of the channel due to the v2 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_holder_counterparty_balances_floor_incl_fee(
&self, funding: &FundingScope,
) -> Result<(Amount, Amount), String> {
@@ -13418,6 +13418,55 @@ where
Ok((holder_balance_floor, counterparty_balance_floor))
}
+ /// 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<Amount, String> {
+ let include_counterparty_unknown_htlcs = true;
+ // We are not interested in dust exposure
+ let dust_exposure_limiting_feerate = None;
+
+ // 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,
+ 0,
+ self.context.feerate_per_kw,
+ dust_exposure_limiting_feerate,
+ )
+ .map_err(|()| "Balance exhausted on remote commitment")?;
+
+ let next_splice_out_maximum_sat =
+ remote_stats.available_balances.next_splice_out_maximum_sat;
+
+ #[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,
+ funding.counterparty_funding_pubkey().clone(),
+ funding.get_holder_pubkeys().clone(),
+ )
+ .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,
+ funding.counterparty_funding_pubkey().clone(),
+ funding.get_holder_pubkeys().clone(),
+ )
+ .unwrap_err();
+ }
+
+ Ok(Amount::from_sat(next_splice_out_maximum_sat))
+ }
+
pub fn splice_locked<NS: NodeSigner, L: Logger>(
&mut self, msg: &msgs::SpliceLocked, node_signer: &NS, chain_hash: ChainHash,
user_config: &UserConfig, block_height: u32, logger: &L,
@@ -13644,6 +13693,9 @@ where
.next_outbound_htlc_minimum_msat
.max(e.next_outbound_htlc_minimum_msat),
dust_exposure_msat: acc.dust_exposure_msat.max(e.dust_exposure_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 d59e30f..39e5cae 100644
--- a/lightning/src/ln/channel_state.rs
+++ b/lightning/src/ln/channel_state.rs
@@ -549,6 +549,7 @@ impl ChannelDetails {
next_outbound_htlc_limit_msat: 0,
next_outbound_htlc_minimum_msat: u64::MAX,
dust_exposure_msat: 0,
+ 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 49d629d..43a90a1 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -8122,6 +8122,7 @@ impl<
next_outbound_htlc_limit_msat: 0,
next_outbound_htlc_minimum_msat: u64::MAX,
dust_exposure_msat: 0,
+ next_splice_out_maximum_sat: 0,
}
});
let is_in_range = (balances.next_outbound_htlc_minimum_msat
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 20366fe..386aa3d 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -192,7 +192,7 @@ impl core::fmt::Display for FundingContributionError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PriorContribution {
contribution: FundingContribution,
- /// The holder's balance, used for feerate adjustment.
+ /// The holder's spliceable balance, used for feerate adjustment.
///
/// This value is captured at [`ChannelManager::splice_channel`] time and may become stale
/// if balances change before the contribution is used. Staleness is acceptable here because
@@ -203,12 +203,12 @@ pub(super) struct PriorContribution {
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
- holder_balance: Amount,
+ spliceable_balance: Amount,
}
impl PriorContribution {
- pub(super) fn new(contribution: FundingContribution, holder_balance: Amount) -> Self {
- Self { contribution, holder_balance }
+ pub(super) fn new(contribution: FundingContribution, spliceable_balance: Amount) -> Self {
+ Self { contribution, spliceable_balance }
}
}
@@ -632,14 +632,14 @@ impl FundingContribution {
/// `target_feerate`. If dropping change leaves surplus value, that surplus remains in the
/// channel contribution.
///
- /// For input-less contributions, `holder_balance` must be provided to cover the outputs and
+ /// For input-less contributions, `spliceable_balance` must be provided to cover the outputs and
/// fees from the channel balance.
///
/// Returns `None` if the request would require new wallet inputs or cannot accommodate the
/// requested feerate.
fn amend_without_coin_selection(
self, inputs: FundingInputs, outputs: &[TxOut], target_feerate: FeeRate,
- max_feerate: FeeRate, holder_balance: Amount,
+ max_feerate: FeeRate, spliceable_balance: Amount,
) -> Option<Self> {
// NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until
// `compute_feerate_adjustment`.
@@ -717,7 +717,7 @@ impl FundingContribution {
let new_contribution_at_current_feerate =
adjust_for_inputs_and_outputs(self, inputs, outputs)?;
let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate
- .at_feerate(target_feerate, holder_balance, true)
+ .at_feerate(target_feerate, spliceable_balance, true)
.ok()?;
new_contribution_at_target_feerate.max_feerate = max_feerate;
@@ -771,7 +771,7 @@ impl FundingContribution {
///
/// Returns `Err` if the contribution cannot accommodate the target feerate.
fn compute_feerate_adjustment(
- &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
+ &self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<(Amount, Option<Amount>), FeeRateAdjustmentError> {
if target_feerate < self.feerate {
return Err(FeeRateAdjustmentError::FeeRateTooLow {
@@ -864,10 +864,12 @@ impl FundingContribution {
let total_cost = target_fee
.checked_add(value_removed)
.ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?;
- if total_cost > holder_balance {
+ if total_cost > spliceable_balance {
return Err(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "channel balance - withdrawal outputs",
- available: holder_balance.checked_sub(value_removed).unwrap_or(Amount::ZERO),
+ available: spliceable_balance
+ .checked_sub(value_removed)
+ .unwrap_or(Amount::ZERO),
required: target_fee,
});
}
@@ -879,10 +881,10 @@ impl FundingContribution {
/// estimate, and feerate. Returns the adjusted contribution, or an error if the feerate
/// can't be accommodated.
fn at_feerate(
- mut self, feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
+ mut self, feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<Self, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
- self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?;
+ self.compute_feerate_adjustment(feerate, spliceable_balance, is_initiator)?;
match new_change {
Some(value) => self.change_output.as_mut().unwrap().value = value,
None => self.change_output = None,
@@ -899,9 +901,9 @@ impl FundingContribution {
/// This adjusts the change output so the acceptor pays their target fee at the target
/// feerate.
pub(super) fn for_acceptor_at_feerate(
- self, feerate: FeeRate, holder_balance: Amount,
+ self, feerate: FeeRate, spliceable_balance: Amount,
) -> Result<Self, FeeRateAdjustmentError> {
- self.at_feerate(feerate, holder_balance, false)
+ self.at_feerate(feerate, spliceable_balance, false)
}
/// Adjusts the contribution's change output for the minimum RBF feerate.
@@ -910,9 +912,9 @@ impl FundingContribution {
/// below the minimum RBF feerate, this adjusts the change output so the initiator pays fees
/// at the minimum RBF feerate.
pub(super) fn for_initiator_at_feerate(
- self, feerate: FeeRate, holder_balance: Amount,
+ self, feerate: FeeRate, spliceable_balance: Amount,
) -> Result<Self, FeeRateAdjustmentError> {
- self.at_feerate(feerate, holder_balance, true)
+ self.at_feerate(feerate, spliceable_balance, true)
}
/// Returns the net value at the given target feerate without mutating `self`.
@@ -921,10 +923,10 @@ impl FundingContribution {
/// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value
/// accounting for the target feerate).
fn net_value_at_feerate(
- &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
+ &self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
- self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?;
+ self.compute_feerate_adjustment(target_feerate, spliceable_balance, is_initiator)?;
let prev_fee = self
.estimated_fee
@@ -952,17 +954,17 @@ impl FundingContribution {
/// Returns the net value at the given target feerate without mutating `self`,
/// assuming acceptor fee responsibility.
pub(super) fn net_value_for_acceptor_at_feerate(
- &self, target_feerate: FeeRate, holder_balance: Amount,
+ &self, target_feerate: FeeRate, spliceable_balance: Amount,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
- self.net_value_at_feerate(target_feerate, holder_balance, false)
+ self.net_value_at_feerate(target_feerate, spliceable_balance, false)
}
/// Returns the net value at the given target feerate without mutating `self`,
/// assuming initiator fee responsibility.
pub(super) fn net_value_for_initiator_at_feerate(
- &self, target_feerate: FeeRate, holder_balance: Amount,
+ &self, target_feerate: FeeRate, spliceable_balance: Amount,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
- self.net_value_at_feerate(target_feerate, holder_balance, true)
+ self.net_value_at_feerate(target_feerate, spliceable_balance, true)
}
/// The net value contributed to a channel by the splice.
@@ -1059,13 +1061,13 @@ impl<State> FundingBuilderInner<State> {
fn build_from_prior_contribution(
&mut self, contribution: PriorContribution,
) -> Result<FundingContribution, FundingContributionError> {
- let PriorContribution { contribution, holder_balance } = contribution;
+ let PriorContribution { contribution, spliceable_balance } = contribution;
if self.request_matches_prior(&contribution) {
// Same request, but the feerate may have changed. Adjust the prior contribution
// to the new feerate if possible.
return contribution
- .for_initiator_at_feerate(self.feerate, holder_balance)
+ .for_initiator_at_feerate(self.feerate, spliceable_balance)
.map(|mut adjusted| {
adjusted.max_feerate = self.max_feerate;
adjusted
@@ -1084,7 +1086,7 @@ impl<State> FundingBuilderInner<State> {
&self.outputs,
self.feerate,
self.max_feerate,
- holder_balance,
+ spliceable_balance,
)
.ok_or_else(|| FundingContributionError::MissingCoinSelectionSource);
}
@@ -2181,8 +2183,8 @@ mod tests {
};
// Balance of 55,000 sats can't cover outputs (50,000) + target_fee at 50k sat/kwu.
- let holder_balance = Amount::from_sat(55_000);
- let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance);
+ let spliceable_balance = Amount::from_sat(55_000);
+ let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
@@ -2601,8 +2603,8 @@ mod tests {
};
// Balance of 40,000 sats is less than outputs (50,000) + target_fee.
- let holder_balance = Amount::from_sat(40_000);
- let result = contribution.for_acceptor_at_feerate(target_feerate, holder_balance);
+ let spliceable_balance = Amount::from_sat(40_000);
+ let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
@@ -2627,9 +2629,9 @@ mod tests {
};
// Balance of 100,000 sats is more than outputs (50,000) + target_fee.
- let holder_balance = Amount::from_sat(100_000);
+ let spliceable_balance = Amount::from_sat(100_000);
let contribution =
- contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap();
+ contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance).unwrap();
let expected_target_fee =
estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate);
assert_eq!(contribution.estimated_fee, expected_target_fee);
@@ -2657,8 +2659,9 @@ mod tests {
};
// Balance of 40,000 sats is less than outputs (50,000) + target_fee.
- let holder_balance = Amount::from_sat(40_000);
- let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance);
+ let spliceable_balance = Amount::from_sat(40_000);
+ let result =
+ contribution.net_value_for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index 400d2cb..986cb9e 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,108 @@ 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 `max_splice_percentage_constraint_sat` is:
+// 1) floor((c - s) / 100) == h - s - d
+// We want the maximum value of s that will satisfy equation 1, therefore, we solve:
+// 2) (c - s) / 100 < h - s - d + 1
+// where c: `channel_value_satoshis`
+// s: `max_splice_percentage_constraint_sat`
+// h: `local_balance_before_fee_sat`
+// d: `post_splice_delta_above_reserve_sat`
+// This results in:
+// 3) s < (100h + 100 - 100d - 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_delta_above_reserve_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_sat = local_balance_before_fee_sat
+ .saturating_mul(100)
+ .saturating_add(100)
+ .saturating_sub(post_splice_delta_above_reserve_sat.saturating_mul(100))
+ .saturating_sub(channel_value_satoshis);
+ // Calculate the greatest integer that is strictly less than the RHS of inequality 3 above
+ let max_splice_percentage_constraint_sat = dividend_sat.saturating_sub(1) / 99;
+ let max_splice_dust_limit_constraint_sat = local_balance_before_fee_sat
+ .saturating_sub(channel_constraints.holder_dust_limit_satoshis)
+ .saturating_sub(post_splice_delta_above_reserve_sat);
+ // Both constraints must be satisfied, so take the minimum of the two maximums
+ let max_splice_out_sat =
+ cmp::min(max_splice_percentage_constraint_sat, max_splice_dust_limit_constraint_sat);
+ #[cfg(debug_assertions)]
+ if max_splice_out_sat == 0 {
+ let current_balance_sat =
+ local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_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_delta_above_reserve_sat`
+ debug_assert_eq!(
+ local_balance_before_fee_sat.saturating_sub(max_splice_out_sat),
+ post_splice_reserve_sat.saturating_add(post_splice_delta_above_reserve_sat)
+ );
+ }
+ max_splice_out_sat
+ } else {
+ // In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat`
+ local_balance_before_fee_sat.saturating_sub(post_splice_delta_above_reserve_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,
@@ -411,6 +515,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);
@@ -585,6 +703,7 @@ fn get_available_balances(
next_outbound_htlc_limit_msat: available_capacity_msat,
next_outbound_htlc_minimum_msat,
dust_exposure_msat,
+ next_splice_out_maximum_sat,
}
}
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.