What changed, and why it matters
This commit adds support for 'splice-out', a way for a Lightning channel partner to remove funds from an existing channel rather than only adding funds. Previously, the code rejected negative contribution values outright. The change removes that blanket rejection and adds checks to ensure the counterparty actually has enough balance in the channel to cover the requested withdrawal. It also centralizes validation of the counterparty's splice contribution in a new helper function used in both incoming and outgoing splice paths.
Review the new validate_splice_contribution logic for off-by-one and unit-conversion issues, especially the conversion from msat to sat for the counterparty's channel balance. Ensure the pending TODO for channel reserve enforcement is completed before splicing is enabled in production, as missing reserve checks could allow a counterparty to push a channel below its reserve requirement. Consider adding tests covering negative contribution edge cases, overflow scenarios, and reserve violations.
Security signals we found
Previously rejected negative splice contributions are now accepted, changing the attack surface
New balance-solvency check attempts to prevent splicing out more than the counterparty owns
Missing channel reserve check is explicitly acknowledged in a TODO
Validation logic is refactored into a shared helper to reduce duplicated checks
Both splice-in and splice-out paths now share the same MAX_MONEY bounds checks
Evidence from the diff
The patch modifies lightning/src/ln/channel.rs to enable accepting counterparty-initiated splice-out operations. It removes the guard that rejected negative funding_contribution_satoshis values in splice_init handling, introduces validate_splice_contribution() to enforce bounds against MAX_MONEY and to verify the counterparty’s post-splice channel balance remains non-negative, and calls this helper from both validate_splice_init() and splice_init(). A TODO notes that channel reserve checking is still pending. The change is gated behind the splicing feature flag.
Changed components
lightning/src/ln/channel.rsSplice negotiation / splice_init handlingCounterparty funding contribution validationInspect captured patch +53 / −21
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 7c1b11d..a04b603 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -10826,35 +10826,31 @@ where
)));
}
+ debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO);
+
// TODO(splicing): Move this check once user-provided contributions are supported for
// counterparty-initiated splices.
if our_funding_contribution > SignedAmount::MAX_MONEY {
return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} cannot be spliced; our contribution exceeds total bitcoin supply: {}",
+ "Channel {} cannot be spliced in; our {} contribution exceeds the total bitcoin supply",
self.context.channel_id(),
our_funding_contribution,
)));
}
- let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
- if their_funding_contribution > SignedAmount::MAX_MONEY {
+ if our_funding_contribution < -SignedAmount::MAX_MONEY {
return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}",
+ "Channel {} cannot be spliced out; our {} contribution exhausts the total bitcoin supply",
self.context.channel_id(),
- their_funding_contribution,
- )));
- }
-
- debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO);
- if their_funding_contribution < SignedAmount::ZERO {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Splice-out not supported, only splice in, contribution is {} ({} + {})",
- their_funding_contribution + our_funding_contribution,
- their_funding_contribution,
our_funding_contribution,
)));
}
+ let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
+ self.validate_splice_contribution(their_funding_contribution)?;
+
+ // TODO(splicing): Check that channel balance does not go below the channel reserve
+
let splice_funding = FundingScope::for_splice(
&self.funding,
&self.context,
@@ -10874,6 +10870,45 @@ where
Ok(splice_funding)
}
+ #[cfg(splicing)]
+ fn validate_splice_contribution(
+ &self, their_funding_contribution: SignedAmount,
+ ) -> Result<(), ChannelError> {
+ if their_funding_contribution > SignedAmount::MAX_MONEY {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} cannot be spliced in; their {} contribution exceeds the total bitcoin supply",
+ self.context.channel_id(),
+ their_funding_contribution,
+ )));
+ }
+
+ if their_funding_contribution < -SignedAmount::MAX_MONEY {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} cannot be spliced out; their {} contribution exhausts the total bitcoin supply",
+ self.context.channel_id(),
+ their_funding_contribution,
+ )));
+ }
+
+ let their_channel_balance = Amount::from_sat(self.funding.get_value_satoshis())
+ - Amount::from_sat(self.funding.get_value_to_self_msat() / 1000);
+ let post_channel_balance = AddSigned::checked_add_signed(
+ their_channel_balance.to_sat(),
+ their_funding_contribution.to_sat(),
+ );
+
+ if post_channel_balance.is_none() {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} cannot be spliced out; their {} contribution exhausts their channel balance: {}",
+ self.context.channel_id(),
+ their_funding_contribution,
+ their_channel_balance,
+ )));
+ }
+
+ Ok(())
+ }
+
/// See also [`validate_splice_init`]
#[cfg(splicing)]
pub(crate) fn splice_init<ES: Deref, L: Deref>(
@@ -10987,13 +11022,7 @@ where
debug_assert!(our_funding_contribution <= SignedAmount::MAX_MONEY);
let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
- if their_funding_contribution > SignedAmount::MAX_MONEY {
- return Err(ChannelError::Warn(format!(
- "Channel {} cannot be spliced; their contribution exceeds total bitcoin supply: {}",
- self.context.channel_id(),
- their_funding_contribution,
- )));
- }
+ self.validate_splice_contribution(their_funding_contribution)?;
let splice_funding = FundingScope::for_splice(
&self.funding,
@@ -11031,6 +11060,9 @@ where
let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message();
debug_assert!(self.interactive_tx_signing_session.is_none());
+
+ let pending_splice =
+ self.pending_splice.as_mut().expect("pending_splice should still be set");
pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction(
splice_funding,
interactive_tx_constructor,
Why this scored 42/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.