Check can_initiate_rbf in stfu handler before sending tx_init_rbf
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's splicing/RBF (fee-bump) protocol. If a splice transaction got confirmed while both sides were exchanging 'quiet' (STFU) messages, the code could wrongly try to start a new RBF round for an already-final splice. The fix adds a re-check before sending the RBF initiation message and disconnects the peer if the splice is already locked, preventing protocol confusion and potential loss or locking of funds.
Review and merge the patch. Ensure downstream users running splicing/RBF nodes upgrade to a version containing this fix, as the race could otherwise lead to protocol desynchronization or fund-locking edge cases.
Security signals we found
Race condition between splice confirmation and RBF initiation
Prevention of invalid tx_init_rbf after splice_locked
New NegotiationFailureReason::CannotInitiateRbf for failed RBF initiation
WarnAndDisconnect because quiescence cannot be cancelled once both STFUs are exchanged
Regression test added in splicing_tests.rs
Evidence from the diff
In rust-lightning’s channel splicing logic, the stfu() handler now calls can_initiate_rbf() again after entering quiescence and before emitting tx_init_rbf. If a splice_locked was already sent (because the splice transaction confirmed during the STFU exchange), can_initiate_rbf fails and the handler returns ChannelError::WarnAndDisconnect with a new NegotiationFailureReason::CannotInitiateRbf. The channel remains operational; the failed RBF contribution is cleaned up via splice_funding_failed_for. A regression test reproduces the race: node 0 sends STFU, the splice confirms, node 1’s STFU ack arrives, and node 0 now disconnects instead of sending tx_init_rbf.
Changed components
lightning/src/ln/channel.rslightning/src/events/mod.rslightning/src/ln/splicing_tests.rsfuzz/src/chanmon_consistency.rsInspect captured patch +130 / −1
diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs
index 678e6a6..55b2a68 100644
--- a/fuzz/src/chanmon_consistency.rs
+++ b/fuzz/src/chanmon_consistency.rs
@@ -930,6 +930,7 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) {
action,
msgs::ErrorAction::DisconnectPeerWithWarning { msg }
if msg.data.contains("Disconnecting due to timeout awaiting response")
+ || msg.data.contains("already sent splice_locked, cannot RBF")
),
"Expected timeout disconnect, got: {:?}",
action,
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 9d00273..0d5b8f7 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -149,6 +149,12 @@ pub enum NegotiationFailureReason {
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
/// [`FundingTemplate`]: crate::ln::funding::FundingTemplate
FeeRateTooLow,
+ /// An RBF attempt could not be initiated (e.g., a prior splice transaction already
+ /// confirmed). The channel remains operational — start a new splice with
+ /// [`ChannelManager::splice_channel`] if further changes are needed.
+ ///
+ /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
+ CannotInitiateRbf,
}
impl NegotiationFailureReason {
@@ -166,7 +172,8 @@ impl NegotiationFailureReason {
Self::CounterpartyAborted { .. }
| Self::NegotiationError { .. }
| Self::LocallyAbandoned
- | Self::ChannelClosing => false,
+ | Self::ChannelClosing
+ | Self::CannotInitiateRbf => false,
}
}
}
@@ -185,6 +192,7 @@ impl core::fmt::Display for NegotiationFailureReason {
Self::ChannelClosing => f.write_str("channel is closing"),
Self::FeeRateTooLow => f.write_str("feerate too low for RBF"),
+ Self::CannotInitiateRbf => f.write_str("cannot initiate RBF"),
}
}
}
@@ -202,6 +210,7 @@ impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason,
(11, LocallyAbandoned) => {},
(13, ChannelClosing) => {},
(15, FeeRateTooLow) => {},
+ (17, CannotInitiateRbf) => {},
);
/// Some information provided on receipt of payment depends on whether the payment received is a
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 82c8835..cff3466 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -14328,6 +14328,16 @@ where
};
if self.pending_splice.is_some() {
+ if let Err(e) = self.can_initiate_rbf() {
+ let failed = self.splice_funding_failed_for(prior_contribution);
+ return Err((
+ ChannelError::WarnAndDisconnect(e),
+ QuiescentError::FailSplice(
+ failed,
+ NegotiationFailureReason::CannotInitiateRbf,
+ ),
+ ));
+ }
let tx_init_rbf = self.send_tx_init_rbf(context);
self.pending_splice.as_mut().unwrap()
.contributions.push(prior_contribution);
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 623a151..a3396d7 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -5198,6 +5198,115 @@ fn test_splice_rbf_after_splice_locked() {
}
}
+#[test]
+fn test_splice_rbf_stfu_after_splice_locked() {
+ // Test that we don't send tx_init_rbf when we've already sent splice_locked.
+ //
+ // Scenario: node 0 initiates an RBF and sends STFU, but before receiving the counterparty's
+ // STFU response, it mines enough blocks to send splice_locked (setting sent_funding_txid).
+ // When node 1's STFU arrives, the stfu() handler should detect that RBF is no longer valid
+ // and return WarnAndDisconnect instead of sending 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 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);
+
+ 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, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Mine the splice tx on both nodes (not enough for splice_locked yet).
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+
+ // Provide more UTXOs for the RBF attempt.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Initiate RBF from node 0 with fresh inputs so the RBF round has a unique input that
+ // survives filtering when the failure cleanup runs.
+ let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template
+ .without_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(added_value)
+ .build()
+ .unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None)
+ .unwrap();
+
+ // Node 0 sends STFU (can_initiate_rbf passes since no splice_locked yet).
+ let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+
+ // Deliver STFU to node 1; extract node 1's STFU response but don't deliver it yet.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+
+ // Mine enough blocks on node 0 so it sends splice_locked (sets sent_funding_txid).
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ let _splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+
+ // Now deliver node 1's STFU to node 0. The stfu() handler should detect that RBF is no
+ // longer valid (we already sent splice_locked) and return WarnAndDisconnect.
+ nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
+
+ 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, .. } => {
+ assert_eq!(
+ *action,
+ msgs::ErrorAction::DisconnectPeerWithWarning {
+ msg: msgs::WarningMessage {
+ channel_id,
+ data: format!(
+ "Channel {} already sent splice_locked, cannot RBF",
+ channel_id,
+ ),
+ },
+ }
+ );
+ },
+ _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
+ }
+
+ // Node 0 should emit DiscardFunding + SpliceNegotiationFailed for the RBF contribution.
+ // The change output is filtered (same script_pubkey as the first splice's change output),
+ // but the input survives because it's a different UTXO from the first splice.
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2, "{events:?}");
+ match &events[0] {
+ Event::DiscardFunding {
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ ..
+ } => {
+ assert!(!inputs.is_empty());
+ assert!(outputs.is_empty());
+ },
+ other => panic!("Expected DiscardFunding, got {:?}", other),
+ }
+ match &events[1] {
+ Event::SpliceNegotiationFailed { channel_id: cid, reason, .. } => {
+ assert_eq!(*cid, channel_id);
+ assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
+ },
+ other => panic!("Expected SpliceNegotiationFailed, got {:?}", other),
+ }
+}
+
#[test]
fn test_splice_zeroconf_no_rbf_feerate() {
// Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a
Why this scored 58/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.