Always emit SpliceNegotiationFailed when contributing
What changed, and why it matters
This patch fixes a bug in Lightning Dev Kit's splicing feature where a channel participant (the 'acceptor') could fail to receive a notification when a splice negotiation they contributed to fell through. Without that notification, the user's wallet software would not know it could safely unlock and reuse the funds it had set aside for the splice. The fix ensures both sides always get a SpliceNegotiationFailed event when a splice they contributed to fails, even if their exact same inputs/outputs were reused from a previous round.
Treat as a reliability/UX bug fix rather than a critical security vulnerability. Users relying on splicing should upgrade so wallets correctly receive SpliceNegotiationFailed events and can resume funding. No immediate emergency response is indicated.
Security signals we found
API guarantee violation: missing failure event for one party
Potential wallet-level fund unavailability / UX degradation
No cryptographic flaw or remote code execution vector
Fix is localized to splicing state-machine event emission
Evidence from the diff
The commit removes an initiator/acceptor asymmetry in the splice_funding_failed_for! macro. Previously, if all of a contribution’s inputs and outputs were filtered out as duplicates of an earlier round, the macro returned None for non-initiators, suppressing the SpliceFundingFailed (and thus Event::SpliceNegotiationFailed) event. The patch makes the macro always return a SpliceFundingFailed record in that case, so callers no longer need to pass is_initiator. Call sites in reset_pending_splice_state and maybe_splice_funding_failed are updated accordingly. A test is updated to assert that the acceptor now also receives SpliceNegotiationFailed after re-contributing the same UTXOs.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsSpliceFundingFailed macro and callersSplice negotiation failure event generationInspect captured patch +15 / −50
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 9fab47f..26325c0 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -7227,8 +7227,7 @@ impl SpliceFundingFailed {
}
macro_rules! splice_funding_failed_for {
- ($self: expr, $is_initiator: expr, $contribution: expr,
- $contributed_inputs: ident, $contributed_outputs: ident) => {{
+ ($self: expr, $contribution: expr, $contributed_inputs: ident, $contributed_outputs: ident) => {{
let contribution = $contribution;
let existing_inputs =
$self.pending_splice.as_ref().into_iter().flat_map(|ps| ps.$contributed_inputs());
@@ -7237,17 +7236,16 @@ macro_rules! splice_funding_failed_for {
let filtered =
contribution.clone().into_unique_contributions(existing_inputs, existing_outputs);
match filtered {
- None if !$is_initiator => None,
- None => Some(SpliceFundingFailed {
+ None => SpliceFundingFailed {
contributed_inputs: vec![],
contributed_outputs: vec![],
contribution: Some(contribution),
- }),
- Some((contributed_inputs, contributed_outputs)) => Some(SpliceFundingFailed {
+ },
+ Some((contributed_inputs, contributed_outputs)) => SpliceFundingFailed {
contributed_inputs,
contributed_outputs,
contribution: Some(contribution),
- }),
+ },
}
}};
}
@@ -7280,14 +7278,7 @@ where
fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed {
// The contribution was never pushed to `contributions`, so `contributed_inputs()` and
// `contributed_outputs()` return only prior rounds' entries for filtering.
- splice_funding_failed_for!(
- self,
- true,
- contribution,
- contributed_inputs,
- contributed_outputs
- )
- .expect("is_initiator is true so this always returns Some")
+ splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs)
}
fn abandon_quiescent_action(&mut self) -> Option<SpliceFundingFailed> {
@@ -7429,11 +7420,7 @@ where
pending_splice.funding_negotiation.is_some(),
"reset_pending_splice_state requires an active funding negotiation"
);
- let is_initiator = pending_splice
- .funding_negotiation
- .take()
- .map(|negotiation| negotiation.is_initiator())
- .unwrap_or(false);
+ pending_splice.funding_negotiation.take();
let contribution = pending_splice.contributions.pop();
if let Some(ref contribution) = contribution {
debug_assert!(
@@ -7447,14 +7434,8 @@ where
// After pop, `contributed_inputs()` / `contributed_outputs()` return only prior
// rounds for filtering.
- let splice_funding_failed = contribution.and_then(|contribution| {
- splice_funding_failed_for!(
- self,
- is_initiator,
- contribution,
- contributed_inputs,
- contributed_outputs
- )
+ let splice_funding_failed = contribution.map(|contribution| {
+ splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs)
});
if self.pending_funding().is_empty() {
@@ -7479,19 +7460,13 @@ where
pending_splice.funding_negotiation.is_some(),
"maybe_splice_funding_failed requires an active funding negotiation"
);
- let is_initiator = pending_splice
- .funding_negotiation
- .as_ref()
- .map(|negotiation| negotiation.is_initiator())
- .unwrap_or(false);
let contribution = pending_splice.contributions.last().cloned()?;
- splice_funding_failed_for!(
+ Some(splice_funding_failed_for!(
self,
- is_initiator,
contribution,
prior_contributed_inputs,
prior_contributed_outputs
- )
+ ))
}
#[rustfmt::skip]
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index b8a6e2b..a612451 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -8300,23 +8300,13 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() {
// The initiator re-used the same UTXOs as round 0. Since those UTXOs are still committed
// to round 0's splice, they are filtered and no DiscardFunding is emitted.
- let events = nodes[0].node.get_and_clear_pending_events();
- assert_eq!(events.len(), 1, "{events:?}");
- match &events[0] {
- Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => {
- assert_eq!(*cid, channel_id);
- assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected);
- assert!(contribution.is_some());
- },
- other => panic!("Expected SpliceNegotiationFailed, got {:?}", other),
- }
+ let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed);
// The acceptor re-contributed the same UTXOs as round 0 (via prior contribution
// adjustment). Since those UTXOs are still committed to round 0's splice, they are
- // filtered and no DiscardFunding is emitted. With all inputs/outputs filtered, no events
- // are emitted for the acceptor.
- let events = nodes[1].node.get_and_clear_pending_events();
- assert_eq!(events.len(), 0, "{events:?}");
+ // filtered and no DiscardFunding is emitted. The contribution still fails and needs a
+ // SpliceNegotiationFailed event so the wallet can resume funding.
+ let _ = get_event!(&nodes[1], Event::SpliceNegotiationFailed);
// Reconnect.
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
Why this scored 32/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.