commands: add test for create_recovery

This commit is contained in:
Michael Mallan 2025-04-07 09:30:49 +01:00
parent 21c899f9ec
commit ce711ae10a
No known key found for this signature in database
GPG Key ID: 5177CDCEDB0EABEB
2 changed files with 137 additions and 3 deletions

View File

@ -1384,6 +1384,7 @@ mod tests {
locktime::absolute,
Amount, OutPoint, ScriptBuf, Sequence, Transaction, Txid, Witness,
};
use spend::InsufficientFunds;
use std::{collections::BTreeMap, str::FromStr};
#[test]
@ -2659,4 +2660,127 @@ 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_op = bitcoin::OutPoint::new(dummy_tx.compute_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),
));
// 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),
));
// 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),
));
// 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());
// 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),
));
// 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),
));
// 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 })
)),
);
ms.shutdown();
}
}

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 {