From 3d632591bfaf7664f30af165eae3a65d70c20733 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Sun, 18 Oct 2026 12:10:53 -0400 Subject: [PATCH] lightningd: convert the PSBTs dual-open commands and hooks take to version 2 --- lightningd/dual_open_control.c | 15 ++++++- tests/plugins/df_accept_psbt_v0.py | 32 +++++++++++++++ tests/test_opening.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100755 tests/plugins/df_accept_psbt_v0.py diff --git a/lightningd/dual_open_control.c b/lightningd/dual_open_control.c index fd4284de5aca..cba10d3ffb99 100644 --- a/lightningd/dual_open_control.c +++ b/lightningd/dual_open_control.c @@ -447,7 +447,8 @@ hook_extract_psbt(const tal_t *ctx, struct subd *dualopend, const char *buffer, } *out = json_to_psbt(ctx, buffer, psbt_tok); - if (!*out) + /* We only deal in v2 */ + if (!*out || !psbt_set_version(*out, 2)) fatal("Plugin must return a valid 'psbt' to a 'continue'd" "%s %.*s", hook_name, toks[0].end - toks[0].start, @@ -2759,6 +2760,10 @@ json_openchannel_bump(struct command *cmd, NULL)) return command_param_failed(); + /* We only deal in v2 */ + if (!psbt_set_version(info->psbt, 2)) + return command_fail(cmd, LIGHTNINGD, "Could not set PSBT version."); + psbt_val = AMOUNT_SAT(0); for (size_t i = 0; i < info->psbt->num_inputs; i++) { struct amount_sat in_amt = psbt_input_get_amount(info->psbt, i); @@ -2931,6 +2936,10 @@ json_openchannel_signed(struct command *cmd, NULL)) return command_param_failed(); + /* We only deal in v2 */ + if (!psbt_set_version(psbt, 2)) + return command_fail(cmd, LIGHTNINGD, "Could not set PSBT version."); + channel = channel_by_cid(cmd->ld, cid); if (!channel) return command_fail(cmd, FUNDING_UNKNOWN_CHANNEL, @@ -3189,6 +3198,10 @@ static struct command_result *json_openchannel_update(struct command *cmd, NULL)) return command_param_failed(); + /* We only deal in v2 */ + if (!psbt_set_version(psbt, 2)) + return command_fail(cmd, LIGHTNINGD, "Could not set PSBT version."); + channel = channel_by_cid(cmd->ld, cid); if (!channel) return command_fail(cmd, FUNDING_UNKNOWN_CHANNEL, diff --git a/tests/plugins/df_accept_psbt_v0.py b/tests/plugins/df_accept_psbt_v0.py new file mode 100755 index 000000000000..43c400bbee80 --- /dev/null +++ b/tests/plugins/df_accept_psbt_v0.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Accepts a dual-funded open with an input of our own, returning version 0 +PSBTs from the hooks, as an external wallet may.""" + +from pyln.client import Plugin + +plugin = Plugin() + + +@plugin.hook('openchannel2') +def on_openchannel2(openchannel2, plugin, **kwargs): + psbt = plugin.rpc.call('fundpsbt', { + 'satoshi': 100000, + 'feerate': '{}perkw'.format(openchannel2['funding_feerate_per_kw']), + 'startweight': 0, + 'min_witness_weight': 110, + 'excess_as_change': True, + 'locktime': openchannel2['locktime'], + 'nonwrapped': True})['psbt'] + return {'result': 'continue', + 'psbt': plugin.rpc.setpsbtversion(psbt, 0)['psbt'], + 'our_funding_msat': 100000 * 1000} + + +@plugin.hook('openchannel2_sign') +def on_openchannel2_sign(openchannel2_sign, plugin, **kwargs): + signed = plugin.rpc.signpsbt(openchannel2_sign['psbt'])['signed_psbt'] + return {'result': 'continue', + 'psbt': plugin.rpc.setpsbtversion(signed, 0)['psbt']} + + +plugin.run() diff --git a/tests/test_opening.py b/tests/test_opening.py index 5008d8c86dc5..efddfc759549 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -4403,3 +4403,68 @@ def test_duplicate_channel_id_in_db(node_factory, bitcoind): # And we can still start with both in the db. l2.restart() assert [c['channel_id'] for c in l2.rpc.listclosedchannels()['closedchannels']] == [cid, cid] + + +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +@pytest.mark.openchannel('v2') +def test_v2_open_psbt_version_0(node_factory, bitcoind): + """openchannel_update and openchannel_signed, and the accepter's hooks, + take version 0 PSBTs, as openchannel_init already does.""" + plugin = os.path.join(os.getcwd(), 'tests/plugins/df_accept_psbt_v0.py') + l1, l2 = node_factory.get_nodes(2, opts=[{}, + {'plugin': plugin, + 'disable-plugin': 'funder'}]) + l1.fundwallet(2000000) + l2.fundwallet(2000000) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + + def v0(psbt): + return l1.rpc.setpsbtversion(psbt, 0)['psbt'] + + psbt = l1.rpc.fundpsbt(500000, '7500perkw', 250, excess_as_change=True)['psbt'] + start = l1.rpc.openchannel_init(l2.info['id'], 500000, v0(psbt)) + chan_id = start['channel_id'] + update = l1.rpc.openchannel_update(chan_id, v0(start['psbt'])) + while not update['commitments_secured']: + update = l1.rpc.openchannel_update(chan_id, v0(update['psbt'])) + l1.rpc.openchannel_signed(chan_id, v0(l1.rpc.signpsbt(update['psbt'])['signed_psbt'])) + l1.daemon.wait_for_log(' to DUALOPEND_AWAITING_LOCKIN') + + bitcoind.generate_block(6, wait_for_mempool=1) + for n in (l1, l2): + n.daemon.wait_for_log(' to CHANNELD_NORMAL') + funding = only_one(l1.rpc.listpeerchannels()['channels'])['funding'] + assert funding['remote_funds_msat'] == Millisatoshi(100000 * 1000) + + +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +@pytest.mark.openchannel('v2') +def test_v2_rbf_psbt_version_0(node_factory, bitcoind): + """openchannel_bump takes a version 0 PSBT.""" + l1, l2 = node_factory.get_nodes(2) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + chan_amount = 100000 + l1.fundwallet(2**24) + + def v0(psbt): + return l1.rpc.setpsbtversion(psbt, 0)['psbt'] + + res = l1.rpc.fundchannel(l2.info['id'], chan_amount) + chan_id = res['channel_id'] + vin = only_one(bitcoind.rpc.decoderawtransaction(res['tx'])['vin']) + l1.daemon.wait_for_log(' to DUALOPEND_AWAITING_LOCKIN') + + startweight = 42 + 172 # base weight, funding output + initpsbt = l1.rpc.utxopsbt(chan_amount, find_next_feerate(l1, l2), startweight, + ["{}:{}".format(vin['txid'], vin['vout'])], + reservedok=True, excess_as_change=True)['psbt'] + bump = l1.rpc.openchannel_bump(chan_id, chan_amount, v0(initpsbt)) + update = l1.rpc.openchannel_update(chan_id, v0(bump['psbt'])) + assert update['commitments_secured'] + l1.rpc.openchannel_signed(chan_id, v0(l1.rpc.signpsbt(update['psbt'])['signed_psbt'])) + + bitcoind.generate_block(6, wait_for_mempool=1) + for n in (l1, l2): + n.daemon.wait_for_log(' to CHANNELD_NORMAL') + # The bump is what confirmed. + assert only_one(l1.rpc.listpeerchannels()['channels'])['funding_txid'] != res['txid']