Avoid splice checks when responding to stfu
What changed, and why it matters
This patch fixes a logic bug in how a Lightning node responds when a peer asks to pause channel activity (a protocol feature called 'quiescence'). Previously, the node would wrongly apply its own local splice/RBF fee checks before replying to the peer's pause request, which could prevent it from responding and stall the protocol. The fix separates 'we initiated the pause' from 'the peer initiated the pause' so the node can answer promptly without running the local checks.
Review and merge; this is a correctness/liveness fix for the BOLT quiescence/splicing flow. Consider whether the stall could be triggered in production and if a release note is warranted, but no immediate emergency response is indicated by the diff alone.
Security signals we found
Protocol stall / liveness issue: local splice RBF eligibility check blocked response to peer-initiated quiescence
State-machine ordering fix: pending-update check now precedes initiator/non-initiator decision
Regression test added: test_peer_initiated_stfu_skips_local_rbf_feerate_check
Evidence from the diff
In rust-lightning’s channel quiescence handling, the code that decides whether to send an stfu message was gating both locally-initiated and remotely-initiated quiescence on splice RBF eligibility checks. The patch moves those checks so they only apply when this node is the initiator. When the counterparty sent stfu first, the node now immediately marks itself quiescent and replies as non-initiator once pending channel updates are clear, instead of being blocked by local splice state. A regression test confirms a peer-initiated stfu no longer triggers the local RBF feerate check.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsInspect captured patch +71 / −20
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index edcaacf..a5f8c6a 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -14636,7 +14636,24 @@ where
return None;
}
- if let Some(action) = self.quiescent_action.as_ref() {
+ if self.context.is_waiting_on_peer_pending_channel_update()
+ || self.context.is_monitor_or_signer_pending_channel_update()
+ {
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting for state machine pending changes to complete before sending stfu"
+ );
+ return None;
+ }
+
+ let initiator = if self.context.channel_state.is_remote_stfu_sent() {
+ // Since we may have also attempted to initiate quiescence but the counterparty
+ // initiated first, we'll retry after we're no longer quiescent.
+ self.context.channel_state.clear_remote_stfu_sent();
+ self.context.channel_state.set_quiescent();
+ false
+ } else if let Some(action) = self.quiescent_action.as_ref() {
#[allow(irrefutable_let_patterns)]
if let QuiescentAction::Splice { contribution, .. } = action {
if self.pending_splice.is_some() {
@@ -14663,29 +14680,16 @@ where
}
}
}
- }
- if self.context.is_waiting_on_peer_pending_channel_update()
- || self.context.is_monitor_or_signer_pending_channel_update()
- {
- log_given_level!(
- logger,
- logger_level,
- "Waiting for state machine pending changes to complete before sending stfu"
- );
- return None;
- }
-
- let initiator = if self.context.channel_state.is_remote_stfu_sent() {
- // Since we may have also attempted to initiate quiescence but the counterparty
- // initiated first, we'll retry after we're no longer quiescent.
- self.context.channel_state.clear_remote_stfu_sent();
- self.context.channel_state.set_quiescent();
- false
- } else {
log_debug!(logger, "Sending stfu as quiescence initiator");
self.context.channel_state.set_local_stfu_sent();
true
+ } else {
+ debug_assert!(
+ false,
+ "Either we have a pending quiescent action or need to respond to the counterparty"
+ );
+ false
};
Some(msgs::Stfu { channel_id: self.context.channel_id, initiator })
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 35c7250..75fc1b9 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -7841,6 +7841,53 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW);
}
+#[test]
+fn test_peer_initiated_stfu_skips_local_rbf_feerate_check() {
+ // Test that a local low-fee splice RBF attempt does not prevent us from responding to a
+ // counterparty-initiated quiescence attempt.
+ 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, 4, added_value * 2);
+
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let node_0_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 node_0_contribution =
+ node_0_template.splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet).unwrap();
+
+ // Node 1 creates a pending splice before node 0 submits its contribution. Node 0's
+ // contribution cannot be adjusted up to the pending splice's minimum RBF feerate, so it must
+ // not send its own stfu yet.
+ let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+ let (_first_splice_tx, _) =
+ splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution);
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, node_0_contribution, None).unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Node 1 can still initiate quiescence for its own RBF attempt. Node 0 should reply as the
+ // non-initiator instead of applying its local splice RBF feerate check to the response.
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let _node_1_rbf_contribution =
+ do_initiate_rbf_splice_in(&nodes[1], &nodes[0], channel_id, min_rbf_feerate);
+ let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ assert!(stfu_init.initiator);
+
+ nodes[0].node.handle_stfu(node_id_1, &stfu_init);
+ let stfu_response = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ assert!(!stfu_response.initiator);
+}
+
#[test]
fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
// Test that when the change output can't absorb the fee increase needed for the minimum RBF feerate
Why this scored 34/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.