Merge #1646: lianad: allow to select coins for recovery

2527bcea2eef753fb776e1618c7d4b9b79801a3c fix: timelock parameter is optional (Michael Mallan)
d4151d88d690d49b779402f61d6d506460fd8d87 rpc: allow to choose outpoints in `createrecovery` (Michael Mallan)
8cc723fb3bca3af661e7ace47ee8cb838d7d0db9 commands: allow to specify coins for recovery (Michael Mallan)
ce711ae10afa343c7228abe4be55cb615c3d971b commands: add test for `create_recovery` (Michael Mallan)
21c899f9ec2121e06189a1c6a63c4d7c9f213585 gui: fix docstring (Michael Mallan)

Pull request description:

  This is to resolve #1637.

  The `coins_outpoints` parameter for the recovery command works similarly to the corresponding parameter for a normal spend. An empty slice means that all recoverable coins will be chosen, i.e. the same as the current behaviour.

  In order to maintain backwards compatibility, the new `outpoints` parameter in the `createrecovery` RPC command has been added as an optional parameter in the last position.

  The GUI's use of this RPC command will be updated in a separate PR.

  Note the first commit is an unrelated docstring fix and the second commit adds some preliminary tests for the `create_recovery` command.

ACKs for top commit:
  edouardparis:
    ACK 2527bcea2eef753fb776e1618c7d4b9b79801a3c

Tree-SHA512: 13e13ec2bccf974ad03424c6fba8ce31eb27af23809420f81ed13ebc114a3620aafa612416b2d2338e1444934f1529ebdcaf2391f750e6e915a28f4f49e53d7b
This commit is contained in:
edouardparis 2025-04-15 17:32:08 +02:00
commit 1e551e15b1
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
9 changed files with 400 additions and 41 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 (optional) | 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,10 +185,14 @@ impl<C: Client + Send + Sync + Debug> Daemon for Lianad<C> {
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
let res: CreateRecoveryResult = self.call(
"createrecovery",
Some(vec![json!(address), json!(feerate_vb), json!(sequence)]),
)?;
// The `outpoints` parameter is omitted, which means all recoverable coins will be used.
let mut params = serde_json::Map::new();
params.insert("address".to_string(), json!(address));
params.insert("feerate".to_string(), json!(feerate_vb));
if let Some(sequence) = sequence {
params.insert("timelock".to_string(), json!(sequence));
}
let res: CreateRecoveryResult = self.call("createrecovery", Some(params))?;
Ok(res.psbt)
}

View File

