diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py index b535f88e9bab..fa8063098e89 100644 --- a/contrib/pyln-client/pyln/client/lightning.py +++ b/contrib/pyln-client/pyln/client/lightning.py @@ -1523,6 +1523,17 @@ def fundpsbt(self, satoshi, feerate, startweight, minconf=None, reserve=None, lo } return self.call("fundpsbt", payload) + def addpsbtoutput(self, satoshi, initialpsbt=None, locktime=None): + """ + Create a PSBT with an output of amount satoshi leading to the on-chain wallet + """ + payload = { + "satoshi": satoshi, + "initialpsbt": initialpsbt, + "locktime": locktime, + } + return self.call("addpsbtoutput", payload) + def utxopsbt(self, satoshi, feerate, startweight, utxos, reserve=None, reservedok=False, locktime=None, min_witness_weight=None, excess_as_change=False): """ Create a PSBT with given inputs, to give an output of satoshi. diff --git a/doc/Makefile b/doc/Makefile index 130d9fdcf5fe..8251ea44ca26 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -50,6 +50,7 @@ MANPAGES := doc/lightning-cli.1 \ doc/lightning-fundchannel_complete.7 \ doc/lightning-fundchannel_cancel.7 \ doc/lightning-funderupdate.7 \ + doc/lightning-addpsbtoutput.7 \ doc/lightning-fundpsbt.7 \ doc/lightning-getroute.7 \ doc/lightning-hsmtool.8 \ diff --git a/doc/index.rst b/doc/index.rst index d59d7700cf9f..b5eae8fa06da 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -85,6 +85,7 @@ Core Lightning Documentation lightning-multifundchannel lightning-multiwithdraw lightning-newaddr + lightning-addpsbtoutput lightning-notifications lightning-offer lightning-openchannel_abort diff --git a/doc/lightning-addpsbtoutput.7.md b/doc/lightning-addpsbtoutput.7.md new file mode 100644 index 000000000000..d91fda910b90 --- /dev/null +++ b/doc/lightning-addpsbtoutput.7.md @@ -0,0 +1,65 @@ +lightning-addpsbtoutput -- Command to populate PSBT outputs from the wallet +================================================================ + +SYNOPSIS +-------- + +**addpsbtoutput** *satoshi* [*initialpsbt*] [*locktime*] + +DESCRIPTION +----------- + +`addpsbtoutput` is a low-level RPC command which creates or modifies a PSBT +by adding a single output of amount *satoshi*. + +This is used to receive funds into the on-chain wallet interactively +using PSBTs. + +*satoshi* is the satoshi value of the output. It can +be a whole number, a whole number ending in *sat*, a whole number +ending in *000msat*, or a number with 1 to 8 decimal places ending in +*btc*. + +*initialpsbt* is a PSBT to add the output to. If not speciifed, a PSBT +will be created automatically. + +*locktime* is an optional locktime: if not set, it is set to a recent +block height (if no initial psbt is specified). + +EXAMPLE USAGE +------------- + +Here is a command to make a PSBT with a 100,000 sat output that leads +to the on-chain wallet. +```shell +lightning-cli addpsbtoutput 100000sat +``` + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +On success, an object is returned, containing: + +- **psbt** (string): Unsigned PSBT which fulfills the parameters given +- **estimated\_added\_weight** (u32): The estimated weight of the added output +- **outnum** (u32): The 0-based number where the output was placed + +[comment]: # (GENERATE-FROM-SCHEMA-END) + +AUTHOR +------ + +@dusty\_daemon + +SEE ALSO +-------- + +lightning-fundpsbt(7), lightning-utxopsbt(7) + +RESOURCES +--------- + +Main web site: + +[comment]: # ( SHA256STAMP:fad04aa277b18392570cc1ba6302506dadc71d1e3844720cb5c1b74812d731f2) diff --git a/doc/schemas/addpsbtoutput.request.json b/doc/schemas/addpsbtoutput.request.json new file mode 100644 index 000000000000..fb9b07e72ee2 --- /dev/null +++ b/doc/schemas/addpsbtoutput.request.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "satoshi" + ], + "added": "v23.11", + "properties": { + "satoshi": { + "type": "msat" + }, + "locktime": { + "type": "u32" + }, + "initialpsbt": { + "type": "string", + "description": "the (optional) base 64 encoded PSBT to begin with. If not specified, one will be generated automatically" + } + } +} diff --git a/doc/schemas/addpsbtoutput.schema.json b/doc/schemas/addpsbtoutput.schema.json new file mode 100644 index 000000000000..6481688d231b --- /dev/null +++ b/doc/schemas/addpsbtoutput.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "psbt", + "estimated_added_weight", + "outnum" + ], + "added": "v23.08.1", + "properties": { + "psbt": { + "type": "string", + "description": "Unsigned PSBT which fulfills the parameters given" + }, + "estimated_added_weight": { + "type": "u32", + "description": "The estimated weight of the added output" + }, + "outnum": { + "type": "u32", + "description": "The 0-based number where the output was placed" + } + } +} diff --git a/tests/test_splicing.py b/tests/test_splicing.py index 959e4d4dc614..83a75b8cd7fa 100644 --- a/tests/test_splicing.py +++ b/tests/test_splicing.py @@ -1,4 +1,5 @@ from fixtures import * # noqa: F401,F403 +from pyln.client import RpcError from utils import TEST_NETWORK import pytest import unittest @@ -39,3 +40,146 @@ def test_splice(node_factory, bitcoind): # Check that the splice doesn't generate a unilateral close transaction time.sleep(5) assert l1.db_query("SELECT count(*) as c FROM channeltxs;")[0]['c'] == 0 + + +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_splice_out(node_factory, bitcoind): + l1, l2 = node_factory.line_graph(2, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None}) + + chan_id = l1.get_channel_id(l2) + + funds_result = l1.rpc.addpsbtoutput(100000) + + # Pay with fee by subjtracting 5000 from channel balance + result = l1.rpc.splice_init(chan_id, -105000, funds_result['psbt']) + result = l1.rpc.splice_update(chan_id, result['psbt']) + result = l1.rpc.splice_signed(chan_id, result['psbt']) + + l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + + mempool = bitcoind.rpc.getrawmempool(True) + assert len(list(mempool.keys())) == 1 + assert result['txid'] in list(mempool.keys()) + + bitcoind.generate_block(6, wait_for_mempool=1) + + l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + + inv = l2.rpc.invoice(10**2, '3', 'no_3') + l1.rpc.pay(inv['bolt11']) + + # Check that the splice doesn't generate a unilateral close transaction + time.sleep(5) + assert l1.db_query("SELECT count(*) as c FROM channeltxs;")[0]['c'] == 0 + + +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_invalid_splice(node_factory, bitcoind): + # Here we do a splice but underfund it purposefully + l1, l2 = node_factory.line_graph(2, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None, + 'may_reconnect': True, + 'allow_warning': True}) + + chan_id = l1.get_channel_id(l2) + + # We claim to add 100000 but in fact add nothing + result = l1.rpc.splice_init(chan_id, 100000) + + with pytest.raises(RpcError) as rpc_error: + result = l1.rpc.splice_update(chan_id, result['psbt']) + + assert rpc_error.value.error["code"] == 357 + assert rpc_error.value.error["message"] == "You provided 1000000000msat but committed to 1100000000msat." + + # The splicing inflight should have been left pending in the DB + assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 0 + + l1.daemon.wait_for_log(r'Peer has reconnected, state CHANNELD_NORMAL') + + # Startup should have cleared the pending inflight + assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 0 + + # Now we do a real splice to confirm everything works after restart + funds_result = l1.rpc.fundpsbt("109000sat", "slow", 166, excess_as_change=True) + + result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt']) + result = l1.rpc.splice_update(chan_id, result['psbt']) + result = l1.rpc.signpsbt(result['psbt']) + result = l1.rpc.splice_signed(chan_id, result['signed_psbt']) + + l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + + mempool = bitcoind.rpc.getrawmempool(True) + assert len(list(mempool.keys())) == 1 + assert result['txid'] in list(mempool.keys()) + + bitcoind.generate_block(6, wait_for_mempool=1) + + l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + + inv = l2.rpc.invoice(10**2, '3', 'no_3') + l1.rpc.pay(inv['bolt11']) + + # Check that the splice doesn't generate a unilateral close transaction + time.sleep(5) + assert l1.db_query("SELECT count(*) as c FROM channeltxs;")[0]['c'] == 0 + + +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_commit_crash_splice(node_factory, bitcoind): + # Here we do a splice but underfund it purposefully + l1, l2 = node_factory.line_graph(2, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None, + 'may_reconnect': True}) + + chan_id = l1.get_channel_id(l2) + + result = l1.rpc.splice_init(chan_id, -105000, l1.rpc.addpsbtoutput(100000)['psbt']) + result = l1.rpc.splice_update(chan_id, result['psbt']) + + l1.daemon.wait_for_log(r"Splice initiator: we commit") + + l1.restart() + + assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 1 + + l1.daemon.wait_for_log(r'Peer has reconnected, state CHANNELD_NORMAL') + + # TODO: Is there a scenario where we want to clear out the inflights? + assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 1 + + result = l1.rpc.splice_init(chan_id, -105000, l1.rpc.addpsbtoutput(100000)['psbt']) + result = l1.rpc.splice_update(chan_id, result['psbt']) + result = l1.rpc.splice_signed(chan_id, result['psbt']) + + l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE') + + mempool = bitcoind.rpc.getrawmempool(True) + assert len(list(mempool.keys())) == 1 + assert result['txid'] in list(mempool.keys()) + + bitcoind.generate_block(6, wait_for_mempool=1) + + l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL') + + time.sleep(1) + + assert l1.db_query("SELECT count(*) as c FROM channel_funding_inflights;")[0]['c'] == 0 + + inv = l2.rpc.invoice(10**2, '3', 'no_3') + l1.rpc.pay(inv['bolt11']) + + # Check that the splice doesn't generate a unilateral close transaction + time.sleep(5) + assert l1.db_query("SELECT count(*) as c FROM channeltxs;")[0]['c'] == 0 diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 86bcf82891be..10813e2acfc3 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -607,6 +607,33 @@ def test_fundpsbt(node_factory, bitcoind, chainparams): l1.rpc.fundpsbt(amount // 2, feerate, 0) +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_addpsbtoutput(node_factory, bitcoind, chainparams): + amount1 = 1000000 + amount2 = 3333333 + locktime = 111 + l1 = node_factory.get_node() + + result = l1.rpc.addpsbtoutput(amount1, locktime=locktime) + assert result['outnum'] == 0 + + psbt_info = bitcoind.rpc.decodepsbt(l1.rpc.setpsbtversion(result['psbt'], 0)['psbt']) + + assert len(psbt_info['tx']['vout']) == 1 + assert psbt_info['tx']['vout'][0]['n'] == result['outnum'] + assert psbt_info['tx']['vout'][0]['value'] * 100000000 == amount1 + assert psbt_info['tx']['locktime'] == locktime + + result = l1.rpc.addpsbtoutput(amount2, result['psbt']) + n = result['outnum'] + + psbt_info = bitcoind.rpc.decodepsbt(l1.rpc.setpsbtversion(result['psbt'], 0)['psbt']) + + assert len(psbt_info['tx']['vout']) == 2 + assert psbt_info['tx']['vout'][n]['value'] * 100000000 == amount2 + assert psbt_info['tx']['vout'][n]['n'] == result['outnum'] + + def test_utxopsbt(node_factory, bitcoind, chainparams): amount = 1000000 l1 = node_factory.get_node() diff --git a/wallet/reservation.c b/wallet/reservation.c index 15e2045b1e5a..5f83fc691c04 100644 --- a/wallet/reservation.c +++ b/wallet/reservation.c @@ -654,6 +654,94 @@ static const struct json_command fundpsbt_command = { }; AUTODATA(json_command, &fundpsbt_command); +static struct command_result *json_addpsbtoutput(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + struct json_stream *response; + struct amount_sat *amount; + struct wally_psbt *psbt; + u32 *locktime; + ssize_t outnum; + u32 weight; + struct pubkey pubkey; + s64 keyidx; + u8 *b32script; + + if (!param(cmd, buffer, params, + p_req("satoshi", param_sat, &amount), + p_opt("initialpsbt", param_psbt, &psbt), + p_opt("locktime", param_number, &locktime), + NULL)) + return command_param_failed(); + + if (!psbt) { + if (!locktime) { + locktime = tal(cmd, u32); + *locktime = default_locktime(cmd->ld->topology); + } + psbt = create_psbt(cmd, 0, 0, *locktime); + } + else if(locktime) { + return command_fail(cmd, FUNDING_PSBT_INVALID, + "Can't set locktime of an existing {initialpsbt}"); + } + + if (!validate_psbt(psbt)) + return command_fail(cmd, + FUNDING_PSBT_INVALID, + "PSBT failed to validate."); + + if (amount_sat_less(*amount, chainparams->dust_limit)) + return command_fail(cmd, FUND_OUTPUT_IS_DUST, + "Receive amount is below dust limit (%s)", + type_to_string(tmpctx, + struct amount_sat, + &chainparams->dust_limit)); + + /* Get a change adddress */ + keyidx = wallet_get_newindex(cmd->ld); + if (keyidx < 0) + return command_fail(cmd, LIGHTNINGD, + "Failed to generate change address." + " Keys exhausted."); + + if (chainparams->is_elements) { + bip32_pubkey(cmd->ld, &pubkey, keyidx); + b32script = scriptpubkey_p2wpkh(tmpctx, &pubkey); + } else { + b32script = p2tr_for_keyidx(tmpctx, cmd->ld, keyidx); + } + if (!b32script) { + return command_fail(cmd, LIGHTNINGD, + "Failed to generate change address." + " Keys generation failure"); + } + txfilter_add_scriptpubkey(cmd->ld->owned_txfilter, b32script); + + outnum = psbt->num_outputs; + psbt_append_output(psbt, b32script, *amount); + /* Add additional weight of output */ + weight = bitcoin_tx_output_weight( + chainparams->is_elements ? BITCOIN_SCRIPTPUBKEY_P2WPKH_LEN : BITCOIN_SCRIPTPUBKEY_P2TR_LEN); + + response = json_stream_success(cmd); + json_add_psbt(response, "psbt", psbt); + json_add_num(response, "estimated_added_weight", weight); + json_add_num(response, "outnum", outnum); + return command_success(cmd, response); +} + +static const struct json_command addpsbtoutput_command = { + "addpsbtoutput", + "bitcoin", + json_addpsbtoutput, + "Create a PSBT (or modify existing {initialpsbt}) with an output receiving {satoshi} amount.", + false +}; +AUTODATA(json_command, &addpsbtoutput_command); + static struct command_result *param_txout(struct command *cmd, const char *name, const char *buffer,