Emit SpliceFailed event during channel shutdown
What changed, and why it matters
This change fixes a bookkeeping gap in the Lightning Dev Kit: when a channel is shut down while a splice (a special Bitcoin transaction that changes a channel's funding) is still pending, the library now emits a 'SpliceFailed' event. That event tells the wallet/user which coins they contributed to the splice so they can spend them again. Before this fix, those contributed inputs could be silently forgotten, potentially locking up funds until the user manually figured out what happened. It is a reliability/availability fix for user funds, not a remote-exploitable vulnerability.
Treat as a recommended reliability fix. Users running nodes that use splicing should upgrade to avoid losing track of splice-contributed UTXOs on forced or cooperative channel close. No immediate emergency response is warranted because the issue is local-availability, not remote code execution or theft.
Security signals we found
Funds-availability issue: pending splice UTXOs could be left unrecoverable after shutdown
New event emission (Event::SpliceFailed) to expose previously silent failure path
State cleanup in force_shutdown for QuiescentAction::Splice
Test coverage added for both interactive-tx and STFU-handshake splice failure paths
Evidence from the diff
The commit adds a splice_funding_failed field to ShutdownResult and populates it in Channel::force_shutdown when a QuiescentAction::Splice is pending. ChannelManager then emits an Event::SpliceFailed containing the abandoned funding txo, channel type, and contributed inputs/outputs. Tests are added/updated to expect this event during both in-progress interactive-tx construction and STFU-handshake splicing states. The change is defensive: it ensures users can reclaim UTXOs that were reserved for a splice that never completed due to channel closure.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rslightning/src/ln/functional_test_utils.rsInspect captured patch +187 / −2
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index bd766fc..6426d30 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -1191,6 +1191,9 @@ pub(crate) struct ShutdownResult {
pub(crate) unbroadcasted_funding_tx: Option<Transaction>,
pub(crate) channel_funding_txo: Option<OutPoint>,
pub(crate) last_local_balance_msat: u64,
+ /// If a splice was in progress when the channel was shut down, this contains
+ /// the splice funding information for emitting a SpliceFailed event.
+ pub(crate) splice_funding_failed: Option<SpliceFundingFailed>,
}
/// Tracks the transaction number, along with current and next commitment points.
@@ -2686,6 +2689,15 @@ pub(crate) struct SpliceInstructions {
locktime: u32,
}
+impl SpliceInstructions {
+ fn into_contributed_inputs_and_outputs(self) -> (Vec<bitcoin::OutPoint>, Vec<TxOut>) {
+ (
+ self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(),
+ self.our_funding_outputs,
+ )
+ }
+}
+
impl_writeable_tlv_based!(SpliceInstructions, {
(1, adjusted_funding_contribution, required),
(3, our_funding_inputs, required_vec),
@@ -6040,6 +6052,7 @@ where
is_manual_broadcast: self.is_manual_broadcast,
channel_funding_txo: funding.get_funding_txo(),
last_local_balance_msat: funding.value_to_self_msat,
+ splice_funding_failed: None,
}
}
@@ -6824,7 +6837,38 @@ where
}
pub fn force_shutdown(&mut self, closure_reason: ClosureReason) -> ShutdownResult {
- self.context.force_shutdown(&self.funding, closure_reason)
+ let splice_funding_failed =
+ if matches!(self.context.channel_state, ChannelState::ChannelReady(_)) {
+ if self.should_reset_pending_splice_state() {
+ self.reset_pending_splice_state()
+ } else {
+ match self.quiescent_action.take() {
+ Some(QuiescentAction::Splice(instructions)) => {
+ self.context.channel_state.clear_awaiting_quiescence();
+ let (inputs, outputs) =
+ instructions.into_contributed_inputs_and_outputs();
+ Some(SpliceFundingFailed {
+ funding_txo: None,
+ channel_type: None,
+ contributed_inputs: inputs,
+ contributed_outputs: outputs,
+ })
+ },
+ #[cfg(any(test, fuzzing))]
+ Some(quiescent_action) => {
+ self.quiescent_action = Some(quiescent_action);
+ None
+ },
+ None => None,
+ }
+ }
+ } else {
+ None
+ };
+
+ let mut shutdown_result = self.context.force_shutdown(&self.funding, closure_reason);
+ shutdown_result.splice_funding_failed = splice_funding_failed;
+ shutdown_result
}
fn interactive_tx_constructor_mut(&mut self) -> Option<&mut InteractiveTxConstructor> {
@@ -10372,6 +10416,7 @@ where
is_manual_broadcast: self.context.is_manual_broadcast,
channel_funding_txo: self.funding.get_funding_txo(),
last_local_balance_msat: self.funding.value_to_self_msat,
+ splice_funding_failed: None,
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 0baa855..6a9f011 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4536,6 +4536,18 @@ where
last_local_balance_msat: Some(shutdown_res.last_local_balance_msat),
}, None));
+ if let Some(splice_funding_failed) = shutdown_res.splice_funding_failed.take() {
+ pending_events.push_back((events::Event::SpliceFailed {
+ channel_id: shutdown_res.channel_id,
+ counterparty_node_id: shutdown_res.counterparty_node_id,
+ user_channel_id: shutdown_res.user_channel_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(transaction) = shutdown_res.unbroadcasted_funding_tx {
let funding_info = if shutdown_res.is_manual_broadcast {
FundingInfo::OutPoint {
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index f7cc818..ec3a7d0 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -2151,6 +2151,7 @@ pub struct ExpectedCloseEvent {
pub channel_id: Option<ChannelId>,
pub counterparty_node_id: Option<PublicKey>,
pub discard_funding: bool,
+ pub splice_failed: bool,
pub reason: Option<ClosureReason>,
pub channel_funding_txo: Option<OutPoint>,
pub user_channel_id: Option<u128>,
@@ -2165,6 +2166,7 @@ impl ExpectedCloseEvent {
channel_id: Some(channel_id),
counterparty_node_id: None,
discard_funding,
+ splice_failed: false,
reason: Some(reason),
channel_funding_txo: None,
user_channel_id: None,
@@ -2176,8 +2178,14 @@ impl ExpectedCloseEvent {
pub fn check_closed_events(node: &Node, expected_close_events: &[ExpectedCloseEvent]) {
let closed_events_count = expected_close_events.len();
let discard_events_count = expected_close_events.iter().filter(|e| e.discard_funding).count();
+ let splice_events_count = expected_close_events.iter().filter(|e| e.splice_failed).count();
let events = node.node.get_and_clear_pending_events();
- assert_eq!(events.len(), closed_events_count + discard_events_count, "{:?}", events);
+ assert_eq!(
+ events.len(),
+ closed_events_count + discard_events_count + splice_events_count,
+ "{:?}",
+ events
+ );
for expected_event in expected_close_events {
assert!(events.iter().any(|e| matches!(
e,
@@ -2207,6 +2215,10 @@ pub fn check_closed_events(node: &Node, expected_close_events: &[ExpectedCloseEv
events.iter().filter(|e| matches!(e, Event::DiscardFunding { .. },)).count(),
discard_events_count
);
+ assert_eq!(
+ events.iter().filter(|e| matches!(e, Event::SpliceFailed { .. },)).count(),
+ splice_events_count
+ );
}
/// Check that a channel's closing channel events has been issued
@@ -2228,6 +2240,7 @@ pub fn check_closed_event(
channel_id: None,
counterparty_node_id: Some(*node_id),
discard_funding: is_check_discard_funding,
+ splice_failed: false,
reason: Some(expected_reason.clone()),
channel_funding_txo: None,
user_channel_id: None,
diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs
index b316381..30ee01f 100644
--- a/lightning/src/ln/monitor_tests.rs
+++ b/lightning/src/ln/monitor_tests.rs
@@ -1467,6 +1467,7 @@ fn do_test_revoked_counterparty_commitment_balances(keyed_anchors: bool, p2a_anc
channel_id: Some(chan_id),
counterparty_node_id: Some(nodes[0].node.get_our_node_id()),
discard_funding: false,
+ splice_failed: false,
reason: None, // Could be due to any HTLC timing out, so don't bother checking
channel_funding_txo: None,
user_channel_id: None,
diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs
index b040f45..ede6acf 100644
--- a/lightning/src/ln/reorg_tests.rs
+++ b/lightning/src/ln/reorg_tests.rs
@@ -504,6 +504,7 @@ fn test_set_outpoints_partial_claiming() {
channel_id: Some(chan.2),
counterparty_node_id: Some(nodes[0].node.get_our_node_id()),
discard_funding: false,
+ splice_failed: false,
reason: None, // Could be due to either HTLC timing out, so don't bother checking
channel_funding_txo: None,
user_channel_id: None,
diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs
index 054842a..437298a 100644
--- a/lightning/src/ln/shutdown_tests.rs
+++ b/lightning/src/ln/shutdown_tests.rs
@@ -636,6 +636,7 @@ fn do_htlc_fail_async_shutdown(blinded_recipient: bool) {
channel_id: None,
counterparty_node_id: Some(node_a_id),
discard_funding: false,
+ splice_failed: false,
reason: Some(ClosureReason::LocallyInitiatedCooperativeClosure),
channel_funding_txo: None,
user_channel_id: None,
@@ -645,6 +646,7 @@ fn do_htlc_fail_async_shutdown(blinded_recipient: bool) {
channel_id: None,
counterparty_node_id: Some(node_c_id),
discard_funding: false,
+ splice_failed: false,
reason: Some(ClosureReason::CounterpartyInitiatedCooperativeClosure),
channel_funding_txo: None,
user_channel_id: None,
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 0af99e9..14f3192 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1341,3 +1341,114 @@ fn fail_splice_on_tx_abort() {
let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
}
+
+#[test]
+fn fail_splice_on_channel_close() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let config = test_default_anchors_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[0];
+ let acceptor = &nodes[1];
+
+ let _node_id_initiator = initiator.node.get_our_node_id();
+ let node_id_acceptor = acceptor.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);
+
+ let coinbase_tx = provide_anchor_reserves(&nodes);
+ let splice_in_amount = initial_channel_capacity / 2;
+ let contribution = SpliceContribution::SpliceIn {
+ value: Amount::from_sat(splice_in_amount),
+ inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()],
+ change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()),
+ };
+
+ // Close the channel before completion of interactive-tx construction.
+ let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone());
+ let _tx_add_input =
+ get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor);
+
+ initiator
+ .node
+ .force_close_broadcasting_latest_txn(&channel_id, &node_id_acceptor, "test".to_owned())
+ .unwrap();
+ handle_bump_events(initiator, true, 0);
+ check_closed_events(
+ &nodes[0],
+ &[ExpectedCloseEvent {
+ channel_id: Some(channel_id),
+ discard_funding: false,
+ splice_failed: true,
+ channel_funding_txo: None,
+ user_channel_id: Some(42),
+ ..Default::default()
+ }],
+ );
+ check_closed_broadcast(&nodes[0], 1, true);
+ check_added_monitors(&nodes[0], 1);
+}
+
+#[test]
+fn fail_quiescent_action_on_channel_close() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let config = test_default_anchors_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[0];
+ let acceptor = &nodes[1];
+
+ let _node_id_initiator = initiator.node.get_our_node_id();
+ let node_id_acceptor = acceptor.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);
+
+ let coinbase_tx = provide_anchor_reserves(&nodes);
+ let splice_in_amount = initial_channel_capacity / 2;
+ let contribution = SpliceContribution::SpliceIn {
+ value: Amount::from_sat(splice_in_amount),
+ inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()],
+ change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()),
+ };
+
+ // Close the channel before completion of STFU handshake.
+ initiator
+ .node
+ .splice_channel(
+ &channel_id,
+ &node_id_acceptor,
+ contribution,
+ FEERATE_FLOOR_SATS_PER_KW,
+ None,
+ )
+ .unwrap();
+
+ let _stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor);
+
+ initiator
+ .node
+ .force_close_broadcasting_latest_txn(&channel_id, &node_id_acceptor, "test".to_owned())
+ .unwrap();
+ handle_bump_events(initiator, true, 0);
+ check_closed_events(
+ &nodes[0],
+ &[ExpectedCloseEvent {
+ channel_id: Some(channel_id),
+ discard_funding: false,
+ splice_failed: true,
+ channel_funding_txo: None,
+ user_channel_id: Some(42),
+ ..Default::default()
+ }],
+ );
+ check_closed_broadcast(&nodes[0], 1, true);
+ check_added_monitors(&nodes[0], 1);
+}
Why this scored 33/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.