Split DiscardFunding from SpliceFailed event
What changed, and why it matters
This commit is a defensive refactor of how a Lightning node library (LDK) tells wallet software to reclaim UTXOs after a splice or dual-funding attempt fails. It splits the cleanup signal out of the general 'splice failed' event into a dedicated 'DiscardFunding' event, and makes sure that signal is emitted on more error paths (wrong peer, unknown channel, duplicate contribution, channel shutting down, etc.). The change is primarily about preventing user funds from being accidentally left locked or double-spent by giving the wallet a clearer, more consistent reclaim signal. It is not a remote exploit fix; it is an API-hardening and reliability improvement.
Review downstream wallet/event-handler code that consumes Event::SpliceFailed. Update handlers to expect a separate Event::DiscardFunding for contributed inputs/outputs, and stop reading contributed_inputs/contributed_outputs from SpliceFailed. Verify that wallets act on DiscardFunding promptly to avoid leaving UTXOs reserved for abandoned funding transactions. No emergency deployment is indicated, but the event contract change is breaking for consumers of these events.
Security signals we found
New event type dedicated to UTXO reclaim after funding failure
Error paths now emit DiscardFunding instead of silently discarding contribution data
Duplicate/overlapping contributions filtered to avoid double-reclaim or reuse confusion
API now returns Err(APIError) on all funding_contributed failure cases
Serialization updated for new FundingInfo::Contribution variant and removed SpliceFailed fields
Extensive new tests cover missing peer, missing channel, duplicate contribution, active negotiation, shutdown, and unfunded-channel error paths
Evidence from the diff
The patch refactors event generation for failed splicing/dual-funding in rust-lightning. It introduces FundingInfo::Contribution and a new Event::DiscardFunding, removes contributed_inputs/contributed_outputs from Event::SpliceFailed, and routes all funding_contributed error paths through a QuiescentError enum that can emit DiscardFunding (and SpliceFailed where appropriate). It also deduplicates contributions against outstanding pending contributions and returns APIError variants from funding_contributed instead of silently dropping contributions. The change is defensive: it reduces the chance that wallet code misses a reclaim signal or reuses UTXOs that are still tied to an abandoned funding transaction.
Changed components
lightning/src/events/mod.rslightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/functional_test_utils.rslightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsInspect captured patch +812 / −102
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 1f030aa..3f6bb0e 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -77,6 +77,13 @@ pub enum FundingInfo {
/// The outpoint of the funding
outpoint: transaction::OutPoint,
},
+ /// The contributions used for a dual funding or splice funding transaction.
+ Contribution {
+ /// UTXOs spent as inputs contributed to the funding transaction.
+ inputs: Vec<OutPoint>,
+ /// Outputs contributed to the funding transaction.
+ outputs: Vec<TxOut>,
+ },
}
impl_writeable_tlv_based_enum!(FundingInfo,
@@ -85,6 +92,10 @@ impl_writeable_tlv_based_enum!(FundingInfo,
},
(1, OutPoint) => {
(1, outpoint, required)
+ },
+ (2, Contribution) => {
+ (1, inputs, optional_vec),
+ (3, outputs, optional_vec),
}
);
@@ -1561,10 +1572,6 @@ pub enum Event {
abandoned_funding_txo: Option<OutPoint>,
/// The features that this channel will operate with, if available.
channel_type: Option<ChannelTypeFeatures>,
- /// UTXOs spent as inputs contributed to the splice transaction.
- contributed_inputs: Vec<OutPoint>,
- /// Outputs contributed to the splice transaction.
- contributed_outputs: Vec<TxOut>,
},
/// Used to indicate to the user that they can abandon the funding transaction and recycle the
/// inputs for another purpose.
@@ -2326,8 +2333,6 @@ impl Writeable for Event {
ref counterparty_node_id,
ref abandoned_funding_txo,
ref channel_type,
- ref contributed_inputs,
- ref contributed_outputs,
} => {
52u8.write(writer)?;
write_tlv_fields!(writer, {
@@ -2336,8 +2341,6 @@ impl Writeable for Event {
(5, user_channel_id, required),
(7, counterparty_node_id, required),
(9, abandoned_funding_txo, option),
- (11, *contributed_inputs, optional_vec),
- (13, *contributed_outputs, optional_vec),
});
},
// Note that, going forward, all new events must only write data inside of
@@ -2965,8 +2968,6 @@ impl MaybeReadable for Event {
(5, user_channel_id, required),
(7, counterparty_node_id, required),
(9, abandoned_funding_txo, option),
- (11, contributed_inputs, optional_vec),
- (13, contributed_outputs, optional_vec),
});
Ok(Some(Event::SpliceFailed {
@@ -2975,8 +2976,6 @@ impl MaybeReadable for Event {
counterparty_node_id: counterparty_node_id.0.unwrap(),
abandoned_funding_txo,
channel_type,
- contributed_inputs: contributed_inputs.unwrap_or_default(),
- contributed_outputs: contributed_outputs.unwrap_or_default(),
}))
};
f()
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 905adb1..cd98ed7 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -3050,6 +3050,35 @@ pub(crate) enum QuiescentAction {
DoNothing,
}
+pub(super) enum QuiescentError {
+ DoNothing,
+ DiscardFunding { inputs: Vec<bitcoin::OutPoint>, outputs: Vec<bitcoin::TxOut> },
+ FailSplice(SpliceFundingFailed),
+}
+
+impl From<QuiescentAction> for QuiescentError {
+ fn from(action: QuiescentAction) -> Self {
+ match action {
+ QuiescentAction::LegacySplice(_) => {
+ debug_assert!(false);
+ QuiescentError::DoNothing
+ },
+ QuiescentAction::Splice { contribution, .. } => {
+ let (contributed_inputs, contributed_outputs) =
+ contribution.into_contributed_inputs_and_outputs();
+ return QuiescentError::FailSplice(SpliceFundingFailed {
+ funding_txo: None,
+ channel_type: None,
+ contributed_inputs,
+ contributed_outputs,
+ });
+ },
+ #[cfg(any(test, fuzzing, feature = "_test_utils"))]
+ QuiescentAction::DoNothing => QuiescentError::DoNothing,
+ }
+ }
+}
+
pub(crate) enum StfuResponse {
Stfu(msgs::Stfu),
SpliceInit(msgs::SpliceInit),
@@ -12215,9 +12244,58 @@ where
pub fn funding_contributed<L: Logger>(
&mut self, contribution: FundingContribution, locktime: LockTime, logger: &L,
- ) -> Result<Option<msgs::Stfu>, SpliceFundingFailed> {
+ ) -> Result<Option<msgs::Stfu>, QuiescentError> {
debug_assert!(contribution.is_splice());
+ if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action
+ {
+ return match contribution.into_unique_contributions(
+ existing.contributed_inputs(),
+ existing.contributed_outputs(),
+ ) {
+ None => Err(QuiescentError::DoNothing),
+ Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }),
+ };
+ }
+
+ let initiated_funding_negotiation = self
+ .pending_splice
+ .as_ref()
+ .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
+ .filter(|funding_negotiation| funding_negotiation.is_initiator());
+
+ if let Some(funding_negotiation) = initiated_funding_negotiation {
+ let unique_contributions = match funding_negotiation {
+ FundingNegotiation::AwaitingAck { context, .. } => contribution
+ .into_unique_contributions(
+ context.contributed_inputs(),
+ context.contributed_outputs(),
+ ),
+ FundingNegotiation::ConstructingTransaction {
+ interactive_tx_constructor, ..
+ } => contribution.into_unique_contributions(
+ interactive_tx_constructor.contributed_inputs(),
+ interactive_tx_constructor.contributed_outputs(),
+ ),
+ FundingNegotiation::AwaitingSignatures { .. } => {
+ let session = self
+ .context
+ .interactive_tx_signing_session
+ .as_ref()
+ .expect("pending splice awaiting signatures");
+ contribution.into_unique_contributions(
+ session.contributed_inputs(),
+ session.contributed_outputs(),
+ )
+ },
+ };
+
+ return match unique_contributions {
+ None => Err(QuiescentError::DoNothing),
+ Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }),
+ };
+ }
+
if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
@@ -12229,37 +12307,15 @@ where
let (contributed_inputs, contributed_outputs) =
contribution.into_contributed_inputs_and_outputs();
- return Err(SpliceFundingFailed {
+ return Err(QuiescentError::FailSplice(SpliceFundingFailed {
funding_txo: None,
channel_type: None,
contributed_inputs,
contributed_outputs,
- });
+ }));
}
- self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err(
- |action| {
- // FIXME: Any better way to do this?
- if let QuiescentAction::Splice { contribution, .. } = action {
- let (contributed_inputs, contributed_outputs) =
- contribution.into_contributed_inputs_and_outputs();
- SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs,
- contributed_outputs,
- }
- } else {
- debug_assert!(false);
- SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs: vec![],
- contributed_outputs: vec![],
- }
- }
- },
- )
+ self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime })
}
fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit {
@@ -13382,19 +13438,19 @@ where
#[rustfmt::skip]
pub fn propose_quiescence<L: Logger>(
&mut self, logger: &L, action: QuiescentAction,
- ) -> Result<Option<msgs::Stfu>, QuiescentAction> {
+ ) -> Result<Option<msgs::Stfu>, QuiescentError> {
log_debug!(logger, "Attempting to initiate quiescence");
if !self.context.is_usable() {
log_debug!(logger, "Channel is not in a usable state to propose quiescence");
- return Err(action);
+ return Err(action.into());
}
if self.quiescent_action.is_some() {
log_debug!(
logger,
"Channel already has a pending quiescent action and cannot start another",
);
- return Err(action);
+ return Err(action.into());
}
// Since we don't have a pending quiescent action, we should never be in a state where we
// sent `stfu` without already having become quiescent.
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index fec6d82..d7c1b60 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -57,11 +57,12 @@ use crate::events::{FundingInfo, PaidBolt12Invoice};
use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight;
#[cfg(any(test, fuzzing, feature = "_test_utils"))]
use crate::ln::channel::QuiescentAction;
+use crate::ln::channel::QuiescentError;
use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult,
FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop,
- OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse,
- UpdateFulfillCommitFetch, WithChannelContext,
+ OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed,
+ StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::ChannelDetails;
use crate::ln::funding::{FundingContribution, FundingTemplate};
@@ -3925,15 +3926,24 @@ impl<
failed_htlcs = htlcs;
if let Some(splice_funding_failed) = splice_funding_failed {
- self.pending_events.lock().unwrap().push_back((
+ let mut pending_events = self.pending_events.lock().unwrap();
+ pending_events.push_back((
events::Event::SpliceFailed {
channel_id: *chan_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,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: *chan_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -4236,8 +4246,16 @@ impl<
user_channel_id: shutdown_res.user_channel_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,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: shutdown_res.channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -4757,8 +4775,16 @@ impl<
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,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: *channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -6418,13 +6444,27 @@ impl<
/// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`]
/// from the returned [`FundingTemplate`] and passing it here.
///
+ /// # Arguments
+ ///
+ /// An optional `locktime` for the funding transaction may be specified. If not given, the
+ /// current best block height is used.
+ ///
+ /// # Events
+ ///
/// Calling this method will commence the process of creating a new funding transaction for the
/// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the
/// transaction is successfully constructed interactively with the counterparty.
- /// If unsuccessful, an [`Event::SpliceFailed`] will be surfaced instead.
///
- /// An optional `locktime` for the funding transaction may be specified. If not given, the
- /// current best block height is used.
+ /// If unsuccessful, an [`Event::SpliceFailed`] will be produced if there aren't any earlier
+ /// splice attempts for the channel outstanding (i.e., haven't yet produced either
+ /// [`Event::SplicePending`] or [`Event::SpliceFailed`]).
+ ///
+ /// If unsuccessful, an [`Event::DiscardFunding`] will be produced for any contributions
+ /// passed in that are not found in any outstanding attempts for the channel. If there are no
+ /// such contributions, then the [`Event::DiscardFunding`] will not be produced since these
+ /// contributions must not be reused yet.
+ ///
+ /// # Errors
///
/// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect
/// `counterparty_node_id` is provided.
@@ -6440,12 +6480,22 @@ impl<
) -> Result<(), APIError> {
let mut result = Ok(());
PersistenceNotifierGuard::optionally_notify(self, || {
+ let push_discard_funding = |contribution: FundingContribution| {
+ let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs();
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::DiscardFunding {
+ channel_id: *channel_id,
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ },
+ None,
+ ));
+ };
+
let per_peer_state = self.per_peer_state.read().unwrap();
let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id);
if peer_state_mutex_opt.is_none() {
- result = Err(APIError::ChannelUnavailable {
- err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}")
- });
+ push_discard_funding(contribution);
+ result = Err(APIError::no_such_peer(counterparty_node_id));
return NotifyOption::SkipPersistNoEvents;
}
@@ -6469,28 +6519,69 @@ impl<
);
}
},
- Err(splice_funding_failed) => {
+ Err(QuiescentError::DoNothing) => {
+ result = Err(APIError::APIMisuseError {
+ err: format!(
+ "Duplicate funding contribution for channel {}",
+ channel_id
+ ),
+ });
+ },
+ Err(QuiescentError::DiscardFunding { inputs, outputs }) => {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::DiscardFunding {
+ channel_id: *channel_id,
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ },
+ None,
+ ));
+ result = Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} already has a pending funding contribution",
+ channel_id
+ ),
+ });
+ },
+ Err(QuiescentError::FailSplice(SpliceFundingFailed {
+ funding_txo,
+ channel_type,
+ contributed_inputs,
+ contributed_outputs,
+ })) => {
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: channel.context().get_user_id(),
- abandoned_funding_txo: splice_funding_failed.funding_txo,
- channel_type: splice_funding_failed.channel_type.clone(),
- contributed_inputs: splice_funding_failed
- .contributed_inputs,
- contributed_outputs: splice_funding_failed
- .contributed_outputs,
+ abandoned_funding_txo: funding_txo,
+ channel_type,
},
None,
));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: *channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: contributed_inputs,
+ outputs: contributed_outputs,
+ },
+ },
+ None,
+ ));
+ result = Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} cannot accept funding contribution",
+ channel_id
+ ),
+ });
},
}
return NotifyOption::DoPersist;
},
None => {
+ push_discard_funding(contribution);
result = Err(APIError::APIMisuseError {
err: format!(
"Channel with id {} not expecting funding contribution",
@@ -6501,12 +6592,9 @@ impl<
},
},
None => {
- result = Err(APIError::ChannelUnavailable {
- err: format!(
- "Channel with id {} not found for the passed counterparty node_id {}",
- channel_id, counterparty_node_id
- ),
- });
+ push_discard_funding(contribution);
+ result =
+ Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id));
return NotifyOption::SkipPersistNoEvents;
},
}
@@ -11369,8 +11457,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
user_channel_id: channel.context().get_user_id(),
abandoned_funding_txo: splice_funding_failed.funding_txo,
channel_type: splice_funding_failed.channel_type.clone(),
- contributed_inputs: splice_funding_failed.contributed_inputs,
- contributed_outputs: splice_funding_failed.contributed_outputs,
+ },
+ None,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -11520,8 +11616,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
user_channel_id: chan.context().get_user_id(),
abandoned_funding_txo: splice_funding_failed.funding_txo,
channel_type: splice_funding_failed.channel_type.clone(),
- contributed_inputs: splice_funding_failed.contributed_inputs,
- contributed_outputs: splice_funding_failed.contributed_outputs,
+ },
+ None,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: msg.channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -11682,8 +11786,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
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,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: msg.channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -11814,15 +11926,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
dropped_htlcs = htlcs;
if let Some(splice_funding_failed) = splice_funding_failed {
- self.pending_events.lock().unwrap().push_back((
+ let mut pending_events = 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.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,
+ ));
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: msg.channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -13424,7 +13545,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
});
notify = NotifyOption::SkipPersistHandleEvents;
},
- Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action),
+ Err(e) => {
+ debug_assert!(matches!(e, QuiescentError::DoNothing));
+ log_trace!(logger, "Failed to propose quiescence");
+ },
}
} else {
result = Err(APIError::APIMisuseError {
@@ -14779,8 +14903,13 @@ impl<
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,
+ });
+ splice_failed_events.push(events::Event::DiscardFunding {
+ channel_id: chan.context().channel_id(),
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
});
}
@@ -17379,9 +17508,9 @@ impl<
let our_pending_intercepts = self.pending_intercepted_htlcs.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.
+ // be failed upon reload. However, as the necessary information for the SpliceFailed and
+ // DiscardFunding events is not persisted, the events need to be persisted even though they
+ // haven'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() {
@@ -17394,8 +17523,16 @@ impl<
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,
+ ));
+ events.push_back((
+ events::Event::DiscardFunding {
+ channel_id: chan.context().channel_id(),
+ funding_info: FundingInfo::Contribution {
+ inputs: splice_funding_failed.contributed_inputs,
+ outputs: splice_funding_failed.contributed_outputs,
+ },
},
None,
));
@@ -17518,7 +17655,7 @@ impl<
(21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required),
});
- // Remove the SpliceFailed events added earlier.
+ // Remove the SpliceFailed and DiscardFunding events added earlier.
events.truncate(event_count);
Ok(())
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index 91e05d2..35138c1 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -17,8 +17,8 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch
use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync;
use crate::events::bump_transaction::BumpTransactionEvent;
use crate::events::{
- ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure,
- PaymentFailureReason, PaymentPurpose,
+ ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice,
+ PathFailure, PaymentFailureReason, PaymentPurpose,
};
use crate::ln::chan_utils::{
commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT,
@@ -3236,16 +3236,48 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>(
pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>(
node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId,
funding_contribution: FundingContribution,
+) {
+ let events = node.node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2);
+ match &events[0] {
+ Event::SpliceFailed { channel_id, .. } => {
+ assert_eq!(*expected_channel_id, *channel_id);
+ },
+ _ => panic!("Unexpected event"),
+ }
+ match &events[1] {
+ Event::DiscardFunding { funding_info, .. } => {
+ if let FundingInfo::Contribution { inputs, outputs } = &funding_info {
+ let (expected_inputs, expected_outputs) =
+ funding_contribution.into_contributed_inputs_and_outputs();
+ assert_eq!(*inputs, expected_inputs);
+ assert_eq!(*outputs, expected_outputs);
+ } else {
+ panic!("Expected FundingInfo::Contribution");
+ }
+ },
+ _ => panic!("Unexpected event"),
+ }
+}
+
+#[cfg(any(test, ldk_bench, feature = "_test_utils"))]
+pub fn expect_discard_funding_event<'a, 'b, 'c, 'd>(
+ node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId,
+ funding_contribution: FundingContribution,
) {
let events = node.node.get_and_clear_pending_events();
assert_eq!(events.len(), 1);
match &events[0] {
- Event::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => {
+ Event::DiscardFunding { channel_id, funding_info } => {
assert_eq!(*expected_channel_id, *channel_id);
- let (expected_inputs, expected_outputs) =
- funding_contribution.into_contributed_inputs_and_outputs();
- assert_eq!(*contributed_inputs, expected_inputs);
- assert_eq!(*contributed_outputs, expected_outputs);
+ if let FundingInfo::Contribution { inputs, outputs } = &funding_info {
+ let (expected_inputs, expected_outputs) =
+ funding_contribution.into_contributed_inputs_and_outputs();
+ assert_eq!(*inputs, expected_inputs);
+ assert_eq!(*outputs, expected_outputs);
+ } else {
+ panic!("Expected FundingInfo::Contribution");
+ }
},
_ => panic!("Unexpected event"),
}
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 935703c..dc29b23 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -305,6 +305,14 @@ impl FundingContribution {
self.is_splice
}
+ pub(super) fn contributed_inputs(&self) -> impl Iterator<Item = OutPoint> + '_ {
+ self.inputs.iter().map(|input| input.utxo.outpoint)
+ }
+
+ pub(super) fn contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ self.outputs.iter().chain(self.change_output.iter())
+ }
+
pub(super) fn into_tx_parts(self) -> (Vec<FundingTxInput>, Vec<TxOut>) {
let FundingContribution { inputs, mut outputs, change_output, .. } = self;
@@ -321,6 +329,24 @@ impl FundingContribution {
(inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs)
}
+ pub(super) fn into_unique_contributions<'a>(
+ self, existing_inputs: impl Iterator<Item = OutPoint>,
+ existing_outputs: impl Iterator<Item = &'a TxOut>,
+ ) -> Option<(Vec<OutPoint>, Vec<TxOut>)> {
+ let (mut inputs, mut outputs) = self.into_contributed_inputs_and_outputs();
+ for existing in existing_inputs {
+ inputs.retain(|input| *input != existing);
+ }
+ for existing in existing_outputs {
+ outputs.retain(|output| *output != *existing);
+ }
+ if inputs.is_empty() && outputs.is_empty() {
+ None
+ } else {
+ Some((inputs, outputs))
+ }
+ }
+
/// Validates that the funding inputs are suitable for use in the interactive transaction
/// protocol, checking prevtx sizes and input sufficiency.
pub fn validate(&self) -> Result<(), String> {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 0c7df06..ab890fd 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -29,9 +29,12 @@ use crate::util::wallet_utils::{WalletSourceSync, WalletSync};
use crate::sync::Arc;
+use bitcoin::hashes::Hash;
use bitcoin::secp256k1::ecdsa::Signature;
-use bitcoin::secp256k1::PublicKey;
-use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut};
+use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
+use bitcoin::{
+ Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash,
+};
#[test]
fn test_splicing_not_supported_api_error() {
@@ -2151,7 +2154,7 @@ fn fail_splice_on_tx_complete_error() {
value: Amount::from_sat(1_000),
script_pubkey: acceptor.wallet_source.get_change_script().unwrap(),
}];
- let _ = initiate_splice_out(initiator, acceptor, channel_id, outputs);
+ let funding_contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs);
let _ = complete_splice_handshake(initiator, acceptor);
// Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence.
@@ -2205,7 +2208,8 @@ fn fail_splice_on_tx_complete_error() {
};
initiator.node.handle_tx_abort(node_id_acceptor, tx_abort);
- let _ = get_event!(initiator, Event::SpliceFailed);
+ expect_splice_failed_events(initiator, &channel_id, funding_contribution);
+
let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort);
@@ -2339,7 +2343,7 @@ fn fail_splice_on_channel_close() {
&nodes[0],
&[ExpectedCloseEvent {
channel_id: Some(channel_id),
- discard_funding: false,
+ discard_funding: true,
splice_failed: true,
channel_funding_txo: None,
user_channel_id: Some(42),
@@ -2385,7 +2389,7 @@ fn fail_quiescent_action_on_channel_close() {
&nodes[0],
&[ExpectedCloseEvent {
channel_id: Some(channel_id),
- discard_funding: false,
+ discard_funding: true,
splice_failed: true,
channel_funding_txo: None,
user_channel_id: Some(42),
@@ -2438,7 +2442,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
// Attempt the splice. `stfu` should not go out yet as the state machine is pending.
let splice_in_amount = initial_channel_capacity / 2;
- let _ =
+ let funding_contribution =
initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount));
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
@@ -2453,7 +2457,7 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id);
closee_node.node.handle_shutdown(closer_node_id, &shutdown);
- let _ = get_event!(nodes[0], Event::SpliceFailed);
+ expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id);
}
@@ -2890,3 +2894,459 @@ fn test_splice_balance_falls_below_reserve() {
// Final sanity check: send a payment using the new spliced capacity.
let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
}
+
+#[test]
+fn test_funding_contributed_counterparty_not_found() {
+ // Tests that calling funding_contributed with an unknown counterparty_node_id returns
+ // ChannelUnavailable and emits a DiscardFunding event.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 2);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // Use a fake/unknown public key as counterparty
+ let fake_node_id =
+ PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap());
+
+ assert_eq!(
+ nodes[0].node.funding_contributed(
+ &channel_id,
+ &fake_node_id,
+ funding_contribution.clone(),
+ None
+ ),
+ Err(APIError::no_such_peer(&fake_node_id)),
+ );
+
+ expect_discard_funding_event(&nodes[0], &channel_id, funding_contribution);
+}
+
+#[test]
+fn test_funding_contributed_channel_not_found() {
+ // Tests that calling funding_contributed with an unknown channel_id returns
+ // ChannelUnavailable and emits a DiscardFunding event.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 2);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // Use a random/unknown channel_id
+ let fake_channel_id = ChannelId::from_bytes([42; 32]);
+
+ assert_eq!(
+ nodes[0].node.funding_contributed(
+ &fake_channel_id,
+ &node_id_1,
+ funding_contribution.clone(),
+ None
+ ),
+ Err(APIError::no_such_channel_for_peer(&fake_channel_id, &node_id_1)),
+ );
+
+ expect_discard_funding_event(&nodes[0], &fake_channel_id, funding_contribution);
+}
+
+#[test]
+fn test_funding_contributed_splice_already_pending() {
+ // Tests that calling funding_contributed when there's already a pending splice
+ // contribution returns Err(APIMisuseError) and emits a DiscardFunding event containing only the
+ // inputs/outputs that are NOT already in the existing contribution.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 2, splice_in_amount * 2);
+
+ // Use splice_in_and_out with an output so we can test output filtering
+ let first_splice_out = TxOut {
+ value: Amount::from_sat(5_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())),
+ };
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let first_contribution = funding_template
+ .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet)
+ .unwrap();
+
+ // Initiate a second splice with a DIFFERENT output to test that different outputs
+ // are included in DiscardFunding (not filtered out)
+ let second_splice_out = TxOut {
+ value: Amount::from_sat(6_000), // Different amount
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())),
+ };
+
+ // Clear UTXOs and add a LARGER one for the second contribution to ensure
+ // the change output will be different from the first contribution's change
+ //
+ // FIXME: Should we actually not consider the change value given DiscardFunding is meant to
+ // reclaim the change script pubkey? But that means for other cases we'd need to track which
+ // output is for change later in the pipeline.
+ nodes[0].wallet_source.clear_utxos();
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 3);
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let second_contribution = funding_template
+ .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet)
+ .unwrap();
+
+ // First funding_contributed - this sets up the quiescent action
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap();
+
+ // Drain the pending stfu message
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+
+ // Second funding_contributed with a different contribution - this should trigger
+ // DiscardFunding because there's already a pending quiescent action (splice contribution).
+ // Only inputs/outputs NOT in the existing contribution should be discarded.
+ let (expected_inputs, expected_outputs) =
+ second_contribution.clone().into_contributed_inputs_and_outputs();
+
+ // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution
+ assert_eq!(
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None),
+ Err(APIError::APIMisuseError {
+ err: format!("Channel {} already has a pending funding contribution", channel_id),
+ })
+ );
+
+ // The second contribution has different outputs (second_splice_out differs from first_splice_out),
+ // so those outputs should NOT be filtered out - they should appear in DiscardFunding.
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ Event::DiscardFunding { channel_id: event_channel_id, funding_info } => {
+ assert_eq!(event_channel_id, &channel_id);
+ if let FundingInfo::Contribution { inputs, outputs } = funding_info {
+ // The input is different, so it should be in the discard event
+ assert_eq!(*inputs, expected_inputs);
+ // The splice-out output is different (6000 vs 5000), so it should be in discard event
+ assert!(expected_outputs.contains(&second_splice_out));
+ assert!(!expected_outputs.contains(&first_splice_out));
+ // The different outputs should NOT be filtered out
+ assert_eq!(*outputs, expected_outputs);
+ } else {
+ panic!("Expected FundingInfo::Contribution");
+ }
+ },
+ _ => panic!("Expected DiscardFunding event"),
+ }
+}
+
+#[test]
+fn test_funding_contributed_duplicate_contribution_no_event() {
+ // Tests that calling funding_contributed with the exact same contribution twice
+ // returns Err(APIMisuseError) and emits no events on the second call (DoNothing path).
+ // This tests the case where all inputs/outputs in the second contribution
+ // are already present in the existing contribution.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 2);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // First funding_contributed - this sets up the quiescent action
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap();
+
+ // Drain the pending stfu message
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+
+ // Second funding_contributed with the SAME contribution (same inputs/outputs)
+ // This should trigger the DoNothing path because all inputs/outputs are duplicates.
+ // Returns Err(APIMisuseError) and emits NO events.
+ assert_eq!(
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None),
+ Err(APIError::APIMisuseError {
+ err: format!("Duplicate funding contribution for channel {}", channel_id),
+ })
+ );
+
+ // Verify no events were emitted - the duplicate contribution is silently ignored
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events);
+}
+
+#[test]
+fn test_funding_contributed_active_funding_negotiation() {
+ do_test_funding_contributed_active_funding_negotiation(0); // AwaitingAck
+ do_test_funding_contributed_active_funding_negotiation(1); // ConstructingTransaction
+ do_test_funding_contributed_active_funding_negotiation(2); // AwaitingSignatures
+}
+
+#[cfg(test)]
+fn do_test_funding_contributed_active_funding_negotiation(state: u8) {
+ // Tests that calling funding_contributed when a splice is already being actively negotiated
+ // (pending_splice.funding_negotiation exists and is_initiator()) returns Err(APIMisuseError)
+ // and emits SpliceFailed + DiscardFunding events for non-duplicate contributions, or
+ // returns Err(APIMisuseError) with no events for duplicate contributions.
+ //
+ // State 0: AwaitingAck (splice_init sent, splice_ack not yet received)
+ // State 1: ConstructingTransaction (splice handshake complete, interactive TX in progress)
+ // State 2: AwaitingSignatures (interactive TX complete, awaiting signing)
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 2, splice_in_amount * 2);
+
+ // Build first contribution
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // Build second contribution with different UTXOs so inputs/outputs don't overlap
+ nodes[0].wallet_source.clear_utxos();
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 3);
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // First funding_contributed - sets up the quiescent action and queues STFU
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, first_contribution.clone(), None)
+ .unwrap();
+
+ // Complete the STFU exchange. This consumes the quiescent_action and creates
+ // FundingNegotiation::AwaitingAck with splice_init queued.
+ let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
+
+ // Drain the splice_init from the initiator's pending message events
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+
+ if state >= 1 {
+ // Process splice_init/ack to move to ConstructingTransaction
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ if state == 2 {
+ // Complete interactive TX negotiation to move to AwaitingSignatures
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ first_contribution.clone(),
+ new_funding_script,
+ );
+
+ // Drain the FundingTransactionReadyForSigning event from the initiator
+ let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+ }
+ }
+
+ // Call funding_contributed with a different contribution (non-overlapping inputs/outputs).
+ // This hits the funding_negotiation path and returns DiscardFunding.
+ let (expected_inputs, expected_outputs) =
+ second_contribution.clone().into_contributed_inputs_and_outputs();
+ assert_eq!(
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None),
+ Err(APIError::APIMisuseError {
+ err: format!("Channel {} already has a pending funding contribution", channel_id),
+ })
+ );
+
+ // Assert DiscardFunding event with the non-duplicate inputs/outputs
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event::DiscardFunding { channel_id: event_channel_id, funding_info } => {
+ assert_eq!(*event_channel_id, channel_id);
+ if let FundingInfo::Contribution { inputs, outputs } = funding_info {
+ assert_eq!(*inputs, expected_inputs);
+ assert_eq!(*outputs, expected_outputs);
+ } else {
+ panic!("Expected FundingInfo::Contribution");
+ }
+ },
+ _ => panic!("Expected DiscardFunding event, got {:?}", events[1]),
+ }
+
+ // Also test the DoNothing path: call funding_contributed with the same contribution
+ // as the existing negotiation. All inputs/outputs are duplicates, so no events.
+ assert_eq!(
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None),
+ Err(APIError::APIMisuseError {
+ err: format!("Duplicate funding contribution for channel {}", channel_id),
+ })
+ );
+
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events);
+
+ // Cleanup: drain leftover message events from the in-progress splice negotiation
+ if state == 1 {
+ // Initiator has its first interactive TX message queued after handle_splice_ack
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ assert!(matches!(msg_events[0], MessageSendEvent::SendTxAddInput { .. }));
+ }
+ if state == 2 {
+ // Acceptor (no contribution) auto-signed and sent commitment_signed
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1, "{msg_events:?}");
+ assert!(matches!(msg_events[0], MessageSendEvent::UpdateHTLCs { .. }));
+ }
+}
+
+#[test]
+fn test_funding_contributed_channel_shutdown() {
+ // Tests that calling funding_contributed after initiating channel shutdown returns Err(APIMisuseError)
+ // and emits both SpliceFailed and DiscardFunding events. The channel is no longer usable
+ // after shutdown is initiated, so quiescence cannot be proposed.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 2);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // Initiate channel shutdown - this makes is_usable() return false
+ nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap();
+
+ // Drain the pending shutdown message
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendShutdown, node_id_1);
+
+ // Now call funding_contributed - this should trigger FailSplice because
+ // propose_quiescence() will fail when is_usable() returns false.
+ // Returns Err(APIMisuseError) and emits both SpliceFailed and DiscardFunding.
+ assert_eq!(
+ nodes[0].node.funding_contributed(
+ &channel_id,
+ &node_id_1,
+ funding_contribution.clone(),
+ None
+ ),
+ Err(APIError::APIMisuseError {
+ err: format!("Channel {} cannot accept funding contribution", channel_id),
+ })
+ );
+
+ expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
+}
+
+#[test]
+fn test_funding_contributed_unfunded_channel() {
+ // Tests that calling funding_contributed on an unfunded channel returns APIMisuseError
+ // and emits a DiscardFunding event. The channel exists but is not yet funded.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ // Create a funded channel for the splice operation
+ let (_, _, funded_channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ // Create an unfunded channel (after open/accept but before funding tx)
+ let unfunded_channel_id = exchange_open_accept_chan(&nodes[0], &nodes[1], 50_000, 0);
+
+ // Drain the FundingGenerationReady event for the unfunded channel
+ let _ = get_event!(nodes[0], Event::FundingGenerationReady);
+
+ let splice_in_amount = Amount::from_sat(20_000);
+ provide_utxo_reserves(&nodes, 1, splice_in_amount * 2);
+
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template =
+ nodes[0].node.splice_channel(&funded_channel_id, &node_id_1, feerate).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap();
+
+ // Call funding_contributed with the unfunded channel's ID instead of the funded one.
+ // Returns APIMisuseError because the channel is not funded.
+ assert_eq!(
+ nodes[0].node.funding_contributed(
+ &unfunded_channel_id,
+ &node_id_1,
+ funding_contribution.clone(),
+ None
+ ),
+ Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel with id {} not expecting funding contribution",
+ unfunded_channel_id
+ ),
+ })
+ );
+
+ expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution);
+}
Why this scored 29/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.