lightningd: fail in-flight HTLCs upstream when dropping withheld channel
What changed, and why it matters
This commit fixes a bug in Core Lightning where money-transfer promises (HTLCs) stuck in a special 'withheld' channel were silently dropped when that channel was force-closed. Previously, the upstream sender was never told the payment failed, so it would eventually force-close its own channel waiting for a resolution that would never arrive. The patch now explicitly fails those HTLCs back upstream before freeing them, preventing unnecessary channel force-closures and fund lockups.
Apply the patch. Operators running nodes that accept zeroconf/withheld channels should upgrade to avoid unnecessary upstream force-closures and fund lockups. Review any historical force-closures that coincided with withheld channel timeouts to assess whether they were triggered by this bug.
Security signals we found
Denial-of-service via forced channel closure: missing HTLC failure caused upstream peers to force-close channels unnecessarily
Funds lockup risk: unresolved HTLCs could leave funds locked until cooperative close or further on-chain resolution
State inconsistency: HTLCs freed locally without upstream failure notification
Fix pattern: explicit fail-back of all in-flight offered HTLCs before channel cleanup
Evidence from the diff
In drop_to_chain(), when a withheld channel is force-closed (e.g., CLTV timeout), the code now iterates over all outstanding offered HTLCs on that channel and calls local_fail_in_htlc() with permanent_channel_failure for any that have not already been settled/failed. This ensures upstream peers receive a failure message rather than being left waiting for resolution. A regression test demonstrates the scenario: l1->l2->l3 with l2->l3 as a withheld zeroconf channel; when the CLTV deadline hits, l2 drops the withheld channel without broadcasting, and the HTLC is failed back to l1, leaving l1->l2 in CHANNELD_NORMAL.
Changed components
lightningd/peer_control.cdrop_to_chain()withheld zeroconf channelsHTLC out map / offered HTLC handlingInspect captured patch +85 / −0
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index fea267e6..34654280 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -356,8 +356,27 @@ void drop_to_chain(struct lightningd *ld, struct channel *channel,
/* If we withheld the funding tx, we simply close */
if (channel->withheld) {
+ struct htlc_out_map_iter outi;
+ struct htlc_out *hout;
+
log_info(channel->log,
"Withheld channel: not sending a close transaction");
+
+ for (hout = htlc_out_map_first(ld->htlcs_out, &outi);
+ hout;
+ hout = htlc_out_map_next(ld->htlcs_out, &outi)) {
+ if (hout->key.channel != channel)
+ continue;
+ /* Has already been settled or failed */
+ if (!hout->in
+ || hout->in->badonion != 0
+ || hout->in->failonion
+ || hout->in->preimage)
+ continue;
+ local_fail_in_htlc(hout->in,
+ take(towire_permanent_channel_failure(NULL)));
+ }
+
resolve_close_command(ld, channel, cooperative,
tal_arr(tmpctx, const struct bitcoin_tx *, 0));
free_htlcs(ld, channel);
diff --git a/tests/test_opening.py b/tests/test_opening.py
index 47db560a..17a389d7 100644
--- a/tests/test_opening.py
+++ b/tests/test_opening.py
@@ -2922,3 +2922,69 @@ def test_zeroconf_withhold(node_factory, bitcoind, stay_withheld, mutual_close):
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'CLOSINGD_COMPLETE')
else:
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'AWAITING_UNILATERAL')
+
+
+def test_zeroconf_withhold_htlc_failback(node_factory, bitcoind):
+ """Test that CLTV timeout on a withheld channel fails HTLCs back upstream without force-close."""
+ zeroconf_plugin = str(Path(__file__).parent / "plugins" / "zeroconf-selective.py")
+ hold_plugin = str(Path(__file__).parent / "plugins" / "hold_htlcs.py")
+
+ l1, l2, l3 = node_factory.get_nodes(3, opts=[
+ {},
+ {},
+ {'plugin': [zeroconf_plugin, hold_plugin],
+ 'zeroconf_allow': 'any',
+ 'hold-time': 10000},
+ ])
+
+ # l1 -> l2: normal funded channel
+ l1.fundwallet(10**7)
+ l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
+ l1.rpc.fundchannel(l2.info['id'], 1000000)
+ bitcoind.generate_block(6, wait_for_mempool=1)
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['state'] == 'CHANNELD_NORMAL')
+ scid12 = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id']
+
+ # l2 -> l3: withheld zeroconf channel
+ l2.fundwallet(10**7)
+ l2.rpc.connect(l3.info['id'], 'localhost', l3.port)
+ amount = 1000000
+ funding_addr = l2.rpc.fundchannel_start(l3.info['id'], f"{amount}sat", mindepth=0)['funding_address']
+ psbt = l2.rpc.fundpsbt(amount, "1000perkw", 1000, excess_as_change=True)['psbt']
+ psbt = l2.rpc.addpsbtoutput(amount, psbt, destination=funding_addr)['psbt']
+ assert l2.rpc.fundchannel_complete(l3.info['id'], psbt, withhold=True)['commitments_secured']
+
+ # Wait for withheld channel to be usable
+ wait_for(lambda: 'remote' in only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['updates'])
+ alias23 = only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['alias']['local']
+
+ # Create invoice on l3 and send payment from l1 via manual route
+ inv = l3.rpc.invoice(10000, 'test_withhold_failback', 'desc')
+ route = [{'amount_msat': 10001,
+ 'id': l2.info['id'],
+ 'delay': 12,
+ 'channel': scid12},
+ {'amount_msat': 10000,
+ 'id': l3.info['id'],
+ 'delay': 6,
+ 'channel': alias23}]
+ l1.rpc.sendpay(route, inv['payment_hash'], payment_secret=inv['payment_secret'])
+
+ # Wait for HTLC to be held at l3
+ l3.daemon.wait_for_log("Holding onto an incoming htlc")
+
+ # Mine blocks to hit the CLTV deadline.
+ bitcoind.generate_block(8)
+
+ # CLTV expiry triggers force-close on the withheld channel
+ l2.daemon.wait_for_log(r'cltv .* hit deadline')
+
+ # Withheld channel is gone (no on-chain tx was broadcast)
+ assert l2.rpc.listpeerchannels(l3.info['id'])['channels'] == []
+
+ # Payment should fail (HTLC was failed back)
+ with pytest.raises(RpcError):
+ l1.rpc.waitsendpay(inv['payment_hash'])
+
+ # l1's channel to l2 is still normal — no force-close
+ assert only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['state'] == 'CHANNELD_NORMAL'
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.