Prefer tx_abort over disconnection for splice negotiation errors
What changed, and why it matters
This commit changes how the Lightning Dev Kit node responds to mistakes or disagreements during splicing (a way to resize a payment channel). Instead of immediately disconnecting from the peer in many cases, it now sends a 'tx_abort' message. This keeps the connection alive, avoids endless reconnect-and-fail loops, and makes the process more robust. It is a protocol-robustness improvement rather than a fix for a critical vulnerability.
Review as a normal robustness improvement. No urgent security patch is indicated by the diff alone. Operators should upgrade in due course to benefit from more stable splice negotiation behavior.
Security signals we found
Change of error response from peer disconnection to tx_abort for splice negotiation failures
New AbortReason::InvalidContribution variant carrying a descriptive string
Preservation of WarnAndDisconnect for spec-mandated conditions (non-live channel, non-quiescent state, zero contribution)
Test updates expect SendTxAbort events instead of HandleError/DisconnectPeerWithWarning
Evidence from the diff
The patch converts many splice/RBF negotiation failures from ChannelError::WarnAndDisconnect to ChannelError::Abort with a new AbortReason::InvalidContribution. The error handling path in ChannelManager already turns ChannelError::Abort into a tx_abort message, so the practical effect is that invalid splice proposals now terminate the quiescence negotiation with tx_abort instead of dropping the peer connection. A few spec-mandated cases (non-live channel, non-quiescent state, zero counterparty contribution) remain WarnAndDisconnect. The change also removes some redundant error string formatting and adds a Clone derive to AbortReason so it can be used in the tx_abort conversion.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +99 / −103
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 26325c0..f24008a 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -13143,11 +13143,13 @@ where
/// Checks during handling splice_init
pub fn validate_splice_init(&self, msg: &msgs::SpliceInit) -> Result<(), ChannelError> {
- if self.holder_commitment_point.current_point().is_none() {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} commitment point needs to be advanced once before spliced",
- self.context.channel_id(),
- )));
+ // - If it has received shutdown:
+ // MUST send a warning and close the connection or send an error
+ // and fail the channel.
+ if !self.context.is_live() {
+ return Err(ChannelError::WarnAndDisconnect(
+ "Splicing requested on a channel that is not live".to_owned(),
+ ));
}
if !self.context.channel_state.is_quiescent() {
@@ -13162,15 +13164,6 @@ where
)));
}
- // - If it has received shutdown:
- // MUST send a warning and close the connection or send an error
- // and fail the channel.
- if !self.context.is_live() {
- return Err(ChannelError::WarnAndDisconnect(
- "Splicing requested on a channel that is not live".to_owned(),
- ));
- }
-
let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
if their_funding_contribution == SignedAmount::ZERO {
return Err(ChannelError::WarnAndDisconnect(format!(
@@ -13179,6 +13172,12 @@ where
)));
}
+ if self.holder_commitment_point.current_point().is_none() {
+ return Err(ChannelError::Abort(AbortReason::InternalError(
+ "Commitment point needs to be advanced once before spliced".into(),
+ )));
+ }
+
Ok(())
}
@@ -13195,13 +13194,10 @@ where
counterparty_funding_pubkey,
our_new_holder_keys,
min_funding_satoshis,
- )
- .map_err(|e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e))?;
+ )?;
let (post_splice_holder_balance, post_splice_counterparty_balance) =
- self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope).map_err(
- |e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e),
- )?;
+ self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope)?;
let holder_selected_channel_reserve =
Amount::from_sat(candidate_scope.holder_selected_channel_reserve_satoshis);
@@ -13211,25 +13207,23 @@ where
// 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 {}",
- self.context.channel_id(),
- if our_funding_contribution.is_positive() { "spliced in" } else { "spliced out" },
- post_splice_holder_balance,
- counterparty_selected_channel_reserve,
- ))?;
+ post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve).ok_or(
+ format!(
+ "Our post-splice channel balance {} is smaller than their selected v2 reserve {}",
+ post_splice_holder_balance,
+ counterparty_selected_channel_reserve,
+ ),
+ )?;
}
if their_funding_contribution != SignedAmount::ZERO {
- 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 {}",
- self.context.channel_id(),
- if their_funding_contribution.is_positive() { "spliced in" } else { "spliced out" },
- post_splice_counterparty_balance,
- holder_selected_channel_reserve,
- ))?;
+ post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve).ok_or(
+ format!(
+ "Their post-splice channel balance {} is smaller than our selected v2 reserve {}",
+ post_splice_counterparty_balance,
+ holder_selected_channel_reserve,
+ ),
+ )?;
}
#[cfg(debug_assertions)]
@@ -13340,7 +13334,11 @@ where
holder_pubkeys,
min_funding_satoshis,
)
- .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?;
+ .map_err(|e| {
+ self.quiescent_negotiation_err(ChannelError::Abort(
+ AbortReason::InvalidContribution(e),
+ ))
+ })?;
// Adjust for the feerate and clone so we can store it for future RBF re-use.
let (adjusted_contribution, our_funding_inputs, our_funding_outputs) =
@@ -13399,17 +13397,16 @@ where
fn validate_tx_init_rbf<F: FeeEstimator>(
&self, msg: &msgs::TxInitRbf, fee_estimator: &LowerBoundedFeeEstimator<F>,
) -> Result<(ChannelPublicKeys, PublicKey), ChannelError> {
- if self.holder_commitment_point.current_point().is_none() {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} commitment point needs to be advanced once before RBF",
- self.context.channel_id(),
- )));
- }
-
if !self.context.channel_state.is_quiescent() {
return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned()));
}
+ if self.holder_commitment_point.current_point().is_none() {
+ return Err(ChannelError::Abort(AbortReason::InternalError(
+ "Commitment point needs to be advanced once before RBF".into(),
+ )));
+ }
+
self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?;
let pending_splice = match &self.pending_splice {
@@ -13523,7 +13520,11 @@ where
holder_pubkeys,
min_funding_satoshis,
)
- .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?;
+ .map_err(|e| {
+ self.quiescent_negotiation_err(ChannelError::Abort(
+ AbortReason::InvalidContribution(e),
+ ))
+ })?;
// Consume the appropriate contribution source.
let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() {
@@ -13623,7 +13624,7 @@ where
holder_pubkeys,
min_funding_satoshis,
)
- .map_err(|e| ChannelError::WarnAndDisconnect(e))?;
+ .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?;
Ok(new_funding)
}
@@ -13700,8 +13701,6 @@ where
fn validate_splice_ack(
&self, msg: &msgs::SpliceAck, min_funding_satoshis: u64,
) -> Result<FundingScope, ChannelError> {
- // TODO(splicing): Add check that we are the splice (quiescence) initiator
-
let pending_splice = self
.pending_splice
.as_ref()
@@ -13724,7 +13723,7 @@ where
new_keys,
min_funding_satoshis,
)
- .map_err(|e| ChannelError::WarnAndDisconnect(e))?;
+ .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?;
Ok(new_funding)
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 2667d5f..88c9b7b 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -1147,7 +1147,7 @@ impl MsgHandleErrInternal {
fn from_chan_no_close(err: ChannelError, channel_id: ChannelId) -> Self {
let tx_abort = match &err {
- &ChannelError::Abort(reason) => Some(reason.into_tx_abort_msg(channel_id)),
+ ChannelError::Abort(reason) => Some(reason.clone().into_tx_abort_msg(channel_id)),
_ => None,
};
let err = match err {
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index dfb702a..a0e325a 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -91,7 +91,7 @@ impl SerialIdExt for SerialId {
}
}
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
pub(crate) enum AbortReason {
InvalidStateTransition,
UnexpectedCounterpartyMessage,
@@ -142,6 +142,8 @@ pub(crate) enum AbortReason {
///
/// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed
ManualIntervention,
+ /// The contribution is not valid given the current balances of the channel.
+ InvalidContribution(String),
/// Internal error
InternalError(&'static str),
}
@@ -209,6 +211,9 @@ impl Display for AbortReason {
f.write_str("The initiator's feerate exceeds our maximum")
},
AbortReason::ManualIntervention => f.write_str("Manually aborted funding negotiation"),
+ AbortReason::InvalidContribution(text) => {
+ f.write_fmt(format_args!("Invalid contribution: {}", text))
+ },
AbortReason::InternalError(text) => {
f.write_fmt(format_args!("Internal error: {}", text))
},
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index a612451..61b9214 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -175,23 +175,21 @@ fn config_with_min_funding_satoshis(min_funding_satoshis: u64) -> UserConfig {
}
#[cfg(test)]
-fn assert_min_funding_error<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, min_funding_satoshis: u64) {
- let msg_events = node.node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1, "{msg_events:?}");
- match &msg_events[0] {
- MessageSendEvent::HandleError {
- action: msgs::ErrorAction::DisconnectPeerWithWarning { msg },
- ..
- } => {
- assert!(
- msg.data
- .contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")),
- "unexpected warning: {}",
- msg.data
- );
- },
- _ => panic!("Expected HandleError with warning, got {:?}", msg_events[0]),
- }
+fn assert_min_funding_error<'a, 'b, 'c>(
+ node: &Node<'a, 'b, 'c>, recipient: PublicKey, min_funding_satoshis: u64,
+) {
+ let msg = get_event_msg!(node, MessageSendEvent::SendTxAbort, recipient);
+ let data = tx_abort_data(&msg);
+ assert!(
+ data.contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")),
+ "unexpected tx_abort: {}",
+ data
+ );
+}
+
+#[cfg(test)]
+fn tx_abort_data(msg: &msgs::TxAbort) -> String {
+ String::from_utf8(msg.data.clone()).expect("tx_abort data should be valid UTF-8")
}
pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>(
@@ -1374,7 +1372,7 @@ fn test_min_funding_satoshis_rejects_splice_init_with_negative_counterparty_cont
let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
assert!(splice_init.funding_contribution_satoshis < 0);
nodes[1].node.handle_splice_init(node_id_0, &splice_init);
- assert_min_funding_error(&nodes[1], min_funding_satoshis);
+ assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis);
}
#[test]
@@ -1472,7 +1470,7 @@ fn test_min_funding_satoshis_rejects_splice_ack_with_negative_counterparty_contr
let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
assert!(splice_ack.funding_contribution_satoshis < 0);
nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
- assert_min_funding_error(&nodes[0], min_funding_satoshis);
+ assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis);
}
#[test]
@@ -1514,7 +1512,7 @@ fn test_min_funding_satoshis_rejects_tx_init_rbf_with_negative_counterparty_cont
let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
assert!(tx_init_rbf.funding_output_contribution.unwrap() < 0);
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
- assert_min_funding_error(&nodes[1], min_funding_satoshis);
+ assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis);
}
#[test]
@@ -1571,7 +1569,7 @@ fn test_min_funding_satoshis_rejects_tx_ack_rbf_with_negative_counterparty_contr
let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0);
assert!(tx_ack_rbf.funding_output_contribution.unwrap() < 0);
nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf);
- assert_min_funding_error(&nodes[0], min_funding_satoshis);
+ assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis);
}
#[test]
@@ -5880,13 +5878,14 @@ fn do_test_splice_pending_htlcs(config: UserConfig) {
splice_init.funding_contribution_satoshis -= 1;
acceptor.node.handle_splice_init(node_id_initiator, &splice_init);
- let msg = get_warning_msg(acceptor, &node_id_initiator);
+ let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
assert_eq!(msg.channel_id, channel_id);
let cannot_be_spliced_out = format!(
- "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}",
- channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve
+ "Their post-splice channel balance {} is smaller than our selected v2 reserve {}",
+ post_splice_reserve - Amount::ONE_SAT,
+ post_splice_reserve
);
- assert_eq!(msg.data, cannot_be_spliced_out);
+ assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_be_spliced_out}"));
acceptor.node.peer_disconnected(node_id_initiator);
initiator.node.peer_disconnected(node_id_acceptor);
@@ -9790,40 +9789,35 @@ fn do_test_0reserve_splice_counterparty_validation(
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 msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
+ assert_eq!(msg.channel_id, channel_id);
let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap()
> initiator_value_to_self_sat
{
// They obviously can't afford their contribution, so we fail before even
// querying `TxBuilder`
format!(
- "Got non-closing error: Channel {channel_id} cannot be spliced; \
- Their contribution candidate {funding_contribution_sat}sat \
+ "Their contribution candidate {funding_contribution_sat}sat \
is greater than their total balance in the channel {initiator_value_to_self_sat}sat"
)
} else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS {
// We require all spliced channels to have a value of at least 1000 satoshis after the splice
format!(
- "Got non-closing error: Channel {channel_id} cannot be spliced; \
- Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \
+ "Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \
It would be {post_channel_value_sat}"
)
} else {
// Last but not least, `TxBuilder` decides whether all parties can afford
// HTLCs, anchors, and transaction fees while retaining at least one
// output on the commitments
- format!(
- "Got non-closing error: Channel {channel_id} cannot \
- be spliced; Balance exhausted on local commitment"
- )
+ "Balance exhausted on local commitment".to_string()
};
- acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1);
+ assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}"));
+ acceptor.logger.assert_log(
+ "lightning::ln::channelmanager",
+ format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"),
+ 1,
+ );
}
channel_type
@@ -10064,18 +10058,12 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments(
// balance, we previously would not complain.
splice_init.funding_contribution_satoshis = funding_contribution_sat;
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 msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
+ assert_eq!(msg.channel_id, channel_id);
let post_splice_channel_value_sat = node_0_balance_leftover_amount.to_sat();
let cannot_splice_out = if matches!(acceptor_balance, AcceptorBalance::NoBalance) {
format!(
- "Got non-closing error: Channel {channel_id} cannot \
- be spliced; The post-splice channel value {post_splice_channel_value_sat} \
+ "The post-splice channel value {post_splice_channel_value_sat} \
is smaller than their dust limit {high_dust_limit_satoshis}"
)
} else {
@@ -10088,13 +10076,17 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments(
high_dust_limit_satoshis
);
format!(
- "Got non-closing error: Channel {channel_id} cannot \
- be spliced out; their post-splice channel balance \
+ "Their post-splice channel balance \
{node_0_balance_leftover_amount} is smaller than our selected v2 reserve \
{v2_channel_reserve}"
)
};
- acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1);
+ assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}"));
+ acceptor.logger.assert_log(
+ "lightning::ln::channelmanager",
+ format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"),
+ 1,
+ );
}
}
Why this scored 37/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.