Emit DiscardFunding events for double spent splice transactions
What changed, and why it matters
This change makes LDK tell users when alternative splice funding transactions are no longer needed, so they can safely reuse the bitcoin inputs. Previously, after a splice locked in, leftover RBF versions of the splice transaction might not have been reported as discardable, which could leave inputs stuck or accidentally double-spent by the user elsewhere. There is no direct exploit here; it is a correctness and usability fix in a splicing feature still behind a compile-time flag.
Treat as a low-severity correctness fix. If running splicing-enabled builds, ensure this patch is included so wallet inputs are correctly released. No urgent mitigation required; no CVE or advisory referenced.
Security signals we found
Missing event emission for abandoned funding transactions could leave user inputs effectively locked
Splicing feature is behind cfg(splicing) and not enabled by default
ChannelMonitor fallback handles channel-closure-before-lock case
No cryptographic, network, or consensus bug visible in diff
Evidence from the diff
The commit extends splice funding promotion to emit DiscardFunding events for every negotiated splice candidate that did not become the locked funding transaction. It updates ChannelMonitorImpl::promote_funding to push DiscardFunding events for pending funding scopes when no further updates are allowed, and updates the channel-level promote_splice_funding! macro to return a Vec
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/chain/channelmonitor.rslightning/src/events/mod.rsInspect captured patch +112 / −46
diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs
index d46cfaa..2d0420c 100644
--- a/lightning/src/chain/channelmonitor.rs
+++ b/lightning/src/chain/channelmonitor.rs
@@ -3906,6 +3906,8 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
}
fn promote_funding(&mut self, new_funding_txid: Txid) -> Result<(), ()> {
+ let prev_funding_txid = self.funding.funding_txid();
+
let new_funding = self
.pending_funding
.iter_mut()
@@ -3921,9 +3923,20 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
self.funding.prev_holder_commitment_tx.clone(),
);
+ let no_further_updates_allowed = self.no_further_updates_allowed();
+
// The swap above places the previous `FundingScope` into `pending_funding`.
for funding in self.pending_funding.drain(..) {
- self.outputs_to_watch.remove(&funding.funding_txid());
+ let funding_txid = funding.funding_txid();
+ self.outputs_to_watch.remove(&funding_txid);
+ if no_further_updates_allowed && funding_txid != prev_funding_txid {
+ self.pending_events.push(Event::DiscardFunding {
+ channel_id: self.channel_id,
+ funding_info: crate::events::FundingInfo::OutPoint {
+ outpoint: funding.funding_outpoint(),
+ },
+ });
+ }
}
if let Some((alternative_funding_txid, _)) = self.alternative_funding_confirmed.take() {
// In exceedingly rare cases, it's possible there was a reorg that caused a potential funding to
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 30c9282..d36143d 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -1502,6 +1502,10 @@ pub enum Event {
/// Used to indicate to the user that they can abandon the funding transaction and recycle the
/// inputs for another purpose.
///
+ /// When splicing, users can expect to receive an event for each negotiated splice transaction
+ /// that did not become locked. The negotiated splice transaction that became locked can be
+ /// obtained via [`Event::ChannelReady::funding_txo`].
+ ///
/// This event is not guaranteed to be generated for channels that are closed due to a restart.
///
/// # Failure Behavior and Persistence
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index e637f44..462c86e 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -39,6 +39,8 @@ use crate::chain::transaction::{OutPoint, TransactionData};
use crate::chain::BestBlock;
use crate::events::bump_transaction::BASE_INPUT_WEIGHT;
use crate::events::ClosureReason;
+#[cfg(splicing)]
+use crate::events::FundingInfo;
use crate::ln::chan_utils;
#[cfg(splicing)]
use crate::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT;
@@ -6086,16 +6088,35 @@ where
#[cfg(splicing)]
macro_rules! promote_splice_funding {
- ($self: expr, $funding: expr) => {
+ ($self: expr, $funding: expr) => {{
+ let prev_funding_txid = $self.funding.get_funding_txid();
if let Some(scid) = $self.funding.short_channel_id {
$self.context.historical_scids.push(scid);
}
core::mem::swap(&mut $self.funding, $funding);
$self.interactive_tx_signing_session = None;
$self.pending_splice = None;
- $self.pending_funding.clear();
$self.context.announcement_sigs_state = AnnouncementSigsState::NotSent;
- };
+
+ // The swap above places the previous `FundingScope` into `pending_funding`.
+ let discarded_funding = $self
+ .pending_funding
+ .drain(..)
+ .filter(|funding| funding.get_funding_txid() != prev_funding_txid)
+ .map(|mut funding| {
+ funding
+ .funding_transaction
+ .take()
+ .map(|tx| FundingInfo::Tx { transaction: tx })
+ .unwrap_or_else(|| FundingInfo::OutPoint {
+ outpoint: funding
+ .get_funding_txo()
+ .expect("Negotiated splices must have a known funding outpoint"),
+ })
+ })
+ .collect::<Vec<_>>();
+ discarded_funding
+ }};
}
#[cfg(any(test, fuzzing))]
@@ -6179,6 +6200,7 @@ pub struct SpliceFundingPromotion {
pub funding_txo: OutPoint,
pub monitor_update: Option<ChannelMonitorUpdate>,
pub announcement_sigs: Option<msgs::AnnouncementSignatures>,
+ pub discarded_funding: Vec<FundingInfo>,
}
impl<SP: Deref> FundedChannel<SP>
@@ -8634,23 +8656,25 @@ where
log_trace!(logger, "Regenerating latest commitment update in channel {} with{} {} update_adds, {} update_fulfills, {} update_fails, and {} update_fail_malformeds",
&self.context.channel_id(), if update_fee.is_some() { " update_fee," } else { "" },
update_add_htlcs.len(), update_fulfill_htlcs.len(), update_fail_htlcs.len(), update_fail_malformed_htlcs.len());
- let commitment_signed =
- if let Ok(update) = self.send_commitment_no_state_update(logger) {
- if self.context.signer_pending_commitment_update {
- log_trace!(
- logger,
- "Commitment update generated: clearing signer_pending_commitment_update"
- );
- self.context.signer_pending_commitment_update = false;
- }
- update
- } else {
- if !self.context.signer_pending_commitment_update {
- log_trace!(logger, "Commitment update awaiting signer: setting signer_pending_commitment_update");
- self.context.signer_pending_commitment_update = true;
- }
- return Err(());
- };
+ let commitment_signed = if let Ok(update) = self.send_commitment_no_state_update(logger) {
+ if self.context.signer_pending_commitment_update {
+ log_trace!(
+ logger,
+ "Commitment update generated: clearing signer_pending_commitment_update"
+ );
+ self.context.signer_pending_commitment_update = false;
+ }
+ update
+ } else {
+ if !self.context.signer_pending_commitment_update {
+ log_trace!(
+ logger,
+ "Commitment update awaiting signer: setting signer_pending_commitment_update"
+ );
+ self.context.signer_pending_commitment_update = true;
+ }
+ return Err(());
+ };
Ok(msgs::CommitmentUpdate {
update_add_htlcs,
update_fulfill_htlcs,
@@ -9954,7 +9978,7 @@ where
&self.context.channel_id,
);
- {
+ let discarded_funding = {
// Scope `funding` since it is swapped within `promote_splice_funding` and we don't want
// to unintentionally use it.
let funding = self
@@ -9962,8 +9986,8 @@ where
.iter_mut()
.find(|funding| funding.get_funding_txid() == Some(splice_txid))
.unwrap();
- promote_splice_funding!(self, funding);
- }
+ promote_splice_funding!(self, funding)
+ };
let funding_txo = self
.funding
@@ -9984,7 +10008,12 @@ where
let announcement_sigs =
self.get_announcement_sigs(node_signer, chain_hash, user_config, block_height, logger);
- Some(SpliceFundingPromotion { funding_txo, monitor_update, announcement_sigs })
+ Some(SpliceFundingPromotion {
+ funding_txo,
+ monitor_update,
+ announcement_sigs,
+ discarded_funding,
+ })
}
/// When a transaction is confirmed, we check whether it is or spends the funding transaction
@@ -10066,16 +10095,17 @@ where
&self.context.channel_id,
);
- let (funding_txo, monitor_update, announcement_sigs) =
+ let (funding_txo, monitor_update, announcement_sigs, discarded_funding) =
self.maybe_promote_splice_funding(
node_signer, chain_hash, user_config, height, logger,
).map(|splice_promotion| (
Some(splice_promotion.funding_txo),
splice_promotion.monitor_update,
splice_promotion.announcement_sigs,
- )).unwrap_or((None, None, None));
+ splice_promotion.discarded_funding,
+ )).unwrap_or((None, None, None, Vec::new()));
- return Ok((Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update)), announcement_sigs));
+ return Ok((Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update, discarded_funding)), announcement_sigs));
}
}
}
@@ -10227,7 +10257,7 @@ where
log_info!(logger, "Sending a splice_locked to our peer for channel {}", &self.context.channel_id);
debug_assert!(chain_node_signer.is_some());
- let (funding_txo, monitor_update, announcement_sigs) = chain_node_signer
+ let (funding_txo, monitor_update, announcement_sigs, discarded_funding) = chain_node_signer
.and_then(|(chain_hash, node_signer, user_config)| {
// We can only promote on blocks connected, which is when we expect
// `chain_node_signer` to be `Some`.
@@ -10237,10 +10267,11 @@ where
Some(splice_promotion.funding_txo),
splice_promotion.monitor_update,
splice_promotion.announcement_sigs,
+ splice_promotion.discarded_funding,
))
- .unwrap_or((None, None, None));
+ .unwrap_or((None, None, None, Vec::new()));
- return Ok((Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update)), timed_out_htlcs, announcement_sigs));
+ return Ok((Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update, discarded_funding)), timed_out_htlcs, announcement_sigs));
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index f42ea46..b35a082 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -11230,19 +11230,30 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
insert_short_channel_id!(short_to_chan_info, chan);
}
- let mut pending_events = self.pending_events.lock().unwrap();
- pending_events.push_back((
- events::Event::ChannelReady {
- channel_id: chan.context.channel_id(),
- user_channel_id: chan.context.get_user_id(),
- counterparty_node_id: chan.context.get_counterparty_node_id(),
- funding_txo: Some(
- splice_promotion.funding_txo.into_bitcoin_outpoint(),
- ),
- channel_type: chan.funding.get_channel_type().clone(),
- },
- None,
- ));
+ {
+ let mut pending_events = self.pending_events.lock().unwrap();
+ pending_events.push_back((
+ events::Event::ChannelReady {
+ channel_id: chan.context.channel_id(),
+ user_channel_id: chan.context.get_user_id(),
+ counterparty_node_id: chan.context.get_counterparty_node_id(),
+ funding_txo: Some(
+ splice_promotion.funding_txo.into_bitcoin_outpoint(),
+ ),
+ channel_type: chan.funding.get_channel_type().clone(),
+ },
+ None,
+ ));
+ splice_promotion.discarded_funding.into_iter().for_each(
+ |funding_info| {
+ let event = Event::DiscardFunding {
+ channel_id: chan.context.channel_id(),
+ funding_info,
+ };
+ pending_events.push_back((event, None));
+ },
+ );
+ }
if let Some(announcement_sigs) = splice_promotion.announcement_sigs {
log_trace!(
@@ -13409,7 +13420,7 @@ where
pub(super) enum FundingConfirmedMessage {
Establishment(msgs::ChannelReady),
#[cfg(splicing)]
- Splice(msgs::SpliceLocked, Option<OutPoint>, Option<ChannelMonitorUpdate>),
+ Splice(msgs::SpliceLocked, Option<OutPoint>, Option<ChannelMonitorUpdate>, Vec<FundingInfo>),
}
impl<
@@ -13485,7 +13496,7 @@ where
}
},
#[cfg(splicing)]
- Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update_opt)) => {
+ Some(FundingConfirmedMessage::Splice(splice_locked, funding_txo, monitor_update_opt, discarded_funding)) => {
let counterparty_node_id = funded_channel.context.get_counterparty_node_id();
let channel_id = funded_channel.context.channel_id();
@@ -13515,6 +13526,13 @@ where
funding_txo: Some(funding_txo.into_bitcoin_outpoint()),
channel_type: funded_channel.funding.get_channel_type().clone(),
}, None));
+ discarded_funding.into_iter().for_each(|funding_info| {
+ let event = Event::DiscardFunding {
+ channel_id: funded_channel.context.channel_id(),
+ funding_info,
+ };
+ pending_events.push_back((event, None));
+ });
}
pending_msg_events.push(MessageSendEvent::SendSpliceLocked {
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.