lightningd: unreserve UTXOs from withheld funding PSBT when dropping channel
What changed, and why it matters
This fix ensures that when a user opens a 'zero-confirmation' channel whose funding transaction is deliberately withheld and then the channel is closed/aborted, any bitcoin inputs that had been set aside (reserved) for that funding transaction are released back to the wallet promptly. Before this fix, those coins could stay locked up until an automatic timeout expired, making them unavailable for other payments or channel opens.
No immediate security response required; this is a wallet-availability bug fix. Users running versions affected by withheld zero-confirmation channel opens should upgrade to avoid temporary UTXO lockups.
Security signals we found
Fixes a resource-availability / denial-of-service condition where wallet UTXOs remain unnecessarily reserved
Adds explicit unreserve logic for a previously unhandled edge case (withheld funding PSBT)
Includes a regression test asserting UTXO status after withheld close
Evidence from the diff
In drop_to_chain(), when a channel with channel->funding_psbt is being dropped, the code now iterates over the PSBT inputs, extracts each outpoint, looks up the corresponding UTXO, and if it is still OUTPUT_STATE_RESERVED, calls wallet_unreserve_utxo(). This prevents reserved UTXOs from remaining blocked until the reservation expires. A regression test in tests/test_opening.py verifies that after a withheld channel close, all UTXOs in listfunds have status ‘confirmed’.
Changed components
lightningd/peer_control.ctests/test_opening.pyInspect captured patch +24 / −0
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index 3465428..8e95aed 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -11,6 +11,7 @@
#include <common/initial_commit_tx.h>
#include <common/json_channel_type.h>
#include <common/json_command.h>
+#include <common/psbt_open.h>
#include <common/timeout.h>
#include <common/version.h>
#include <common/wire_error.h>
@@ -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 17a389d..13863df 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')
Why this scored 28/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.