spender: look for unsigned PSBT on awaiting channels on startup, and re-send.
What changed, and why it matters
This commit fixes a crash-recovery bug in Core Lightning's channel-opening plugin. If the node crashed after receiving a funding PSBT but before signing and broadcasting it, the channel could get stuck in an 'awaiting lock-in' state. On startup, the plugin now scans for such stuck channels, signs the PSBT, and re-sends it so the opening process can continue. The change is defensive and improves reliability rather than introducing a security vulnerability.
No immediate security action required; this is a reliability fix. Operators should upgrade to avoid stuck channel openings after crashes. Reviewers may want to confirm that automatic re-signing cannot race with concurrent manual operations or a partially signed PSBT.
Security signals we found
Crash-recovery gap could leave channels stuck in AWAITING_LOCKIN
Unsigned PSBT now signed and broadcast automatically on startup
Asynchronous signpsbt/sendpsbt to avoid blocking init on expected failures
Test previously expected to fail (xfail) now passes
Evidence from the diff
The spender plugin’s openchannel_init() now calls a new list_awaiting_channels() helper at startup. This helper synchronously calls listpeerchannels, iterates channels in CHANNELD_AWAITING_LOCKIN or DUALOPEND_AWAITING_LOCKIN that have an unsigned funding PSBT, and asynchronously issues signpsbt followed by sendpsbt. The test test_sendpsbt_crash is no longer marked xfail and asserts the recovery log line appears. The change addresses a crash-before-signing corner case in dual-funded/v2 channel opens.
Changed components
plugins/spender/openchannel.cplugins/spender/openchannel.hplugins/spender/main.ctests/test_opening.pyInspect captured patch +91 / −4
diff --git a/plugins/spender/main.c b/plugins/spender/main.c
index 0f41518..a94e785 100644
--- a/plugins/spender/main.c
+++ b/plugins/spender/main.c
@@ -11,7 +11,7 @@
static
const char *spender_init(struct command *init_cmd, const char *b, const jsmntok_t *t)
{
- openchannel_init(init_cmd->plugin, b, t);
+ openchannel_init(init_cmd, b, t);
/* whatever_init(p, b, t); */
return NULL;
}
diff --git a/plugins/spender/openchannel.c b/plugins/spender/openchannel.c
index 7d21916..fe1d4d4 100644
--- a/plugins/spender/openchannel.c
+++ b/plugins/spender/openchannel.c
@@ -1034,10 +1034,95 @@ openchannel_init_dest(struct multifundchannel_destination *dest)
return send_outreq(req);
}
-void openchannel_init(struct plugin *p, const char *b, const jsmntok_t *t)
+static struct command_result *psbt_error(struct command *aux_cmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ struct channel_id *cid)
+{
+ plugin_log(aux_cmd->plugin, LOG_UNUSUAL,
+ "Failed %s for waiting channel %s: %.*s",
+ methodname,
+ fmt_channel_id(tmpctx, cid),
+ json_tok_full_len(result),
+ json_tok_full(buf, result));
+ return aux_command_done(aux_cmd);
+}
+
+static struct command_result *sendpsbt_done(struct command *aux_cmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ struct channel_id *cid)
+{
+ plugin_log(aux_cmd->plugin, LOG_INFORM,
+ "Signed and sent psbt for waiting channel %s",
+ fmt_channel_id(tmpctx, cid));
+ return aux_command_done(aux_cmd);
+}
+
+static struct command_result *signpsbt_done(struct command *aux_cmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ struct channel_id *cid)
+{
+ const jsmntok_t *psbttok = json_get_member(buf, result, "signed_psbt");
+ struct wally_psbt *psbt = json_to_psbt(tmpctx, buf, psbttok);
+ struct out_req *req;
+
+ req = jsonrpc_request_start(aux_cmd, "sendpsbt",
+ sendpsbt_done, psbt_error,
+ cid);
+ json_add_psbt(req->js, "psbt", psbt);
+ return send_outreq(req);
+}
+
+/* If there are any channels with unsigned PSBTs in AWAITING_LOCKIN,
+ * sign them now (assume we crashed) */
+static void list_awaiting_channels(struct command *init_cmd)
+{
+ const char *buf;
+ size_t i;
+ const jsmntok_t *resp, *t, *channels;
+
+ resp = jsonrpc_request_sync(tmpctx, init_cmd,
+ "listpeerchannels",
+ NULL, &buf);
+ channels = json_get_member(buf, resp, "channels");
+ json_for_each_arr(i, t, channels) {
+ struct out_req *req;
+ const char *state;
+ struct channel_id cid;
+ struct command *aux_cmd;
+ struct wally_psbt *psbt;
+
+ if (json_scan(tmpctx, buf, t, "{state:%,channel_id:%,funding:{psbt:%}}",
+ JSON_SCAN_TAL(tmpctx, json_strdup, &state),
+ JSON_SCAN(json_tok_channel_id, &cid),
+ JSON_SCAN_TAL(tmpctx, json_to_psbt, &psbt)) != NULL)
+ continue;
+
+ if (!streq(state, "CHANNELD_AWAITING_LOCKIN")
+ && !streq(state, "DUALOPEND_AWAITING_LOCKIN"))
+ continue;
+
+ /* Don't do this sync, as it can reasonably fail! */
+ aux_cmd = aux_command(init_cmd);
+ req = jsonrpc_request_start(aux_cmd, "signpsbt",
+ signpsbt_done, psbt_error,
+ tal_dup(aux_cmd, struct channel_id, &cid));
+ json_add_psbt(req->js, "psbt", psbt);
+ send_outreq(req);
+ }
+}
+
+void openchannel_init(struct command *init_cmd, const char *b, const jsmntok_t *t)
{
/* Initialize our list! */
list_head_init(&mfc_commands);
+
+ list_awaiting_channels(init_cmd);
}
const struct plugin_notification openchannel_notifs[] = {
diff --git a/plugins/spender/openchannel.h b/plugins/spender/openchannel.h
index bd2957b..3dfea2c 100644
--- a/plugins/spender/openchannel.h
+++ b/plugins/spender/openchannel.h
@@ -4,6 +4,7 @@
#include <ccan/tal/tal.h>
struct wally_psbt;
+struct command;
extern const struct plugin_notification openchannel_notifs[];
extern const size_t num_openchannel_notifs;
@@ -15,7 +16,7 @@ void register_mfc(struct multifundchannel_command *mfc);
struct command_result *
openchannel_init_dest(struct multifundchannel_destination *dest);
-void openchannel_init(struct plugin *p, const char *b,
+void openchannel_init(struct command *init_cmd, const char *b,
const jsmntok_t *t);
struct command_result *
diff --git a/tests/test_opening.py b/tests/test_opening.py
index 2258a14..19e1a27 100644
--- a/tests/test_opening.py
+++ b/tests/test_opening.py
@@ -2849,7 +2849,6 @@ def test_opening_crash(bitcoind, node_factory):
bitcoind.generate_block(1, wait_for_mempool=txid)
-@pytest.mark.xfail(strict=True)
@pytest.mark.openchannel('v1')
def test_sendpsbt_crash(bitcoind, node_factory):
"""Stop sendpsbt, check it eventually opens"""
@@ -2866,3 +2865,5 @@ def test_sendpsbt_crash(bitcoind, node_factory):
del l1.daemon.opts['plugin']
l1.start()
bitcoind.generate_block(1, wait_for_mempool=1)
+
+ assert l1.daemon.is_in_log('Signed and sent psbt for waiting channel')
Why this scored 30/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.