Re-validate contribution at quiescence time
What changed, and why it matters
This commit fixes a bug in Lightning Dev Kit's channel splicing logic. When users add or remove funds from a channel (splicing), the software now re-checks whether the proposed funding contribution is still valid once the channel becomes 'quiescent' (paused for the splice). The bug was that new outbound payments sent between when the contribution was first accepted and when quiescence actually occurred could reduce the user's balance, making a previously-valid splice-out invalid. Without this fix, the splice could proceed based on stale balance information, potentially creating an invalid funding transaction or putting the channel in an inconsistent state. The fix emits failure events and disconnects the peer so both sides cleanly abort the splice.
Treat as a security-relevant correctness fix. Review splicing flows for any other stale-balance assumptions, ensure `DiscardFunding` events are handled by downstream wallets to reclaim inputs, and consider whether this bug class affects dual-funded or RBF splice paths similarly.
Security signals we found
Stale-balance validation gap in splicing protocol
Potential invalid funding transaction or channel state inconsistency
Peer disconnection on validation failure to force clean quiescence exit
New event emission (SpliceFailed + DiscardFunding) to reclaim user inputs
Regression test reproduces the balance-change window
Evidence from the diff
The patch changes Channel::stfu() to return a tuple (Result<Option<StfuResponse>, ChannelError>, QuiescentError) instead of just the result. When quiescence is reached and the pending action is a splice, the code now re-validates the FundingContribution and calls validate_splice_contributions() with the current holder balance. If validation fails, it returns ChannelError::WarnAndDisconnect paired with QuiescentError::FailSplice(...). ChannelManager adds handle_quiescent_error() to translate QuiescentError into SpliceFailed and DiscardFunding events. A regression test test_splice_revalidation_at_quiescence demonstrates an outbound HTLC committed after funding_contributed but before quiescence causing re-validation to fail.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsfuzz/src/chanmon_consistency.rsInspect captured patch +265 / −65
diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs
index 1e8effe..9fafa4f 100644
--- a/fuzz/src/chanmon_consistency.rs
+++ b/fuzz/src/chanmon_consistency.rs
@@ -1949,6 +1949,10 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(
chain_state.confirm_tx(splice_tx);
},
events::Event::SpliceFailed { .. } => {},
+ events::Event::DiscardFunding {
+ funding_info: events::FundingInfo::Contribution { .. },
+ ..
+ } => {},
_ => {
if out.may_fail.load(atomic::Ordering::Acquire) {
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index a0f1609..0882880 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -13690,27 +13690,27 @@ where
#[rustfmt::skip]
pub fn stfu<L: Logger>(
&mut self, msg: &msgs::Stfu, logger: &L
- ) -> Result<Option<StfuResponse>, ChannelError> {
+ ) -> Result<Option<StfuResponse>, (ChannelError, QuiescentError)> {
if self.context.channel_state.is_quiescent() {
- return Err(ChannelError::Warn("Channel is already quiescent".to_owned()));
+ return Err((ChannelError::Warn("Channel is already quiescent".to_owned()), QuiescentError::DoNothing));
}
if self.context.channel_state.is_remote_stfu_sent() {
- return Err(ChannelError::Warn(
+ return Err((ChannelError::Warn(
"Peer sent `stfu` when they already sent it and we've yet to become quiescent".to_owned()
- ));
+ ), QuiescentError::DoNothing));
}
if !self.context.is_live() {
- return Err(ChannelError::Warn(
+ return Err((ChannelError::Warn(
"Peer sent `stfu` when we were not in a live state".to_owned()
- ));
+ ), QuiescentError::DoNothing));
}
if !self.context.channel_state.is_local_stfu_sent() {
if !msg.initiator {
- return Err(ChannelError::WarnAndDisconnect(
+ return Err((ChannelError::WarnAndDisconnect(
"Peer sent unexpected `stfu` without signaling as initiator".to_owned()
- ));
+ ), QuiescentError::DoNothing));
}
// We don't check `is_waiting_on_peer_pending_channel_update` prior to setting the flag
@@ -13740,9 +13740,9 @@ where
// have a monitor update pending if we've processed a message from the counterparty, but
// we don't consider this when becoming quiescent since the states are not mutually
// exclusive.
- return Err(ChannelError::WarnAndDisconnect(
+ return Err((ChannelError::WarnAndDisconnect(
"Received counterparty stfu while having pending counterparty updates".to_owned()
- ));
+ ), QuiescentError::DoNothing));
}
self.context.channel_state.clear_local_stfu_sent();
@@ -13758,11 +13758,33 @@ where
match self.quiescent_action.take() {
None => {
debug_assert!(false);
- return Err(ChannelError::WarnAndDisconnect(
+ return Err((ChannelError::WarnAndDisconnect(
"Internal Error: Didn't have anything to do after reaching quiescence".to_owned()
- ));
+ ), QuiescentError::DoNothing));
},
Some(QuiescentAction::Splice { contribution, locktime }) => {
+ // Re-validate the contribution now that we're quiescent and
+ // balances are stable. Outbound HTLCs may have been sent between
+ // funding_contributed and quiescence, reducing the holder's
+ // balance. If invalid, disconnect and return the contribution so
+ // the user can reclaim their inputs.
+ if let Err(e) = contribution.validate().and_then(|()| {
+ let our_funding_contribution = contribution.net_value();
+ self.validate_splice_contributions(
+ our_funding_contribution,
+ SignedAmount::ZERO,
+ )
+ }) {
+ let failed = self.splice_funding_failed_for(contribution);
+ return Err((
+ ChannelError::WarnAndDisconnect(format!(
+ "Channel {} contribution no longer valid at quiescence: {}",
+ self.context.channel_id(),
+ e,
+ )),
+ QuiescentError::FailSplice(failed),
+ ));
+ }
let prior_contribution = contribution.clone();
let prev_funding_input = self.funding.to_splice_funding_input();
let our_funding_contribution = contribution.net_value();
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index a2df8bd..f33873c 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -6487,6 +6487,57 @@ impl<
result
}
+ /// Emits events for a [`QuiescentError`], if applicable.
+ fn handle_quiescent_error(
+ &self, channel_id: ChannelId, counterparty_node_id: PublicKey, user_channel_id: u128,
+ error: QuiescentError,
+ ) {
+ match error {
+ QuiescentError::DoNothing => {},
+ QuiescentError::DiscardFunding { inputs, outputs } => {
+ if !inputs.is_empty() || !outputs.is_empty() {
+ self.pending_events.lock().unwrap().push_back((
+ events::Event::DiscardFunding {
+ channel_id,
+ funding_info: FundingInfo::Contribution { inputs, outputs },
+ },
+ None,
+ ));
+ }
+ },
+ 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,
+ counterparty_node_id,
+ user_channel_id,
+ abandoned_funding_txo: funding_txo,
+ channel_type,
+ },
+ None,
+ ));
+ if !contributed_inputs.is_empty() || !contributed_outputs.is_empty() {
+ pending_events.push_back((
+ events::Event::DiscardFunding {
+ channel_id,
+ funding_info: FundingInfo::Contribution {
+ inputs: contributed_inputs,
+ outputs: contributed_outputs,
+ },
+ },
+ None,
+ ));
+ }
+ },
+ }
+ }
+
/// Adds or removes funds from the given channel as specified by a [`FundingContribution`].
///
/// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`]
@@ -6593,62 +6644,29 @@ impl<
);
}
},
- 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,
- ));
+ Err(e) => {
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: 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,
- },
+ err: match &e {
+ QuiescentError::DoNothing => format!(
+ "Duplicate funding contribution for channel {}",
+ channel_id,
+ ),
+ QuiescentError::DiscardFunding { .. } => format!(
+ "Channel {} already has a pending funding contribution",
+ channel_id,
+ ),
+ QuiescentError::FailSplice(_) => format!(
+ "Channel {} cannot accept funding contribution",
+ channel_id,
+ ),
},
- None,
- ));
- result = Err(APIError::APIMisuseError {
- err: format!(
- "Channel {} cannot accept funding contribution",
- channel_id
- ),
});
+ self.handle_quiescent_error(
+ *channel_id,
+ *counterparty_node_id,
+ channel.context().get_user_id(),
+ e,
+ );
},
}
@@ -12793,6 +12811,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
);
let res = chan.stfu(&msg, &&logger);
+ let (res, quiescent_error) = match res {
+ Ok(resp) => (Ok(resp), QuiescentError::DoNothing),
+ Err((chan_err, quiescent_err)) => (Err(chan_err), quiescent_err),
+ };
+ self.handle_quiescent_error(
+ chan_entry.get().context().channel_id(),
+ *counterparty_node_id,
+ chan_entry.get().context().get_user_id(),
+ quiescent_error,
+ );
let resp = try_channel_entry!(self, peer_state, res, chan_entry);
match resp {
None => Ok(false),
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 0b1e427..0ba4ed1 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -176,6 +176,16 @@ pub(super) struct PriorContribution {
contribution: FundingContribution,
/// The holder's balance, used for feerate adjustment. `None` when the balance computation
/// fails, in which case adjustment is skipped and coin selection is re-run.
+ ///
+ /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale
+ /// if balances change before the contribution is used. Staleness is acceptable here because
+ /// this is only used as an optimization to determine if the prior contribution can be
+ /// reused with adjusted fees — the contribution is re-validated at
+ /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the
+ /// current balances.
+ ///
+ /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
+ /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
holder_balance: Option<Amount>,
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index fa95e4a..7c169b4 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -6168,3 +6168,139 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() {
Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
+
+#[test]
+fn test_splice_revalidation_at_quiescence() {
+ // When an outbound HTLC is committed between funding_contributed and quiescence, the
+ // holder's balance decreases. If the splice-out was marginal at funding_contributed time,
+ // the re-validation at quiescence should fail and emit SpliceFailed + DiscardFunding.
+ //
+ // Flow:
+ // 1. Send payment #1 (update_add + CS) → node 0 awaits RAA
+ // 2. funding_contributed with splice-out → passes, stfu delayed (awaiting RAA)
+ // 3. Process node 1's RAA → node 0 free to send
+ // 4. Send payment #2 (update_add + CS) → balance reduced
+ // 5. Process node 1's CS → node 0 sends RAA, stfu delayed (payment #2 pending)
+ // 6. Complete payment #2's exchange → stfu fires
+ // 7. stfu exchange → quiescence → re-validation fails
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let mut config = test_default_channel_config();
+ config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ 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 _ = provide_anchor_reserves(&nodes);
+
+ // Step 1: Send payment #1 (update_add + CS). Node 0 awaits RAA.
+ let payment_1_msat = 20_000_000;
+ let (route_1, payment_hash_1, _, payment_secret_1) =
+ get_route_and_payment_hash!(nodes[0], nodes[1], payment_1_msat);
+ nodes[0]
+ .node
+ .send_payment_with_route(
+ route_1,
+ payment_hash_1,
+ RecipientOnionFields::secret_only(payment_secret_1, payment_1_msat),
+ PaymentId(payment_hash_1.0),
+ )
+ .unwrap();
+ check_added_monitors(&nodes[0], 1);
+ let payment_1_msgs = nodes[0].node.get_and_clear_pending_msg_events();
+
+ // Step 2: funding_contributed with splice-out. Passes because the balance floor only
+ // includes payment #1. stfu is delayed — awaiting RAA.
+ let outputs = vec![TxOut {
+ value: Amount::from_sat(70_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ }];
+
+ 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).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution =
+ funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet).unwrap();
+
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap();
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty(), "stfu should be delayed");
+
+ // Step 3: Deliver payment #1 to node 1 and process RAA.
+ let payment_1_event = SendEvent::from_event(payment_1_msgs.into_iter().next().unwrap());
+ nodes[1].node.handle_update_add_htlc(node_id_0, &payment_1_event.msgs[0]);
+ nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_1_event.commitment_msg);
+ check_added_monitors(&nodes[1], 1);
+ let (raa, cs) = get_revoke_commit_msgs(&nodes[1], &node_id_0);
+
+ // Process node 1's RAA. After this, node 0 is free to send new HTLCs.
+ nodes[0].node.handle_revoke_and_ack(node_id_1, &raa);
+ check_added_monitors(&nodes[0], 1);
+
+ // Step 4: Send payment #2 in the window between RAA and CS processing.
+ let payment_2_msat = 20_000_000;
+ let (route_2, payment_hash_2, _, payment_secret_2) =
+ get_route_and_payment_hash!(nodes[0], nodes[1], payment_2_msat);
+ nodes[0]
+ .node
+ .send_payment_with_route(
+ route_2,
+ payment_hash_2,
+ RecipientOnionFields::secret_only(payment_secret_2, payment_2_msat),
+ PaymentId(payment_hash_2.0),
+ )
+ .unwrap();
+ check_added_monitors(&nodes[0], 1);
+ let payment_2_msgs = nodes[0].node.get_and_clear_pending_msg_events();
+
+ // Step 5: Process node 1's CS. Node 0 sends RAA but stfu is delayed (payment #2 pending).
+ nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs);
+ check_added_monitors(&nodes[0], 1);
+ let raa_0 = get_event_msg!(nodes[0], MessageSendEvent::SendRevokeAndACK, node_id_1);
+ nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0);
+ check_added_monitors(&nodes[1], 1);
+
+ // Step 6: Complete payment #2's commitment exchange. stfu fires afterward.
+ let payment_2_event = SendEvent::from_event(payment_2_msgs.into_iter().next().unwrap());
+ nodes[1].node.handle_update_add_htlc(node_id_0, &payment_2_event.msgs[0]);
+ nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &payment_2_event.commitment_msg);
+ check_added_monitors(&nodes[1], 1);
+ let (raa_1b, cs_1b) = get_revoke_commit_msgs(&nodes[1], &node_id_0);
+ nodes[0].node.handle_revoke_and_ack(node_id_1, &raa_1b);
+ check_added_monitors(&nodes[0], 1);
+ nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &cs_1b);
+ check_added_monitors(&nodes[0], 1);
+
+ // RAA and stfu sent together.
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let raa_0b = match &msg_events[0] {
+ MessageSendEvent::SendRevokeAndACK { msg, .. } => msg.clone(),
+ other => panic!("Expected SendRevokeAndACK, got {:?}", other),
+ };
+ let stfu_0 = match &msg_events[1] {
+ MessageSendEvent::SendStfu { msg, .. } => msg.clone(),
+ other => panic!("Expected SendStfu, got {:?}", other),
+ };
+
+ nodes[1].node.handle_revoke_and_ack(node_id_0, &raa_0b);
+ check_added_monitors(&nodes[1], 1);
+
+ // Step 7: stfu exchange → quiescence → re-validation fails → disconnect.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ // handle_stfu returns WarnAndDisconnect (triggering disconnect) alongside the
+ // QuiescentError containing the failed contribution's events.
+ 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::HandleError { .. }));
+
+ expect_splice_failed_events(&nodes[0], &channel_id, contribution);
+}
Why this scored 60/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.