Don't validate a splice if updates are pending
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where a proposed channel splice could be incorrectly rejected when there were still unconfirmed updates (like HTLC fulfillments) pending in the channel. The fix skips an internal consistency check in that specific situation, because the check would compare numbers that are temporarily out of sync but will match once the pending updates clear. The bug was found by the developers' own fuzzing, not by an external security researcher.
Treat as a normal correctness/robustness fix. Reviewers should confirm that skipping validate_splice_contributions while updates are pending does not mask real underflow or reserve violations, and that the validation is re-evaluated after updates clear. No immediate security response is indicated.
Security signals we found
Logic bug in channel-state validation during splice negotiation
Debug-only assertion could trigger on legitimate protocol state
State inconsistency between advertised splice maximum and current commitment views
Fix gated on pending-update flags rather than changing balance arithmetic
Evidence from the diff
In rust-lightning, FundedChannel::splice_channel can be called while pending channel updates are still in flight. FundedChannel::get_next_splice_out_maximum may then report a value that is not yet valid on both commitment transactions, causing FundedChannel::validate_splice_contributions to fail under debug_assertions. The patch wraps the debug-only validation so it only runs when neither peer-pending nor monitor/signer-pending updates exist. A regression test confirms that a pending claimed inbound HTLC is included in the advertised splice-out maximum and that splice_channel succeeds once the update is pending.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsFundedChannel::get_next_splice_out_maximumFundedChannel::validate_splice_contributionsFundedChannel::splice_channelInspect captured patch +43 / −0
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index d0072da..7d7b835 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -13774,6 +13774,8 @@ where
remote_stats.available_balances.next_splice_out_maximum_sat;
#[cfg(debug_assertions)]
+ if !self.context.is_waiting_on_peer_pending_channel_update()
+ && !self.context.is_monitor_or_signer_pending_channel_update()
{
// After this max splice out, validation passes, accounting for the updated reserves
self.validate_splice_contributions(
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index c0ec89f..dfcc339 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -9770,3 +9770,44 @@ fn test_splice_out_maximum_on_both_commitments_dust_on_funder_commitment() {
CHANNEL_VALUE_SAT - node_0_payment_sat - TOTAL_ANCHORS_SAT - reserved_fee_sat;
assert_eq!(details.next_splice_out_maximum_sat, expected_next_splice_out_maximum_sat);
}
+
+// When we advertise the next splice out maximum, we include any HTLCs in the state
+// `InboundHTLCState::LocalRemoved(Fulfill { .. })` in our balance; by the time we clear this update
+// and splice the channel, our settled balance will include it.
+#[test]
+fn test_splice_out_maximum_includes_pending_claimed_inbound_htlc() {
+ 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);
+
+ const CHANNEL_VALUE_MSAT: u64 = 100_000_000;
+ const PENDING_CLAIMED_INBOUND_HTLC_MSAT: u64 = 10_000_000;
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, CHANNEL_VALUE_MSAT / 1000, 0);
+
+ let (payment_preimage, payment_hash, ..) =
+ route_payment(&nodes[0], &[&nodes[1]], PENDING_CLAIMED_INBOUND_HTLC_MSAT);
+
+ nodes[1].node.claim_funds(payment_preimage);
+ check_added_monitors(&nodes[1], 1);
+ expect_payment_claimed!(nodes[1], payment_hash, PENDING_CLAIMED_INBOUND_HTLC_MSAT);
+
+ let updates = get_htlc_update_msgs(&nodes[1], &node_id_0);
+ assert!(updates.update_add_htlcs.is_empty());
+ assert_eq!(updates.update_fulfill_htlcs.len(), 1);
+ assert!(updates.update_fail_htlcs.is_empty());
+ assert!(updates.update_fail_malformed_htlcs.is_empty());
+ assert!(updates.update_fee.is_none());
+ assert_eq!(updates.commitment_signed.len(), 1);
+
+ let node_1_details = &nodes[1].node.list_channels()[0];
+ let local_balance_before_fee_sat = PENDING_CLAIMED_INBOUND_HTLC_MSAT / 1000;
+ let dividend_sat = local_balance_before_fee_sat * 100 + 100 - CHANNEL_VALUE_MSAT / 1000;
+ let expected_splice_out_max = (dividend_sat - 1) / 99;
+ assert_eq!(node_1_details.next_splice_out_maximum_sat, expected_splice_out_max);
+
+ assert!(nodes[1].node.splice_channel(&channel_id, &node_id_0).is_ok());
+}
Why this scored 43/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.