From 0c634218f7e86628978d7f571731e19aad15e4f3 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 16 Mar 2026 04:18:48 +0100 Subject: [PATCH 1/3] lightningd: fail in-flight HTLCs upstream when dropping withheld channel When a withheld channel is force-closed (e.g. due to CLTV timeout), in-flight HTLCs were being freed without failing them back to the upstream sender. This caused the upstream peer to eventually force-close its own channel waiting for a resolution that would never come. Iterate over all offered HTLCs on the withheld channel in drop_to_chain() and fail them back upstream with permanent_channel_failure before freeing them. Changelog-fixed: withheld channel now fail back incoming inflight htlc --- lightningd/peer_control.c | 19 +++++++++++ tests/test_opening.py | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index fea267e61541..34654280899b 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 47db560aadcc..17a389d7f66b 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' From d8f74227d321d6dbbe1e8f916ebb9edc25b00247 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 16 Mar 2026 12:13:09 +0100 Subject: [PATCH 2/3] lightningd: unreserve UTXOs from withheld funding PSBT when dropping channel When a channel using a withheld funding PSBT is dropped, we need to unreserve the UTXOs from the PSBT. Otherwise those funds will remain blocked until the reservation expires, delaying their availability for other transactions. Changelog-Fixed: lightningd: unreserve UTXOs from withheld funding PSBT on channel close. --- lightningd/peer_control.c | 19 +++++++++++++++++++ tests/test_opening.py | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index 34654280899b..8e95aed099f4 100644 --- a/lightningd/peer_control.c +++ b/lightningd/peer_control.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -377,6 +378,24 @@ void drop_to_chain(struct lightningd *ld, struct channel *channel, take(towire_permanent_channel_failure(NULL))); } + /* Unreserve any UTXOs from the withheld funding PSBT */ + if (channel->funding_psbt) { + for (size_t i = 0; i < channel->funding_psbt->num_inputs; i++) { + struct bitcoin_outpoint outpoint; + struct utxo *utxo; + + wally_psbt_input_get_outpoint( + &channel->funding_psbt->inputs[i], &outpoint); + utxo = wallet_utxo_get(tmpctx, ld->wallet, &outpoint); + if (!utxo || utxo->status != OUTPUT_STATE_RESERVED) + continue; + + wallet_unreserve_utxo(ld->wallet, utxo, + get_block_height(ld->topology), + utxo->reserved_til); + } + } + 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 17a389d7f66b..13863df45b92 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -2917,6 +2917,11 @@ def test_zeroconf_withhold(node_factory, bitcoind, stay_withheld, mutual_close): if stay_withheld: assert l1.rpc.listpeerchannels()['channels'] == [] assert only_one(l1.rpc.listclosedchannels()['closedchannels'])['funding_withheld'] is True + # Verify UTXOs are unreserved after withheld channel close + funds = l1.rpc.listfunds() + for utxo in funds['outputs']: + assert utxo['status'] == 'confirmed', \ + f"UTXO still reserved after withheld close" else: if mutual_close: wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'CLOSINGD_COMPLETE') From ac1551a1b41e0938558ec25341bdb89e988be7fd Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Sun, 15 Mar 2026 16:53:09 +0100 Subject: [PATCH 3/3] lightningd: add preimage to forward_event notification Adds the preimage to the forward_event notification when status is settled. This can be useful to gather some context when listening to this event Changelog-Changed: forward_event notification no has preimage set if status is settled. Signed-off-by: Peter Neuroth --- .../plugin-development/event-notifications.md | 4 +++- lightningd/forwards.c | 9 +++++++-- lightningd/forwards.h | 3 ++- lightningd/notification.c | 4 ++-- tests/test_plugin.py | 5 ++++- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/doc/developers-guide/plugin-development/event-notifications.md b/doc/developers-guide/plugin-development/event-notifications.md index 4ee034a8548d..ea906c25c17b 100644 --- a/doc/developers-guide/plugin-development/event-notifications.md +++ b/doc/developers-guide/plugin-development/event-notifications.md @@ -223,7 +223,8 @@ A notification for topic `forward_event` is sent every time the status of a forw "fee_msat": 1001, "status": "settled", "received_time": 1560696342.368, - "resolved_time": 1560696342.556 + "resolved_time": 1560696342.556, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" } } ``` @@ -262,6 +263,7 @@ or fields; - `received_time` means when we received the htlc of this payment from the previous peer. It will be contained into all status case; - `resolved_time` means when the htlc of this payment between us and the next peer was resolved. The resolved result may success or fail, so only `settled` and `failed` case contain `resolved_time`; +- `preimage` is the 64-hex-char payment preimage revealed when the HTLC was fulfilled. Only present when `status` is `settled`; - The `failcode` and `failreason` are defined in [BOLT 4](https://github.com/lightning/bolts/blob/master/04-onion-routing.md#failure-messages). ### `sendpay_success` diff --git a/lightningd/forwards.c b/lightningd/forwards.c index 6c86d5063614..4ca7c0ee3803 100644 --- a/lightningd/forwards.c +++ b/lightningd/forwards.c @@ -1,7 +1,9 @@ #include "config.h" +#include #include #include #include +#include #include #include #include @@ -86,7 +88,8 @@ bool string_to_forward_status(const char *status_str, * between 'listforwards' API and 'forward_event' notification. */ void json_add_forwarding_fields(struct json_stream *response, const struct forwarding *cur, - const struct sha256 *payment_hash) + const struct sha256 *payment_hash, + const struct preimage *preimage) { /* We don't bother grabbing id from db on update. */ if (cur->created_index) @@ -136,6 +139,8 @@ void json_add_forwarding_fields(struct json_stream *response, json_add_timeabs(response, "received_time", cur->received_time); if (cur->resolved_time) json_add_timeabs(response, "resolved_time", *cur->resolved_time); + if (preimage) + json_add_preimage(response, "preimage", preimage); } static void listforwardings_add_forwardings(struct json_stream *response, @@ -155,7 +160,7 @@ static void listforwardings_add_forwardings(struct json_stream *response, while (stmt) { const struct forwarding *cur = forwarding_details(tmpctx, wallet, stmt); json_object_start(response, NULL); - json_add_forwarding_fields(response, cur, NULL); + json_add_forwarding_fields(response, cur, NULL, NULL); json_object_end(response); tal_free(cur); stmt = forwarding_next(wallet, stmt); diff --git a/lightningd/forwards.h b/lightningd/forwards.h index 0436035b1e76..0156e63d384c 100644 --- a/lightningd/forwards.h +++ b/lightningd/forwards.h @@ -56,7 +56,8 @@ struct forwarding { * `listforwardings_add_forwardings()`. */ void json_add_forwarding_fields(struct json_stream *response, const struct forwarding *cur, - const struct sha256 *payment_hash); + const struct sha256 *payment_hash, + const struct preimage *preimage); static inline const char* forward_status_name(enum forward_status status) { diff --git a/lightningd/notification.c b/lightningd/notification.c index 755a606b17cd..9a5eac5041c3 100644 --- a/lightningd/notification.c +++ b/lightningd/notification.c @@ -1,4 +1,5 @@ #include "config.h" +#include #include #include #include @@ -389,8 +390,7 @@ static void forward_event_notification_serialize(struct json_stream *stream, cur->htlc_id_in = in->key.id; cur->created_index = created_index; cur->updated_index = updated_index; - - json_add_forwarding_fields(stream, cur, &in->payment_hash); + json_add_forwarding_fields(stream, cur, &in->payment_hash, in->preimage); } REGISTER_NOTIFICATION(forward_event); diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 594e4ab3bec4..75e8b51fa3b6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1490,9 +1490,12 @@ def test_forward_event_notification(node_factory, bitcoind, executor): plugin_stats = l2.rpc.call('listforwards_plugin')['forwards'] assert len(plugin_stats) == 6 - # We don't have payment_hash in listforwards any more. + # We don't have payment_hash in listforwards any more. We also don't have + # preimage in listforwards for p in plugin_stats: del p['payment_hash'] + if p.get('preimage') is not None: + del p['preimage'] # use stats to build what we expect went to plugin. expect = stats[0].copy()