Merge initial and retry stfu send paths
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where a node could send a 'stop the flow and update' (stfu) message too early during a channel splice. If a splice was already pending and a new one was requested, the old code path could send stfu prematurely, potentially causing the splice protocol to get out of sync with the peer. The fix merges two similar code paths into one and adds a regression test.
Review the merged try_send_stfu logic to confirm all prior send_stfu invariants (debug_asserts, initiator flag handling) are preserved. Run the new splicing regression test and existing splice-related tests. Consider whether any other duplicated quiescence paths need similar unification.
Security signals we found
Protocol-state desynchronization during splicing
Missing guard condition duplicated across two code paths
Regression test added for premature stfu behavior
Evidence from the diff
The commit merges FundedChannel::send_stfu into FundedChannel::try_send_stfu, ensuring the pending-splice guard (introduced in 15b04b5) applies to both initial and retry stfu send paths. Previously, send_stfu did not check pending_splice, so a new splice could send stfu while an existing splice was still pending. The unified try_send_stfu now returns Option
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +106 / −88
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 7943ed9..35d5864 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -80,7 +80,7 @@ use crate::util::config::{
MaxDustHTLCExposure, UserConfig,
};
use crate::util::errors::APIError;
-use crate::util::logger::{Logger, Record, WithContext};
+use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext};
use crate::util::scid_utils::{block_from_scid, scid_from_parts};
use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer};
use crate::util::wallet_utils::Input;
@@ -13410,65 +13410,19 @@ where
);
return Err(action);
}
+ // Since we don't have a pending quiescent action, we should never be in a state where we
+ // sent `stfu` without already having become quiescent.
+ debug_assert!(!self.context.channel_state.is_awaiting_quiescence());
+ debug_assert!(!self.context.channel_state.is_local_stfu_sent());
self.quiescent_action = Some(action);
- if self.context.channel_state.is_quiescent()
- || self.context.channel_state.is_awaiting_quiescence()
- || self.context.channel_state.is_local_stfu_sent()
- {
- log_debug!(logger, "Channel is either pending quiescence or already quiescent");
+ if self.context.channel_state.is_quiescent() {
+ log_debug!(logger, "Channel is already quiescent");
return Ok(None);
}
self.context.channel_state.set_awaiting_quiescence();
- if self.context.is_live() {
- match self.send_stfu(logger) {
- Ok(stfu) => Ok(Some(stfu)),
- Err(e) => {
- log_debug!(logger, "{e}");
- Ok(None)
- },
- }
- } else {
- log_debug!(logger, "Waiting for peer reconnection to send stfu");
- Ok(None)
- }
- }
-
- // Assumes we are either awaiting quiescence or our counterparty has requested quiescence.
- #[rustfmt::skip]
- pub fn send_stfu<L: Logger>(&mut self, logger: &L) -> Result<msgs::Stfu, &'static str> {
- debug_assert!(!self.context.channel_state.is_local_stfu_sent());
- debug_assert!(
- self.context.channel_state.is_awaiting_quiescence()
- || self.context.channel_state.is_remote_stfu_sent()
- );
- debug_assert!(self.context.is_live());
-
- if self.context.is_waiting_on_peer_pending_channel_update()
- || self.context.is_monitor_or_signer_pending_channel_update()
- {
- return Err("We cannot send `stfu` while state machine is pending")
- }
-
- let initiator = if self.context.channel_state.is_remote_stfu_sent() {
- // We may have also attempted to initiate quiescence.
- self.context.channel_state.clear_awaiting_quiescence();
- self.context.channel_state.clear_remote_stfu_sent();
- self.context.channel_state.set_quiescent();
- // 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());
- self.context.channel_state.clear_awaiting_quiescence();
- self.context.channel_state.set_local_stfu_sent();
- true
- };
-
- Ok(msgs::Stfu { channel_id: self.context.channel_id, initiator })
+ Ok(self.try_send_stfu(false, logger))
}
#[rustfmt::skip]
@@ -13505,10 +13459,7 @@ where
self.context.channel_state.set_remote_stfu_sent();
log_debug!(logger, "Received counterparty stfu proposing quiescence");
- return self
- .send_stfu(logger)
- .map(|stfu| Some(StfuResponse::Stfu(stfu)))
- .map_err(|e| ChannelError::Ignore(e.to_owned()));
+ return Ok(self.try_send_stfu(false, logger).map(|stfu| StfuResponse::Stfu(stfu)))
}
// We already sent `stfu` and are now processing theirs. It may be in response to ours, or
@@ -13610,17 +13561,30 @@ where
Ok(None)
}
- pub fn try_send_stfu<L: Logger>(
- &mut self, logger: &L,
- ) -> Result<Option<msgs::Stfu>, ChannelError> {
+ pub fn try_send_stfu<L: Logger>(&mut self, is_retry: bool, logger: &L) -> Option<msgs::Stfu> {
// We must never see both stfu flags set, we always set the quiescent flag instead.
debug_assert!(
!(self.context.channel_state.is_local_stfu_sent()
&& self.context.channel_state.is_remote_stfu_sent())
);
+ // We only need to send `stfu` when we're awaiting quiescence and haven't sent it yet, or
+ // in response to a counterparty one.
+ if self.context.channel_state.is_local_stfu_sent()
+ || self.context.channel_state.is_quiescent()
+ {
+ return None;
+ }
+ if !self.context.channel_state.is_awaiting_quiescence()
+ && !self.context.channel_state.is_remote_stfu_sent()
+ {
+ return None;
+ }
+
+ let logger_level = if is_retry { LoggerLevel::Trace } else { LoggerLevel::Debug };
if !self.context.is_live() {
- return Ok(None);
+ log_given_level!(logger, logger_level, "Waiting for peer reconnection to send stfu");
+ return None;
}
if let Some(action) = self.quiescent_action.as_ref() {
@@ -13630,27 +13594,44 @@ where
let has_splice_action = matches!(action, QuiescentAction::Splice { .. })
|| matches!(action, QuiescentAction::LegacySplice(_));
if has_splice_action && self.pending_splice.is_some() {
- return Ok(None);
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting for pending splice to lock before sending stfu for new splice"
+ );
+ return None;
}
}
- // We need to send our `stfu`, either because we're trying to initiate quiescence, or the
- // counterparty is and we've yet to send ours.
- if self.context.channel_state.is_awaiting_quiescence()
- || (self.context.channel_state.is_remote_stfu_sent()
- && !self.context.channel_state.is_local_stfu_sent())
+ if self.context.is_waiting_on_peer_pending_channel_update()
+ || self.context.is_monitor_or_signer_pending_channel_update()
{
- return self
- .send_stfu(logger)
- .map(|stfu| Some(stfu))
- .map_err(|e| ChannelError::Ignore(e.to_owned()));
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting for state machine pending changes to complete before sending stfu"
+ );
+ return None;
}
- // We're either:
- // - already quiescent
- // - in a state where quiescence is not possible
- // - not currently trying to become quiescent
- Ok(None)
+ let initiator = if self.context.channel_state.is_remote_stfu_sent() {
+ // We may have also attempted to initiate quiescence.
+ self.context.channel_state.clear_awaiting_quiescence();
+ self.context.channel_state.clear_remote_stfu_sent();
+ self.context.channel_state.set_quiescent();
+ // We are sending an stfu in response to our counterparty'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());
+ self.context.channel_state.clear_awaiting_quiescence();
+ self.context.channel_state.set_local_stfu_sent();
+ true
+ };
+
+ Some(msgs::Stfu { channel_id: self.context.channel_id, initiator })
}
#[cfg(any(test, fuzzing, feature = "_test_utils"))]
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 08cbb6f..5cb7d36 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -13342,17 +13342,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
let logger = WithContext::from(
&self.logger, Some(*counterparty_node_id), Some(*channel_id), None
);
- match funded_chan.try_send_stfu(&&logger) {
- Ok(None) => {},
- Ok(Some(stfu)) => {
- pending_msg_events.push(MessageSendEvent::SendStfu {
- node_id: chan.context().get_counterparty_node_id(),
- msg: stfu,
- });
- },
- Err(e) => {
- log_debug!(logger, "Could not advance quiescence handshake: {}", e);
- }
+ if let Some(stfu) = funded_chan.try_send_stfu(true, &&logger) {
+ pending_msg_events.push(MessageSendEvent::SendStfu {
+ node_id: chan.context().get_counterparty_node_id(),
+ msg: stfu,
+ });
}
}
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 92a298f..ed11126 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1145,6 +1145,49 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
);
}
+#[test]
+fn test_initiating_splice_holds_stfu_with_pending_splice() {
+ // Test that we don't send stfu too early for a new splice while we're already pending one.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let config = test_default_channel_config();
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_0_id = nodes[0].node.get_our_node_id();
+ provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC);
+
+ 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);
+
+ // Have both nodes attempt a splice, but only node 0 will call back and negotiate the splice.
+ let value_added = Amount::from_sat(10_000);
+ let funding_contribution_0 = initiate_splice_in(&nodes[0], &nodes[1], channel_id, value_added);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[1].node.splice_channel(&channel_id, &node_0_id, feerate).unwrap();
+
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution_0);
+
+ // With the splice negotiated, have node 1 call back. This will queue the quiescent action, but
+ // it shouldn't send stfu yet as there's a pending splice.
+ let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), &nodes[1].logger);
+ let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_0_id, funding_contribution.clone(), None)
+ .unwrap();
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+ let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5);
+ assert!(
+ matches!(stfu, Some(MessageSendEvent::SendStfu { node_id, .. }) if node_id == node_0_id)
+ );
+}
+
#[cfg(test)]
#[derive(PartialEq)]
enum SpliceStatus {
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.