Emit SpliceFailed event upon reload
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where a wallet reload could silently drop an in-progress channel splice. Previously, if the program restarted while a splice was in an early negotiation state, the user would never receive a 'SpliceFailed' event, so their funds could appear stuck or the failure could go unnoticed. The fix temporarily saves a failure event during persistence so it can be emitted after the reload, then removes it from storage. It is a reliability/notification fix, not a direct theft or remote-exploitation vulnerability.
Treat as a bug-fix commit with minor operational-security relevance. Reviewers should verify that the appended SpliceFailed events are always truncated after write, that no double-emission occurs on reload, and that the new to_contributed_inputs_and_outputs accessors do not expose sensitive data beyond what was already available via the consuming into_ variant. No immediate security response is indicated.
Security signals we found
State loss across persistence boundary leading to missing failure notification
Event queue manipulation during serialization (append-write-truncate)
Refactoring of disconnect-time splice failure logic into shared macro
New read-only accessors for contributed inputs/outputs to support non-destructive event creation
Evidence from the diff
The change ensures that SpliceFailed events are emitted after a node reload when a pending splice has not yet reached FundingNegotiation::AwaitingSignatures. Because only AwaitingSignatures is persisted, splicing states AwaitingAck and ConstructingTransaction are lost on reload, and with them the data needed to construct a SpliceFailed event. The patch introduces a maybe_create_splice_funding_failed macro and a read-only to_contributed_inputs_and_outputs accessor, then during ChannelManager serialization it opportunistically appends SpliceFailed events to the pending event queue, writes them, and truncates them back off so they are not persisted long-term. The existing disconnect path is refactored to share the same macro logic.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +112 / −41
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index a0f64d4..cd95c27 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -6732,6 +6732,13 @@ impl FundingNegotiationContext {
let contributed_outputs = self.our_funding_outputs;
(contributed_inputs, contributed_outputs)
}
+
+ fn to_contributed_inputs_and_outputs(&self) -> (Vec<bitcoin::OutPoint>, Vec<TxOut>) {
+ let contributed_inputs =
+ self.our_funding_inputs.iter().map(|input| input.utxo.outpoint).collect();
+ let contributed_outputs = self.our_funding_outputs.clone();
+ (contributed_inputs, contributed_outputs)
+ }
}
// Holder designates channel data owned for the benefit of the user client.
@@ -6865,6 +6872,45 @@ pub struct SpliceFundingFailed {
pub contributed_outputs: Vec<bitcoin::TxOut>,
}
+macro_rules! maybe_create_splice_funding_failed {
+ ($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| {
+ let funding_txo = funding_negotiation
+ .as_funding()
+ .and_then(|funding| funding.get_funding_txo())
+ .map(|txo| txo.into_bitcoin_outpoint());
+
+ let channel_type = funding_negotiation
+ .as_funding()
+ .map(|funding| funding.get_channel_type().clone());
+
+ let (contributed_inputs, contributed_outputs) = match funding_negotiation {
+ FundingNegotiation::AwaitingAck { context } => {
+ context.$contributed_inputs_and_outputs()
+ },
+ FundingNegotiation::ConstructingTransaction {
+ interactive_tx_constructor,
+ ..
+ } => interactive_tx_constructor.$contributed_inputs_and_outputs(),
+ FundingNegotiation::AwaitingSignatures { .. } => {
+ debug_assert!(false);
+ (Vec::new(), Vec::new())
+ },
+ };
+
+ SpliceFundingFailed {
+ funding_txo,
+ channel_type,
+ contributed_inputs,
+ contributed_outputs,
+ }
+ })
+ }};
+}
+
pub struct SpliceFundingPromotion {
pub funding_txo: OutPoint,
pub monitor_update: Option<ChannelMonitorUpdate>,
@@ -6977,42 +7023,11 @@ where
debug_assert!(self.context.interactive_tx_signing_session.is_none());
self.context.channel_state.clear_quiescent();
- let splice_funding_failed = self
- .pending_splice
- .as_mut()
- .and_then(|pending_splice| pending_splice.funding_negotiation.take())
- .filter(|funding_negotiation| funding_negotiation.is_initiator())
- .map(|funding_negotiation| {
- let funding_txo = funding_negotiation
- .as_funding()
- .and_then(|funding| funding.get_funding_txo())
- .map(|txo| txo.into_bitcoin_outpoint());
-
- let channel_type = funding_negotiation
- .as_funding()
- .map(|funding| funding.get_channel_type().clone());
-
- let (contributed_inputs, contributed_outputs) = match funding_negotiation {
- FundingNegotiation::AwaitingAck { context } => {
- context.into_contributed_inputs_and_outputs()
- },
- FundingNegotiation::ConstructingTransaction {
- interactive_tx_constructor,
- ..
- } => interactive_tx_constructor.into_contributed_inputs_and_outputs(),
- FundingNegotiation::AwaitingSignatures { .. } => {
- debug_assert!(false);
- (Vec::new(), Vec::new())
- },
- };
-
- SpliceFundingFailed {
- funding_txo,
- channel_type,
- contributed_inputs,
- contributed_outputs,
- }
- });
+ let splice_funding_failed = maybe_create_splice_funding_failed!(
+ self.pending_splice.as_mut(),
+ take,
+ into_contributed_inputs_and_outputs
+ );
if self.pending_funding().is_empty() {
self.pending_splice.take();
@@ -7021,6 +7036,18 @@ where
splice_funding_failed
}
+ pub(super) fn maybe_splice_funding_failed(&self) -> Option<SpliceFundingFailed> {
+ if !self.should_reset_pending_splice_state() {
+ return None;
+ }
+
+ maybe_create_splice_funding_failed!(
+ self.pending_splice.as_ref(),
+ as_ref,
+ to_contributed_inputs_and_outputs
+ )
+ }
+
#[rustfmt::skip]
fn check_remote_fee<F: Deref, L: Deref>(
channel_type: &ChannelTypeFeatures, fee_estimator: &LowerBoundedFeeEstimator<F>,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 1de9ad9..f58fe71 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -16142,7 +16142,32 @@ where
}
}
- let events = self.pending_events.lock().unwrap();
+
+ // Since some FundingNegotiation variants are not persisted, any splice in such state must
+ // be failed upon reload. However, as the necessary information for the SpliceFailed event
+ // is not persisted, the event itself needs to be persisted even though it hasn't been
+ // emitted yet. These are removed after the events are written.
+ let mut events = self.pending_events.lock().unwrap();
+ let event_count = events.len();
+ for peer_state in peer_states.iter() {
+ 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() {
+ events.push_back((
+ events::Event::SpliceFailed {
+ channel_id: chan.context.channel_id(),
+ counterparty_node_id: chan.context.get_counterparty_node_id(),
+ user_channel_id: chan.context.get_user_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,
+ ));
+ }
+ }
+ }
+
// LDK versions prior to 0.0.115 don't support post-event actions, thus if there's no
// actions at all, skip writing the required TLV. Otherwise, pre-0.0.115 versions will
// refuse to read the new ChannelManager.
@@ -16259,6 +16284,9 @@ where
(21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required),
});
+ // Remove the SpliceFailed events added earlier.
+ events.truncate(event_count);
+
Ok(())
}
}
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index b3c7356..a912db0 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -2100,6 +2100,22 @@ impl InteractiveTxConstructor {
(contributed_inputs, contributed_outputs)
}
+ pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec<BitcoinOutPoint>, Vec<TxOut>) {
+ let contributed_inputs = self
+ .inputs_to_contribute
+ .iter()
+ .filter(|(_, input)| !input.is_shared())
+ .map(|(_, input)| input.tx_in().previous_output)
+ .collect();
+ let contributed_outputs = self
+ .outputs_to_contribute
+ .iter()
+ .filter(|(_, output)| !output.is_shared())
+ .map(|(_, output)| output.tx_out().clone())
+ .collect();
+ (contributed_inputs, contributed_outputs)
+ }
+
pub fn is_initiator(&self) -> bool {
self.is_initiator
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 2211695..3edd051 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -431,10 +431,10 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) {
} else {
nodes[0].node.peer_disconnected(node_id_1);
nodes[1].node.peer_disconnected(node_id_0);
-
- let _event = get_event!(nodes[0], Event::SpliceFailed);
}
+ let _event = get_event!(nodes[0], Event::SpliceFailed);
+
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
reconnect_args.send_channel_ready = (true, true);
reconnect_args.send_announcement_sigs = (true, true);
@@ -490,10 +490,10 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) {
} else {
nodes[0].node.peer_disconnected(node_id_1);
nodes[1].node.peer_disconnected(node_id_0);
-
- let _event = get_event!(nodes[0], Event::SpliceFailed);
}
+ let _event = get_event!(nodes[0], Event::SpliceFailed);
+
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
reconnect_args.send_channel_ready = (true, true);
reconnect_args.send_announcement_sigs = (true, true);
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.