lightningd: immediately close without broadcast whenever we close a withheld channel.
What changed, and why it matters
This change fixes how Core Lightning handles closing channels where the funding transaction was deliberately withheld (not broadcast). Previously, the node tried to broadcast a unilateral close transaction for a channel that had no funding transaction on the blockchain, which is impossible and could cause errors or confusion. Now it immediately marks the channel as closed without broadcasting anything. This is a correctness and robustness fix rather than a direct theft-of-funds vulnerability.
Review and merge. The change is defensive and improves correctness for an edge case. No immediate emergency response is indicated, but operators should ensure withheld-channel workflows are covered by tests and monitoring.
Security signals we found
Avoids broadcasting invalid/unspendable close transactions for channels with no on-chain funding
Prevents potential crash or assertion failure in `resolve_one_close_command()` when `close_txs` is empty
Corrects state-machine handling for withheld channels during permanent failure
Plugin no longer attempts to sign PSBTs for withheld channels
Evidence from the diff
The patch adds a channel->withheld check in the permanent channel-failure path and in drop_to_chain(). When a channel’s funding transaction is withheld, the node now skips creating or broadcasting a close transaction, resolves any pending close RPC commands with an empty transaction list, frees HTLCs, and deletes the channel. It also updates the spender plugin to ignore withheld channels when listing channels awaiting PSBT signing. A unit-test stub for free_htlcs is added.
Changed components
lightningd/channel.clightningd/closing_control.clightningd/peer_control.cplugins/spender/openchannel.clightningd/test/run-invoice-select-inchan.cInspect captured patch +36 / −8
diff --git a/lightningd/channel.c b/lightningd/channel.c
index bd2f4de7..e92ff8d7 100644
--- a/lightningd/channel.c
+++ b/lightningd/channel.c
@@ -1088,12 +1088,18 @@ static void channel_fail_perm(struct channel *channel,
channel_set_owner(channel, NULL);
- if (channel_state_wants_onchain_fail(channel->state))
+ if (channel_state_wants_onchain_fail(channel->state) && !channel->withheld) {
channel_set_state(channel,
channel->state,
AWAITING_UNILATERAL,
reason,
why);
+ }
+
+ if (channel_state_open_uncommitted(channel->state)) {
+ delete_channel(channel, false);
+ return;
+ }
/* Drop non-cooperatively (unilateral) to chain. If we detect
* the close from the blockchain, then we can observe
@@ -1101,8 +1107,6 @@ static void channel_fail_perm(struct channel *channel,
* it doesn't stand a chance anyway. */
drop_to_chain(ld, channel, false, spent_by);
- if (channel_state_open_uncommitted(channel->state))
- delete_channel(channel, false);
}
void channel_fail_permanent(struct channel *channel,
diff --git a/lightningd/closing_control.c b/lightningd/closing_control.c
index c3f2d7b8..e86efa23 100644
--- a/lightningd/closing_control.c
+++ b/lightningd/closing_control.c
@@ -38,13 +38,18 @@ static void
resolve_one_close_command(struct close_command *cc, bool cooperative,
const struct bitcoin_tx **close_txs)
{
- assert(tal_count(close_txs));
struct json_stream *result = json_stream_success(cc->cmd);
- const struct bitcoin_tx *close_tx = close_txs[tal_count(close_txs) - 1];
+ const struct bitcoin_tx *close_tx;
- if (command_deprecated_out_ok(cc->cmd, "tx", "v24.11", "v25.12"))
+ /* Withheld funding channels can have no close_txs! */
+ if (tal_count(close_txs) != 0)
+ close_tx = close_txs[tal_count(close_txs) - 1];
+ else
+ close_tx = NULL;
+
+ if (close_tx && command_deprecated_out_ok(cc->cmd, "tx", "v24.11", "v25.12"))
json_add_tx(result, "tx", close_tx);
- if (!invalid_last_tx(close_tx)) {
+ if (close_tx && !invalid_last_tx(close_tx)) {
struct bitcoin_txid txid;
bitcoin_txid(close_tx, &txid);
if (command_deprecated_out_ok(cc->cmd, "txid", "v24.11", "v25.12"))
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index 96929541..a4aac0e7 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -348,6 +348,17 @@ void drop_to_chain(struct lightningd *ld, struct channel *channel,
struct channel_inflight *inflight;
const char *cmd_id;
+ /* If we withheld the funding tx, we simply close */
+ if (channel->withheld) {
+ log_info(channel->log,
+ "Withheld channel: not sending a close transaction");
+ resolve_close_command(ld, channel, cooperative,
+ tal_arr(tmpctx, const struct bitcoin_tx *, 0));
+ free_htlcs(ld, channel);
+ delete_channel(channel, false);
+ return;
+ }
+
/* If we're not already (e.g. close before channel fully open),
* make sure we're watching for the funding spend */
if (!channel->funding_spend_watch) {
diff --git a/lightningd/test/run-invoice-select-inchan.c b/lightningd/test/run-invoice-select-inchan.c
index 1f4ff87f..64e4168e 100644
--- a/lightningd/test/run-invoice-select-inchan.c
+++ b/lightningd/test/run-invoice-select-inchan.c
@@ -276,6 +276,9 @@ void force_peer_disconnect(struct lightningd *ld UNNEEDED,
const struct peer *peer UNNEEDED,
const char *why UNNEEDED)
{ fprintf(stderr, "force_peer_disconnect called!\n"); abort(); }
+/* Generated stub for free_htlcs */
+void free_htlcs(struct lightningd *ld UNNEEDED, const struct channel *channel UNNEEDED)
+{ fprintf(stderr, "free_htlcs called!\n"); abort(); }
/* Generated stub for fromwire_channeld_dev_memleak_reply */
bool fromwire_channeld_dev_memleak_reply(const void *p UNNEEDED, bool *leak UNNEEDED)
{ fprintf(stderr, "fromwire_channeld_dev_memleak_reply called!\n"); abort(); }
diff --git a/plugins/spender/openchannel.c b/plugins/spender/openchannel.c
index fe1d4d4d..432963d4 100644
--- a/plugins/spender/openchannel.c
+++ b/plugins/spender/openchannel.c
@@ -1096,10 +1096,12 @@ static void list_awaiting_channels(struct command *init_cmd)
struct channel_id cid;
struct command *aux_cmd;
struct wally_psbt *psbt;
+ bool withheld;
- if (json_scan(tmpctx, buf, t, "{state:%,channel_id:%,funding:{psbt:%}}",
+ if (json_scan(tmpctx, buf, t, "{state:%,channel_id:%,funding:{withheld:%,psbt:%}}",
JSON_SCAN_TAL(tmpctx, json_strdup, &state),
JSON_SCAN(json_tok_channel_id, &cid),
+ JSON_SCAN(json_to_bool, &withheld),
JSON_SCAN_TAL(tmpctx, json_to_psbt, &psbt)) != NULL)
continue;
@@ -1107,6 +1109,9 @@ static void list_awaiting_channels(struct command *init_cmd)
&& !streq(state, "DUALOPEND_AWAITING_LOCKIN"))
continue;
+ if (withheld)
+ continue;
+
/* Don't do this sync, as it can reasonably fail! */
aux_cmd = aux_command(init_cmd);
req = jsonrpc_request_start(aux_cmd, "signpsbt",
Why this scored 35/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.