From 51e342bbe7cc47516d2b0c035ad0889b4657c616 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 26 Aug 2025 09:33:27 -0700 Subject: [PATCH] Emit DiscardFunding events for double spent splice transactions Once we see a splice transaction become locked, we want to emit a `DiscardFunding` event for every alternative funding transaction candidate that may have been negotiated due to RBFs, as they may contain inputs the user has considered "locked" that do not exist in the confirmed transaction. If the channel closes before the splice is locked, we rely on the `ChannelMonitor` to produce these events for us instead. --- lightning/src/chain/channelmonitor.rs | 15 ++++- lightning/src/events/mod.rs | 4 ++ lightning/src/ln/channel.rs | 91 ++++++++++++++++++--------- lightning/src/ln/channelmanager.rs | 48 +++++++++----- 4 files changed, 112 insertions(+), 46 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index d46cfaa636c..2d0420c48c8 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -3906,6 +3906,8 @@ impl ChannelMonitorImpl { } 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 ChannelMonitorImpl { 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 30c928297a8..d36143dce9f 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 e637f44d3cf..462c86eab36 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::>(); + discarded_funding + }}; } #[cfg(any(test, fuzzing))] @@ -6179,6 +6200,7 @@ pub struct SpliceFundingPromotion { pub funding_txo: OutPoint, pub monitor_update: Option, pub announcement_sigs: Option, + pub discarded_funding: Vec, } impl FundedChannel @@ -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 f42ea461891..b35a0824084 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, Option), + Splice(msgs::SpliceLocked, Option, Option, Vec), } 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 {