Free holding cell upon handling an invalid interactive-tx message
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where a channel that had paused normal traffic to negotiate a transaction (called 'quiescence') could fail to resume normal traffic if the negotiation broke due to an invalid message. Payments or other updates that were queued during the pause could get stuck until the channel was closed. The fix makes sure those queued items are released when the negotiation aborts.
Apply the patch and run the updated splicing tests. Nodes running versions between cad88af and this commit should upgrade to avoid stuck HTLCs during failed splices.
Security signals we found
State inconsistency: quiescence flag reset without freeing the holding cell
Resource/availability impact: queued HTLCs stuck until channel close
Fix is a follow-up to a prior patch (cad88af) that addressed related paths
New regression tests explicitly exercise the stuck-HTLC scenario
Evidence from the diff
The patch extends the error path for invalid interactive-tx messages (tx_add_input/output, tx_remove_input/output, tx_complete) so that the channel manager is told whether the channel has exited quiescence. If it has, the manager calls the existing holding-cell freeing logic. Previously this was only done for some quiescence-exit paths (commit cad88af), but not for the interactive-tx processing-error path. The change adds an InteractiveTxMsgError struct carrying an exited_quiescence flag, propagates it through MsgHandleErrInternal, and triggers check_free_peer_holding_cells in the message-handling error path. Tests are added/updated to verify HTLCs queued in the holding cell are freed after such failures.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rsInteractive transaction negotiation (splicing / dual-funding)Quiescence / holding-cell logicInspect captured patch +221 / −45
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 0f1916a..d1adbf7 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -1204,6 +1204,18 @@ pub enum UpdateFulfillCommitFetch {
DuplicateClaim {},
}
+/// Error returned when processing an invalid interactive-tx message from our counterparty.
+pub(super) struct InteractiveTxMsgError {
+ /// The underlying error.
+ pub(super) err: ChannelError,
+ /// If a splice was in progress when processing the message, this contains the splice funding
+ /// information for emitting a `SpliceFailed` event.
+ pub(super) splice_funding_failed: Option<SpliceFundingFailed>,
+ /// Whether we were quiescent when we received the message, and are no longer due to aborting
+ /// the session.
+ pub(super) exited_quiescence: bool,
+}
+
/// The return value of `monitor_updating_restored`
pub(super) struct MonitorRestoreUpdates {
pub raa: Option<msgs::RevokeAndACK>,
@@ -1846,104 +1858,118 @@ where
fn fail_interactive_tx_negotiation<L: Logger>(
&mut self, reason: AbortReason, logger: &L,
- ) -> (ChannelError, Option<SpliceFundingFailed>) {
+ ) -> InteractiveTxMsgError {
let logger = WithChannelContext::from(logger, &self.context(), None);
log_info!(logger, "Failed interactive transaction negotiation: {reason}");
- let splice_funding_failed = match &mut self.phase {
+ let (splice_funding_failed, exited_quiescence) = match &mut self.phase {
ChannelPhase::Undefined => unreachable!(),
- ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => None,
+ ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => {
+ (None, false)
+ },
ChannelPhase::UnfundedV2(pending_v2_channel) => {
pending_v2_channel.interactive_tx_constructor.take();
- None
+ (None, false)
},
ChannelPhase::Funded(funded_channel) => {
if funded_channel.should_reset_pending_splice_state(false) {
- funded_channel.reset_pending_splice_state()
+ (funded_channel.reset_pending_splice_state(), true)
} else {
debug_assert!(false, "We should never fail an interactive funding negotiation once we're exchanging tx_signatures");
- None
+ (None, false)
}
},
};
- (ChannelError::Abort(reason), splice_funding_failed)
+ InteractiveTxMsgError {
+ err: ChannelError::Abort(reason),
+ splice_funding_failed,
+ exited_quiescence,
+ }
}
pub fn tx_add_input<L: Logger>(
&mut self, msg: &msgs::TxAddInput, logger: &L,
- ) -> Result<InteractiveTxMessageSend, (ChannelError, Option<SpliceFundingFailed>)> {
+ ) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
match self.interactive_tx_constructor_mut() {
Some(interactive_tx_constructor) => interactive_tx_constructor
.handle_tx_add_input(msg)
.map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err((
- ChannelError::WarnAndDisconnect(
+ None => Err(InteractiveTxMsgError {
+ err: ChannelError::WarnAndDisconnect(
"Received unexpected interactive transaction negotiation message".to_owned(),
),
- None,
- )),
+ splice_funding_failed: None,
+ exited_quiescence: false,
+ }),
}
}
pub fn tx_add_output<L: Logger>(
&mut self, msg: &msgs::TxAddOutput, logger: &L,
- ) -> Result<InteractiveTxMessageSend, (ChannelError, Option<SpliceFundingFailed>)> {
+ ) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
match self.interactive_tx_constructor_mut() {
Some(interactive_tx_constructor) => interactive_tx_constructor
.handle_tx_add_output(msg)
.map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err((
- ChannelError::WarnAndDisconnect(
+ None => Err(InteractiveTxMsgError {
+ err: ChannelError::WarnAndDisconnect(
"Received unexpected interactive transaction negotiation message".to_owned(),
),
- None,
- )),
+ splice_funding_failed: None,
+ exited_quiescence: false,
+ }),
}
}
pub fn tx_remove_input<L: Logger>(
&mut self, msg: &msgs::TxRemoveInput, logger: &L,
- ) -> Result<InteractiveTxMessageSend, (ChannelError, Option<SpliceFundingFailed>)> {
+ ) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
match self.interactive_tx_constructor_mut() {
Some(interactive_tx_constructor) => interactive_tx_constructor
.handle_tx_remove_input(msg)
.map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err((
- ChannelError::WarnAndDisconnect(
+ None => Err(InteractiveTxMsgError {
+ err: ChannelError::WarnAndDisconnect(
"Received unexpected interactive transaction negotiation message".to_owned(),
),
- None,
- )),
+ splice_funding_failed: None,
+ exited_quiescence: false,
+ }),
}
}
pub fn tx_remove_output<L: Logger>(
&mut self, msg: &msgs::TxRemoveOutput, logger: &L,
- ) -> Result<InteractiveTxMessageSend, (ChannelError, Option<SpliceFundingFailed>)> {
+ ) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
match self.interactive_tx_constructor_mut() {
Some(interactive_tx_constructor) => interactive_tx_constructor
.handle_tx_remove_output(msg)
.map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err((
- ChannelError::WarnAndDisconnect(
+ None => Err(InteractiveTxMsgError {
+ err: ChannelError::WarnAndDisconnect(
"Received unexpected interactive transaction negotiation message".to_owned(),
),
- None,
- )),
+ splice_funding_failed: None,
+ exited_quiescence: false,
+ }),
}
}
pub fn tx_complete<F: FeeEstimator, L: Logger>(
&mut self, msg: &msgs::TxComplete, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
- ) -> Result<TxCompleteResult, (ChannelError, Option<SpliceFundingFailed>)> {
+ ) -> Result<TxCompleteResult, InteractiveTxMsgError> {
let tx_complete_action = match self.interactive_tx_constructor_mut() {
Some(interactive_tx_constructor) => interactive_tx_constructor
.handle_tx_complete(msg)
.map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))?,
None => {
let err = "Received unexpected interactive transaction negotiation message";
- return Err((ChannelError::WarnAndDisconnect(err.to_owned()), None));
+ return Err(InteractiveTxMsgError {
+ err: ChannelError::WarnAndDisconnect(err.to_owned()),
+ splice_funding_failed: None,
+ exited_quiescence: false,
+ });
},
};
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 75de6ab..67b2dc8 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -59,8 +59,8 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight;
use crate::ln::channel::QuiescentAction;
use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult,
- FundedChannel, FundingTxSigned, InboundV1Channel, OutboundHop, OutboundV1Channel,
- PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, StfuResponse,
+ FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop,
+ OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse,
UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::ChannelDetails;
@@ -938,6 +938,7 @@ struct MsgHandleErrInternal {
closes_channel: bool,
shutdown_finish: Option<(ShutdownResult, Option<(msgs::ChannelUpdate, NodeId, NodeId)>)>,
tx_abort: Option<msgs::TxAbort>,
+ exited_quiescence: bool,
}
impl MsgHandleErrInternal {
@@ -952,6 +953,7 @@ impl MsgHandleErrInternal {
closes_channel: false,
shutdown_finish: None,
tx_abort: None,
+ exited_quiescence: false,
}
}
@@ -970,7 +972,13 @@ impl MsgHandleErrInternal {
}
fn from_no_close(err: msgs::LightningError) -> Self {
- Self { err, closes_channel: false, shutdown_finish: None, tx_abort: None }
+ Self {
+ err,
+ closes_channel: false,
+ shutdown_finish: None,
+ tx_abort: None,
+ exited_quiescence: false,
+ }
}
fn from_finish_shutdown(
@@ -991,6 +999,7 @@ impl MsgHandleErrInternal {
closes_channel: true,
shutdown_finish: Some((shutdown_res, channel_update)),
tx_abort: None,
+ exited_quiescence: false,
}
}
@@ -1026,7 +1035,13 @@ impl MsgHandleErrInternal {
},
},
};
- Self { err, closes_channel: false, shutdown_finish: None, tx_abort }
+ Self {
+ err,
+ closes_channel: false,
+ shutdown_finish: None,
+ tx_abort,
+ exited_quiescence: false,
+ }
}
fn dont_send_error_message(&mut self) {
@@ -1042,6 +1057,11 @@ impl MsgHandleErrInternal {
fn closes_channel(&self) -> bool {
self.closes_channel
}
+
+ fn with_exited_quiescence(mut self, exited_quiescence: bool) -> Self {
+ self.exited_quiescence = exited_quiescence;
+ self
+ }
}
/// For events which result in both a RevokeAndACK and a CommitmentUpdate, by default they should
@@ -4350,15 +4370,26 @@ impl<
});
}
- if let Some(msg_event) = msg_event {
+ let mut holding_cell_res = None;
+ if msg_event.is_some() || err_internal.exited_quiescence {
let per_peer_state = self.per_peer_state.read().unwrap();
if let Some(peer_state_mutex) = per_peer_state.get(&counterparty_node_id) {
let mut peer_state = peer_state_mutex.lock().unwrap();
- if peer_state.is_connected {
- peer_state.pending_msg_events.push(msg_event);
+ if let Some(msg_event) = msg_event {
+ if peer_state.is_connected {
+ peer_state.pending_msg_events.push(msg_event);
+ }
}
+ // We need to enqueue the `tx_abort` in `pending_msg_events` above before we
+ // enqueue any commitment updates generated by freeing holding cell HTLCs.
+ holding_cell_res = err_internal
+ .exited_quiescence
+ .then(|| self.check_free_peer_holding_cells(&mut peer_state));
}
}
+ if let Some(res) = holding_cell_res {
+ self.handle_holding_cell_free_result(res);
+ }
// Return error in case higher-API need one
err_internal.err
@@ -11301,9 +11332,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
fn internal_tx_msg<
- HandleTxMsgFn: Fn(
- &mut Channel<SP>,
- ) -> Result<InteractiveTxMessageSend, (ChannelError, Option<SpliceFundingFailed>)>,
+ HandleTxMsgFn: Fn(&mut Channel<SP>) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError>,
>(
&self, counterparty_node_id: &PublicKey, channel_id: ChannelId,
tx_msg_handler: HandleTxMsgFn,
@@ -11324,7 +11353,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
peer_state.pending_msg_events.push(msg_send_event);
Ok(NotifyOption::SkipPersistHandleEvents)
},
- Err((error, splice_funding_failed)) => {
+ Err(InteractiveTxMsgError {
+ err,
+ splice_funding_failed,
+ exited_quiescence,
+ }) => {
if let Some(splice_funding_failed) = splice_funding_failed {
let pending_events = &mut self.pending_events.lock().unwrap();
pending_events.push_back((
@@ -11340,7 +11373,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
None,
));
}
- Err(MsgHandleErrInternal::from_chan_no_close(error, channel_id))
+ debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_)));
+
+ Err(MsgHandleErrInternal::from_chan_no_close(err, channel_id)
+ .with_exited_quiescence(exited_quiescence))
},
}
},
@@ -11470,7 +11506,11 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
Ok(persist)
},
- Err((error, splice_funding_failed)) => {
+ Err(InteractiveTxMsgError {
+ err,
+ splice_funding_failed,
+ exited_quiescence,
+ }) => {
if let Some(splice_funding_failed) = splice_funding_failed {
let pending_events = &mut self.pending_events.lock().unwrap();
pending_events.push_back((
@@ -11486,7 +11526,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
None,
));
}
- Err(MsgHandleErrInternal::from_chan_no_close(error, msg.channel_id))
+ debug_assert!(!exited_quiescence || matches!(err, ChannelError::Abort(_)));
+
+ Err(MsgHandleErrInternal::from_chan_no_close(err, msg.channel_id)
+ .with_exited_quiescence(exited_quiescence))
},
}
},
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index cc422d6..c051f29 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1962,6 +1962,13 @@ fn fail_splice_on_interactive_tx_error() {
initiate_splice_in(initiator, acceptor, channel_id, Amount::from_sat(splice_in_amount));
let _ = complete_splice_handshake(initiator, acceptor);
+ // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence.
+ let (route, payment_hash, _payment_preimage, payment_secret) =
+ get_route_and_payment_hash!(initiator, acceptor, 1_000_000);
+ let onion = RecipientOnionFields::secret_only(payment_secret);
+ let payment_id = PaymentId(payment_hash.0);
+ initiator.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap();
+
let tx_add_input =
get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor);
acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input);
@@ -1979,11 +1986,28 @@ fn fail_splice_on_interactive_tx_error() {
_ => panic!("Expected Event::SpliceFailed"),
}
- let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
- acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
+ // We exit quiescence upon sending `tx_abort`, so we should see the holding cell be immediately
+ // freed.
+ let msg_events = initiator.node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let tx_abort = if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] {
+ msg
+ } else {
+ panic!("Unexpected event {:?}", msg_events[0]);
+ };
+ let update = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] {
+ updates
+ } else {
+ panic!("Unexpected event {:?}", msg_events[1]);
+ };
+ check_added_monitors(initiator, 1);
+ acceptor.node.handle_tx_abort(node_id_initiator, tx_abort);
let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort);
+
+ acceptor.node.handle_update_add_htlc(node_id_initiator, &update.update_add_htlcs[0]);
+ do_commitment_signed_dance(acceptor, initiator, &update.commitment_signed, false, false);
}
#[test]
@@ -2037,6 +2061,89 @@ fn fail_splice_on_tx_abort() {
acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
}
+#[test]
+fn fail_splice_on_tx_complete_error() {
+ 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, &[Some(config.clone()), Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let initiator = &nodes[1];
+ let acceptor = &nodes[0];
+
+ let node_id_initiator = initiator.node.get_our_node_id();
+ let node_id_acceptor = acceptor.node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000);
+
+ let outputs = vec![TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: acceptor.wallet_source.get_change_script().unwrap(),
+ }];
+ let _ = initiate_splice_out(initiator, acceptor, channel_id, outputs);
+ let _ = complete_splice_handshake(initiator, acceptor);
+
+ // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence.
+ let (route, payment_hash, _payment_preimage, payment_secret) =
+ get_route_and_payment_hash!(initiator, acceptor, 1_000_000);
+ let onion = RecipientOnionFields::secret_only(payment_secret);
+ let payment_id = PaymentId(payment_hash.0);
+ acceptor.node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap();
+
+ let tx_add_input =
+ get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor);
+ acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input);
+ let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator);
+ initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete);
+
+ // Tamper the shared funding output such that the acceptor fails upon `tx_complete`.
+ let mut tx_add_output =
+ get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor);
+ if tx_add_output.script.is_p2wsh() {
+ tx_add_output.sats *= 2;
+ }
+ acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output);
+ let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator);
+ initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete);
+
+ let mut tx_add_output =
+ get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor);
+ if tx_add_output.script.is_p2wsh() {
+ tx_add_output.sats *= 2;
+ }
+ acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output);
+ let tx_complete = get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator);
+ initiator.node.handle_tx_complete(node_id_acceptor, &tx_complete);
+
+ let _ = get_event!(initiator, Event::FundingTransactionReadyForSigning);
+ let tx_complete = get_event_msg!(initiator, MessageSendEvent::SendTxComplete, node_id_acceptor);
+ acceptor.node.handle_tx_complete(node_id_initiator, &tx_complete);
+
+ let msg_events = acceptor.node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ check_added_monitors(acceptor, 1);
+ let tx_abort = if let MessageSendEvent::SendTxAbort { msg, .. } = &msg_events[0] {
+ msg
+ } else {
+ panic!("Unexpected event {:?}", msg_events[0]);
+ };
+ let update = if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[1] {
+ updates
+ } else {
+ panic!("Unexpected event {:?}", msg_events[1]);
+ };
+
+ initiator.node.handle_tx_abort(node_id_acceptor, tx_abort);
+ let _ = get_event!(initiator, Event::SpliceFailed);
+ let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
+ acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
+
+ initiator.node.handle_update_add_htlc(node_id_acceptor, &update.update_add_htlcs[0]);
+ do_commitment_signed_dance(initiator, acceptor, &update.commitment_signed, false, false);
+}
+
#[test]
fn fail_splice_on_channel_close() {
let chanmon_cfgs = create_chanmon_cfgs(2);
Why this scored 56/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.