From 35f12b4ca10a4387f8ad02322ff616600faff298 Mon Sep 17 00:00:00 2001 From: niftynei Date: Wed, 26 Oct 2022 15:01:27 -0500 Subject: [PATCH] upgradewallet: JSONRPC call to update p2sh outputs to a native segwit v2 opens require you to use native segwit inputs Changelog-Added: JSONRPC: `upgradewallet` command, sweeps all p2sh-wrapped outputs to a native segwit output --- doc/index.rst | 1 + doc/lightning-upgradewallet.7.md | 58 +++++++++ doc/schemas/upgradewallet.request.json | 18 +++ doc/schemas/upgradewallet.schema.json | 30 +++++ plugins/txprepare.c | 170 ++++++++++++++++++++++++- tests/data/p2sh_wallet_hsm_secret | Bin 0 -> 32 bytes tests/test_wallet.py | 57 +++++++++ 7 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 doc/lightning-upgradewallet.7.md create mode 100644 doc/schemas/upgradewallet.request.json create mode 100644 doc/schemas/upgradewallet.schema.json create mode 100644 tests/data/p2sh_wallet_hsm_secret diff --git a/doc/index.rst b/doc/index.rst index 2f05acd7f..b0d8ae799 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -125,6 +125,7 @@ Core Lightning Documentation lightning-txprepare lightning-txsend lightning-unreserveinputs + lightning-upgradewallet lightning-utxopsbt lightning-waitanyinvoice lightning-waitblockheight diff --git a/doc/lightning-upgradewallet.7.md b/doc/lightning-upgradewallet.7.md new file mode 100644 index 000000000..7df504509 --- /dev/null +++ b/doc/lightning-upgradewallet.7.md @@ -0,0 +1,58 @@ +lightning-upgradewallet -- Command to spend all P2SH-wrapped inputs into a Native Segwit output +================================================================ + +SYNOPSIS +-------- + +**upgradewallet** [*feerate*] [*reservedok*] + +DESCRIPTION +----------- + +`upgradewallet` is a convenience RPC which will spend all p2sh-wrapped +Segwit deposits in a wallet into a single Native Segwit P2WPKH address. + +*feerate* can be one of the feerates listed in lightning-feerates(7), +or one of the strings *urgent* (aim for next block), *normal* (next 4 +blocks or so) or *slow* (next 100 blocks or so) to use lightningd's +internal estimates. It can also be a *feerate* is a number, with an +optional suffix: *perkw* means the number is interpreted as +satoshi-per-kilosipa (weight), and *perkb* means it is interpreted +bitcoind-style as satoshi-per-kilobyte. Omitting the suffix is +equivalent to *perkb*. + +*reservedok* tells the wallet to include all P2SH-wrapped inputs, including +reserved ones. + +EXAMPLE USAGE +------------- + +The caller is trying to buy a liquidity ad but the command keeps failing. +They have funds in their wallet, but they're all P2SH-wrapped outputs. + +The caller can call `upgradewallet` to convert their funds to native segwit +outputs, which are valid for liquidity ad buys. + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +[comment]: # (GENERATE-FROM-SCHEMA-END) + + +AUTHOR +------ + +~niftynei~ <> is mainly responsible. + +SEE ALSO +-------- + +lightning-utxopsbt(7), lightning-reserveinputs(7), lightning-unreserveinputs(7). + +RESOURCES +--------- + +Main web site: + +[comment]: # ( SHA256STAMP:0f290582f49c6103258b7f781a9e7fa4075ec6c05335a459a91da0b6fd58c68d) diff --git a/doc/schemas/upgradewallet.request.json b/doc/schemas/upgradewallet.request.json new file mode 100644 index 000000000..60f58ec17 --- /dev/null +++ b/doc/schemas/upgradewallet.request.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "feerate": { + "type": "feerate", + "description": "Feerate for the upgrade transaction", + "added": "v23.02" + }, + "reservedok": { + "type": "boolean", + "description": "Include already reserved funds or not", + "added": "v23.02" + } + } +} diff --git a/doc/schemas/upgradewallet.schema.json b/doc/schemas/upgradewallet.schema.json new file mode 100644 index 000000000..cd4a5c957 --- /dev/null +++ b/doc/schemas/upgradewallet.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "upgraded_outs" + ], + "properties": { + "upgraded_outs": { + "type": "u64", + "description": "Count of spent/upgraded UTXOs", + "added": "v23.02" + }, + "psbt": { + "type": "string", + "description": "The PSBT that was finalized and sent", + "added": "v23.02" + }, + "tx": { + "type": "hex", + "description": "The raw transaction which was sent", + "added": "v23.02" + }, + "txid": { + "type": "txid", + "description": "The txid of the **tx**", + "added": "v23.02" + } + } +} diff --git a/plugins/txprepare.c b/plugins/txprepare.c index d4a355634..5b668043f 100644 --- a/plugins/txprepare.c +++ b/plugins/txprepare.c @@ -35,6 +35,9 @@ struct txprepare { /* For withdraw, we actually send immediately. */ bool is_withdraw; + + /* Keep track if upgrade, so we can report on finish */ + bool is_upgrade; }; struct unreleased_tx { @@ -42,6 +45,7 @@ struct unreleased_tx { struct bitcoin_txid txid; struct wally_tx *tx; struct wally_psbt *psbt; + bool is_upgrade; }; static LIST_HEAD(unreleased_txs); @@ -137,6 +141,8 @@ static struct command_result *sendpsbt_done(struct command *cmd, json_add_hex_talarr(out, "tx", linearize_wtx(tmpctx, utx->tx)); json_add_txid(out, "txid", &utx->txid); json_add_psbt(out, "psbt", utx->psbt); + if (utx->is_upgrade) + json_add_num(out, "upgraded_outs", utx->tx->num_inputs); return command_finished(cmd, out); } @@ -208,6 +214,7 @@ static struct command_result *finish_txprepare(struct command *cmd, psbt_elements_normalize_fees(txp->psbt); utx = tal(NULL, struct unreleased_tx); + utx->is_upgrade = txp->is_upgrade; utx->psbt = tal_steal(utx, txp->psbt); psbt_txid(utx, txp->psbt, &utx->txid, &utx->tx); @@ -351,7 +358,8 @@ static struct command_result *txprepare_continue(struct command *cmd, const char *feerate, unsigned int *minconf, struct bitcoin_outpoint *utxos, - bool is_withdraw) + bool is_withdraw, + bool reservedok) { struct out_req *req; @@ -372,11 +380,13 @@ static struct command_result *txprepare_continue(struct command *cmd, json_add_outpoint(req->js, NULL, &utxos[i]); } json_array_end(req->js); + json_add_bool(req->js, "reservedok", reservedok); } else { req = jsonrpc_request_start(cmd->plugin, cmd, "fundpsbt", psbt_created, forward_error, txp); - json_add_u32(req->js, "minconf", *minconf); + if (minconf) + json_add_u32(req->js, "minconf", *minconf); } if (txp->all_output_idx == -1) @@ -407,7 +417,8 @@ static struct command_result *json_txprepare(struct command *cmd, NULL)) return command_param_failed(); - return txprepare_continue(cmd, txp, feerate, minconf, utxos, false); + txp->is_upgrade = false; + return txprepare_continue(cmd, txp, feerate, minconf, utxos, false, false); } /* Called after we've unreserved the inputs. */ @@ -533,7 +544,151 @@ static struct command_result *json_withdraw(struct command *cmd, txp->weight = bitcoin_tx_core_weight(1, tal_count(txp->outputs)) + bitcoin_tx_output_weight(tal_bytelen(scriptpubkey)); - return txprepare_continue(cmd, txp, feerate, minconf, utxos, true); + txp->is_upgrade = false; + return txprepare_continue(cmd, txp, feerate, minconf, utxos, true, false); +} + +struct listfunds_info { + struct txprepare *txp; + const char *feerate; + bool reservedok; +}; + +/* Find all the utxos that are p2sh in our wallet */ +static struct command_result *listfunds_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct listfunds_info *info) +{ + struct bitcoin_outpoint *utxos; + const jsmntok_t *outputs_tok, *tok; + size_t i; + struct txprepare *txp = info->txp; + + /* Find all the utxos in our wallet that are p2sh! */ + outputs_tok = json_get_member(buf, result, "outputs"); + txp->output_total = AMOUNT_SAT(0); + if (!outputs_tok) + plugin_err(cmd->plugin, + "`listfunds` payload has no outputs token: %*.s", + json_tok_full_len(result), + json_tok_full(buf, result)); + + utxos = tal_arr(cmd, struct bitcoin_outpoint, 0); + json_for_each_arr(i, tok, outputs_tok) { + struct bitcoin_outpoint prev_out; + struct amount_sat val; + bool is_reserved; + char *status; + const char *err; + + err = json_scan(tmpctx, buf, tok, + "{amount_msat:%" + ",status:%" + ",reserved:%" + ",txid:%" + ",output:%}", + JSON_SCAN(json_to_sat, &val), + JSON_SCAN_TAL(cmd, json_strdup, &status), + JSON_SCAN(json_to_bool, &is_reserved), + JSON_SCAN(json_to_txid, &prev_out.txid), + JSON_SCAN(json_to_number, &prev_out.n)); + if (err) + plugin_err(cmd->plugin, + "`listfunds` payload did not scan. %s: %*.s", + err, json_tok_full_len(result), + json_tok_full(buf, result)); + + /* Skip non-p2sh outputs */ + if (!json_get_member(buf, tok, "redeemscript")) + continue; + + /* only include confirmed + unconfirmed outputs */ + if (!streq(status, "confirmed") + && !streq(status, "unconfirmed")) + continue; + + if (!info->reservedok && is_reserved) + continue; + + tal_arr_expand(&utxos, prev_out); + } + + /* Nothing found to upgrade, return a success */ + if (tal_count(utxos) == 0) { + struct json_stream *out; + out = jsonrpc_stream_success(cmd); + json_add_num(out, "upgraded_outs", tal_count(utxos)); + return command_finished(cmd, out); + } + + return txprepare_continue(cmd, txp, info->feerate, + NULL, utxos, true, + info->reservedok); +} + +/* We've got an address for sending funds */ +static struct command_result *newaddr_sweep_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct listfunds_info *info) +{ + struct out_req *req; + const jsmntok_t *addr = json_get_member(buf, result, "bech32"); + + info->txp = tal(info, struct txprepare); + info->txp->is_upgrade = true; + + /* Add output for 'all' to txp */ + info->txp->outputs = tal_arr(info->txp, struct tx_output, 1); + info->txp->all_output_idx = 0; + info->txp->output_total = AMOUNT_SAT(0); + info->txp->outputs[0].amount = AMOUNT_SAT(-1ULL); + info->txp->outputs[0].is_to_external = false; + + if (json_to_address_scriptpubkey(info->txp, chainparams, buf, addr, + &info->txp->outputs[0].script) + != ADDRESS_PARSE_SUCCESS) { + return command_fail(cmd, LIGHTNINGD, + "Change address '%.*s' unparsable?", + addr->end - addr->start, + buf + addr->start); + } + + info->txp->weight = bitcoin_tx_core_weight(0, 1) + + bitcoin_tx_output_weight(tal_bytelen(info->txp->outputs[0].script)); + + /* Find all the utxos we want to spend on this tx */ + req = jsonrpc_request_start(cmd->plugin, cmd, + "listfunds", + listfunds_done, + forward_error, + info); + return send_outreq(cmd->plugin, req); +} + +static struct command_result *json_upgradewallet(struct command *cmd, + const char *buffer, + const jsmntok_t *params) +{ + bool *reservedok; + struct out_req *req; + struct listfunds_info *info = tal(cmd, struct listfunds_info); + + if (!param(cmd, buffer, params, + p_opt("feerate", param_string, &info->feerate), + p_opt_def("reservedok", param_bool, &reservedok, false), + NULL)) + return command_param_failed(); + + info->reservedok = *reservedok; + /* Get an address to send everything to */ + req = jsonrpc_request_start(cmd->plugin, cmd, + "newaddr", + newaddr_sweep_done, + forward_error, + info); + return send_outreq(cmd->plugin, req); } static const struct plugin_command commands[] = { @@ -565,6 +720,13 @@ static const struct plugin_command commands[] = { "Send to {destination} {satoshi} (or 'all') at optional {feerate} using utxos from {minconf} or {utxos}.", json_withdraw }, + { + "upgradewallet", + "bitcoin", + "Spend p2sh wrapped outputs into a native segwit output", + "Send all p2sh-wrapped outputs to a bech32 native segwit address", + json_upgradewallet + }, }; #if DEVELOPER diff --git a/tests/data/p2sh_wallet_hsm_secret b/tests/data/p2sh_wallet_hsm_secret new file mode 100644 index 0000000000000000000000000000000000000000..6f2556e071f791e312c50c3525001124ea9326f9 GIT binary patch literal 32 Ucmd1FOwTCE%gjsHHDtgB0CCI&BLDyZ literal 0 HcmV?d00001 diff --git a/tests/test_wallet.py b/tests/test_wallet.py index c3919a1f6..6d4ccac3c 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -3,6 +3,7 @@ from decimal import Decimal from fixtures import * # noqa: F401,F403 from fixtures import TEST_NETWORK from pyln.client import RpcError, Millisatoshi +from shutil import copyfile from utils import ( only_one, wait_for, sync_blockheight, EXPERIMENTAL_FEATURES, VALGRIND, check_coin_moves, TailableProc, scriptpubkey_addr, @@ -1496,3 +1497,59 @@ def test_withdraw_bech32m(node_factory, bitcoind): for addr in addrs: args += [{addr: 10**3}] l1.rpc.multiwithdraw(args)["txid"] + + +@unittest.skipIf(TEST_NETWORK != 'regtest', "Address is network specific") +def test_upgradewallet(node_factory, bitcoind): + # Make sure bitcoind doesn't think it's going backwards + bitcoind.generate_block(104) + l1 = node_factory.get_node(start=False) + + # Write the data/p2sh_wallet_hsm_secret to the hsm_path, + # so node can spend funds at p2sh_wrapped_addr + p2sh_wrapped_addr = '2N2V4ee2vMkiXe5FSkRqFjQhiS9hKqNytv3' + hsm_path_dest = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + hsm_path_origin = os.path.join('tests/data', 'p2sh_wallet_hsm_secret') + copyfile(hsm_path_origin, hsm_path_dest) + + l1.start() + assert l1.daemon.is_in_log('Server started with public key 0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518') + + # No funds in wallet, upgrading does nothing + upgrade = l1.rpc.upgradewallet() + assert upgrade['upgraded_outs'] == 0 + + l1.fundwallet(10000000, addrtype="bech32") + + # Funds are in wallet but they're already native segwit + upgrade = l1.rpc.upgradewallet() + assert upgrade['upgraded_outs'] == 0 + + # Send funds to wallet-compatible p2sh-segwit funds + txid = bitcoind.rpc.sendtoaddress(p2sh_wrapped_addr, 20000000 / 10 ** 8) + bitcoind.generate_block(1) + l1.daemon.wait_for_log('Owning output .* txid {} CONFIRMED'.format(txid)) + + upgrade = l1.rpc.upgradewallet() + assert upgrade['upgraded_outs'] == 1 + assert bitcoind.rpc.getmempoolinfo()['size'] == 1 + + # Should be reserved! + res_funds = only_one([out for out in l1.rpc.listfunds()['outputs'] if out['reserved']]) + assert 'redeemscript' in res_funds + + # Running it again should be no-op because reservedok is false + upgrade = l1.rpc.upgradewallet() + assert upgrade['upgraded_outs'] == 0 + + # Doing it with 'reserved ok' should have 1 + # We use a big feerate so we can get over the RBF hump + upgrade = l1.rpc.upgradewallet(feerate="max_acceptable", reservedok=True) + assert upgrade['upgraded_outs'] == 1 + assert bitcoind.rpc.getmempoolinfo()['size'] == 1 + + # Mine it, nothing to upgrade + l1.bitcoin.generate_block(1) + sync_blockheight(l1.bitcoin, [l1]) + upgrade = l1.rpc.upgradewallet(feerate="max_acceptable", reservedok=True) + assert upgrade['upgraded_outs'] == 0