Emit SpliceFailed for acceptor contributions
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's splicing code. When two Lightning nodes tried to update ('splice') a channel together and the negotiation failed, only the node that started the splice got a notification to reclaim its locked-up bitcoins. The other node (the 'acceptor') was silently left without any event, so its wallet software wouldn't know it needed to reclaim its contributed coins. The patch now emits the proper failure event for the acceptor too, but only when the acceptor actually contributed something.
Treat as a bug-fix commit with moderate security relevance. Reviewers should confirm that the new empty-contribution guard prevents spurious events for passive acceptors, and that the regression test covers both splice-in and splice-out acceptor contribution paths. No immediate emergency response is indicated, but downstream wallets should ensure they handle SpliceFailed/DiscardFunding events for acceptor roles.
Security signals we found
Funds-availability bug: acceptor UTXOs could remain un-reclaimed after splice failure
Missing event emission for failure path
State/event asymmetry between initiator and acceptor
Regression test added for disconnect-during-splice scenario
Evidence from the diff
The maybe_create_splice_funding_failed! macro in lightning/src/ln/channel.rs previously filtered out all non-initiator splice negotiations via is_initiator(). As a result, when a splice negotiation failed (e.g., peer disconnect during interactive transaction construction), acceptor contributions were discarded without generating SpliceFailed and DiscardFunding events. The patch removes the initiator-only filter and instead checks post-hoc whether the acceptor has any contributed_inputs or contributed_outputs; if both are empty, no event is emitted. A regression test test_splice_acceptor_disconnect_emits_events verifies both nodes receive the events after a mid-negotiation disconnect and that the channel remains operational after reconnect.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsSpliceFundingFailed event generationDiscardFunding event generationInspect captured patch +82 / −4
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 9361cd3..05bd9b3 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -6568,8 +6568,9 @@ macro_rules! maybe_create_splice_funding_failed {
($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{
$pending_splice
.and_then(|pending_splice| pending_splice.funding_negotiation.$get())
- .filter(|funding_negotiation| funding_negotiation.is_initiator())
- .map(|funding_negotiation| {
+ .and_then(|funding_negotiation| {
+ let is_initiator = funding_negotiation.is_initiator();
+
let funding_txo = funding_negotiation
.as_funding()
.and_then(|funding| funding.get_funding_txo())
@@ -6595,12 +6596,17 @@ macro_rules! maybe_create_splice_funding_failed {
.$contributed_inputs_and_outputs(),
};
- SpliceFundingFailed {
+ if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty()
+ {
+ return None;
+ }
+
+ Some(SpliceFundingFailed {
funding_txo,
channel_type,
contributed_inputs,
contributed_outputs,
- }
+ })
})
}};
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 486e386..f8c188c 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -4006,3 +4006,75 @@ fn do_test_splice_pending_htlcs(config: UserConfig) {
let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000);
let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000);
}
+
+#[test]
+fn test_splice_acceptor_disconnect_emits_events() {
+ // When both nodes contribute to a splice and the negotiation fails due to disconnect,
+ // both the initiator and acceptor should receive SpliceFailed + DiscardFunding events
+ // so each can reclaim their UTXOs.
+ 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);
+
+ 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_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+
+ // Both nodes initiate splice-in (tiebreak: node 0 wins).
+ let node_0_funding_contribution =
+ do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let _node_1_funding_contribution =
+ do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ assert_ne!(splice_ack.funding_contribution_satoshis, 0);
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ // Disconnect mid-interactive-TX negotiation.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ // The initiator should get SpliceFailed + DiscardFunding.
+ expect_splice_failed_events(&nodes[0], &channel_id, node_0_funding_contribution);
+
+ // The acceptor should also get SpliceFailed + DiscardFunding with its contributions
+ // so it can reclaim its UTXOs. The contribution is feerate-adjusted by handle_splice_init,
+ // so we check for non-empty inputs/outputs rather than exact values.
+ let events = nodes[1].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2, "{events:?}");
+ match &events[0] {
+ Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id),
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
+ match &events[1] {
+ Event::DiscardFunding {
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ ..
+ } => {
+ assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty");
+ assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty");
+ },
+ other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
+ }
+
+ // Reconnect and verify the channel is still operational.
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_channel_ready = (true, true);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_nodes(reconnect_args);
+}
Why this scored 58/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.