Filter prior contributions from SpliceFundingFailed events
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's splicing feature. When a user tries to resize a Lightning channel (a 'splice') and the attempt fails, the software tells the user which bitcoins (UTXOs) are no longer tied up so they can spend them again. The bug was that during a follow-up fee-bump attempt (RBF), the software could incorrectly tell the user that UTXOs from the original splice attempt were free to spend, even though they were still needed. This could lead a user to accidentally double-spend their own funds and lose money. The fix filters out any UTXOs that are still committed to an earlier splice attempt before reporting the failed ones.
Review the output filtering logic to confirm that script_pubkey equality is sufficient for distinguishing reusable change outputs across RBF rounds. Consider whether additional output metadata (e.g., value, position, or a unique contribution identifier) should be included to avoid over-filtering or under-filtering. Users running nodes with splicing enabled should upgrade to include this fix to avoid accidental premature UTXO unlocking.
Security signals we found
Incorrect UTXO unlock reporting could lead to user double-spending funds still committed to an active funding transaction
Fix specifically targets RBF splice scenarios where prior contributions remain pending
Filtering logic relies on script_pubkey equality for outputs, which may not uniquely identify outputs if the same change script is reused across rounds
No explicit CVE, advisory, or vendor security disclosure present in commit or references
Evidence from the diff
The patch modifies lightning/src/ln/channel.rs to prevent SpliceFundingFailed events from including inputs and outputs that are already consumed by prior splice contributions when an RBF round is in progress. It introduces prior_contributed_inputs() and prior_contributed_outputs() on PendingFunding, and updates the maybe_create_splice_funding_failed! macro and quiescent_action_into_error() to filter those prior contributions. The filtering uses OutPoint equality for inputs and script_pubkey equality for outputs. Tests in splicing_tests.rs are added/updated to verify that only the current RBF round’s contributions are returned, while prior-round UTXOs remain locked.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsSpliceFundingFailed event generationPendingFunding contribution trackingRBF splice-in/splice-out flowInspect captured patch +248 / −39
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index f5272e2..0ab7292 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -3082,6 +3082,16 @@ impl PendingFunding {
self.contributions.iter().flat_map(|c| c.contributed_outputs())
}
+ fn prior_contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ {
+ let len = self.contributions.len();
+ self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs())
+ }
+
+ fn prior_contributed_outputs(&self) -> impl Iterator<Item = &TxOut> + '_ {
+ let len = self.contributions.len();
+ self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs())
+ }
+
fn check_get_splice_locked<SP: SignerProvider>(
&mut self, context: &ChannelContext<SP>, confirmed_funding_index: usize, height: u32,
) -> Option<msgs::SpliceLocked> {
@@ -3130,25 +3140,6 @@ pub(super) enum QuiescentError {
FailSplice(SpliceFundingFailed),
}
-impl From<QuiescentAction> for QuiescentError {
- fn from(action: QuiescentAction) -> Self {
- match action {
- 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),
@@ -6686,7 +6677,7 @@ pub struct SpliceFundingFailed {
}
macro_rules! maybe_create_splice_funding_failed {
- ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{
+ ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{
$pending_splice
.and_then(|pending_splice| pending_splice.funding_negotiation.$get())
.and_then(|funding_negotiation| {
@@ -6701,7 +6692,7 @@ macro_rules! maybe_create_splice_funding_failed {
.as_funding()
.map(|funding| funding.get_channel_type().clone());
- let (contributed_inputs, contributed_outputs) = match funding_negotiation {
+ let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation {
FundingNegotiation::AwaitingAck { context, .. } => {
context.$contributed_inputs_and_outputs()
},
@@ -6717,6 +6708,15 @@ macro_rules! maybe_create_splice_funding_failed {
.$contributed_inputs_and_outputs(),
};
+ if let Some(pending_splice) = $pending_splice_ref {
+ for input in pending_splice.prior_contributed_inputs() {
+ contributed_inputs.retain(|i| *i != input);
+ }
+ for output in pending_splice.prior_contributed_outputs() {
+ contributed_outputs.retain(|o| o.script_pubkey != output.script_pubkey);
+ }
+ }
+
if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty()
{
return None;
@@ -6755,11 +6755,19 @@ where
shutdown_result
}
- fn abandon_quiescent_action(&mut self) -> Option<SpliceFundingFailed> {
- match self.quiescent_action.take() {
- Some(QuiescentAction::Splice { contribution, .. }) => {
- let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs();
- Some(SpliceFundingFailed {
+ fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError {
+ match action {
+ QuiescentAction::Splice { contribution, .. } => {
+ let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs();
+ if let Some(ref pending_splice) = self.pending_splice {
+ for input in pending_splice.contributed_inputs() {
+ inputs.retain(|i| *i != input);
+ }
+ for output in pending_splice.contributed_outputs() {
+ outputs.retain(|o| o.script_pubkey != output.script_pubkey);
+ }
+ }
+ QuiescentError::FailSplice(SpliceFundingFailed {
funding_txo: None,
channel_type: None,
contributed_inputs: inputs,
@@ -6767,11 +6775,20 @@ where
})
},
#[cfg(any(test, fuzzing, feature = "_test_utils"))]
- Some(quiescent_action) => {
- self.quiescent_action = Some(quiescent_action);
+ QuiescentAction::DoNothing => QuiescentError::DoNothing,
+ }
+ }
+
+ fn abandon_quiescent_action(&mut self) -> Option<SpliceFundingFailed> {
+ let action = self.quiescent_action.take()?;
+ match self.quiescent_action_into_error(action) {
+ QuiescentError::FailSplice(failed) => Some(failed),
+ #[cfg(any(test, fuzzing, feature = "_test_utils"))]
+ QuiescentError::DoNothing => None,
+ _ => {
+ debug_assert!(false);
None
},
- None => None,
}
}
@@ -6895,6 +6912,7 @@ where
let splice_funding_failed = maybe_create_splice_funding_failed!(
self,
self.pending_splice.as_mut(),
+ self.pending_splice.as_ref(),
take,
into_contributed_inputs_and_outputs
);
@@ -6919,6 +6937,7 @@ where
maybe_create_splice_funding_failed!(
self,
self.pending_splice.as_ref(),
+ self.pending_splice.as_ref(),
as_ref,
to_contributed_inputs_and_outputs
)
@@ -13549,14 +13568,14 @@ where
if !self.context.is_usable() {
log_debug!(logger, "Channel is not in a usable state to propose quiescence");
- return Err(action.into());
+ return Err(self.quiescent_action_into_error(action));
}
if self.quiescent_action.is_some() {
log_debug!(
logger,
"Channel already has a pending quiescent action and cannot start another",
);
- return Err(action.into());
+ return Err(self.quiescent_action_into_error(action));
}
// 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/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 656d3c1..bf689ae 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -250,6 +250,22 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>(
funding_contribution
}
+pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>(
+ node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
+ value_added: Amount, outputs: Vec<TxOut>, feerate: FeeRate,
+) -> FundingContribution {
+ let node_id_counterparty = counterparty.node.get_our_node_id();
+ let funding_template =
+ node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger);
+ let funding_contribution =
+ funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap();
+ node.node
+ .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None)
+ .unwrap();
+ funding_contribution
+}
+
pub fn initiate_splice_out<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
outputs: Vec<TxOut>,
@@ -2865,12 +2881,14 @@ fn fail_quiescent_action_on_channel_close() {
#[test]
fn abandon_splice_quiescent_action_on_shutdown() {
- do_abandon_splice_quiescent_action_on_shutdown(true);
- do_abandon_splice_quiescent_action_on_shutdown(false);
+ do_abandon_splice_quiescent_action_on_shutdown(true, false);
+ do_abandon_splice_quiescent_action_on_shutdown(false, false);
+ do_abandon_splice_quiescent_action_on_shutdown(true, true);
+ do_abandon_splice_quiescent_action_on_shutdown(false, true);
}
#[cfg(test)]
-fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
+fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) {
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]);
@@ -2884,6 +2902,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
let (_, _, channel_id, _) =
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0);
+ // When testing with a prior pending splice, complete splice A first so that
+ // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`.
+ if pending_splice {
+ let funding_contribution = do_initiate_splice_in(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ Amount::from_sat(initial_channel_capacity / 2),
+ );
+ let (_splice_tx, _new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+ }
+
// Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to
// splice, the `stfu` message is held back.
let payment_amount = 1_000_000;
@@ -2896,7 +2927,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
check_added_monitors(&nodes[0], 1);
nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]);
- nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]);
+ // After a splice, commitment_signed messages are batched across funding scopes.
+ nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed);
check_added_monitors(&nodes[1], 1);
let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0);
@@ -2904,9 +2936,29 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
check_added_monitors(&nodes[0], 1);
// Attempt the splice. `stfu` should not go out yet as the state machine is pending.
- let splice_in_amount = initial_channel_capacity / 2;
- let funding_contribution =
- initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount));
+ // When there's a prior splice, include a splice-out output with a different script_pubkey
+ // so the test can verify selective filtering: the change output (same script_pubkey as
+ // the prior splice) is filtered, while the splice-out output (different script_pubkey)
+ // survives.
+ let splice_in_amount =
+ if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 };
+ let splice_out_output = if pending_splice {
+ let script_pubkey = nodes[1].wallet_source.get_change_script().unwrap();
+ Some(TxOut { value: Amount::from_sat(1_000), script_pubkey })
+ } else {
+ None
+ };
+ let funding_contribution = if let Some(ref output) = splice_out_output {
+ initiate_splice_in_and_out(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ Amount::from_sat(splice_in_amount),
+ vec![output.clone()],
+ )
+ } else {
+ 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());
// Close the channel. We should see a `SpliceFailed` event for the pending splice
@@ -2920,7 +2972,33 @@ 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);
- expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
+ if pending_splice {
+ // With a prior pending splice, contributions are filtered against committed inputs/outputs.
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2, "{events:?}");
+ match &events[0] {
+ Event::SpliceFailed { channel_id: cid, .. } => {
+ assert_eq!(*cid, channel_id);
+ },
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
+ match &events[1] {
+ Event::DiscardFunding {
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ ..
+ } => {
+ // The UTXO was filtered: it's still committed to the prior splice.
+ assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
+ // The change output was filtered (same script_pubkey as the prior splice's
+ // change output), but the splice-out output survives (different script_pubkey).
+ let expected_outputs: Vec<_> = splice_out_output.into_iter().collect();
+ assert_eq!(*outputs, expected_outputs);
+ },
+ other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
+ }
+ } else {
+ expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
+ }
let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id);
}
@@ -5309,3 +5387,115 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() {
reconnect_args.send_announcement_sigs = (true, true);
reconnect_nodes(reconnect_args);
}
+
+#[test]
+fn test_splice_rbf_disconnect_filters_prior_contributions() {
+ // When disconnecting during an RBF round that reuses the same UTXOs as a prior round,
+ // the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior
+ // round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed`
+ // macro path.
+ 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 initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ // Provide exactly 1 UTXO per node so coin selection is deterministic.
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+
+ // --- Round 0: Initial splice-in at floor feerate (253). ---
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_splice_tx_0, _new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // --- Round 1: RBF at higher feerate without providing new UTXOs. ---
+ // The wallet reselects the same UTXO since the splice tx hasn't been mined.
+ // Include a splice-out output with a different script_pubkey so the test can verify
+ // selective filtering: the change output (same script_pubkey as round 0) is filtered,
+ // while the splice-out output (different script_pubkey) survives.
+ let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu);
+ let splice_out_output = TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(),
+ };
+ let _funding_contribution_1 = do_initiate_rbf_splice_in_and_out(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ added_value,
+ vec![splice_out_output.clone()],
+ rbf_feerate,
+ );
+
+ // STFU exchange + RBF handshake to start interactive TX.
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+
+ // Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ // The initiator should get SpliceFailed + DiscardFunding with filtered contributions.
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2, "{events:?}");
+ match &events[0] {
+ Event::SpliceFailed { channel_id: cid, .. } => {
+ assert_eq!(*cid, channel_id);
+ },
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
+ match &events[1] {
+ Event::DiscardFunding {
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ ..
+ } => {
+ // The UTXO was filtered out: it's still committed to round 0's splice.
+ assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
+ // The change output was filtered (same script_pubkey as round 0's change output),
+ // but the splice-out output survives (different script_pubkey).
+ assert_eq!(*outputs, vec![splice_out_output.clone()]);
+ },
+ other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
+ }
+
+ // Reconnect. After a completed splice, channel_ready is not re-sent.
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_nodes(reconnect_args);
+
+ // --- Round 2: RBF at the same feerate as the failed round 1 (264). ---
+ // This should succeed because the failed round never updated the feerate floor, which
+ // remains at round 0's rate (253), and 264 >= ceil(253 * 25/24).
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+
+ let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu);
+ let _funding_contribution_2 =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+
+ // Disconnect again to clean up the in-progress interactive TX negotiation.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 2, "{events:?}");
+ match &events[0] {
+ Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id),
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
+ match &events[1] {
+ Event::DiscardFunding { .. } => {},
+ other => panic!("Expected DiscardFunding, got {:?}", other),
+ }
+
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_nodes(reconnect_args);
+}
Why this scored 54/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.