fuzz: allow empty-channel force close with in-flight payments
What changed, and why it matters
This commit changes a fuzz-testing harness, not the production Lightning node code. It loosens a test rule so the fuzzer can simulate force-closing a channel that has no pending payments of its own, even if other channels in the test still have in-flight payments. When that happens, the test now marks any payment that routed through the closed channel as allowed to fail. This is a testing-infrastructure improvement and does not change how real users' funds or channels are handled.
No security action required. Treat as a normal test/QA improvement. Reviewers may optionally verify that the route-aware failure marking correctly limits allowed failures to payments traversing the closed channel.
Security signals we found
Fuzz harness behavior change only
No modifications to production consensus, cryptography, or networking code
No privilege boundary crossed
No input validation or serialization changes
No disclosed vulnerability or CVE referenced
Evidence from the diff
The change is confined to fuzz/src/chanmon_consistency.rs. It replaces a global check for any pending HTLCs with a per-channel check, adds route-aware failure tracking for pending payments whose paths include the closed channel, and updates the force_close harness method accordingly. The production channel monitor, chain tracker, and payment logic are untouched.
Changed components
fuzz/src/chanmon_consistency.rsFuzz test PaymentTracker and NodePayments helpersHarness::force_close fuzz opcodeInspect captured patch +32 / −7
diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs
index b08b0f8..ecc87c6 100644
--- a/fuzz/src/chanmon_consistency.rs
+++ b/fuzz/src/chanmon_consistency.rs
@@ -2028,6 +2028,18 @@ impl NodePayments {
}
}
+ fn allow_failure_for_closed_channel(&mut self, channel_id: ChannelId) {
+ for pending in &mut self.pending {
+ let uses_channel = pending
+ .paths
+ .iter()
+ .any(|path| path.iter().any(|hop| hop.channel_id == channel_id));
+ if uses_channel {
+ pending.expectation = PaymentExpectation::MayFail;
+ }
+ }
+ }
+
fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) {
let unsafe_receive_height =
current_height.saturating_add(channelmonitor::HTLC_FAIL_BACK_BUFFER + 1);
@@ -2195,6 +2207,12 @@ impl PaymentTracker {
}
}
+ fn allow_failure_for_closed_channel(&mut self, channel_id: ChannelId) {
+ for node in &mut self.nodes {
+ node.allow_failure_for_closed_channel(channel_id);
+ }
+ }
+
fn allow_failure_for_receive_cltv_buffer(&mut self, current_height: u32) {
for node in &mut self.nodes {
node.allow_failure_for_receive_cltv_buffer(current_height);
@@ -3783,19 +3801,23 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> {
self.bc_link.reconnect(&self.nodes);
}
- fn has_pending_htlcs(&self) -> bool {
+ fn channel_has_pending_htlcs(&self, channel_id: ChannelId) -> bool {
self.nodes.iter().any(|node| {
node.list_channels().iter().any(|chan| {
- !chan.pending_inbound_htlcs.is_empty() || !chan.pending_outbound_htlcs.is_empty()
+ chan.channel_id == channel_id
+ && (!chan.pending_inbound_htlcs.is_empty()
+ || !chan.pending_outbound_htlcs.is_empty())
})
})
}
fn force_close(&mut self, closer_idx: usize, channel_id: ChannelId, counterparty_idx: usize) {
- if self.close_tracker.is_closed_or_closing(&channel_id) || self.has_pending_htlcs() {
- // This opcode only models HTLC-free local closes. Leave it as a no-op
- // while any channel has pending HTLCs, rather than mixing local
- // force-close coverage with HTLC settlement.
+ if self.close_tracker.is_closed_or_closing(&channel_id)
+ || self.channel_has_pending_htlcs(channel_id)
+ {
+ // This opcode only models closes whose target channel has no
+ // pending HTLCs. Other channels may still carry HTLCs that later
+ // fail back through normal peer messages during settlement.
return;
}
assert!(
@@ -3810,7 +3832,10 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> {
&self.nodes[counterparty_idx].get_our_node_id(),
reason.clone(),
) {
- Ok(()) => self.close_tracker.expect_channel_close(channel_id, reason),
+ Ok(()) => {
+ self.payments.allow_failure_for_closed_channel(channel_id);
+ self.close_tracker.expect_channel_close(channel_id, reason);
+ },
Err(e) => panic!("{e:?}"),
}
}
Why this scored 17/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.