From f54441f2340a7d914b005ccb5064ea45f2532336 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 25 Mar 2026 14:51:33 +0100 Subject: [PATCH 1/2] dual_open_control: check dualopend liveness before validating PSBT signatures When the peer disconnects during the openchannel2_sign hook, the disconnect notification can race with the hook in plugins (e.g. funder), causing them to clean up state and return the PSBT unsigned. Previously the signature check ran first, logging a spurious BROKEN message before discovering that dualopend had already died. Move the dualopend liveness check before the PSBT validation: if the daemon is gone there is no point checking signatures since we cannot send them anyway. Fixes: #8902 --- lightningd/dual_open_control.c | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lightningd/dual_open_control.c b/lightningd/dual_open_control.c index f5f63b7b827a..1cab7444aa7f 100644 --- a/lightningd/dual_open_control.c +++ b/lightningd/dual_open_control.c @@ -1070,6 +1070,19 @@ openchannel2_sign_hook_cb(struct openchannel2_psbt_payload *payload STEALS) /* Whatever happens, we free the payload */ tal_steal(tmpctx, payload); + /* Peer's gone away, let's try reconnecting. + * Check this first: if dualopend died (e.g. peer disconnected), + * there's no point validating signatures since we can't send + * them anyway. The disconnect notification can also race with + * this hook in plugins, causing them to clean up state and + * return the PSBT unsigned. */ + if (!payload->dualopend) { + channel_saved_err_broken_reconn(channel, + "dualopend daemon died" + " before signed PSBT returned"); + return; + } + /* Finalize it, if not already. It shouldn't work entirely */ psbt_finalize(payload->psbt); @@ -1115,14 +1128,6 @@ openchannel2_sign_hook_cb(struct openchannel2_psbt_payload *payload STEALS) msg = towire_dualopend_send_tx_sigs(NULL, inflight->funding_psbt); send_msg: - /* Peer's gone away, let's try reconnecting */ - if (!payload->dualopend) { - channel_saved_err_broken_reconn(channel, - "dualopend daemon died" - " before signed PSBT returned"); - tal_free(msg); - return; - } tal_del_destructor2(payload->dualopend, openchannel2_psbt_remove_dualopend, payload); From 3a2f1a34af81daf5c8febc3db2955858d5db3a39 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 25 Mar 2026 14:51:40 +0100 Subject: [PATCH 2/2] tests: add regression test for #8902 dual-fund disconnect BROKEN Add test_inflight_dbload which triggers a disconnect at +WIRE_COMMITMENT_SIGNED during a dual-funded lease open. Before the fix in the previous commit, the disconnect notification race would cause a spurious 'Plugin must return a psbt with signatures' BROKEN log. Now only the expected 'dualopend daemon died' BROKEN appears. --- tests/test_opening.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_opening.py b/tests/test_opening.py index 13863df45b92..701e1333a6d7 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -2993,3 +2993,45 @@ def test_zeroconf_withhold_htlc_failback(node_factory, bitcoind): # l1's channel to l2 is still normal — no force-close assert only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['state'] == 'CHANNELD_NORMAL' + + +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +@pytest.mark.openchannel('v2') +def test_inflight_dbload(node_factory, bitcoind): + """Disconnect during dual-fund sign should not trigger spurious BROKEN. + + Regression test for #8902: when the peer disconnects while the + openchannel2_sign hook is being processed, the funder plugin's + disconnect notification races with the hook, causing it to return + the PSBT unsigned. Before the fix, this produced a spurious + 'Plugin must return a psbt with signatures' BROKEN message. + """ + disconnects = ["+WIRE_COMMITMENT_SIGNED"] + + opts = [{'experimental-dual-fund': None, 'dev-no-reconnect': None, + 'may_reconnect': True, 'disconnect': disconnects}, + {'experimental-dual-fund': None, 'dev-no-reconnect': None, + 'may_reconnect': True, 'funder-policy': 'match', + 'funder-policy-mod': 100, 'lease-fee-base-sat': '100sat', + 'lease-fee-basis': 100, + # The daemon-death BROKEN is expected on disconnect + 'broken_log': 'dualopend daemon died before signed PSBT returned'}] + + l1, l2 = node_factory.get_nodes(2, opts=opts) + + feerate = 2000 + amount = 500000 + l1.fundwallet(20000000) + l2.fundwallet(20000000) + + # l1 leases a channel from l2 + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + rates = l1.rpc.dev_queryrates(l2.info['id'], amount, amount) + l1.rpc.fundchannel(l2.info['id'], amount, request_amt=amount, + feerate='{}perkw'.format(feerate), + compact_lease=rates['compact_lease']) + l1.daemon.wait_for_log(r'dev_disconnect: \+WIRE_COMMITMENT_SIGNED') + + # Restart l1; before the fix this would leave a spurious BROKEN + # 'Plugin must return a psbt with signatures' in l2's log. + l1.restart()