Emit DiscardFunding before SpliceFailed
What changed, and why it matters
This commit fixes an ordering bug in how LDK tells the user about a failed splice. Previously, the 'splice failed' notice was sent before the 'you may now unlock your funds' notice. If a user automatically retried the splice upon seeing the failure, their old inputs could still be locked, and the later unlock message would accidentally free inputs that were now being used by the retry. The commit simply reverses the order so funds are unlocked before the failure notice is delivered, and updates tests to match.
Review the new ordering at all splice-failure emission sites to confirm no site was missed, and ensure downstream consumers of these events do not rely on the old order. The change is safe to apply and is accompanied by tests.
Security signals we found
State-management bug in event ordering for failed splicing
Potential double-spend / input-reuse hazard if user retries splice automatically
Fix is purely reordering of existing events; no new cryptographic checks
Tests updated and an assertion added to prevent regression
Evidence from the diff
The patch reorders event emission so Event::DiscardFunding is always pushed to pending_events before Event::SpliceFailed at every splice-failure site in channelmanager.rs. It also updates functional_test_utils.rs and splicing_tests.rs to expect the new order. A runtime assertion is added in the event-processing test helper to enforce that SpliceFailed is never immediately followed by DiscardFunding for the same channel. The bug is a logic/state-management issue, not memory corruption or cryptographic failure.
Changed components
lightning/src/ln/channelmanager.rslightning/src/ln/functional_test_utils.rslightning/src/ln/splicing_tests.rsInspect captured patch +120 / −104
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 2d6aaa5..6a3be0c 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4163,6 +4163,15 @@ impl<
if let Some(splice_funding_failed) = splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let mut pending_events = self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: *chan_id,
+ funding_info,
+ },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id: *chan_id,
@@ -4173,15 +4182,6 @@ impl<
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding {
- channel_id: *chan_id,
- funding_info,
- },
- None,
- ));
- }
}
// We can send the `shutdown` message before updating the `ChannelMonitor`
@@ -4469,6 +4469,15 @@ impl<
if let Some(splice_funding_failed) = shutdown_res.splice_funding_failed.take() {
let (funding_info, contribution) = splice_funding_failed.into_parts();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: shutdown_res.channel_id,
+ funding_info,
+ },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id: shutdown_res.channel_id,
@@ -4479,15 +4488,6 @@ impl<
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding {
- channel_id: shutdown_res.channel_id,
- funding_info,
- },
- None,
- ));
- }
}
if let Some(transaction) = shutdown_res.unbroadcasted_funding_tx {
@@ -4975,6 +4975,15 @@ impl<
if let Some(splice_funding_failed) = splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let pending_events = &mut self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: *channel_id,
+ funding_info,
+ },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id: *channel_id,
@@ -4985,15 +4994,6 @@ impl<
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding {
- channel_id: *channel_id,
- funding_info,
- },
- None,
- ));
- }
}
Ok(())
@@ -6676,6 +6676,12 @@ impl<
QuiescentError::FailSplice(splice_funding_failed, reason) => {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let pending_events = &mut self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding { channel_id, funding_info },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id,
@@ -6686,12 +6692,6 @@ impl<
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding { channel_id, funding_info },
- None,
- ));
- }
},
}
}
@@ -11967,6 +11967,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(splice_funding_failed) = err.splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let pending_events = &mut self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events
+ .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id,
@@ -11979,10 +11983,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events
- .push_back((events::Event::DiscardFunding { channel_id, funding_info }, None));
- }
}
MsgHandleErrInternal::from_chan_no_close(err.err, channel_id)
}
@@ -12300,6 +12300,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(splice_funding_failed) = splice_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let pending_events = &mut self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: msg.channel_id,
+ funding_info,
+ },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id: msg.channel_id,
@@ -12314,15 +12323,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding {
- channel_id: msg.channel_id,
- funding_info,
- },
- None,
- ));
- }
}
let holding_cell_res = if needs_holding_cell_release {
@@ -12452,6 +12452,15 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(splice_funding_failed) = splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let mut pending_events = self.pending_events.lock().unwrap();
+ if let Some(funding_info) = funding_info {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: msg.channel_id,
+ funding_info,
+ },
+ None,
+ ));
+ }
pending_events.push_back((
events::Event::SpliceFailed {
channel_id: msg.channel_id,
@@ -12462,15 +12471,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
},
None,
));
- if let Some(funding_info) = funding_info {
- pending_events.push_back((
- events::Event::DiscardFunding {
- channel_id: msg.channel_id,
- funding_info,
- },
- None,
- ));
- }
}
if let Some(msg) = shutdown {
@@ -15253,6 +15253,22 @@ impl<
self.process_pending_events(&event_handler);
let collected_events = events.into_inner();
+ // When both DiscardFunding and SpliceFailed are emitted for the same channel,
+ // DiscardFunding must come first so that inputs are unlocked before any retry.
+ // Each pair is emitted adjacently under a single lock, so checking adjacent
+ // events is sufficient.
+ for window in collected_events.windows(2) {
+ if let events::Event::SpliceFailed { channel_id, .. } = &window[0] {
+ if let events::Event::DiscardFunding { channel_id: cid, .. } = &window[1] {
+ assert!(
+ channel_id != cid,
+ "DiscardFunding must precede SpliceFailed for channel {}",
+ channel_id,
+ );
+ }
+ }
+ }
+
// To expand the coverage and make sure all events are properly serialised and deserialised,
// we test all generated events round-trip:
for event in &collected_events {
@@ -15529,6 +15545,12 @@ impl<
if let Some(splice_funding_failed) = splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
+ if let Some(funding_info) = funding_info {
+ splice_failed_events.push(events::Event::DiscardFunding {
+ channel_id: chan.context().channel_id(),
+ funding_info,
+ });
+ }
splice_failed_events.push(events::Event::SpliceFailed {
channel_id: chan.context().channel_id(),
counterparty_node_id,
@@ -15536,12 +15558,6 @@ impl<
contribution,
reason: events::NegotiationFailureReason::PeerDisconnected,
});
- if let Some(funding_info) = funding_info {
- splice_failed_events.push(events::Event::DiscardFunding {
- channel_id: chan.context().channel_id(),
- funding_info,
- });
- }
}
if is_resumable {
@@ -18156,6 +18172,15 @@ impl<
for chan in peer_state.channel_by_id.values().filter_map(Channel::as_funded) {
if let Some(splice_funding_failed) = chan.maybe_splice_funding_failed() {
let (funding_info, contribution) = splice_funding_failed.into_parts();
+ if let Some(funding_info) = funding_info {
+ events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: chan.context().channel_id(),
+ funding_info,
+ },
+ None,
+ ));
+ }
events.push_back((
events::Event::SpliceFailed {
channel_id: chan.context.channel_id(),
@@ -18166,15 +18191,6 @@ impl<
},
None,
));
- if let Some(funding_info) = funding_info {
- events.push_back((
- events::Event::DiscardFunding {
- channel_id: chan.context().channel_id(),
- funding_info,
- },
- None,
- ));
- }
}
}
}
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index df16171..c5b1104 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -3237,18 +3237,10 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>(
let events = node.node.get_and_clear_pending_events();
assert_eq!(events.len(), 2);
match &events[0] {
- Event::SpliceFailed { channel_id, reason, contribution, .. } => {
- assert_eq!(*expected_channel_id, *channel_id);
- assert_eq!(expected_reason, *reason);
- assert_eq!(contribution.as_ref(), Some(&funding_contribution));
- },
- _ => panic!("Unexpected event"),
- }
- match &events[1] {
Event::DiscardFunding { funding_info, .. } => {
if let FundingInfo::Contribution { inputs, outputs } = &funding_info {
let (expected_inputs, expected_outputs) =
- funding_contribution.into_contributed_inputs_and_outputs();
+ funding_contribution.clone().into_contributed_inputs_and_outputs();
assert_eq!(*inputs, expected_inputs);
assert_eq!(*outputs, expected_outputs);
} else {
@@ -3257,6 +3249,14 @@ pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>(
},
_ => panic!("Unexpected event"),
}
+ match &events[1] {
+ Event::SpliceFailed { channel_id, reason, contribution, .. } => {
+ assert_eq!(*expected_channel_id, *channel_id);
+ assert_eq!(expected_reason, *reason);
+ assert_eq!(contribution.as_ref(), Some(&funding_contribution));
+ },
+ _ => panic!("Unexpected event"),
+ }
}
#[cfg(any(test, ldk_bench, feature = "_test_utils"))]
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 63d0b32..1c6ad83 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -3219,14 +3219,6 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_
let events = nodes[0].node.get_and_clear_pending_events();
assert_eq!(events.len(), 2, "{events:?}");
match &events[0] {
- Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
- assert_eq!(*cid, channel_id);
- assert_eq!(*reason, NegotiationFailureReason::ChannelClosing);
- assert!(contribution.is_some());
- },
- other => panic!("Expected SpliceFailed, got {:?}", other),
- }
- match &events[1] {
Event::DiscardFunding {
funding_info: FundingInfo::Contribution { inputs, outputs },
..
@@ -3240,6 +3232,14 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_
},
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
}
+ match &events[1] {
+ Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
+ assert_eq!(*cid, channel_id);
+ assert_eq!(*reason, NegotiationFailureReason::ChannelClosing);
+ assert!(contribution.is_some());
+ },
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
} else {
expect_splice_failed_events(
&nodes[0],
@@ -4614,14 +4614,6 @@ fn test_splice_acceptor_disconnect_emits_events() {
let events = nodes[1].node.get_and_clear_pending_events();
assert_eq!(events.len(), 2, "{events:?}");
match &events[0] {
- Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
- assert_eq!(*cid, channel_id);
- assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected);
- assert!(contribution.is_some());
- },
- other => panic!("Expected SpliceFailed, got {:?}", other),
- }
- match &events[1] {
Event::DiscardFunding {
funding_info: FundingInfo::Contribution { inputs, outputs },
..
@@ -4631,6 +4623,14 @@ fn test_splice_acceptor_disconnect_emits_events() {
},
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
}
+ match &events[1] {
+ Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
+ assert_eq!(*cid, channel_id);
+ assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected);
+ assert!(contribution.is_some());
+ },
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
// Reconnect and verify the channel is still operational.
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
@@ -6594,18 +6594,10 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() {
nodes[0].node.peer_disconnected(node_id_1);
nodes[1].node.peer_disconnected(node_id_0);
- // The initiator should get SpliceFailed + DiscardFunding with filtered contributions.
+ // The initiator should get DiscardFunding + SpliceFailed with filtered contributions.
let events = nodes[0].node.get_and_clear_pending_events();
assert_eq!(events.len(), 2, "{events:?}");
match &events[0] {
- Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
- assert_eq!(*cid, channel_id);
- assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected);
- assert!(contribution.is_some());
- },
- other => panic!("Expected SpliceFailed, got {:?}", other),
- }
- match &events[1] {
Event::DiscardFunding {
funding_info: FundingInfo::Contribution { inputs, outputs },
..
@@ -6618,6 +6610,14 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() {
},
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
}
+ match &events[1] {
+ Event::SpliceFailed { channel_id: cid, reason, contribution, .. } => {
+ assert_eq!(*cid, channel_id);
+ assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected);
+ assert!(contribution.is_some());
+ },
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
// Reconnect. After a completed splice, channel_ready is not re-sent.
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
Why this scored 45/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.