Emit SpliceFailed event when tx_abort is received
What changed, and why it matters
This commit fixes a missing notification in the Lightning Dev Kit. When a splice operation (a way to resize a Lightning channel) is cancelled by the other party via a tx_abort message, LDK now emits a SpliceFailed event so the user knows what happened and can reclaim any funds they contributed. Previously, the abort was handled silently, which could leave users unaware that their contributed inputs/outputs were no longer tied to a pending splice.
Treat as a normal functional/UX fix. Reviewers should verify that SpliceFundingFailed is only produced when a funded channel has a pending splice with active funding negotiation, and that the new event is emitted exactly once per abort. No immediate security response is indicated.
Security signals we found
Missing event emission on protocol abort could cause users to believe a splice is still pending, leading to incorrect wallet/accounting decisions
New event carries sensitive splice metadata (funding txo, contributed inputs/outputs) that callers must handle appropriately
Persistence logic changed to DoPersist when a SpliceFailed event is generated, ensuring state is saved before user is notified
Evidence from the diff
The patch changes Channel::tx_abort to return an optional SpliceFundingFailed struct alongside the ack message. When a funded channel has an active splice negotiation and receives tx_abort, reset_pending_splice_state now produces SpliceFundingFailed containing the abandoned funding txo, channel type, and contributed inputs/outputs. ChannelManager::internal_tx_abort pushes Event::SpliceFailed into pending_events when this data is present, and persists state. A test-only abandon_splice path is also added for both Channel and ChannelManager. The change is purely an eventing/state-visibility improvement; no cryptographic or consensus logic is altered.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +212 / −13
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index f4687b2..bd766fc 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -1834,7 +1834,7 @@ where
pub fn tx_abort<L: Deref>(
&mut self, msg: &msgs::TxAbort, logger: &L,
- ) -> Result<Option<msgs::TxAbort>, ChannelError>
+ ) -> Result<(Option<msgs::TxAbort>, Option<SpliceFundingFailed>), ChannelError>
where
L::Target: Logger,
{
@@ -1843,14 +1843,16 @@ where
// https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L560-L561
// For rationale why we echo back `tx_abort`:
// https://github.com/lightning/bolts/blob/247e83d/02-peer-protocol.md?plain=1#L578-L580
- let should_ack = match &mut self.phase {
+ let (should_ack, splice_funding_failed) = match &mut self.phase {
ChannelPhase::Undefined => unreachable!(),
ChannelPhase::UnfundedOutboundV1(_) | ChannelPhase::UnfundedInboundV1(_) => {
let err = "Got an unexpected tx_abort message: This is an unfunded channel created with V1 channel establishment";
return Err(ChannelError::Warn(err.into()));
},
ChannelPhase::UnfundedV2(pending_v2_channel) => {
- pending_v2_channel.interactive_tx_constructor.take().is_some()
+ let had_constructor =
+ pending_v2_channel.interactive_tx_constructor.take().is_some();
+ (had_constructor, None)
},
ChannelPhase::Funded(funded_channel) => {
if funded_channel.has_pending_splice_awaiting_signatures() {
@@ -1865,17 +1867,17 @@ where
.map(|pending_splice| pending_splice.funding_negotiation.is_some())
.unwrap_or(false);
debug_assert!(has_funding_negotiation);
- funded_channel.reset_pending_splice_state();
- true
+ let splice_funding_failed = funded_channel.reset_pending_splice_state();
+ (true, splice_funding_failed)
} else {
// We were not tracking the pending funding negotiation state anymore, likely
// due to a disconnection or already having sent our own `tx_abort`.
- false
+ (false, None)
}
},
};
- Ok(should_ack.then(|| {
+ let tx_abort = should_ack.then(|| {
let logger = WithChannelContext::from(logger, &self.context(), None);
let reason =
types::string::UntrustedString(String::from_utf8_lossy(&msg.data).to_string());
@@ -1884,7 +1886,9 @@ where
channel_id: msg.channel_id,
data: "Acknowledged tx_abort".to_string().into_bytes(),
}
- }))
+ });
+
+ Ok((tx_abort, splice_funding_failed))
}
#[rustfmt::skip]
@@ -11828,6 +11832,32 @@ where
})
}
+ #[cfg(test)]
+ pub fn abandon_splice(
+ &mut self,
+ ) -> Result<(msgs::TxAbort, Option<SpliceFundingFailed>), APIError> {
+ if self.should_reset_pending_splice_state() {
+ let tx_abort =
+ msgs::TxAbort { channel_id: self.context.channel_id(), data: Vec::new() };
+ let splice_funding_failed = self.reset_pending_splice_state();
+ Ok((tx_abort, splice_funding_failed))
+ } else if self.has_pending_splice_awaiting_signatures() {
+ Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} splice cannot be abandoned; already awaiting signatures",
+ self.context.channel_id(),
+ ),
+ })
+ } else {
+ Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} splice cannot be abandoned; no pending splice",
+ self.context.channel_id(),
+ ),
+ })
+ }
+ }
+
/// Checks during handling splice_init
pub fn validate_splice_init(
&self, msg: &msgs::SpliceInit, our_funding_contribution: SignedAmount,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 46ca760..0baa855 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4744,6 +4744,94 @@ where
}
}
+ #[cfg(test)]
+ pub(crate) fn abandon_splice(
+ &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
+ ) -> Result<(), APIError> {
+ let mut res = Ok(());
+ PersistenceNotifierGuard::optionally_notify(self, || {
+ let result = self.internal_abandon_splice(channel_id, counterparty_node_id);
+ res = result;
+ match res {
+ Ok(_) => NotifyOption::SkipPersistHandleEvents,
+ Err(_) => NotifyOption::SkipPersistNoEvents,
+ }
+ });
+ res
+ }
+
+ #[cfg(test)]
+ fn internal_abandon_splice(
+ &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
+ ) -> Result<(), APIError> {
+ let per_peer_state = self.per_peer_state.read().unwrap();
+
+ let peer_state_mutex = match per_peer_state.get(counterparty_node_id).ok_or_else(|| {
+ APIError::ChannelUnavailable {
+ err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}"),
+ }
+ }) {
+ Ok(p) => p,
+ Err(e) => return Err(e),
+ };
+
+ let mut peer_state_lock = peer_state_mutex.lock().unwrap();
+ let peer_state = &mut *peer_state_lock;
+
+ // Look for the channel
+ match peer_state.channel_by_id.entry(*channel_id) {
+ hash_map::Entry::Occupied(mut chan_phase_entry) => {
+ if !chan_phase_entry.get().context().is_connected() {
+ // TODO: We should probably support this, but right now `splice_channel` refuses when
+ // the peer is disconnected, so we just check it here.
+ return Err(APIError::ChannelUnavailable {
+ err: "Cannot abandon splice while peer is disconnected".to_owned(),
+ });
+ }
+
+ if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() {
+ let (tx_abort, splice_funding_failed) = chan.abandon_splice()?;
+
+ peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort {
+ node_id: *counterparty_node_id,
+ msg: tx_abort,
+ });
+
+ if let Some(splice_funding_failed) = splice_funding_failed {
+ let pending_events = &mut self.pending_events.lock().unwrap();
+ pending_events.push_back((
+ events::Event::SpliceFailed {
+ channel_id: *channel_id,
+ counterparty_node_id: *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,
+ ));
+ }
+
+ Ok(())
+ } else {
+ Err(APIError::ChannelUnavailable {
+ err: format!(
+ "Channel with id {} is not funded, cannot abandon splice",
+ channel_id
+ ),
+ })
+ }
+ },
+ hash_map::Entry::Vacant(_) => Err(APIError::ChannelUnavailable {
+ err: format!(
+ "Channel with id {} not found for the passed counterparty node_id {}",
+ channel_id, counterparty_node_id,
+ ),
+ }),
+ }
+ }
+
#[rustfmt::skip]
fn can_forward_htlc_to_outgoing_channel(
&self, chan: &mut FundedChannel<SP>, msg: &msgs::UpdateAddHTLC, next_packet: &NextPacketDetails
@@ -10501,7 +10589,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
#[rustfmt::skip]
fn internal_tx_abort(&self, counterparty_node_id: &PublicKey, msg: &msgs::TxAbort)
- -> Result<(), MsgHandleErrInternal> {
+ -> Result<NotifyOption, MsgHandleErrInternal> {
let per_peer_state = self.per_peer_state.read().unwrap();
let peer_state_mutex = per_peer_state.get(counterparty_node_id)
.ok_or_else(|| {
@@ -10515,13 +10603,35 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
match peer_state.channel_by_id.entry(msg.channel_id) {
hash_map::Entry::Occupied(mut chan_entry) => {
let res = chan_entry.get_mut().tx_abort(msg, &self.logger);
- if let Some(msg) = try_channel_entry!(self, peer_state, res, chan_entry) {
+ let (tx_abort, splice_failed) = try_channel_entry!(self, peer_state, res, chan_entry);
+
+ let persist = if tx_abort.is_some() || splice_failed.is_some() {
+ NotifyOption::DoPersist
+ } else {
+ NotifyOption::SkipPersistNoEvents
+ };
+
+ if let Some(tx_abort_msg) = tx_abort {
peer_state.pending_msg_events.push(MessageSendEvent::SendTxAbort {
node_id: *counterparty_node_id,
- msg,
+ msg: tx_abort_msg,
});
}
- Ok(())
+
+ if let Some(splice_funding_failed) = splice_failed {
+ let pending_events = &mut self.pending_events.lock().unwrap();
+ pending_events.push_back((events::Event::SpliceFailed {
+ channel_id: msg.channel_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan_entry.get().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));
+ }
+
+ Ok(persist)
},
hash_map::Entry::Vacant(_) => {
Err(MsgHandleErrInternal::send_err_msg_no_close(format!("Got a message for a channel from the wrong node! No such channel for the passed counterparty_node_id {}", counterparty_node_id), msg.channel_id))
@@ -14875,8 +14985,13 @@ where
// be persisted before any signatures are exchanged.
let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || {
let res = self.internal_tx_abort(&counterparty_node_id, msg);
+ let persist = match &res {
+ Err(e) if e.closes_channel() => NotifyOption::DoPersist,
+ Err(_) => NotifyOption::SkipPersistHandleEvents,
+ Ok(persist) => *persist,
+ };
let _ = handle_error!(self, res, counterparty_node_id);
- NotifyOption::SkipPersistHandleEvents
+ persist
});
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 4b24047..0af99e9 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1287,3 +1287,57 @@ fn fail_splice_on_interactive_tx_error() {
let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort);
}
+
+#[test]
+fn fail_splice_on_tx_abort() {
+ 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()),
+ };
+
+ // Fail during interactive-tx construction by having the acceptor send tx_abort instead of
+ // tx_complete.
+ let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone());
+
+ let tx_add_input =
+ get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor);
+ acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input);
+
+ let _tx_complete =
+ get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator);
+
+ acceptor.node.abandon_splice(&channel_id, &node_id_initiator).unwrap();
+ let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator);
+ initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort);
+
+ let event = get_event!(initiator, Event::SpliceFailed);
+ match event {
+ Event::SpliceFailed { contributed_inputs, .. } => {
+ assert_eq!(contributed_inputs.len(), 1);
+ assert_eq!(contributed_inputs[0], contribution.inputs()[0].outpoint());
+ },
+ _ => panic!("Expected Event::SpliceFailed"),
+ }
+
+ let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
+ acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
+}
Why this scored 25/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.