Prefer tx_abort over disconnection for inability to RBF
What changed, and why it matters
This change makes a Lightning node send a 'tx_abort' message instead of disconnecting from a peer when an RBF (fee-bump) request cannot be honored during a splice. The goal is to avoid unnecessary reconnections and keep the channel usable. It is a protocol-robustness improvement rather than a fix for a critical vulnerability.
Treat as a normal reliability/protocol-correctness patch. No urgent security response is indicated by the commit itself. Reviewers may want to confirm that tx_abort is handled cleanly by counterparties and does not leave channels in an inconsistent quiescent state.
Security signals we found
Change from peer disconnection to in-protocol tx_abort for non-fatal RBF rejections
New AbortReason::RbfUnavailable variant added to interactivetxs.rs
Test expectations changed from HandleError/DisconnectPeerWithWarning to SendTxAbort
Avoids reconnection loops caused by repeated RBF rejection disconnects
Evidence from the diff
The patch converts several RBF-incompatibility error paths in Channel::rbf_from_tx_init_rbf from ChannelError::WarnAndDisconnect to ChannelError::Abort with a new AbortReason::RbfUnavailable variant. The resulting behavior emits a tx_abort message to terminate quiescence instead of dropping the peer connection. Tests are updated to expect SendTxAbort events rather than DisconnectPeerWithWarning errors.
Changed components
lightning/src/ln/channel.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +41 / −78
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index f24008a..7f5c4f2 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -13407,46 +13407,41 @@ where
)));
}
- self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?;
+ self.is_rbf_compatible()
+ .map_err(|msg| ChannelError::Abort(AbortReason::RbfUnavailable(msg)))?;
- let pending_splice = match &self.pending_splice {
- Some(pending_splice) => pending_splice,
- None => {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} has no pending splice to RBF",
- self.context.channel_id(),
- )));
- },
- };
+ let (pending_splice, last_candidate) = self
+ .pending_splice
+ .as_ref()
+ .filter(|pending_splice| !pending_splice.negotiated_candidates.is_empty())
+ .map(|pending_splice| {
+ (
+ pending_splice,
+ pending_splice.negotiated_candidates.last().expect("checked above"),
+ )
+ })
+ .ok_or_else(|| {
+ ChannelError::Abort(AbortReason::RbfUnavailable(
+ "No pending splice available to RBF".into(),
+ ))
+ })?;
if pending_splice.funding_negotiation.is_some() {
return Err(ChannelError::Abort(AbortReason::NegotiationInProgress));
}
if pending_splice.received_funding_txid.is_some() {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} counterparty already sent splice_locked, cannot RBF",
- self.context.channel_id(),
+ return Err(ChannelError::Abort(AbortReason::RbfUnavailable(
+ "Already received splice_locked".into(),
)));
}
if pending_splice.sent_funding_txid.is_some() {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} already sent splice_locked, cannot RBF",
- self.context.channel_id(),
+ return Err(ChannelError::Abort(AbortReason::RbfUnavailable(
+ "Already sent splice_locked".into(),
)));
}
- let last_candidate = match pending_splice.negotiated_candidates.last() {
- Some(candidate) => candidate,
- None => {
- return Err(ChannelError::WarnAndDisconnect(format!(
- "Channel {} has no negotiated splice candidates to RBF",
- self.context.channel_id(),
- )));
- },
- };
-
let prev_feerate =
pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| {
fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep)
@@ -13611,7 +13606,9 @@ where
};
let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| {
- ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned())
+ ChannelError::Abort(AbortReason::RbfUnavailable(
+ "No pending splice available to RBF".into(),
+ ))
})?;
let holder_pubkeys = last_candidate.get_holder_pubkeys().clone();
let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey();
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index a0e325a..6769e2d 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -144,6 +144,8 @@ pub(crate) enum AbortReason {
ManualIntervention,
/// The contribution is not valid given the current balances of the channel.
InvalidContribution(String),
+ /// A RBF is not available at this time.
+ RbfUnavailable(String),
/// Internal error
InternalError(&'static str),
}
@@ -214,6 +216,9 @@ impl Display for AbortReason {
AbortReason::InvalidContribution(text) => {
f.write_fmt(format_args!("Invalid contribution: {}", text))
},
+ AbortReason::RbfUnavailable(text) => {
+ f.write_fmt(format_args!("Rejecting RBF attempt: {}", 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 61b9214..16dec1d 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -6654,22 +6654,11 @@ fn test_splice_rbf_no_pending_splice() {
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
- let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1);
- match &msg_events[0] {
- MessageSendEvent::HandleError { action, .. } => {
- assert_eq!(
- *action,
- msgs::ErrorAction::DisconnectPeerWithWarning {
- msg: msgs::WarningMessage {
- channel_id,
- data: format!("Channel {} has no pending splice to RBF", channel_id),
- },
- }
- );
- },
- _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
- }
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(
+ tx_abort_data(&tx_abort),
+ "Rejecting RBF attempt: No pending splice available to RBF"
+ );
}
#[test]
@@ -6767,25 +6756,8 @@ fn test_splice_rbf_after_splice_locked() {
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
- let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1);
- match &msg_events[0] {
- MessageSendEvent::HandleError { action, .. } => {
- assert_eq!(
- *action,
- msgs::ErrorAction::DisconnectPeerWithWarning {
- msg: msgs::WarningMessage {
- channel_id,
- data: format!(
- "Channel {} counterparty already sent splice_locked, cannot RBF",
- channel_id,
- ),
- },
- }
- );
- },
- _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
- }
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(tx_abort_data(&tx_abort), "Rejecting RBF attempt: Already received splice_locked");
}
#[test]
@@ -6968,22 +6940,11 @@ fn test_splice_rbf_zeroconf_rejected() {
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
- let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1);
- match &msg_events[0] {
- MessageSendEvent::HandleError { action, .. } => {
- assert_eq!(
- *action,
- msgs::ErrorAction::DisconnectPeerWithWarning {
- msg: msgs::WarningMessage {
- channel_id,
- data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,),
- },
- }
- );
- },
- _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
- }
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(
+ tx_abort_data(&tx_abort),
+ format!("Rejecting RBF attempt: Channel {} has option_zeroconf, cannot RBF", channel_id)
+ );
}
#[test]
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.