Emit SplicePending event when splice funding is negotiated
What changed, and why it matters
This commit adds a new notification event, SplicePending, that tells users when a splice funding transaction has been negotiated and broadcast. It is a feature/visibility improvement rather than a fix for an active security bug. The change helps wallet developers avoid accidentally re-spending splice inputs, but it does not by itself close a vulnerability.
No immediate action required. Users integrating LDK should handle the new Event::SplicePending in their event loop and ensure splice inputs are not reused except for RBF. Reviewers may want to verify that the reestablishment persistence change does not introduce missed persistence edge cases.
Security signals we found
New event emission for splice funding negotiation
Prevents accidental double-spend/reuse of splice inputs by informing consumers
Refactors internal return types without changing wire protocol
Adds test assertions for the new event
Minor persistence-notifier refactor in channel reestablishment
Evidence from the diff
The patch refactors the return type of funding_transaction_signed and tx_signatures from a tuple to a new FundingTxSigned struct, carrying an optional SpliceFundingNegotiated payload. When a splice candidate is negotiated and its funding transaction is broadcast, ChannelManager now emits an Event::SplicePending containing the new funding outpoint and channel type. Tests are updated to expect this event. A small related change makes internal_channel_reestablish return () and rely on notify_on_drop for persistence. The commit is defensive: it surfaces state so callers can prevent input reuse, but the actual safety still depends on caller behavior.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/functional_test_utils.rslightning/src/ln/splicing_tests.rsInspect captured patch +147 / −41
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index d66ddc9..9142650 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -6718,6 +6718,27 @@ type BestBlockUpdatedRes = (
Option<msgs::AnnouncementSignatures>,
);
+/// The result of signing a funding transaction negotiated using the interactive-tx protocol.
+pub struct FundingTxSigned {
+ /// Signatures that should be sent to the counterparty, if necessary.
+ pub tx_signatures: Option<msgs::TxSignatures>,
+
+ /// The fully-signed funding transaction to be broadcast.
+ pub funding_tx: Option<Transaction>,
+
+ /// Information about the completed funding negotiation.
+ pub splice_negotiated: Option<SpliceFundingNegotiated>,
+}
+
+/// Information about a splice funding negotiation that has been completed.
+pub struct SpliceFundingNegotiated {
+ /// The outpoint of the channel's splice funding transaction.
+ pub funding_txo: bitcoin::OutPoint,
+
+ /// The features that this channel will operate with.
+ pub channel_type: ChannelTypeFeatures,
+}
+
pub struct SpliceFundingPromotion {
pub funding_txo: OutPoint,
pub monitor_update: Option<ChannelMonitorUpdate>,
@@ -8636,30 +8657,46 @@ where
}
}
- fn on_tx_signatures_exchange(&mut self, funding_tx: Transaction) {
+ fn on_tx_signatures_exchange(
+ &mut self, funding_tx: Transaction,
+ ) -> Option<SpliceFundingNegotiated> {
debug_assert!(!self.context.channel_state.is_monitor_update_in_progress());
debug_assert!(!self.context.channel_state.is_awaiting_remote_revoke());
if let Some(pending_splice) = self.pending_splice.as_mut() {
+ self.context.channel_state.clear_quiescent();
if let Some(FundingNegotiation::AwaitingSignatures { mut funding }) =
pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
+
+ let funding_txo =
+ funding.get_funding_txo().expect("funding outpoint should be set");
+ let channel_type = funding.get_channel_type().clone();
+
pending_splice.negotiated_candidates.push(funding);
+
+ let splice_negotiated = SpliceFundingNegotiated {
+ funding_txo: funding_txo.into_bitcoin_outpoint(),
+ channel_type,
+ };
+
+ Some(splice_negotiated)
} else {
debug_assert!(false);
+ None
}
- self.context.channel_state.clear_quiescent();
} else {
self.funding.funding_transaction = Some(funding_tx);
self.context.channel_state =
ChannelState::AwaitingChannelReady(AwaitingChannelReadyFlags::new());
+ None
}
}
pub fn funding_transaction_signed(
&mut self, funding_txid_signed: Txid, witnesses: Vec<Witness>,
- ) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), APIError> {
+ ) -> Result<FundingTxSigned, APIError> {
let signing_session =
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if let Some(pending_splice) = self.pending_splice.as_ref() {
@@ -8676,7 +8713,7 @@ where
if signing_session.holder_tx_signatures().is_some() {
// Our `tx_signatures` either should've been the first time we processed them,
// or we're waiting for our counterparty to send theirs first.
- return Ok((None, None));
+ return Ok(FundingTxSigned { tx_signatures: None, funding_tx: None, splice_negotiated: None });
}
signing_session
@@ -8684,7 +8721,7 @@ where
if Some(funding_txid_signed) == self.funding.get_funding_txid() {
// We may be handling a duplicate call and the funding was already locked so we
// no longer have the signing session present.
- return Ok((None, None));
+ return Ok(FundingTxSigned { tx_signatures: None, funding_tx: None, splice_negotiated: None });
}
let err =
format!("Channel {} not expecting funding signatures", self.context.channel_id);
@@ -8722,21 +8759,23 @@ where
witnesses,
shared_input_signature,
};
- let (tx_signatures_opt, funding_tx_opt) = signing_session
+ let (tx_signatures, funding_tx) = signing_session
.provide_holder_witnesses(tx_signatures, &self.context.secp_ctx)
.map_err(|err| APIError::APIMisuseError { err })?;
- if let Some(funding_tx) = funding_tx_opt.clone() {
- debug_assert!(tx_signatures_opt.is_some());
- self.on_tx_signatures_exchange(funding_tx);
- }
+ let splice_negotiated = if let Some(funding_tx) = funding_tx.clone() {
+ debug_assert!(tx_signatures.is_some());
+ self.on_tx_signatures_exchange(funding_tx)
+ } else {
+ None
+ };
- Ok((tx_signatures_opt, funding_tx_opt))
+ Ok(FundingTxSigned { tx_signatures, funding_tx, splice_negotiated })
}
pub fn tx_signatures(
&mut self, msg: &msgs::TxSignatures,
- ) -> Result<(Option<msgs::TxSignatures>, Option<Transaction>), ChannelError> {
+ ) -> Result<FundingTxSigned, ChannelError> {
let signing_session = if let Some(signing_session) =
self.context.interactive_tx_signing_session.as_mut()
{
@@ -8779,14 +8818,16 @@ where
}
}
- let (holder_tx_signatures_opt, funding_tx_opt) =
+ let (holder_tx_signatures, funding_tx) =
signing_session.received_tx_signatures(msg).map_err(|msg| ChannelError::Warn(msg))?;
- if let Some(funding_tx) = funding_tx_opt.clone() {
- self.on_tx_signatures_exchange(funding_tx);
- }
+ let splice_negotiated = if let Some(funding_tx) = funding_tx.clone() {
+ self.on_tx_signatures_exchange(funding_tx)
+ } else {
+ None
+ };
- Ok((holder_tx_signatures_opt, funding_tx_opt))
+ Ok(FundingTxSigned { tx_signatures: holder_tx_signatures, funding_tx, splice_negotiated })
}
/// Queues up an outbound update fee by placing it in the holding cell. You should call
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 5cd5e80..9b0086c 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -60,8 +60,8 @@ use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight;
use crate::ln::channel::QuiescentAction;
use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, FundedChannel,
- InboundV1Channel, OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult,
- StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
+ FundingTxSigned, InboundV1Channel, OutboundV1Channel, PendingV2Channel, ReconnectionMsg,
+ ShutdownResult, StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::ChannelDetails;
use crate::ln::funding::SpliceContribution;
@@ -6298,10 +6298,26 @@ where
.filter(|witness| !witness.is_empty())
.collect();
match chan.funding_transaction_signed(txid, witnesses) {
- Ok((Some(tx_signatures), funding_tx_opt)) => {
- if let Some(funding_tx) = funding_tx_opt {
+ Ok(FundingTxSigned {
+ tx_signatures: Some(tx_signatures),
+ funding_tx,
+ splice_negotiated,
+ }) => {
+ if let Some(funding_tx) = funding_tx {
self.broadcast_interactive_funding(chan, &funding_tx);
}
+ if let Some(splice_negotiated) = splice_negotiated {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SplicePending {
+ channel_id: *channel_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan.context.get_user_id(),
+ new_funding_txo: splice_negotiated.funding_txo,
+ channel_type: splice_negotiated.channel_type,
+ },
+ None,
+ ));
+ }
peer_state.pending_msg_events.push(
MessageSendEvent::SendTxSignatures {
node_id: *counterparty_node_id,
@@ -6314,7 +6330,13 @@ where
result = Err(err);
return NotifyOption::SkipPersistNoEvents;
},
- _ => {
+ Ok(FundingTxSigned {
+ tx_signatures: None,
+ funding_tx,
+ splice_negotiated,
+ }) => {
+ debug_assert!(funding_tx.is_none());
+ debug_assert!(splice_negotiated.is_none());
return NotifyOption::SkipPersistNoEvents;
},
}
@@ -9413,10 +9435,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
} else {
let txid = signing_session.unsigned_tx().compute_txid();
match channel.funding_transaction_signed(txid, vec![]) {
- Ok((Some(tx_signatures), funding_tx_opt)) => {
- if let Some(funding_tx) = funding_tx_opt {
+ Ok(FundingTxSigned { tx_signatures: Some(tx_signatures), funding_tx, splice_negotiated }) => {
+ if let Some(funding_tx) = funding_tx {
self.broadcast_interactive_funding(channel, &funding_tx);
}
+
+ if let Some(splice_negotiated) = splice_negotiated {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SplicePending {
+ channel_id: channel.context.channel_id(),
+ counterparty_node_id,
+ user_channel_id: channel.context.get_user_id(),
+ new_funding_txo: splice_negotiated.funding_txo,
+ channel_type: splice_negotiated.channel_type,
+ },
+ None,
+ ));
+ }
+
if channel.context.is_connected() {
pending_msg_events.push(MessageSendEvent::SendTxSignatures {
node_id: counterparty_node_id,
@@ -9424,7 +9460,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
});
}
},
- Ok((None, _)) => {
+ Ok(FundingTxSigned { tx_signatures: None, .. }) => {
debug_assert!(false, "If our tx_signatures is empty, then we should send it first!");
},
Err(err) => {
@@ -10373,20 +10409,33 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
hash_map::Entry::Occupied(mut chan_entry) => {
match chan_entry.get_mut().as_funded_mut() {
Some(chan) => {
- let (tx_signatures_opt, funding_tx_opt) = try_channel_entry!(self, peer_state, chan.tx_signatures(msg), chan_entry);
- if let Some(tx_signatures) = tx_signatures_opt {
+ let FundingTxSigned { tx_signatures, funding_tx, splice_negotiated } =
+ try_channel_entry!(self, peer_state, chan.tx_signatures(msg), chan_entry);
+ if let Some(tx_signatures) = tx_signatures {
peer_state.pending_msg_events.push(MessageSendEvent::SendTxSignatures {
node_id: *counterparty_node_id,
msg: tx_signatures,
});
}
- if let Some(ref funding_tx) = funding_tx_opt {
+ if let Some(ref funding_tx) = funding_tx {
self.tx_broadcaster.broadcast_transactions(&[funding_tx]);
{
let mut pending_events = self.pending_events.lock().unwrap();
emit_channel_pending_event!(pending_events, chan);
}
}
+ if let Some(splice_negotiated) = splice_negotiated {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::SplicePending {
+ channel_id: msg.channel_id,
+ counterparty_node_id: *counterparty_node_id,
+ user_channel_id: chan.context.get_user_id(),
+ new_funding_txo: splice_negotiated.funding_txo,
+ channel_type: splice_negotiated.channel_type,
+ },
+ None,
+ ));
+ }
},
None => {
let msg = "Got an unexpected tx_signatures message";
@@ -11337,7 +11386,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
#[rustfmt::skip]
- fn internal_channel_reestablish(&self, counterparty_node_id: &PublicKey, msg: &msgs::ChannelReestablish) -> Result<NotifyOption, MsgHandleErrInternal> {
+ fn internal_channel_reestablish(&self, counterparty_node_id: &PublicKey, msg: &msgs::ChannelReestablish) -> Result<(), MsgHandleErrInternal> {
let (inferred_splice_locked, need_lnd_workaround) = {
let per_peer_state = self.per_peer_state.read().unwrap();
@@ -11448,10 +11497,9 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(splice_locked) = inferred_splice_locked {
self.internal_splice_locked(counterparty_node_id, &splice_locked)?;
- return Ok(NotifyOption::DoPersist);
}
- Ok(NotifyOption::SkipPersistHandleEvents)
+ Ok(())
}
/// Handle incoming splice request, transition channel to splice-pending (unless some check fails).
@@ -14570,16 +14618,9 @@ where
fn handle_channel_reestablish(
&self, counterparty_node_id: PublicKey, msg: &msgs::ChannelReestablish,
) {
- let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || {
- let res = self.internal_channel_reestablish(&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);
- persist
- });
+ let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
+ let res = self.internal_channel_reestablish(&counterparty_node_id, msg);
+ let _ = handle_error!(self, res, counterparty_node_id);
}
#[rustfmt::skip]
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index 0bf60e4..28341e8 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -3067,6 +3067,21 @@ pub fn expect_channel_ready_event<'a, 'b, 'c, 'd>(
}
}
+#[cfg(any(test, ldk_bench, feature = "_test_utils"))]
+pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>(
+ node: &'a Node<'b, 'c, 'd>, expected_counterparty_node_id: &PublicKey,
+) -> ChannelId {
+ let events = node.node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ crate::events::Event::SplicePending { channel_id, counterparty_node_id, .. } => {
+ assert_eq!(*expected_counterparty_node_id, *counterparty_node_id);
+ *channel_id
+ },
+ _ => panic!("Unexpected event"),
+ }
+}
+
pub fn expect_probe_successful_events(
node: &Node, mut probe_results: Vec<(PaymentHash, PaymentId)>,
) {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index ac84eea..4ce6976 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -246,6 +246,9 @@ fn splice_channel<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
initiator_contribution: SpliceContribution,
) -> Transaction {
+ let node_id_initiator = initiator.node.get_our_node_id();
+ let node_id_acceptor = acceptor.node.get_our_node_id();
+
let initial_commit_sig_for_acceptor =
negotiate_splice_tx(initiator, acceptor, channel_id, initiator_contribution);
sign_interactive_funding_transaction(initiator, acceptor, initial_commit_sig_for_acceptor);
@@ -257,6 +260,10 @@ fn splice_channel<'a, 'b, 'c, 'd>(
assert_eq!(initiator_txn, acceptor_txn);
initiator_txn.remove(0)
};
+
+ expect_splice_pending_event(initiator, &node_id_acceptor);
+ expect_splice_pending_event(acceptor, &node_id_initiator);
+
splice_tx
}
@@ -999,11 +1006,13 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) {
nodes[0].node.funding_transaction_signed(&channel_id, &node_id_1, tx).unwrap();
}
let _ = get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
// Reconnect to make sure node 0 retransmits its `tx_signatures` as it was never delivered.
reconnect_nodes!(|reconnect_args: &mut ReconnectArgs| {
reconnect_args.send_interactive_tx_sigs = (false, true);
});
+ expect_splice_pending_event(&nodes[1], &node_id_0);
// Reestablish the channel again to make sure node 0 doesn't retransmit `tx_signatures`
// unnecessarily as it was delivered in the previous reestablishment.
Why this scored 24/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.