From d4151d88d690d49b779402f61d6d506460fd8d87 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 8 Apr 2025 10:34:07 +0100 Subject: [PATCH] rpc: allow to choose outpoints in `createrecovery` To maintain backwards compatibility, the `outpoints` parameter is the final positional argument and can be omitted entirely. --- doc/API.md | 21 +++++++---- liana-gui/src/daemon/client/mod.rs | 1 + lianad/src/jsonrpc/api.rs | 20 +++++++++- tests/test_rpc.py | 59 +++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/doc/API.md b/doc/API.md index a7b0f9ff..b6ce771e 100644 --- a/doc/API.md +++ b/doc/API.md @@ -406,8 +406,13 @@ Confirmation time is based on the timestamp of blocks. ### `createrecovery` -Create a transaction that sweeps all coins for which a timelocked recovery path is -currently available to a provided address with the provided feerate. +Create a transaction that sweeps coins using a timelocked recovery path to a provided address +with the provided feerate. + +If `outpoints` is empty or missing, then all coins for which the given recovery path is currently +available will be used. Otherwise, only those specified will be considered. An error will +be returned if any coins specified by `outpoints` are unknown, already spent or otherwise +not currently recoverable using the given recovery path. The `timelock` parameter can be used to specify which recovery path to use. By default, we'll use the first recovery path available. If created for a later timelock a recovery @@ -421,11 +426,13 @@ cover the requested feerate. #### Request -| Field | Type | Description | -| ---------- | ----------------- | ----------------------------------------------------------------------------------------- | -| `address` | str | The Bitcoin address to sweep the coins to. | -| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. | -| `timelock` | int or `null` | Recovery path to be used, identified by the number of blocks after which it is available. | +| Field | Type | Description | +| ---------- | ---------------------- | ----------------------------------------------------------------------------------------- | +| `address` | str | The Bitcoin address to sweep the coins to. | +| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. | +| `timelock` | int or `null` | Recovery path to be used, identified by the number of blocks after which it is available. | +| `outpoints`| list of str (optional) | List of the coins to be recovered, as `txid:vout`. | + #### Response diff --git a/liana-gui/src/daemon/client/mod.rs b/liana-gui/src/daemon/client/mod.rs index 53651d36..e663e475 100644 --- a/liana-gui/src/daemon/client/mod.rs +++ b/liana-gui/src/daemon/client/mod.rs @@ -185,6 +185,7 @@ impl Daemon for Lianad { feerate_vb: u64, sequence: Option, ) -> Result { + // The `outpoints` parameter is omitted, which means all recoverable coins will be used. let res: CreateRecoveryResult = self.call( "createrecovery", Some(vec![json!(address), json!(feerate_vb), json!(sequence)]), diff --git a/lianad/src/jsonrpc/api.rs b/lianad/src/jsonrpc/api.rs index 02e5426e..dc2cf5a0 100644 --- a/lianad/src/jsonrpc/api.rs +++ b/lianad/src/jsonrpc/api.rs @@ -342,8 +342,26 @@ fn create_recovery(control: &DaemonControl, params: Params) -> Result>>() + }) + .ok_or_else(|| Error::invalid_params("Invalid 'outpoints' parameter.")) + }) + .transpose()? + .unwrap_or_default(); // missing is same as empty array - let res = control.create_recovery(address, &[], feerate, timelock)?; + let res = control.create_recovery(address, &outpoints, feerate, timelock)?; Ok(serde_json::json!(&res)) } diff --git a/tests/test_rpc.py b/tests/test_rpc.py index c5df1bee..5334c4ed 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1037,6 +1037,7 @@ def test_create_recovery(lianad, bitcoind): wait_for( lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount() ) + first_outpoints = [c["outpoint"] for c in lianad.rpc.listcoins()["coins"]] # There's nothing to sweep with pytest.raises( @@ -1044,6 +1045,26 @@ def test_create_recovery(lianad, bitcoind): match="No coin currently spendable through this timelocked recovery path", ): lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2) + # Same if we specify timelock: + with pytest.raises( + RpcError, + match="No coin currently spendable through this timelocked recovery path", + ): + lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2, 10) + # And if we use empty array for outpoints: + with pytest.raises( + RpcError, + match="No coin currently spendable through this timelocked recovery path", + ): + lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2, 10, []) + # If we specify a coin, the error will be different: + with pytest.raises( + RpcError, + match=f"Coin at '{first_outpoints[0]}' is not recoverable with timelock '10'", + ): + lianad.rpc.createrecovery( + bitcoind.rpc.getnewaddress(), 2, 10, [f"{first_outpoints[0]}"] + ) # Receive another coin, it will be one block after the others txid = bitcoind.rpc.sendtoaddress(lianad.rpc.getnewaddress()["address"], 0.4) @@ -1055,9 +1076,17 @@ def test_create_recovery(lianad, bitcoind): wait_for( lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount() ) - res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 18) + new_outpoint = [ + c["outpoint"] for c in lianad.rpc.listcoins()["coins"] if txid in c["outpoint"] + ][0] + reco_address = bitcoind.rpc.getnewaddress() + res = lianad.rpc.createrecovery(reco_address, 18) reco_psbt = PSBT.from_base64(res["psbt"]) + # Do the same passing all three coins explicitly: + res_op = lianad.rpc.createrecovery(reco_address, 18, 10, first_outpoints) + reco_psbt_op = PSBT.from_base64(res_op["psbt"]) + # Check locktime being set correctly. tip_height = bitcoind.rpc.getblockcount() assert tip_height > 100 @@ -1065,8 +1094,34 @@ def test_create_recovery(lianad, bitcoind): assert tip_height - 100 <= locktime <= tip_height assert len(reco_psbt.tx.vin) == 3, "The last coin's timelock hasn't matured yet" - assert len(reco_psbt.tx.vout) == 1 + assert len(reco_psbt.tx.vout) == len(reco_psbt_op.tx.vout) == 1 + # The inputs are the same for both explicit and implicit outpoints: + assert sorted(i.prevout.serialize() for i in reco_psbt.tx.vin) == sorted( + i.prevout.serialize() for i in reco_psbt_op.tx.vin + ) + assert reco_psbt.tx.vout[0].nValue == reco_psbt_op.tx.vout[0].nValue + assert reco_psbt.tx.vout[0].scriptPubKey == reco_psbt_op.tx.vout[0].scriptPubKey assert int(0.5999 * COIN) < int(reco_psbt.tx.vout[0].nValue) < int(0.6 * COIN) + + # Now use only 2 of the 3 coins: + res_op_2 = lianad.rpc.createrecovery( + bitcoind.rpc.getnewaddress(), 18, 10, first_outpoints[:2] + ) + reco_psbt_op_2 = PSBT.from_base64(res_op_2["psbt"]) + assert len(reco_psbt_op_2.tx.vin) == 2 + assert sorted( + f"{i.prevout.hash:064x}:{i.prevout.n}" for i in reco_psbt_op_2.tx.vin + ) == sorted(first_outpoints[:2]) + + # If we try to include the newest coin, an error will be returned: + with pytest.raises( + RpcError, + match=f"Coin at '{new_outpoint}' is not recoverable with timelock '10'", + ): + lianad.rpc.createrecovery( + bitcoind.rpc.getnewaddress(), 2, 10, [first_outpoints[0], new_outpoint] + ) + txid = sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True) # And by mining one more block we'll be able to sweep the last coin.