diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py index a56612c7e..f374eafbc 100644 --- a/contrib/pyln-client/pyln/client/lightning.py +++ b/contrib/pyln-client/pyln/client/lightning.py @@ -1107,6 +1107,28 @@ class LightningRpc(UnixDomainSocketRpc): } return self.call("txsend", payload) + def reserveinputs(self, outputs, feerate=None, minconf=None, utxos=None): + """ + Reserve UTXOs and return a psbt for a 'stub' transaction that + spends the reserved UTXOs. + """ + payload = { + "outputs": outputs, + "feerate": feerate, + "minconf": minconf, + "utxos": utxos, + } + return self.call("reserveinputs", payload) + + def unreserveinputs(self, psbt): + """ + Unreserve UTXOs that were previously reserved. + """ + payload = { + "psbt": psbt, + } + return self.call("unreserveinputs", payload) + def signmessage(self, message): """ Sign a message with this node's secret key. diff --git a/tests/test_wallet.py b/tests/test_wallet.py index e3a982b17..a60677a64 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -436,6 +436,131 @@ def test_txprepare(node_factory, bitcoind, chainparams): assert decode['vout'][changenum]['scriptPubKey']['type'] == 'witness_v0_keyhash' +def test_reserveinputs(node_factory, bitcoind, chainparams): + """ + Reserve inputs is basically the same as txprepare, with the + slight exception that 'reserveinputs' doesn't keep the + unsent transaction around + """ + amount = 1000000 + total_outs = 12 + l1 = node_factory.get_node(feerates=(7500, 7500, 7500, 7500)) + addr = chainparams['example_addr'] + + # Add a medley of funds to withdraw later, bech32 + p2sh-p2wpkh + for i in range(total_outs // 2): + bitcoind.rpc.sendtoaddress(l1.rpc.newaddr()['bech32'], + amount / 10**8) + bitcoind.rpc.sendtoaddress(l1.rpc.newaddr('p2sh-segwit')['p2sh-segwit'], + amount / 10**8) + + bitcoind.generate_block(1) + wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == total_outs) + + utxo_count = 8 + sent = Decimal('0.01') * (utxo_count - 1) + reserved = l1.rpc.reserveinputs(outputs=[{addr: Millisatoshi(amount * (utxo_count - 1) * 1000)}]) + assert reserved['feerate_per_kw'] == 7500 + psbt = bitcoind.rpc.decodepsbt(reserved['psbt']) + out_found = False + + assert len(psbt['inputs']) == utxo_count + outputs = l1.rpc.listfunds()['outputs'] + assert len([x for x in outputs if not x['reserved']]) == total_outs - utxo_count + assert len([x for x in outputs if x['reserved']]) == utxo_count + total_outs -= utxo_count + saved_input = psbt['tx']['vin'][0] + + # We should have two outputs + for vout in psbt['tx']['vout']: + if vout['scriptPubKey']['addresses'][0] == addr: + assert vout['value'] == sent + out_found = True + assert out_found + + # Do it again, but for too many inputs + utxo_count = 12 - utxo_count + 1 + sent = Decimal('0.01') * (utxo_count - 1) + with pytest.raises(RpcError, match=r"Cannot afford transaction"): + reserved = l1.rpc.reserveinputs(outputs=[{addr: Millisatoshi(amount * (utxo_count - 1) * 1000)}]) + + utxo_count -= 1 + sent = Decimal('0.01') * (utxo_count - 1) + reserved = l1.rpc.reserveinputs(outputs=[{addr: Millisatoshi(amount * (utxo_count - 1) * 1000)}], feerate='10000perkw') + + assert reserved['feerate_per_kw'] == 10000 + psbt = bitcoind.rpc.decodepsbt(reserved['psbt']) + + assert len(psbt['inputs']) == utxo_count + outputs = l1.rpc.listfunds()['outputs'] + assert len([x for x in outputs if not x['reserved']]) == total_outs - utxo_count == 0 + assert len([x for x in outputs if x['reserved']]) == 12 + + # No more available + with pytest.raises(RpcError, match=r"Cannot afford transaction"): + reserved = l1.rpc.reserveinputs(outputs=[{addr: Millisatoshi(amount * 1)}], feerate='253perkw') + + # Unreserve three, from different psbts + unreserve_utxos = [ + { + 'txid': saved_input['txid'], + 'vout': saved_input['vout'], + 'sequence': saved_input['sequence'] + }, { + 'txid': psbt['tx']['vin'][0]['txid'], + 'vout': psbt['tx']['vin'][0]['vout'], + 'sequence': psbt['tx']['vin'][0]['sequence'] + }, { + 'txid': psbt['tx']['vin'][1]['txid'], + 'vout': psbt['tx']['vin'][1]['vout'], + 'sequence': psbt['tx']['vin'][1]['sequence'] + }] + unreserve_psbt = bitcoind.rpc.createpsbt(unreserve_utxos, []) + + unreserved = l1.rpc.unreserveinputs(unreserve_psbt) + assert unreserved['all_unreserved'] + outputs = l1.rpc.listfunds()['outputs'] + assert len([x for x in outputs if not x['reserved']]) == len(unreserved['outputs']) + for i in range(len(unreserved['outputs'])): + un = unreserved['outputs'][i] + u_utxo = unreserve_utxos[i] + assert un['txid'] == u_utxo['txid'] and un['vout'] == u_utxo['vout'] and un['unreserved'] + + # Try unreserving the same utxos again, plus one that's not included + # We expect this to be a no-op. + unreserve_utxos.append({'txid': 'b' * 64, 'vout': 0, 'sequence': 0}) + unreserve_psbt = bitcoind.rpc.createpsbt(unreserve_utxos, []) + unreserved = l1.rpc.unreserveinputs(unreserve_psbt) + assert not unreserved['all_unreserved'] + for un in unreserved['outputs']: + assert not un['unreserved'] + assert len([x for x in l1.rpc.listfunds()['outputs'] if not x['reserved']]) == 3 + + # passing in an empty string should fail + with pytest.raises(RpcError, match=r"should be a PSBT, not "): + l1.rpc.unreserveinputs('') + + # reserve one of the utxos that we just unreserved + utxos = [] + utxos.append(saved_input['txid'] + ":" + str(saved_input['vout'])) + reserved = l1.rpc.reserveinputs([{addr: Millisatoshi(amount * 0.5 * 1000)}], feerate='253perkw', utxos=utxos) + assert len([x for x in l1.rpc.listfunds()['outputs'] if not x['reserved']]) == 2 + psbt = bitcoind.rpc.decodepsbt(reserved['psbt']) + assert len(psbt['inputs']) == 1 + vin = psbt['tx']['vin'][0] + assert vin['txid'] == saved_input['txid'] and vin['vout'] == saved_input['vout'] + + # reserve them all! + reserved = l1.rpc.reserveinputs([{addr: 'all'}]) + outputs = l1.rpc.listfunds()['outputs'] + assert len([x for x in outputs if not x['reserved']]) == 0 + assert len([x for x in outputs if x['reserved']]) == 12 + + # FIXME: restart the node, nothing will remain reserved + l1.restart() + assert len(l1.rpc.listfunds()['outputs']) == 12 + + def test_txsend(node_factory, bitcoind, chainparams): amount = 1000000 l1 = node_factory.get_node(random_hsm=True) diff --git a/wallet/wallet.c b/wallet/wallet.c index 1a5ce0677..a64f787e5 100644 --- a/wallet/wallet.c +++ b/wallet/wallet.c @@ -355,6 +355,15 @@ struct utxo *wallet_utxo_get(const tal_t *ctx, struct wallet *w, return utxo; } +bool wallet_unreserve_output(struct wallet *w, + const struct bitcoin_txid *txid, + const u32 outnum) +{ + return wallet_update_output_status(w, txid, outnum, + output_state_reserved, + output_state_available); +} + /** * unreserve_utxo - Mark a reserved UTXO as available again */ @@ -376,6 +385,11 @@ static void destroy_utxos(const struct utxo **utxos, struct wallet *w) unreserve_utxo(w, utxos[i]); } +void wallet_persist_utxo_reservation(struct wallet *w, const struct utxo **utxos) +{ + tal_del_destructor2(utxos, destroy_utxos, w); +} + void wallet_confirm_utxos(struct wallet *w, const struct utxo **utxos) { tal_del_destructor2(utxos, destroy_utxos, w); diff --git a/wallet/wallet.h b/wallet/wallet.h index 0f0d132e0..8d326ddb2 100644 --- a/wallet/wallet.h +++ b/wallet/wallet.h @@ -1246,6 +1246,20 @@ void add_unreleased_tx(struct wallet *w, struct unreleased_tx *utx); /* These will touch the db, so need to be explicitly freed. */ void free_unreleased_txs(struct wallet *w); +/* wallet_persist_utxo_reservation - Removes destructor + * + * Persists the reservation in the database (until a restart) + * instead of clearing the reservation when the utxo object + * is destroyed */ +void wallet_persist_utxo_reservation(struct wallet *w, const struct utxo **utxos); + +/* wallet_unreserve_output - Unreserve a utxo + * + * We unreserve utxos so that they can be spent elsewhere. + * */ +bool wallet_unreserve_output(struct wallet *w, + const struct bitcoin_txid *txid, + const u32 outnum); /** * Get a list of transactions that we track in the wallet. * diff --git a/wallet/walletrpc.c b/wallet/walletrpc.c index 4afc14b03..0612fd358 100644 --- a/wallet/walletrpc.c +++ b/wallet/walletrpc.c @@ -1158,3 +1158,103 @@ static const struct json_command listtransactions_command = { "it closes the channel and returns funds to the wallet." }; AUTODATA(json_command, &listtransactions_command); + +static struct command_result *json_reserveinputs(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + struct command_result *res; + struct json_stream *response; + struct unreleased_tx *utx; + + u32 feerate; + + res = json_prepare_tx(cmd, buffer, params, false, &utx, &feerate); + if (res) + return res; + + /* Unlike json_txprepare, we don't keep the utx object + * around, so we remove the auto-cleanup that happens + * when the utxo objects are free'd */ + wallet_persist_utxo_reservation(cmd->ld->wallet, utx->wtx->utxos); + + response = json_stream_success(cmd); + json_add_psbt(response, "psbt", utx->tx->psbt); + json_add_u32(response, "feerate_per_kw", feerate); + return command_success(cmd, response); +} +static const struct json_command reserveinputs_command = { + "reserveinputs", + "bitcoin", + json_reserveinputs, + "Reserve inputs and pass back the resulting psbt", + false +}; +AUTODATA(json_command, &reserveinputs_command); + +static struct command_result *param_psbt(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct wally_psbt **psbt) +{ + /* Pull out the token into a string, then pass to + * the PSBT parser; PSBT parser can't handle streaming + * atm as it doesn't accept a len value */ + char *psbt_buff = json_strdup(cmd, buffer, tok); + if (psbt_from_b64(psbt_buff, psbt)) + return NULL; + + return command_fail(cmd, LIGHTNINGD, "'%s' should be a PSBT, not '%.*s'", + name, json_tok_full_len(tok), + json_tok_full(buffer, tok)); +} + +static struct command_result *json_unreserveinputs(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + struct json_stream *response; + struct wally_psbt *psbt; + bool all_unreserved; + + /* for each input in the psbt, attempt to 'unreserve' it */ + if (!param(cmd, buffer, params, + p_req("psbt", param_psbt, &psbt), + NULL)) + return command_param_failed(); + + response = json_stream_success(cmd); + all_unreserved = psbt->tx->num_inputs != 0; + json_array_start(response, "outputs"); + for (size_t i = 0; i < psbt->tx->num_inputs; i++) { + struct wally_tx_input *in; + struct bitcoin_txid txid; + bool unreserved; + + in = &psbt->tx->inputs[i]; + wally_tx_input_get_txid(in, &txid); + unreserved = wallet_unreserve_output(cmd->ld->wallet, + &txid, in->index); + json_object_start(response, NULL); + json_add_txid(response, "txid", &txid); + json_add_u64(response, "vout", in->index); + json_add_bool(response, "unreserved", unreserved); + json_object_end(response); + all_unreserved &= unreserved; + } + json_array_end(response); + + json_add_bool(response, "all_unreserved", all_unreserved); + return command_success(cmd, response); +} +static const struct json_command unreserveinputs_command = { + "unreserveinputs", + "bitcoin", + json_unreserveinputs, + "Unreserve inputs, freeing them up to be reused", + false +}; +AUTODATA(json_command, &unreserveinputs_command);