Disconnect on overlapping splice RBF negotiation
What changed, and why it matters
This change fixes a protocol edge case in Lightning splicing where receiving a second overlapping RBF (fee-bump) request during an active funding negotiation could accidentally abort the wrong negotiation. Instead of sending tx_abort, which would ambiguously cancel the ongoing negotiation, the node now disconnects with a warning and keeps the existing negotiation state so it can resume after reconnecting. This prevents a remote peer from potentially disrupting an in-progress splice/RBF by sending a conflicting request.
Review and merge. The change is defensive and improves protocol robustness. No immediate incident response is indicated, but operators should upgrade to avoid potential splice/RBF disruption by a malicious or buggy peer.
Security signals we found
Protocol-state ambiguity fixed: abort message no longer implicitly cancels wrong negotiation
Denial-of-service hardening: overlapping RBF cannot force premature abort of active splice/RBF
State preservation across disconnect for AwaitingSignatures negotiation
Regression test renamed and extended to cover reconnect/resume path
Evidence from the diff
In rust-lightning, when an inbound tx_init_rbf arrives while another funding negotiation is already active, the code previously returned ChannelError::Abort(AbortReason::NegotiationInProgress). Because tx_abort is ambiguous in this context, it would abort the active negotiation and exit quiescence while protocol state remained. The patch changes the response to ChannelError::WarnAndDisconnect with a descriptive message, preserving an AwaitingSignatures negotiation for resumption across the disconnect. It removes the now-unused NegotiationInProgress abort reason and updates regression tests to verify the disconnect-and-resume behavior.
Changed components
lightning/src/ln/channel.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +91 / −74
### lightning/src/ln/channel.rs
@@ -14001,7 +14001,10 @@ where
})?;
if pending_splice.funding_negotiation.is_some() {
- return Err(ChannelError::Abort(AbortReason::NegotiationInProgress));
+ return Err(ChannelError::WarnAndDisconnect(
+ "Received tx_init_rbf while a funding negotiation is already in progress"
+ .to_owned(),
+ ));
}
if pending_splice.received_funding_txid.is_some() {
### lightning/src/ln/interactivetxs.rs
@@ -133,8 +133,6 @@ pub(crate) enum AbortReason {
/// The RBF feerate is insufficient (e.g., doesn't satisfy the minimum feerate increase rule or
/// can't accommodate prior contributions).
InsufficientRbfFeerate,
- /// A funding negotiation is already in progress.
- NegotiationInProgress,
/// The initiator's feerate exceeds our maximum.
FeeRateTooHigh,
/// The user manually intervened to abort the funding negotiation via
@@ -206,9 +204,6 @@ impl Display for AbortReason {
},
AbortReason::DuplicateFundingInput => f.write_str("More than one funding input found"),
AbortReason::InsufficientRbfFeerate => f.write_str("Insufficient RBF feerate"),
- AbortReason::NegotiationInProgress => {
- f.write_str("A funding negotiation is already in progress")
- },
AbortReason::FeeRateTooHigh => {
f.write_str("The initiator's feerate exceeds our maximum")
},
### lightning/src/ln/splicing_tests.rs
@@ -7274,48 +7274,6 @@ fn test_splice_rbf_no_pending_splice() {
);
}
-#[test]
-fn test_splice_rbf_active_negotiation() {
- // Test that tx_init_rbf is rejected when a funding negotiation is already in progress.
- // Start a splice but don't complete interactive TX construction, then send tx_init_rbf.
- 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, &[None, None]);
- let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
-
- let node_id_0 = nodes[0].node.get_our_node_id();
-
- let initial_channel_value_sat = 100_000;
- let (_, _, channel_id, _) =
- create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
-
- let added_value = Amount::from_sat(50_000);
- provide_utxo_reserves(&nodes, 2, added_value * 2);
-
- // Initiate a splice but only complete the handshake (STFU + splice_init/ack),
- // leaving interactive TX construction in progress.
- let _funding_contribution =
- do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
- let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]);
-
- // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction).
- // Sending tx_init_rbf should be rejected.
- let tx_init_rbf = msgs::TxInitRbf {
- channel_id,
- locktime: 0,
- feerate_sat_per_1000_weight: 500,
- funding_output_contribution: Some(added_value.to_sat() as i64),
- };
-
- nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
-
- let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
- assert_eq!(tx_abort.channel_id, channel_id);
-
- // Clear the initiator's pending interactive TX messages from the incomplete splice handshake.
- nodes[0].node.get_and_clear_pending_msg_events();
-}
-
#[test]
fn test_splice_rbf_after_splice_locked() {
// Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked.
@@ -7882,46 +7840,49 @@ fn test_splice_rbf_zeroconf_rejected() {
}
#[test]
-fn test_splice_rbf_not_quiescence_initiator() {
- // Test that tx_init_rbf from the non-quiescence-initiator is rejected because the
- // quiescence initiator's RBF flow has already set funding_negotiation to AwaitingAck.
+fn test_overlapping_tx_init_rbf_disconnects() {
+ // A competing tx_init_rbf cannot identify a separate negotiation while the quiescence
+ // initiator's RBF flow is awaiting signatures, so disconnect instead of aborting the active
+ // negotiation.
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, &[None, None]);
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 initial_channel_value_sat = 100_000;
let (_, _, channel_id, _) =
- create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
-
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
- // Complete a splice-in from node 0.
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
- let (_splice_tx, _new_funding_script) =
+ let (_, funding_script) =
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
- // Provide more UTXO reserves for the RBF attempt.
provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ None,
+ tx_ack_rbf.funding_output_contribution.unwrap_or(0),
+ funding_script,
+ );
- // Initiate RBF from node 0 (quiescence initiator).
- let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25;
- let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu);
- let _funding_contribution =
- do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate);
-
- // STFU exchange: node 0 initiates quiescence.
- let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
- nodes[1].node.handle_stfu(node_id_0, &stfu_init);
- let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
- nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
-
- // Node 0 sends tx_init_rbf as the quiescence initiator — grab and discard.
- let _tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures { is_initiator: true, .. }
+ ));
+ let rbf_txid = candidate_txid(&details.candidates[1]);
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+ let signing_event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
// Now craft a competing tx_init_rbf from node 1 (the non-initiator).
let tx_init_rbf = msgs::TxInitRbf {
@@ -7933,8 +7894,66 @@ fn test_splice_rbf_not_quiescence_initiator() {
nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf);
- let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
- assert_eq!(tx_abort.channel_id, channel_id);
+ let msg_events = nodes[0].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_eq!(
+ msg.data,
+ "Received tx_init_rbf while a funding negotiation is already in progress"
+ ),
+ _ => panic!("Expected DisconnectPeerWithWarning, got {:?}", msg_events[0]),
+ }
+
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ // Disconnecting preserves an AwaitingSignatures negotiation so it can resume after reconnecting.
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures {
+ is_initiator: true,
+ txid,
+ ..
+ } if txid == rbf_txid
+ ));
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution));
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+
+ // Provide our funding signatures while disconnected, then reconnect and resume the original
+ // negotiation's commitment signature exchange.
+ let unsigned_transaction = match signing_event {
+ Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } => {
+ unsigned_transaction
+ },
+ other => panic!("Expected FundingTransactionReadyForSigning, got {other:?}"),
+ };
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[0].node.funding_transaction_signed(&channel_id, &node_id_1, partially_signed_tx).unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_args.send_interactive_tx_commit_sig = (true, true);
+ reconnect_nodes(reconnect_args);
+ check_added_monitors(&nodes[0], 1);
+ check_added_monitors(&nodes[1], 1);
+
+ let acceptor_tx_signatures =
+ get_event_msg!(nodes[1], MessageSendEvent::SendTxSignatures, node_id_0);
+ assert_eq!(acceptor_tx_signatures.tx_hash, rbf_txid);
+ nodes[0].node.handle_tx_signatures(node_id_1, &acceptor_tx_signatures);
+ let initiator_tx_signatures =
+ get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1);
+ assert_eq!(initiator_tx_signatures.tx_hash, rbf_txid);
+ nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures);
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
}
#[test]Why this scored 45/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.