Stop skipping the line in quiescence if our peer speaks first
What changed, and why it matters
This commit removes a small optimization in the Lightning quiescence protocol. Previously, if both sides were about to request a pause (quiescence) at nearly the same time, the code tried to let one side 'go first' based on a tie-breaker. The commit simplifies this by always treating the peer that sends its pause message first as the initiator. This is a cleanup change that reduces state-tracking complexity and lowers the chance of subtle state-machine bugs, but it does not by itself fix a known active exploit.
Treat as a normal code-quality and protocol-correctness improvement. Reviewers should verify that removing the tie-breaker does not violate the BOLT-2 quiescence spec or break any dual-funded / splicing flows that rely on deterministic initiator selection. No urgent security response is indicated by the commit materials alone.
Security signals we found
Protocol state machine simplification
Removal of optional tie-breaker state that could become inconsistent
Change in initiator semantics when both peers request quiescence concurrently
No explicit security bug or CVE referenced in commit message
Evidence from the diff
The patch removes the is_holder_quiescence_initiator field and all tie-breaker logic from the BOLT-2 quiescence (stfu) state machine in lightning/src/ln/channel.rs. When the local node is awaiting quiescence but has not yet sent stfu, and the peer sends stfu first, the code now always returns false from send_stfu, meaning the peer is considered the initiator. The tie-break based on funding.is_outbound() is dropped. Test expectations in quiescence_tests.rs are updated so that both sides report they were quiescent after a simultaneous-initiator exchange, and exit_quiescence returns false when full quiescence was never reached.
Changed components
lightning/src/ln/channel.rslightning/src/ln/quiescence_tests.rsBOLT-2 quiescence / stfu handlingInspect captured patch +18 / −51
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 115d68a..a9a21c5 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -2728,10 +2728,6 @@ where
/// If we can't release a [`ChannelMonitorUpdate`] until some external action completes, we
/// store it here and only release it to the `ChannelManager` once it asks for it.
blocked_monitor_updates: Vec<PendingChannelMonitorUpdate>,
-
- /// Only set when a counterparty `stfu` has been processed to track which node is allowed to
- /// propose "something fundamental" upon becoming quiescent.
- is_holder_quiescence_initiator: Option<bool>,
}
/// A channel struct implementing this trait can receive an initial counterparty commitment
@@ -3306,8 +3302,6 @@ where
blocked_monitor_updates: Vec::new(),
is_manual_broadcast: false,
-
- is_holder_quiescence_initiator: None,
};
Ok((funding, channel_context))
@@ -3544,8 +3538,6 @@ where
blocked_monitor_updates: Vec::new(),
local_initiated_shutdown: None,
is_manual_broadcast: false,
-
- is_holder_quiescence_initiator: None,
};
Ok((funding, channel_context))
@@ -8202,7 +8194,6 @@ where
self.context.channel_state.clear_local_stfu_sent();
self.context.channel_state.clear_remote_stfu_sent();
self.context.channel_state.clear_quiescent();
- self.context.is_holder_quiescence_initiator.take();
}
self.context.channel_state.set_peer_disconnected();
@@ -11591,18 +11582,10 @@ where
self.context.channel_state.clear_awaiting_quiescence();
self.context.channel_state.clear_remote_stfu_sent();
self.context.channel_state.set_quiescent();
- if let Some(initiator) = self.context.is_holder_quiescence_initiator.as_ref() {
- log_debug!(
- logger,
- "Responding to counterparty stfu with our own, channel is now quiescent and we are{} the initiator",
- if !initiator { " not" } else { "" }
- );
-
- *initiator
- } else {
- debug_assert!(false, "Quiescence initiator must have been set when we received stfu");
- false
- }
+ // We are sending an stfu in response to our couterparty's stfu, but had not yet sent
+ // our own stfu (even if `awaiting_quiescence` was set). Thus, the counterparty is the
+ // initiator and they can do "something fundamental".
+ false
} else {
log_debug!(logger, "Sending stfu as quiescence initiator");
debug_assert!(self.context.channel_state.is_awaiting_quiescence());
@@ -11633,9 +11616,7 @@ where
));
}
- if self.context.channel_state.is_awaiting_quiescence()
- || !self.context.channel_state.is_local_stfu_sent()
- {
+ if !self.context.channel_state.is_local_stfu_sent() {
if !msg.initiator {
return Err(ChannelError::WarnAndDisconnect(
"Peer sent unexpected `stfu` without signaling as initiator".to_owned()
@@ -11649,15 +11630,6 @@ where
// then.
self.context.channel_state.set_remote_stfu_sent();
- let is_holder_initiator = if self.context.channel_state.is_awaiting_quiescence() {
- // We were also planning to propose quiescence, let the tie-breaker decide the
- // initiator.
- self.funding.is_outbound()
- } else {
- false
- };
- self.context.is_holder_quiescence_initiator = Some(is_holder_initiator);
-
log_debug!(logger, "Received counterparty stfu proposing quiescence");
return self.send_stfu(logger).map(|stfu| Some(stfu));
}
@@ -11665,7 +11637,6 @@ where
// We already sent `stfu` and are now processing theirs. It may be in response to ours, or
// we happened to both send `stfu` at the same time and a tie-break is needed.
let is_holder_quiescence_initiator = !msg.initiator || self.funding.is_outbound();
- self.context.is_holder_quiescence_initiator = Some(is_holder_quiescence_initiator);
// We were expecting to receive `stfu` because we already sent ours.
self.mark_response_received();
@@ -11733,13 +11704,10 @@ where
debug_assert!(!self.context.channel_state.is_local_stfu_sent());
debug_assert!(!self.context.channel_state.is_remote_stfu_sent());
- if self.context.channel_state.is_quiescent() {
- self.mark_response_received();
- self.context.channel_state.clear_quiescent();
- self.context.is_holder_quiescence_initiator.take().expect("Must always be set while quiescent")
- } else {
- false
- }
+ self.mark_response_received();
+ let was_quiescent = self.context.channel_state.is_quiescent();
+ self.context.channel_state.clear_quiescent();
+ was_quiescent
}
pub fn remove_legacy_scids_before_block(&mut self, height: u32) -> alloc::vec::Drain<'_, u64> {
@@ -14001,8 +13969,6 @@ where
blocked_monitor_updates: blocked_monitor_updates.unwrap(),
is_manual_broadcast: is_manual_broadcast.unwrap_or(false),
-
- is_holder_quiescence_initiator: None,
},
interactive_tx_signing_session,
holder_commitment_point,
diff --git a/lightning/src/ln/quiescence_tests.rs b/lightning/src/ln/quiescence_tests.rs
index 211e79a..d6cdd3c 100644
--- a/lightning/src/ln/quiescence_tests.rs
+++ b/lightning/src/ln/quiescence_tests.rs
@@ -33,7 +33,7 @@ fn test_quiescence_tie() {
assert!(stfu_node_0.initiator && stfu_node_1.initiator);
assert!(nodes[0].node.exit_quiescence(&nodes[1].node.get_our_node_id(), &chan_id).unwrap());
- assert!(!nodes[1].node.exit_quiescence(&nodes[0].node.get_our_node_id(), &chan_id).unwrap());
+ assert!(nodes[1].node.exit_quiescence(&nodes[0].node.get_our_node_id(), &chan_id).unwrap());
}
#[test]
@@ -173,7 +173,8 @@ fn allow_shutdown_while_awaiting_quiescence(local_shutdown: bool) {
// Now that the state machine is no longer pending, and `closing_signed` is ready to be sent,
// make sure we're still not waiting for the quiescence handshake to complete.
- local_node.node.exit_quiescence(&remote_node_id, &chan_id).unwrap();
+ // Note that we never actually reached full quiescence here.
+ assert!(!local_node.node.exit_quiescence(&remote_node_id, &chan_id).unwrap());
let _ = get_event_msg!(local_node, MessageSendEvent::SendClosingSigned, remote_node_id);
check_added_monitors(local_node, 2); // One for the last revoke_and_ack, another for closing_signed
@@ -279,8 +280,8 @@ fn test_quiescence_waits_for_async_signer_and_monitor_update() {
let stfu = get_event_msg!(&nodes[0], MessageSendEvent::SendStfu, node_id_1);
nodes[1].node.handle_stfu(node_id_0, &stfu);
- nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap();
- nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap();
+ assert!(nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap());
+ assert!(nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap());
// After exiting quiescence, we should be able to resume payments from nodes[0].
send_payment(&nodes[0], &[&nodes[1]], payment_amount);
@@ -336,8 +337,8 @@ fn test_quiescence_on_final_revoke_and_ack_pending_monitor_update() {
panic!();
}
- nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap();
- nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap();
+ assert!(nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap());
+ assert!(nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap());
}
#[test]
@@ -406,8 +407,8 @@ fn quiescence_updates_go_to_holding_cell(fail_htlc: bool) {
let stfu = get_event_msg!(&nodes[0], MessageSendEvent::SendStfu, node_id_1);
nodes[1].node.handle_stfu(node_id_0, &stfu);
- nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap();
- nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap();
+ assert!(nodes[0].node.exit_quiescence(&node_id_1, &chan_id).unwrap());
+ assert!(nodes[1].node.exit_quiescence(&node_id_0, &chan_id).unwrap());
// Now that quiescence is over, nodes are allowed to make updates again. nodes[1] will have its
// outbound HTLC finally go out, along with the fail/claim of nodes[0]'s payment.
Why this scored 27/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.