Abandon pending quiescent action upon shutdown initiation
What changed, and why it matters
This change fixes a state-handling bug in the Lightning Dev Kit where a channel that is about to be closed could still try to enter a 'quiet' negotiation phase used for splicing (modifying a channel's funds). After a recent refactor removed an 'awaiting quiescence' flag, the code could send a 'stfu' (stop, wait) message for a channel the user actually wants to shut down. The patch makes shutdown cleanly abandon any pending splice/quiescent action and notify the user with a SpliceFailed event. It is a protocol-correctness and user-experience fix rather than a direct funds-loss vulnerability.
Treat as a recommended bug-fix patch. Reviewers should confirm that abandon_quiescent_action is invoked from every shutdown entry point and that no other quiescent state (e.g., from dual-funded or future splice variants) is left dangling. Users running nodes with splicing enabled should upgrade to avoid stuck splice states and unexpected stfu traffic during channel close.
Security signals we found
Protocol state inconsistency: shutdown initiation no longer protected by awaiting-quiescence flag
Potential sending of stfu for a channel that is closing
Missing cleanup of pending splice state on shutdown
New event emission to inform caller of abandoned splice funding inputs/outputs
Regression test added for both local and remote shutdown cases
Evidence from the diff
The commit extracts the quiescent-action cleanup logic into a new Channel::abandon_quiescent_action() helper and calls it from both shutdown paths (local-initiated get_shutdown and peer-initiated shutdown). The helper removes any pending QuiescentAction::LegacySplice or QuiescentAction::Splice and returns a SpliceFundingFailed so the ChannelManager can emit Event::SpliceFailed. Return signatures of get_shutdown and shutdown are extended with an Option
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +153 / −47
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 033e2ac..fee74aa 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -7065,37 +7065,41 @@ where
shutdown_result
}
+ fn abandon_quiescent_action(&mut self) -> Option<SpliceFundingFailed> {
+ match self.quiescent_action.take() {
+ Some(QuiescentAction::LegacySplice(instructions)) => {
+ let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs();
+ Some(SpliceFundingFailed {
+ funding_txo: None,
+ channel_type: None,
+ contributed_inputs: inputs,
+ contributed_outputs: outputs,
+ })
+ },
+ Some(QuiescentAction::Splice { contribution, .. }) => {
+ let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs();
+ Some(SpliceFundingFailed {
+ funding_txo: None,
+ channel_type: None,
+ contributed_inputs: inputs,
+ contributed_outputs: outputs,
+ })
+ },
+ #[cfg(any(test, fuzzing, feature = "_test_utils"))]
+ Some(quiescent_action) => {
+ self.quiescent_action = Some(quiescent_action);
+ None
+ },
+ None => None,
+ }
+ }
+
fn maybe_fail_splice_negotiation(&mut self) -> Option<SpliceFundingFailed> {
if matches!(self.context.channel_state, ChannelState::ChannelReady(_)) {
if self.should_reset_pending_splice_state(false) {
self.reset_pending_splice_state()
} else {
- match self.quiescent_action.take() {
- Some(QuiescentAction::LegacySplice(instructions)) => {
- let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs();
- Some(SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs: inputs,
- contributed_outputs: outputs,
- })
- },
- Some(QuiescentAction::Splice { contribution, .. }) => {
- let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs();
- Some(SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs: inputs,
- contributed_outputs: outputs,
- })
- },
- #[cfg(any(test, fuzzing, feature = "_test_utils"))]
- Some(quiescent_action) => {
- self.quiescent_action = Some(quiescent_action);
- None
- },
- None => None,
- }
+ self.abandon_quiescent_action()
}
} else {
None
@@ -10638,7 +10642,12 @@ where
&mut self, logger: &L, signer_provider: &SP, their_features: &InitFeatures,
msg: &msgs::Shutdown,
) -> Result<
- (Option<msgs::Shutdown>, Option<ChannelMonitorUpdate>, Vec<(HTLCSource, PaymentHash)>),
+ (
+ Option<msgs::Shutdown>,
+ Option<ChannelMonitorUpdate>,
+ Vec<(HTLCSource, PaymentHash)>,
+ Option<SpliceFundingFailed>,
+ ),
ChannelError,
> {
if self.context.channel_state.is_peer_disconnected() {
@@ -10779,7 +10788,9 @@ where
self.context.channel_state.set_local_shutdown_sent();
self.context.update_time_counter += 1;
- Ok((shutdown, monitor_update, dropped_outbound_htlcs))
+ let splice_funding_failed = self.abandon_quiescent_action();
+
+ Ok((shutdown, monitor_update, dropped_outbound_htlcs, splice_funding_failed))
}
fn build_signed_closing_transaction(
@@ -13206,7 +13217,12 @@ where
target_feerate_sats_per_kw: Option<u32>, override_shutdown_script: Option<ShutdownScript>,
logger: &L,
) -> Result<
- (msgs::Shutdown, Option<ChannelMonitorUpdate>, Vec<(HTLCSource, PaymentHash)>),
+ (
+ msgs::Shutdown,
+ Option<ChannelMonitorUpdate>,
+ Vec<(HTLCSource, PaymentHash)>,
+ Option<SpliceFundingFailed>,
+ ),
APIError,
> {
let logger = WithChannelContext::from(logger, &self.context, None);
@@ -13328,7 +13344,9 @@ where
"we can't both complete shutdown and return a monitor update"
);
- Ok((shutdown, monitor_update, dropped_outbound_htlcs))
+ let splice_funding_failed = self.abandon_quiescent_action();
+
+ Ok((shutdown, monitor_update, dropped_outbound_htlcs, splice_funding_failed))
}
// Miscellaneous utilities
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 5cb7d36..0e764d6 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -3901,15 +3901,31 @@ impl<
if let Some(chan) = chan_entry.get_mut().as_funded_mut() {
let funding_txo_opt = chan.funding.get_funding_txo();
let their_features = &peer_state.latest_features;
- let (shutdown_msg, mut monitor_update_opt, htlcs) = chan.get_shutdown(
- &self.signer_provider,
- their_features,
- target_feerate_sats_per_1000_weight,
- override_shutdown_script,
- &self.logger,
- )?;
+ let (shutdown_msg, mut monitor_update_opt, htlcs, splice_funding_failed) =
+ chan.get_shutdown(
+ &self.signer_provider,
+ their_features,
+ target_feerate_sats_per_1000_weight,
+ override_shutdown_script,
+ &self.logger,
+ )?;
failed_htlcs = htlcs;
+ if let Some(splice_funding_failed) = splice_funding_failed {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SpliceFailed {
+ channel_id: *chan_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan.context().get_user_id(),
+ abandoned_funding_txo: splice_funding_failed.funding_txo,
+ channel_type: splice_funding_failed.channel_type,
+ contributed_inputs: splice_funding_failed.contributed_inputs,
+ contributed_outputs: splice_funding_failed.contributed_outputs,
+ },
+ None,
+ ));
+ }
+
// We can send the `shutdown` message before updating the `ChannelMonitor`
// here as we don't need the monitor update to complete until we send a
// `shutdown_signed`, which we'll delay if we're pending a monitor update.
@@ -11779,19 +11795,31 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
let funding_txo_opt = chan.funding.get_funding_txo();
- let (shutdown, monitor_update_opt, htlcs) = try_channel_entry!(
- self,
- peer_state,
- chan.shutdown(
- &self.logger,
- &self.signer_provider,
- &peer_state.latest_features,
- &msg
- ),
- chan_entry
+ let res = chan.shutdown(
+ &self.logger,
+ &self.signer_provider,
+ &peer_state.latest_features,
+ &msg,
);
+ let (shutdown, monitor_update_opt, htlcs, splice_funding_failed) =
+ try_channel_entry!(self, peer_state, res, chan_entry);
dropped_htlcs = htlcs;
+ if let Some(splice_funding_failed) = splice_funding_failed {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SpliceFailed {
+ channel_id: msg.channel_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan.context().get_user_id(),
+ abandoned_funding_txo: splice_funding_failed.funding_txo,
+ channel_type: splice_funding_failed.channel_type,
+ contributed_inputs: splice_funding_failed.contributed_inputs,
+ contributed_outputs: splice_funding_failed.contributed_outputs,
+ },
+ None,
+ ));
+ }
+
if let Some(msg) = shutdown {
// We can send the `shutdown` message before updating the `ChannelMonitor`
// here as we don't need the monitor update to complete until we send a
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index ed11126..fc18a9e 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -2406,6 +2406,66 @@ fn fail_quiescent_action_on_channel_close() {
check_added_monitors(&nodes[0], 1);
}
+#[test]
+fn abandon_splice_quiescent_action_on_shutdown() {
+ do_abandon_splice_quiescent_action_on_shutdown(true);
+ do_abandon_splice_quiescent_action_on_shutdown(false);
+}
+
+#[cfg(test)]
+fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
+ 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);
+ provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC);
+
+ 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_capacity = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0);
+
+ // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to
+ // splice, the `stfu` message is held back.
+ let (route, payment_hash, _payment_preimage, payment_secret) =
+ get_route_and_payment_hash!(&nodes[0], &nodes[1], 1_000_000);
+ let onion = RecipientOnionFields::secret_only(payment_secret);
+ let payment_id = PaymentId(payment_hash.0);
+ nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap();
+ let update = get_htlc_update_msgs(&nodes[0], &node_id_1);
+ check_added_monitors(&nodes[0], 1);
+
+ nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]);
+ nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]);
+ check_added_monitors(&nodes[1], 1);
+ let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0);
+
+ nodes[0].node.handle_revoke_and_ack(node_id_1, &revoke_and_ack);
+ check_added_monitors(&nodes[0], 1);
+
+ // Attempt the splice. `stfu` should not go out yet as the state machine is pending.
+ let splice_in_amount = initial_channel_capacity / 2;
+ let _ =
+ initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount));
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Close the channel. We should see a `SpliceFailed` event for the pending splice
+ // `QuiescentAction`.
+ let (closer_node, closee_node) =
+ if local_shutdown { (&nodes[0], &nodes[1]) } else { (&nodes[1], &nodes[0]) };
+ let closer_node_id = closer_node.node.get_our_node_id();
+ let closee_node_id = closee_node.node.get_our_node_id();
+
+ closer_node.node.close_channel(&channel_id, &closee_node_id).unwrap();
+ let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id);
+ closee_node.node.handle_shutdown(closer_node_id, &shutdown);
+
+ let _ = get_event!(nodes[0], Event::SpliceFailed);
+ let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id);
+}
+
#[cfg(test)]
fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forward: bool) {
// Test that we are still able to forward and resolve HTLCs while the original SCIDs contained
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.