@ -217,7 +217,7 @@ impl Daemon for EmbeddedDaemon {
) -> Result<Psbt, DaemonError> {
self.command(|daemon| {
daemon
.create_recovery(address, feerate_vb, sequence)
.create_recovery(address, &[], feerate_vb, sequence)
.map(|res| res.psbt)
.map_err(|e| DaemonError::Unexpected(e.to_string()))
})

View File

@ -407,7 +407,7 @@ pub mod payload {
pub recipients: Vec<Recipient>,
/// The outpoints of coins to use as transaction inputs. If empty,
/// coins will be selected automatically from the set of confirmed coins
/// and those unconfirmed coins at a change address, excluding immature
/// and those unconfirmed coins that are from self, excluding immature
/// coins.
pub inputs: &'a [bitcoin::OutPoint],
// The feerate to use for this transaction.

View File

@ -68,6 +68,8 @@ pub enum CommandError {
/// An error that might occur in the racy rescan triggering logic.
RescanTrigger(String),
RecoveryNotAvailable,
// Include timelock in error as it may not have been set explicitly by the user.
OutpointNotRecoverable(bitcoin::OutPoint, /* timelock */ u16),
/// Overflowing or unhardened derivation index.
InvalidDerivationIndex,
RbfError(RbfErrorInfo),
@ -120,6 +122,11 @@ impl fmt::Display for CommandError {
f,
"No coin currently spendable through this timelocked recovery path."
),
Self::OutpointNotRecoverable(op, t) => write!(
f,
"Coin at '{}' is not recoverable with timelock '{}'",
op, t
),
Self::InvalidDerivationIndex => {
write!(f, "Unhardened or overflowing BIP32 derivation index.")
}
@ -1141,16 +1148,22 @@ impl DaemonControl {
ListTransactionsResult { transactions }
}
/// 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.
///
/// The `timelock` parameter can be used to specify which recovery path to use. By default,
/// we'll use the first recovery path available.
///
/// If `coins_outpoints` is empty, 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 `coins_outpoints` are unknown, already spent or
/// otherwise not currently recoverable using the given recovery path.
///
/// Note that not all coins may be spendable through a single recovery path at the same time.
pub fn create_recovery(
&self,
address: bitcoin::Address<address::NetworkUnchecked>,
coins_outpoints: &[bitcoin::OutPoint],
feerate_vb: u64,
timelock: Option<u16>,
) -> Result<CreateRecoveryResult, CommandError> {
@ -1167,26 +1180,42 @@ impl DaemonControl {
let timelock =
timelock.unwrap_or_else(|| self.config.main_descriptor.first_timelock_value());
let height_delta: i32 = timelock.into();
let sweepable_coins: Vec<_> = db_conn
.coins(&[CoinStatus::Confirmed], &[])
.into_values()
.filter_map(|c| {
// We are interested in coins available at the *next* block
if c.block_info
.map(|b| current_height + 1 >= b.height + height_delta)
.unwrap_or(false)
{
Some(coin_to_candidate(
&c,
/*must_select=*/ true,
/*sequence=*/ Some(bitcoin::Sequence::from_height(timelock)),
/*ancestor_info=*/ None,
))
} else {
None
let coins = if coins_outpoints.is_empty() {
db_conn.coins(&[CoinStatus::Confirmed], &[])
} else {
// We could have used the same DB call for both cases by specifying the status and outpoints,
// but in order to give more helpful errors, we filter the DB call here only for outpoints
// and then check for coin status separately.
let coins_by_op = db_conn.coins(&[], coins_outpoints);
for op in coins_outpoints {
let coin = coins_by_op
.get(op)
.ok_or(CommandError::UnknownOutpoint(*op))?;
// We only check for spent coins here. Unconfirmed coins (including immature)
// will fail the check for recoverability further below.
if coin.is_spent() {
return Err(CommandError::AlreadySpent(*op));
}
})
.collect();
}
coins_by_op
};
let mut sweepable_coins = Vec::with_capacity(coins.len());
for (op, c) in coins {
// We are interested in coins available at the *next* block
if c.block_info
.map(|b| current_height + 1 >= b.height + height_delta)
.unwrap_or(false)
{
sweepable_coins.push(coin_to_candidate(
&c,
/*must_select=*/ true,
/*sequence=*/ Some(bitcoin::Sequence::from_height(timelock)),
/*ancestor_info=*/ None,
));
} else if !coins_outpoints.is_empty() {
return Err(CommandError::OutpointNotRecoverable(op, timelock));
}
}
if sweepable_coins.is_empty() {
return Err(CommandError::RecoveryNotAvailable);
}
@ -1384,6 +1413,7 @@ mod tests {
locktime::absolute,
Amount, OutPoint, ScriptBuf, Sequence, Transaction, Txid, Witness,
};
use spend::InsufficientFunds;
use std::{collections::BTreeMap, str::FromStr};
#[test]
@ -2659,4 +2689,238 @@ mod tests {
ms.shutdown();
}
#[test]
fn create_recovery() {
let dummy_tx = bitcoin::Transaction {
version: TxVersion::TWO,
lock_time: absolute::LockTime::Blocks(absolute::Height::ZERO),
input: vec![],
output: vec![],
};
let dummy_txid = dummy_tx.compute_txid();
let dummy_op = bitcoin::OutPoint::new(dummy_txid, 0);
let ms = DummyLiana::new_timelock(DummyBitcoind::new(), DummyDatabase::new(), 10);
let control = &ms.control();
let mut db_conn = control.db().lock().unwrap().connection();
db_conn.new_txs(&[dummy_tx]);
// Arguments sanity checking
let dummy_addr =
bitcoin::Address::from_str("bc1qnsexk3gnuyayu92fc3tczvc7k62u22a22ua2kv").unwrap();
// Feerate cannot be less than 1.
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[], 0, None),
Err(CommandError::InvalidFeerate(0))
);
// If we ask to sweep to an address from another network, it will fail.
let invalid_addr =
bitcoin::Address::from_str("tb1qfufcrdyarcg5eph608c6l8vktrc9re6agu4se2").unwrap();
assert!(matches!(
control.create_recovery(invalid_addr, &[], 1, None),
Err(CommandError::Address(
address::error::ParseError::NetworkValidation { .. }
))
));
// We have no coins to create recovery.
assert!(matches!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::RecoveryNotAvailable),
));
// Coin is unknown.
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, None),
Err(CommandError::UnknownOutpoint(dummy_op)),
);
// Add unconfirmed coin.
let dummy_coin = Coin {
outpoint: dummy_op,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(100_000),
derivation_index: bip32::ChildNumber::from(13),
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
};
db_conn.new_unspent_coins(&[dummy_coin]);
// Recovery not available for unconfirmed coins.
assert!(matches!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::RecoveryNotAvailable),
));
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, None),
Err(CommandError::OutpointNotRecoverable(dummy_op, 10)),
);
// Confirm coin such that timelock (10) has not expired at next block (101).
db_conn.confirm_coins(&[(dummy_op, 92, 100_000)]);
assert!(matches!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::RecoveryNotAvailable),
));
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, None),
Err(CommandError::OutpointNotRecoverable(dummy_op, 10)),
);
// If we use a smaller timelock value it works, even though we don't have any such
// recovery timelock (see https://github.com/wizardsardine/liana/issues/1089).
assert!(control
.create_recovery(dummy_addr.clone(), &[], 1, Some(9))
.is_ok());
assert!(control
.create_recovery(dummy_addr.clone(), &[dummy_op], 1, Some(9))
.is_ok());
// Remove coin, re-add and confirm such that recovery available at next block.
db_conn.remove_coins(&[dummy_op]);
db_conn.new_unspent_coins(&[dummy_coin]);
db_conn.confirm_coins(&[(dummy_op, 91, 100_000)]);
let res = control.create_recovery(dummy_addr.clone(), &[], 1, None);
assert!(res.is_ok());
let psbt = res.unwrap().psbt;
assert_eq!(psbt.outputs.len(), 1);
assert_eq!(psbt.unsigned_tx.output.len(), 1);
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().script_pubkey,
dummy_addr.assume_checked_ref().script_pubkey()
);
// Amount is coin value minus fee.
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().value,
Amount::from_sat(100_000 - 127)
);
// If we pass a larger timelock, it no longer works:
assert!(matches!(
control.create_recovery(dummy_addr.clone(), &[], 1, Some(11)),
Err(CommandError::RecoveryNotAvailable),
));
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, Some(11)),
Err(CommandError::OutpointNotRecoverable(dummy_op, 11)),
);
// If the coin is spending, it is no longer recoverable.
db_conn.spend_coins(&[(
dummy_op,
Txid::from_str("84f09bddfe0f036d0390edf655636ad6092c3ab8f09b2bb1503caa393463f241")
.unwrap(),
)]);
assert!(matches!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::RecoveryNotAvailable),
));
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, None),
Err(CommandError::AlreadySpent(dummy_op)),
);
// Now remove the coin and re-add, but this time with an amount that is too small to create an output.
// This will give a coin selection error due to insufficient funds.
db_conn.remove_coins(&[dummy_op]);
let mut dummy_coin = dummy_coin;
dummy_coin.amount = Amount::from_sat(5_000 + 126);
db_conn.new_unspent_coins(&[dummy_coin]);
db_conn.confirm_coins(&[(dummy_op, 91, 100_000)]);
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::SpendCreation(
SpendCreationError::CoinSelection(InsufficientFunds { missing: 1 })
)),
);
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op], 1, None),
Err(CommandError::SpendCreation(
SpendCreationError::CoinSelection(InsufficientFunds { missing: 1 })
)),
);
// Add a new coin so that we have enough funds for the recovery.
let dummy_op_2 = bitcoin::OutPoint::new(dummy_txid, 1);
let dummy_coin_2 = Coin {
outpoint: dummy_op_2,
is_immature: false,
block_info: None,
amount: bitcoin::Amount::from_sat(10_000),
derivation_index: bip32::ChildNumber::from(1378),
is_change: false,
spend_txid: None,
spend_block: None,
is_from_self: false,
};
db_conn.new_unspent_coins(&[dummy_coin_2]);
db_conn.confirm_coins(&[(dummy_op_2, 92, 200_000)]);
// Coin cannot be used as the timelock will still be in place at the next block.
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[], 1, None),
Err(CommandError::SpendCreation(
SpendCreationError::CoinSelection(InsufficientFunds { missing: 1 })
)),
);
// If we try to specify the new coin, we'll get an error that the coin is not recoverable.
assert_eq!(
control.create_recovery(dummy_addr.clone(), &[dummy_op, dummy_op_2], 1, None),
Err(CommandError::OutpointNotRecoverable(dummy_op_2, 10)),
);
// Using a shorter timelock parameter works:
assert!(control
.create_recovery(dummy_addr.clone(), &[], 1, Some(9))
.is_ok());
assert!(control
.create_recovery(dummy_addr.clone(), &[dummy_op, dummy_op_2], 1, Some(9))
.is_ok());
// Now re-add the coin with a confirmation one block earlier.
db_conn.remove_coins(&[dummy_op_2]);
db_conn.new_unspent_coins(&[dummy_coin_2]);
db_conn.confirm_coins(&[(dummy_op_2, 91, 200_000)]);
// Now both coins are used in the recovery and we have enough funds.
let res = control.create_recovery(dummy_addr.clone(), &[], 1, None);
assert!(res.is_ok());
let psbt = res.unwrap().psbt;
assert_eq!(psbt.outputs.len(), 1);
assert_eq!(psbt.unsigned_tx.output.len(), 1);
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().script_pubkey,
dummy_addr.assume_checked_ref().script_pubkey()
);
// Amount is coin value minus fee.
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().value,
Amount::from_sat(/* coin 1 */ 5_000 + 126 + /* coin 2 */10_000 - /* fee */ 211)
);
// Do the same again, now specifying the outpoints explicitly.
let res = control.create_recovery(dummy_addr.clone(), &[dummy_op, dummy_op_2], 1, None);
assert!(res.is_ok());
let psbt = res.unwrap().psbt;
assert_eq!(psbt.outputs.len(), 1);
assert_eq!(psbt.unsigned_tx.output.len(), 1);
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().script_pubkey,
dummy_addr.assume_checked_ref().script_pubkey()
);
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().value,
Amount::from_sat(/* coin 1 */ 5_000 + 126 + /* coin 2 */10_000 - /* fee */ 211)
);
// Now check that increasing the feerate increases the fee.
let res = control.create_recovery(dummy_addr.clone(), &[], 2, None);
assert!(res.is_ok());
let psbt = res.unwrap().psbt;
assert_eq!(
psbt.unsigned_tx.output.first().unwrap().value,
Amount::from_sat(/* coin 1 */ 5_000 + 126 + /* coin 2 */10_000 - /* fee */ 2 * 211)
);
ms.shutdown();
}
}

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

