rpc: allow to choose outpoints in createrecovery

To maintain backwards compatibility, the `outpoints` parameter
is the final positional argument and can be omitted entirely.
This commit is contained in:
Michael Mallan 2025-04-08 10:34:07 +01:00
parent 8cc723fb3b
commit d4151d88d6
No known key found for this signature in database
GPG Key ID: 5177CDCEDB0EABEB
4 changed files with 91 additions and 10 deletions

View File

@ -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

View File

@ -185,6 +185,7 @@ impl<C: Client + Send + Sync + Debug> Daemon for Lianad<C> {
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
// 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)]),

View File

@ -342,8 +342,26 @@ fn create_recovery(control: &DaemonControl, params: Params) -> Result<serde_json
.ok_or_else(|| Error::invalid_params("Invalid 'timelock' parameter."))
})
.transpose()?;
let outpoints = params
.get(3, "outpoints")
.map(|param| {
param
.as_array()
.and_then(|arr| {
arr.iter()
.map(|entry| {
entry
.as_str()
.and_then(|e| bitcoin::OutPoint::from_str(e).ok())
})
.collect::<Option<Vec<_>>>()
})
.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))
}

View File

@ -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.