@ -163,7 +163,8 @@ impl From<commands::CommandError> for Error {
| commands::CommandError::InvalidDerivationIndex
| commands::CommandError::RbfError(..)
| commands::CommandError::EmptyFilterList
| commands::CommandError::RecoveryNotAvailable => {
| commands::CommandError::RecoveryNotAvailable
| commands::CommandError::OutpointNotRecoverable(..) => {
Error::new(ErrorCode::InvalidParams, e.to_string())
}
commands::CommandError::RescanTrigger(..) => {

View File

@ -558,6 +558,7 @@ impl DummyLiana {
bitcoin_interface: impl BitcoinInterface + 'static,
database: impl DatabaseInterface + 'static,
rpc_server: bool,
timelock: u16,
) -> DummyLiana {
let tmp_dir = tmp_dir();
fs::create_dir_all(&tmp_dir).unwrap();
@ -574,7 +575,7 @@ impl DummyLiana {
let heir_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap());
let policy = descriptors::LianaPolicy::new_legacy(
owner_key,
[(10_000, heir_key)].iter().cloned().collect(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap();
let desc = descriptors::LianaDescriptor::new(policy);
@ -597,7 +598,16 @@ impl DummyLiana {
bitcoin_interface: impl BitcoinInterface + 'static,
database: impl DatabaseInterface + 'static,
) -> DummyLiana {
Self::_new(bitcoin_interface, database, false)
Self::_new(bitcoin_interface, database, false, 10_000)
}
/// Creates a new DummyLiana interface with the specified recovery path timelock.
pub fn new_timelock(
bitcoin_interface: impl BitcoinInterface + 'static,
database: impl DatabaseInterface + 'static,
timelock: u16,
) -> DummyLiana {
Self::_new(bitcoin_interface, database, false, timelock)
}
/// Creates a new DummyLiana interface which also spins up an RPC server.
@ -605,7 +615,7 @@ impl DummyLiana {
bitcoin_interface: impl BitcoinInterface + 'static,
database: impl DatabaseInterface + 'static,
) -> DummyLiana {
Self::_new(bitcoin_interface, database, true)
Self::_new(bitcoin_interface, database, true, 10_000)
}
pub fn control(&self) -> &DaemonControl {

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.