Merge #388: Multipath descriptors

7211b96ca936a449916b0cca3e0a64b09463e813 tests: extend the recovery functional tests for multipath descriptors (Antoine Poinsot)
b20cdd0cd81ed0f90ce36192aa8607206c12f3df tests: introduce a lianad_multipath fixture as well as a trivial test (Antoine Poinsot)
0d75f0a2c77b05b247adac3a08371203d12ac1d6 tests: handle multiple recovery paths in MultiSigner (Antoine Poinsot)
3aa99806359efd7043b79becd71ad26877e63a9e tests: simplify the MultiSigner (Antoine Poinsot)
75ea3672357e30418c49fa4d444f85d7d248e0ba tests: make most of the body of the multisig test a general helper (Antoine Poinsot)
d68d0e113473e59310723f805fd86ca204e5f1ed commands: adapt 'createrecovery' to multiple recovery paths (Antoine Poinsot)
cfbb02c7c8cda56216cfefc84bc6be3ed37a7847 descriptors: multi-recovery-path Liana descriptor (Antoine Poinsot)
ec0009113a8405396950f5c347d0dace3c3c33d0 descriptors: lift the bounds to create a Liana policy (Antoine Poinsot)
55d86c62a59bcfe632b866d1b24653219f4c4399 descriptors: use the Miniscript compiler to create a descriptor (Antoine Poinsot)

Pull request description:

  This introduces the possibility to have multiple recovery paths in a Liana wallet. This is particularly helpful for decaying multisigs or a spending policy that involves a third-party co-signer in the recovery (in order to have a later recovery path that gets rid of the co-signer).

  We start by introducing the Miniscript policy compiler to create the Liana descriptor from spending policies configuration. As a by-product, we also lift the limits on the number of keys usable per spending path that were in place because we hardcoded the use of `multi()`. The partial spend analysis and the `createrecovery` commands are adapted to support multiple recovery paths.

  Is there any place i might be missing that needs to be adapted or that would assume single-recovery-path descriptors?

  Closes https://github.com/wizardsardine/liana/issues/54.

ACKs for top commit:
  darosior:
    re-ACK 7211b96ca936a449916b0cca3e0a64b09463e813 -- re-applying Edouard's ack after a trivial rebase.

Tree-SHA512: f1f9f6080bda17768b087958e6236e8b7470043d1f570ce4a3f3826a5b4a13d37461bfa9ee64acc4e1ee103f0c82e03f79c2df999351a86f18cc35ac9cdc3c45
This commit is contained in:
Antoine Poinsot 2023-04-04 12:19:06 +02:00
commit 045182e7ea
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
12 changed files with 679 additions and 306 deletions

View File

@ -25,7 +25,7 @@ daemon = ["libc"]
[dependencies]
# For managing transactions (it re-exports the bitcoin crate)
miniscript = { git = "https://github.com/darosior/rust-miniscript", branch = "multipath_descriptors_on_9.0", features = ["serde"] }
miniscript = { git = "https://github.com/darosior/rust-miniscript", branch = "multipath_descriptors_on_9.0", features = ["serde", "compiler"] }
# Don't reinvent the wheel
dirs = "5.0"

View File

@ -267,18 +267,26 @@ Confirmation time is based on the timestamp of blocks.
### `createrecovery`
Create a transaction that sweeps all coins whose timelocked recovery path is available to a provided
address at a provided feerate.
Create a transaction that sweeps all coins for which a timelocked recovery path is
currently available to a provided address with the provided feerate.
Will error if no such coins are available or the sum of their value is not enough to cover the
requested 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 created for a later timelock a recovery
transaction may be satisfied using an earlier timelock but not the opposite.
Due to the fact coins are generally received at different block heights, not all coins may be
spendable through a single recovery path at the same time.
This command will error if no such coins are available or the sum of their value is not enough to
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. |
| 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. |
#### Response

View File

@ -124,8 +124,8 @@ impl fmt::Display for CommandError {
Self::RescanTrigger(s) => write!(f, "Error while starting rescan: '{}'", s),
Self::RecoveryNotAvailable => write!(
f,
"No coin currently available through the timelocked recovery path."
),
"No coin currently spendable through this timelocked recovery path."
),
}
}
}
@ -668,14 +668,18 @@ impl DaemonControl {
ListTransactionsResult { transactions }
}
/// Create a transaction that sweeps all coins whose timelocked recovery path is currently
/// available to a provided address with the provided feerate.
/// Create a transaction that sweeps all coins for which a timelocked recovery path is
/// currently available to a provided address with the provided feerate.
///
/// Note that not all coins may be spendable through the recovery path at the same time.
/// The `timelock` parameter can be used to specify which recovery path to use. By default,
/// we'll use the first recovery path available.
///
/// 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,
feerate_vb: u64,
timelock: Option<u16>,
) -> Result<CreateRecoveryResult, CommandError> {
if feerate_vb < 1 {
return Err(CommandError::InvalidFeerate(feerate_vb));
@ -702,27 +706,24 @@ impl DaemonControl {
outputs: vec![PsbtOut::default()],
};
// Query the coins that we can spend through the recovery path from the database.
// Query the coins that we can spend through the specified recovery path (if no recovery
// path specified, use the first available one) from the database.
let current_height = self.bitcoin.chain_tip().height;
let desc_timelock = self.config.main_descriptor.timelock_value();
let timelock: i32 = desc_timelock
.try_into()
.expect("Must fit, it's effectively a u16");
let timelock =
timelock.unwrap_or_else(|| self.config.main_descriptor.first_timelock_value());
let height_delta: i32 = timelock.try_into().expect("Must fit, it's a u16");
let sweepable_coins = db_conn
.coins(CoinType::Unspent)
.into_iter()
.filter(|(_, c)| {
// We are interested in coins available at the *next* block
c.block_info
.map(|b| current_height + 1 >= b.height + timelock)
.map(|b| current_height + 1 >= b.height + height_delta)
.unwrap_or(false)
});
// Fill-in the transaction inputs and PSBT inputs information. Record the value
// that is fed to the transaction while doing so, to compute the fees afterward.
let csv_value: u16 = desc_timelock
.try_into()
.expect("Must fit, it's effectively a u16");
let mut in_value = bitcoin::Amount::from_sat(0);
let txin_sat_vb = self.config.main_descriptor.max_sat_vbytes();
let mut sat_vb = 0;
@ -731,7 +732,7 @@ impl DaemonControl {
in_value += coin.amount;
psbt.unsigned_tx.input.push(bitcoin::TxIn {
previous_output: coin.outpoint,
sequence: bitcoin::Sequence::from_height(csv_value),
sequence: bitcoin::Sequence::from_height(timelock),
// TODO: once we move to Taproot, anti-fee-sniping using nSequence
..bitcoin::TxIn::default()
});

View File

@ -1,29 +1,34 @@
use miniscript::{
bitcoin::{util::bip32, Sequence},
descriptor,
policy::{Liftable, Semantic as SemanticPolicy},
Miniscript, ScriptContext, Terminal,
policy::{compiler, Concrete as ConcretePolicy, Liftable, Semantic as SemanticPolicy},
ScriptContext,
};
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet},
convert::TryFrom,
error, fmt, sync,
error, fmt,
};
#[derive(Debug)]
pub enum LianaPolicyError {
MissingRecoveryPath,
InsaneTimelock(u32),
InvalidKey(Box<descriptor::DescriptorPublicKey>),
DuplicateKey(Box<descriptor::DescriptorPublicKey>),
InvalidMultiThresh(usize),
InvalidMultiKeys(usize),
IncompatibleDesc,
/// The spending policy is not a valid Miniscript policy: it may for instance be malleable, or
/// overflow some limit.
InvalidPolicy(compiler::CompilerError),
}
impl std::fmt::Display for LianaPolicyError {
fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result {
match self {
Self::MissingRecoveryPath => write!(f, "A Liana policy requires at least one recovery path."),
Self::InsaneTimelock(tl) => {
write!(f, "Timelock value '{}' isn't valid or safe to use", tl)
}
@ -43,6 +48,7 @@ impl std::fmt::Display for LianaPolicyError {
f,
"Descriptor is not compatible with a Liana spending policy."
),
Self::InvalidPolicy(e) => write!(f, "Invalid Miniscript policy: {}", e),
}
}
}
@ -60,17 +66,31 @@ fn is_single_key_or_multisig(policy: &SemanticPolicy<descriptor::DescriptorPubli
}
}
// We require the descriptor key to:
// - Be deriveable (to contain a wildcard)
// - Be multipath (to contain a step in the derivation path with multiple indexes)
// - The multipath step to only contain two indexes, 0 and 1.
// - Be 'signable' by an external signer (to contain an origin)
fn is_valid_desc_key(key: &descriptor::DescriptorPublicKey) -> bool {
match *key {
descriptor::DescriptorPublicKey::Single(..) | descriptor::DescriptorPublicKey::XPub(..) => {
false
struct DescKeyChecker {
keys_set: HashSet<bip32::ExtendedPubKey>,
}
impl DescKeyChecker {
pub fn new() -> DescKeyChecker {
DescKeyChecker {
keys_set: HashSet::new(),
}
descriptor::DescriptorPublicKey::MultiXPub(ref xpub) => {
}
/// We require the descriptor key to:
/// - Be deriveable (to contain a wildcard)
/// - Be multipath (to contain a step in the derivation path with multiple indexes)
/// - The multipath step to only contain two indexes, 0 and 1.
/// - Be 'signable' by an external signer (to contain an origin)
/// - Have an xpub that is not a duplicate.
pub fn check(&mut self, key: &descriptor::DescriptorPublicKey) -> Result<(), LianaPolicyError> {
if let descriptor::DescriptorPublicKey::MultiXPub(ref xpub) = *key {
// First make sure it's not a duplicate and record seeing it.
if self.keys_set.contains(&xpub.xkey) {
return Err(LianaPolicyError::DuplicateKey(key.clone().into()));
}
self.keys_set.insert(xpub.xkey);
// Then perform the contextless checks.
let der_paths = xpub.derivation_paths.paths();
// Rust-miniscript enforces BIP389 which states that all paths must have the same len.
let len = der_paths.get(0).expect("Cannot be empty").len();
@ -78,12 +98,16 @@ fn is_valid_desc_key(key: &descriptor::DescriptorPublicKey) -> bool {
// no unlikely (and easily fixable) while users shooting themselves in the foot by
// forgetting to provide the origin is so likely that it's worth ruling out xpubs
// without origin entirely.
xpub.origin.is_some()
let valid = xpub.origin.is_some()
&& xpub.wildcard == descriptor::Wildcard::Unhardened
&& der_paths.len() == 2
&& der_paths[0][len - 1] == 0.into()
&& der_paths[1][len - 1] == 1.into()
&& der_paths[1][len - 1] == 1.into();
if valid {
return Ok(());
}
}
Err(LianaPolicyError::InvalidKey(key.clone().into()))
}
}
@ -279,62 +303,43 @@ impl PathInfo {
}
}
// TODO: avoid using a vec...
/// Get the keys contained in this spending path.
pub fn keys(&self) -> Vec<descriptor::DescriptorPublicKey> {
/// Get a Miniscript Policy for this path.
pub fn into_ms_policy(self) -> ConcretePolicy<descriptor::DescriptorPublicKey> {
match self {
PathInfo::Single(ref key) => vec![key.clone()],
PathInfo::Multi(_, keys) => keys.clone(),
}
}
/// Returns `None` if it is a multisig that does not fit inside a CHECKMULTISIG.
pub fn into_miniscript(
self,
as_hash: bool,
) -> Option<Miniscript<descriptor::DescriptorPublicKey, miniscript::Segwitv0>> {
match self {
PathInfo::Single(key) => Some(
Miniscript::from_ast(Terminal::Check(sync::Arc::from(
Miniscript::from_ast(if as_hash {
Terminal::PkH(key)
} else {
Terminal::PkK(key)
})
.expect("pk_k is a valid Miniscript"),
)))
.expect("Well typed"),
PathInfo::Single(key) => ConcretePolicy::Key(key),
PathInfo::Multi(thresh, keys) => ConcretePolicy::Threshold(
thresh,
keys.into_iter().map(ConcretePolicy::Key).collect(),
),
PathInfo::Multi(thresh, keys) => {
if thresh < 1 || keys.len() > 20 || thresh > keys.len() {
None
} else {
Some(
Miniscript::from_ast(Terminal::Multi(thresh, keys))
.expect("multi is a valid Miniscript"),
)
}
}
}
}
}
/// A Liana spending policy. Can be created from some settings (the primary and recovery keys, the
/// A Liana spending policy is one composed of at least two spending paths:
/// - A directly available path with any number of keys checks; or
/// - One or more recovery paths with any number of keys checks, behind increasing relative
/// timelocks. No two recovery paths may have the same timelock.
/// A Liana policy can be created from some settings (the primary and recovery keys, the
/// timelock(s)) and be used to derive a descriptor. It can also be inferred from a descriptor and
/// be used to retrieve the settings.
#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)]
/// Do note however that the descriptor generation process is not deterministic, therefore you
/// **cannot roundtrip** a descriptor through a `LianaPolicy`.
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct LianaPolicy {
pub(super) primary_path: PathInfo,
pub(super) recovery_path: (u16, PathInfo),
pub(super) recovery_paths: BTreeMap<u16, PathInfo>,
}
impl LianaPolicy {
/// Create a new Liana policy from a given configuration.
pub fn new(
primary_path: PathInfo,
recovery_path: PathInfo,
recovery_timelock: u16,
recovery_paths: BTreeMap<u16, PathInfo>,
) -> Result<LianaPolicy, LianaPolicyError> {
if recovery_paths.is_empty() {
return Err(LianaPolicyError::MissingRecoveryPath);
}
// We require the locktime to:
// - not be disabled
// - be in number of blocks
@ -342,47 +347,36 @@ impl LianaPolicy {
// - be positive (Miniscript requires it not to be 0)
//
// All this is achieved through asking for a 16-bit integer.
if recovery_timelock == 0 {
return Err(LianaPolicyError::InsaneTimelock(recovery_timelock as u32));
}
// If any of the paths is a multisig, make sure they are within the CHECKMULTISIG bounds.
for path_info in &[&primary_path, &recovery_path] {
if let PathInfo::Multi(thresh, keys) = path_info {
if keys.len() < 2 || keys.len() > 20 {
return Err(LianaPolicyError::InvalidMultiKeys(keys.len()));
}
if thresh == &0 || thresh > &keys.len() {
return Err(LianaPolicyError::InvalidMultiThresh(*thresh));
}
}
if recovery_paths.contains_key(&0) {
return Err(LianaPolicyError::InsaneTimelock(0));
}
// Check all keys are valid according to our standard (this checks all are multipath keys).
let (prim_keys, rec_keys) = (primary_path.keys(), recovery_path.keys());
let all_keys = prim_keys.iter().chain(rec_keys.iter());
if let Some(key) = all_keys.clone().find(|k| !is_valid_desc_key(k)) {
return Err(LianaPolicyError::InvalidKey((*key).clone().into()));
}
// Check for key duplicates. They are invalid in (nonmalleable) miniscripts.
let mut key_set = HashSet::new();
for key in all_keys {
let xpub = match key {
descriptor::DescriptorPublicKey::MultiXPub(ref multi_xpub) => multi_xpub.xkey,
_ => unreachable!("Just checked it was a multixpub above"),
};
if key_set.contains(&xpub) {
return Err(LianaPolicyError::DuplicateKey(key.clone().into()));
// Note while the Miniscript compiler does check for duplicate, it does so at the
// "descriptor key expression" level. We don't want duplicate xpubs at all so we do it
// ourselves here.
let spending_paths = recovery_paths
.values()
.chain(std::iter::once(&primary_path));
let mut key_checker = DescKeyChecker::new();
for path in spending_paths {
match path {
PathInfo::Single(ref key) => key_checker.check(key)?,
PathInfo::Multi(_, ref keys) => {
for key in keys {
key_checker.check(key)?
}
}
}
key_set.insert(xpub);
}
assert!(!key_set.is_empty());
Ok(LianaPolicy {
// Make sure it is a valid Miniscript policy by (ab)using the compiler.
let policy = LianaPolicy {
primary_path,
recovery_path: (recovery_timelock, recovery_path),
})
recovery_paths,
};
policy.clone().into_miniscript()?;
Ok(policy)
}
/// Create a Liana policy from a descriptor. This will check the descriptor is correctly formed
@ -396,25 +390,12 @@ impl LianaPolicy {
_ => return Err(LianaPolicyError::IncompatibleDesc),
};
// Get the Miniscript from the descriptor and make sure it only contains valid multipath
// descriptor keys.
// Lift a semantic policy out of this Miniscript and normalize it to make sure we compare
// apples to apples below.
let ms = match wsh_desc.as_inner() {
descriptor::WshInner::Ms(ms) => ms,
_ => return Err(LianaPolicyError::IncompatibleDesc),
};
let invalid_key = ms.iter_pk().find_map(|pk| {
if is_valid_desc_key(&pk) {
None
} else {
Some(pk)
}
});
if let Some(key) = invalid_key {
return Err(LianaPolicyError::InvalidKey(key.into()));
}
// Now lift a semantic policy out of this Miniscript and normalize it to make sure we
// compare apples to apples below.
let policy = ms
.lift()
.expect("Lifting can't fail on a Miniscript")
@ -431,7 +412,7 @@ impl LianaPolicy {
// Fetch the two spending paths' semantic policies. The primary path is identified as the
// only one that isn't timelocked.
let (mut primary_path, mut recovery_path) = (None::<PathInfo>, None);
let (mut primary_path, mut recovery_paths) = (None::<PathInfo>, BTreeMap::new());
for sub in subs {
// This is a (multi)key check. It must be the primary path.
if is_single_key_or_multisig(&sub) {
@ -450,70 +431,75 @@ impl LianaPolicy {
}
} else {
// If it's not a simple (multi)key check, it must be the timelocked recovery path.
// For now, we only support a single recovery path.
if recovery_path.is_some() {
let (timelock, path_info) = PathInfo::from_recovery_path(sub)?;
if recovery_paths.contains_key(&timelock) {
return Err(LianaPolicyError::IncompatibleDesc);
}
recovery_path = Some(PathInfo::from_recovery_path(sub)?);
recovery_paths.insert(timelock, path_info);
}
}
Ok(LianaPolicy {
primary_path: primary_path.ok_or(LianaPolicyError::IncompatibleDesc)?,
recovery_path: recovery_path.ok_or(LianaPolicyError::IncompatibleDesc)?,
})
// Use the constructor for sanity checking the keys and the Miniscript policy. Note this
// makes sure the recovery paths mapping isn't empty, too.
let prim_path = primary_path.ok_or(LianaPolicyError::IncompatibleDesc)?;
LianaPolicy::new(prim_path, recovery_paths)
}
pub fn primary_path(&self) -> &PathInfo {
&self.primary_path
}
/// Timelock and path info for the recovery path.
pub fn recovery_path(&self) -> (u16, &PathInfo) {
(self.recovery_path.0, &self.recovery_path.1)
/// Timelocks and path info of the recovery paths. Note we guarantee this mapping is never
/// empty, as there is always at least one recovery path.
pub fn recovery_paths(&self) -> &BTreeMap<u16, PathInfo> {
assert!(!self.recovery_paths.is_empty());
&self.recovery_paths
}
/// Create a descriptor from this spending policy with multipath key expressions.
///
/// Although for now this function is deterministic, it **will not** be in the future.
fn into_miniscript(
self,
) -> Result<
miniscript::Miniscript<descriptor::DescriptorPublicKey, miniscript::Segwitv0>,
LianaPolicyError,
> {
let LianaPolicy {
primary_path,
recovery_paths,
} = self;
// Start with the primary spending path. We'll then or() all the recovery paths to it.
let primary_keys = primary_path.into_ms_policy();
// Incrementally create the top-level policy using all recovery paths.
assert!(!recovery_paths.is_empty());
let tl_policy =
recovery_paths
.into_iter()
.fold(primary_keys, |tl_policy, (timelock, path_info)| {
let timelock = ConcretePolicy::Older(Sequence::from_height(timelock));
let keys = path_info.into_ms_policy();
let recovery_branch = ConcretePolicy::And(vec![keys, timelock]);
// We assume the larger the timelock the less likely a branch would be used.
ConcretePolicy::Or(vec![(99, tl_policy), (1, recovery_branch)])
});
tl_policy
.compile::<miniscript::Segwitv0>()
.map_err(LianaPolicyError::InvalidPolicy)
}
/// Create a descriptor from this spending policy with multipath key expressions. Note this
/// involves a Miniscript policy compilation: this function is **not deterministic**. If you
/// are inferring a `LianaPolicy` from a descriptor, generating a descriptor from this
/// `LianaPolicy` may not yield the same descriptor.
pub fn into_multipath_descriptor(
self,
) -> descriptor::Descriptor<descriptor::DescriptorPublicKey> {
let LianaPolicy {
primary_path,
recovery_path: (timelock, recovery_path),
} = self;
// Create the timelocked spending path. If there is a single key we make it a pk_h() in
// order to save on the script size (since we assume the timelocked recovery path will
// seldom be used).
let recovery_timelock = Terminal::Older(Sequence::from_height(timelock));
let recovery_keys = recovery_path
.into_miniscript(true)
.expect("We check the multisig never overflows in our constructors.");
let recovery_branch = Miniscript::from_ast(Terminal::AndV(
Miniscript::from_ast(Terminal::Verify(recovery_keys.into()))
.expect("Well typed")
.into(),
Miniscript::from_ast(recovery_timelock)
.expect("Well typed")
.into(),
))
.expect("Well typed");
// Combine the timelocked spending path with the simple "primary" path. For the primary key
// we don't use a pkh since it's the one that will likely always be used.
let primary_keys = primary_path
.into_miniscript(false)
.expect("We check the multisig never overflows in our constructors.");
let tl_miniscript =
Miniscript::from_ast(Terminal::OrD(primary_keys.into(), recovery_branch.into()))
.expect("Well typed");
miniscript::Segwitv0::check_local_validity(&tl_miniscript)
.expect("Miniscript must be sane");
descriptor::Descriptor::Wsh(
descriptor::Wsh::new(tl_miniscript).expect("Must pass sanity checks"),
)
let ms = self
.into_miniscript()
.expect("This is always checked when creating a LianaPolicy.");
miniscript::Segwitv0::check_local_validity(&ms).expect("Miniscript must be sane");
descriptor::Descriptor::Wsh(descriptor::Wsh::new(ms).expect("Must pass sanity checks"))
}
}
@ -534,9 +520,9 @@ pub struct PathSpendInfo {
pub struct PartialSpendInfo {
/// Number of signatures present for the primary path
pub(super) primary_path: PathSpendInfo,
/// Number of signatures present for the recovery path, only present if the path is available
/// in the first place.
pub(super) recovery_path: Option<PathSpendInfo>,
/// Number of signatures present for the recovery path, only present for the recovery paths
/// that are available.
pub(super) recovery_paths: BTreeMap<u16, PathSpendInfo>,
}
impl PartialSpendInfo {
@ -545,9 +531,9 @@ impl PartialSpendInfo {
&self.primary_path
}
/// Get the number of signatures present for the recovery path. Only present if the path is
/// available in the first place.
pub fn recovery_path(&self) -> &Option<PathSpendInfo> {
&self.recovery_path
/// Get the number of signatures present for each recovery path. Only present for available
/// paths.
pub fn recovery_paths(&self) -> &BTreeMap<u16, PathSpendInfo> {
&self.recovery_paths
}
}

View File

@ -178,10 +178,15 @@ impl LianaDescriptor {
.expect("We never create a Liana descriptor with an invalid Liana policy.")
}
/// Get the value (in blocks) of the relative timelock for the heir's spending path.
pub fn timelock_value(&self) -> u32 {
// TODO: make it return a u16
self.policy().recovery_path.0 as u32
/// Get the value (in blocks) of the smallest relative timelock of the recovery paths.
pub fn first_timelock_value(&self) -> u16 {
*self
.policy()
.recovery_paths
.iter()
.next()
.expect("There is always at least one recovery path")
.0
}
/// Get the maximum size in WU of a satisfaction for this descriptor.
@ -229,17 +234,21 @@ impl LianaDescriptor {
// (ie if the nSequence is >= to the chosen CSV value).
let desc_info = self.policy();
let primary_path = desc_info.primary_path.spend_info(pubkeys_signed.clone());
let recovery_path = if txin.sequence.is_height_locked()
&& txin.sequence.0 >= desc_info.recovery_path.0 as u32
{
Some(desc_info.recovery_path.1.spend_info(pubkeys_signed))
} else {
None
};
let recovery_paths = desc_info
.recovery_paths
.iter()
.filter_map(|(timelock, path_info)| {
if txin.sequence.is_height_locked() && txin.sequence.0 >= *timelock as u32 {
Some((*timelock, path_info.spend_info(pubkeys_signed.clone())))
} else {
None
}
})
.collect();
PartialSpendInfo {
primary_path,
recovery_path,
recovery_paths,
}
}
@ -394,12 +403,28 @@ mod tests {
use crate::signer::HotSigner;
fn random_desc_key(
secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
) -> descriptor::DescriptorPublicKey {
let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap();
let xpub_str = format!(
"[{}]{}/<0;1>/*",
signer.fingerprint(secp),
signer.xpub_at(&bip32::DerivationPath::from_str("m").unwrap(), secp)
);
descriptor::DescriptorPublicKey::from_str(&xpub_str).unwrap()
}
#[test]
fn descriptor_creation() {
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap());
let timelock = 52560;
let policy = LianaPolicy::new(owner_key.clone(), heir_key.clone(), timelock).unwrap();
let policy = LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key.clone())].iter().cloned().collect(),
)
.unwrap();
assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(pk([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#g7vk9r5l");
// A decaying multisig after 6 months. Note we can't duplicate the keys, so different ones
@ -420,8 +445,12 @@ mod tests {
descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*").unwrap(),
],
);
let policy = LianaPolicy::new(primary_keys, recovery_keys, 26352).unwrap();
assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[abcdef01]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:multi(2,[abcdef01]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[abcdef01]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#s0zsa6uc");
let policy = LianaPolicy::new(
primary_keys,
[(26352, recovery_keys)].iter().cloned().collect(),
)
.unwrap();
assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[abcdef01]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:thresh(2,pkh([abcdef01]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*),a:pkh([abcdef01]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*),a:pkh([aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*)),older(26352))))#hmsqemgr");
// We prevent footguns with timelocks by requiring a u16. Note how the following wouldn't
// compile:
@ -430,32 +459,52 @@ mod tests {
//LianaPolicy::new(owner_key, heir_key, (1 << 22) + 1).unwrap_err();
// You can't use a null timelock in Miniscript.
LianaPolicy::new(owner_key, heir_key, 0).unwrap_err();
LianaPolicy::new(owner_key, [(0, heir_key)].iter().cloned().collect()).unwrap_err();
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap());
let timelock = 57600;
let policy = LianaPolicy::new(owner_key.clone(), heir_key, timelock).unwrap();
let policy = LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap();
assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(pk([aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*),and_v(v:pkh([abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*),older(57600))))#ak4cm093");
// We can't pass a raw key, an xpub that is not deriveable, only hardened derivable,
// without both the change and receive derivation paths, or with more than 2 different
// derivation paths.
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap());
LianaPolicy::new(owner_key.clone(), heir_key, timelock).unwrap_err();
LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap_err();
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap());
LianaPolicy::new(owner_key.clone(), heir_key, timelock).unwrap_err();
LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap_err();
let heir_key = PathInfo::Single(
descriptor::DescriptorPublicKey::from_str(
"[abcdef01]02e24913be26dbcfdf8e8e94870b28725cdae09b448b6c127767bf0154e3a3c8e5",
)
.unwrap(),
);
LianaPolicy::new(owner_key.clone(), heir_key, timelock).unwrap_err();
LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap_err();
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap());
LianaPolicy::new(owner_key.clone(), heir_key, timelock).unwrap_err();
LianaPolicy::new(
owner_key.clone(),
[(timelock, heir_key)].iter().cloned().collect(),
)
.unwrap_err();
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap());
LianaPolicy::new(owner_key, heir_key, timelock).unwrap_err();
LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
// And it's checked even in a multisig. For instance:
let primary_keys = PathInfo::Multi(
@ -472,18 +521,22 @@ mod tests {
descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(),
],
);
LianaPolicy::new(primary_keys, recovery_keys, 26352).unwrap_err();
LianaPolicy::new(
primary_keys,
[(26352, recovery_keys)].iter().cloned().collect(),
)
.unwrap_err();
// You can't pass duplicate keys, even if they are encoded differently.
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
LianaPolicy::new(owner_key, heir_key, timelock).unwrap_err();
LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
LianaPolicy::new(owner_key, heir_key, timelock).unwrap_err();
LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[11223344/2/98]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
LianaPolicy::new(owner_key, heir_key, timelock).unwrap_err();
LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
// You can't pass duplicate keys, even across multisigs.
let primary_keys = PathInfo::Multi(
@ -502,13 +555,17 @@ mod tests {
descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(),
],
);
LianaPolicy::new(primary_keys, recovery_keys, 26352).unwrap_err();
LianaPolicy::new(
primary_keys,
[(26352, recovery_keys)].iter().cloned().collect(),
)
.unwrap_err();
// No origin in one of the keys
let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap());
let timelock = 52560;
LianaPolicy::new(owner_key, heir_key, timelock).unwrap_err();
LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
// A 1-of-N multisig as primary path.
LianaDescriptor::from_str("wsh(or_d(multi(1,[573fb35b/48'/1'/0'/2']tpubDFKp9T7WAYDcENSjoifkrpq1gMDF47KGJcJrpxzX23Qor8wuGbrEVs9utNq1MDS8E2WXJSBk1qoPQLpwyokW7DiUNPwFuxQkL7owNkLAb9W/<0;1>/*,[573fb35b/48'/1'/1'/2']tpubDFGezyzuHJPhdP3jHGW7v7Hwes4Hihqv5W2yyCmRY9VZJCRchETvxrMC8uECeJZdxQ14V4iD4DecoArkUSDwj8ogYE9WEv4MNZr12thNHCs/<0;1>/*),and_v(v:multi(2,[573fb35b/48'/1'/2'/2']tpubDDwxQauiaU964vPzt5Vd7jnDHEUtp2Vc34PaWpEXg5TQ3bRccxnc1MKKh88Hi7xiMeZo9Tm6fBcq4UGXqnDtGUniJLjqAD8SjQ8Eci3aSR7/<0;1>/*,[573fb35b/48'/1'/3'/2']tpubDE37XAVB5CQ1x85md3BQ5uHCoMwT5fgT8X13zzCUQ3x5o2jskYxKjj7Qcxt1Jpj4QB8tqspn2dooPCekRuQDYrDHov7J1ueUNu2wcvgRDxr/<0;1>/*),older(1000))))#qjx6ycpc").unwrap();
@ -536,13 +593,13 @@ mod tests {
LianaDescriptor::from_str("wsh(or_i(pk([abcdef01]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),pk([abcdef01]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap_err();
let desc = LianaDescriptor::from_str("wsh(andor(pk([abcdef01]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(1),pk([abcdef01]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap();
assert_eq!(desc.timelock_value(), 1);
assert_eq!(desc.first_timelock_value(), 1);
let desc = LianaDescriptor::from_str("wsh(andor(pk([abcdef01]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(42000),pk([abcdef01]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap();
assert_eq!(desc.timelock_value(), 42000);
assert_eq!(desc.first_timelock_value(), 42000);
let desc = LianaDescriptor::from_str("wsh(andor(pk([abcdef01]tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(65535),pk([abcdef01]tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap();
assert_eq!(desc.timelock_value(), 0xffff);
assert_eq!(desc.first_timelock_value(), 0xffff);
}
#[test]
@ -574,65 +631,88 @@ mod tests {
#[test]
fn liana_desc_keys() {
let secp = secp256k1::Secp256k1::signing_only();
let random_desc_key = || {
let xpub_str = format!(
"[aabbccdd]{}/<0;1>/*",
HotSigner::generate(bitcoin::Network::Bitcoin)
.unwrap()
.xpub_at(&bip32::DerivationPath::from_str("m").unwrap(), &secp)
);
descriptor::DescriptorPublicKey::from_str(&xpub_str).unwrap()
};
let prim_path = PathInfo::Single(random_desc_key());
let twenty_keys: Vec<descriptor::DescriptorPublicKey> =
(0..20).map(|_| random_desc_key()).collect();
let mut twenty_one_keys = twenty_keys.clone();
twenty_one_keys.push(random_desc_key());
let prim_path = PathInfo::Single(random_desc_key(&secp));
let twenty_eight_keys: Vec<descriptor::DescriptorPublicKey> =
(0..28).map(|_| random_desc_key(&secp)).collect();
let mut twenty_nine_keys = twenty_eight_keys.clone();
twenty_nine_keys.push(random_desc_key(&secp));
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(1, vec![random_desc_key()]),
1,
[(1, PathInfo::Multi(2, vec![random_desc_key(&secp)]))]
.iter()
.cloned()
.collect(),
)
.unwrap_err();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(2, vec![random_desc_key()]),
1,
)
.unwrap_err();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(1, vec![random_desc_key(), random_desc_key()]),
1,
[(
1,
PathInfo::Multi(1, vec![random_desc_key(&secp), random_desc_key(&secp)]),
)]
.iter()
.cloned()
.collect(),
)
.unwrap();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(0, vec![random_desc_key(), random_desc_key()]),
1,
[(
1,
PathInfo::Multi(0, vec![random_desc_key(&secp), random_desc_key(&secp)]),
)]
.iter()
.cloned()
.collect(),
)
.unwrap_err();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(2, vec![random_desc_key(), random_desc_key()]),
1,
[(
1,
PathInfo::Multi(2, vec![random_desc_key(&secp), random_desc_key(&secp)]),
)]
.iter()
.cloned()
.collect(),
)
.unwrap();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(3, vec![random_desc_key(), random_desc_key()]),
1,
[(
1,
PathInfo::Multi(3, vec![random_desc_key(&secp), random_desc_key(&secp)]),
)]
.iter()
.cloned()
.collect(),
)
.unwrap_err();
LianaPolicy::new(
prim_path.clone(),
PathInfo::Multi(3, twenty_keys.clone()),
1,
[(1, PathInfo::Multi(3, twenty_eight_keys.clone()))]
.iter()
.cloned()
.collect(),
)
.unwrap();
LianaPolicy::new(prim_path.clone(), PathInfo::Multi(20, twenty_keys), 1).unwrap();
LianaPolicy::new(prim_path, PathInfo::Multi(20, twenty_one_keys), 1).unwrap_err();
LianaPolicy::new(
prim_path.clone(),
[(1, PathInfo::Multi(20, twenty_eight_keys))]
.iter()
.cloned()
.collect(),
)
.unwrap();
LianaPolicy::new(
prim_path,
[(1, PathInfo::Multi(20, twenty_nine_keys))]
.iter()
.cloned()
.collect(),
)
.unwrap_err();
}
fn roundtrip(desc_str: &str) {
@ -649,7 +729,7 @@ mod tests {
// A single key as primary path, a multisig as recovery
roundtrip("wsh(or_d(pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:multi(2,[aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[aabbccdd]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#7du8x4v7");
// The other way around
roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabbccdd]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),older(26352))))#y9l4ldvr");
roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabbccdd]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:pk([aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*),older(26352))))#0y77q9d6");
}
fn psbt_from_str(psbt_str: &str) -> Psbt {
@ -658,6 +738,8 @@ mod tests {
#[test]
fn partial_spend_info() {
let secp = secp256k1::Secp256k1::signing_only();
// A simple descriptor with 1 keys as primary path and 1 recovery key.
let desc = LianaDescriptor::from_str("wsh(or_d(pk([f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*),and_v(v:pkh([8a64f2a9]tpubD6NzVbkrYhZ4WmzFjvQrp7sDa4ECUxTi9oby8K4FZkd3XCBtEdKwUiQyYJaxiJo5y42gyDWEczrFpozEjeLxMPxjf2WtkfcbpUdfvNnozWF/<0;1>/*),older(10))))#d72le4dr").unwrap();
let desc_info = desc.policy();
@ -677,25 +759,24 @@ mod tests {
assert_eq!(info.primary_path.threshold, 1);
assert_eq!(info.primary_path.sigs_count, 0);
assert!(info.primary_path.signed_pubkeys.is_empty());
assert!(info.recovery_path.is_none());
assert!(info.recovery_paths.is_empty());
// If we set the sequence too low we still won't have the recovery path info.
unsigned_single_psbt.unsigned_tx.input[0].sequence =
Sequence::from_height(desc_info.recovery_path.0 - 1);
Sequence::from_height(desc_info.recovery_paths.keys().next().unwrap() - 1);
let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
assert!(info.recovery_path.is_none());
assert!(info.recovery_paths.is_empty());
// Now if we set the sequence at the right value we'll have it.
unsigned_single_psbt.unsigned_tx.input[0].sequence =
Sequence::from_height(desc_info.recovery_path.0);
let timelock = *desc_info.recovery_paths.keys().next().unwrap();
unsigned_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock);
let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
assert!(info.recovery_path.is_some());
assert!(info.recovery_paths.contains_key(&timelock));
// Even if it's a bit too high (as long as it's still a block height and activated)
unsigned_single_psbt.unsigned_tx.input[0].sequence =
Sequence::from_height(desc_info.recovery_path.0 + 42);
unsigned_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock + 42);
let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
let recov_info = info.recovery_path.unwrap();
let recov_info = info.recovery_paths.get(&timelock).unwrap();
assert_eq!(recov_info.threshold, 1);
assert_eq!(recov_info.sigs_count, 0);
assert!(recov_info.signed_pubkeys.is_empty());
@ -713,11 +794,10 @@ mod tests {
.signed_pubkeys
.contains_key(&prim_key_origin)
);
assert!(info.recovery_path.is_none());
assert!(info.recovery_paths.is_empty());
// Now enable the recovery path and add a signature for the recovery key.
signed_single_psbt.unsigned_tx.input[0].sequence =
Sequence::from_height(desc_info.recovery_path.0);
signed_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock);
let recov_pubkey = bitcoin::PublicKey {
compressed: true,
inner: *signed_single_psbt.inputs[0]
@ -748,7 +828,7 @@ mod tests {
assert_eq!(info.primary_path.threshold, 1);
assert_eq!(info.primary_path.sigs_count, 0);
assert!(info.primary_path.signed_pubkeys.is_empty());
let recov_info = info.recovery_path.unwrap();
let recov_info = info.recovery_paths.get(&timelock).unwrap();
assert_eq!(recov_info.threshold, 1);
assert_eq!(recov_info.sigs_count, 1);
assert!(
@ -772,12 +852,12 @@ mod tests {
.signed_pubkeys
.contains_key(&prim_key_origin)
);
assert!(info.recovery_path.is_none());
assert!(info.recovery_paths.is_empty());
// Enable the recovery path, it should show no recovery sig.
let mut rec_psbt = psbt.clone();
for txin in rec_psbt.unsigned_tx.input.iter_mut() {
txin.sequence = Sequence::from_height(desc_info.recovery_path.0);
txin.sequence = Sequence::from_height(timelock);
}
let info = desc.partial_spend_info(&rec_psbt).unwrap();
assert!(rec_psbt
@ -793,7 +873,7 @@ mod tests {
.signed_pubkeys
.contains_key(&prim_key_origin)
);
let recov_info = info.recovery_path.unwrap();
let recov_info = info.recovery_paths.get(&timelock).unwrap();
assert_eq!(recov_info.threshold, 1);
assert_eq!(recov_info.sigs_count, 0);
assert!(recov_info.signed_pubkeys.is_empty());
@ -801,8 +881,7 @@ mod tests {
// If the sequence of one of the input is different from the other ones, it'll return
// an error since the analysis is on the whole transaction.
let mut inconsistent_psbt = psbt.clone();
inconsistent_psbt.unsigned_tx.input[0].sequence =
Sequence::from_height(desc_info.recovery_path.0 + 1);
inconsistent_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock + 1);
assert!(desc
.partial_spend_info(&inconsistent_psbt)
.unwrap_err()
@ -834,7 +913,7 @@ mod tests {
.signed_pubkeys
.contains_key(&prim_key_origin)
);
assert!(info.recovery_path.is_none());
assert!(info.recovery_paths.is_empty());
let desc = LianaDescriptor::from_str("wsh(or_d(multi(2,[636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<0;1>/*,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*),and_v(v:multi(2,[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D/<0;1>/*,[ffd63c8d/48'/1'/1'/2']tpubDFMs44FD4kFt3M7Z317cFh5tdKEGN8tyQRY6Q5gcSha4NtxZfGmTVRMbsD1bWN469LstXU4aVSARDxrvxFCUjHeegfEY2cLSazMBkNCmDPD/<0;1>/*),older(2))))#xcf6jr2r").unwrap();
let info = desc.policy();
@ -845,19 +924,163 @@ mod tests {
descriptor::DescriptorPublicKey::from_str("[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*").unwrap(),
],
));
assert_eq!(info.recovery_path, (2, PathInfo::Multi(
assert_eq!(info.recovery_paths, [(2, PathInfo::Multi(
2,
vec![
descriptor::DescriptorPublicKey::from_str("[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D/<0;1>/*").unwrap(),
descriptor::DescriptorPublicKey::from_str("[ffd63c8d/48'/1'/1'/2']tpubDFMs44FD4kFt3M7Z317cFh5tdKEGN8tyQRY6Q5gcSha4NtxZfGmTVRMbsD1bWN469LstXU4aVSARDxrvxFCUjHeegfEY2cLSazMBkNCmDPD/<0;1>/*").unwrap(),
],
)));
let psbt = psbt_from_str("cHNidP8BAIkCAAAAAWi3OFgkj1CqCDT3Swm8kbxZS9lxz4L3i4W2v9KGC7nqAQAAAAD9////AkANAwAAAAAAIgAg27lNc1rog+dOq80ohRuds4Hgg/RcpxVun2XwgpuLSrFYMwwAAAAAACIAIDyWveqaElWmFGkTbFojg1zXWHODtiipSNjfgi2DqBy9AAAAAAABAOoCAAAAAAEBsRWl70USoAFFozxc86pC7Dovttdg4kvja//3WMEJskEBAAAAAP7///8CWKmCIk4GAAAWABRKBWYWkCNS46jgF0r69Ehdnq+7T0BCDwAAAAAAIgAgTt5fs+CiB+FRzNC8lHcgWLH205sNjz1pT59ghXlG5tQCRzBEAiBXK9MF8z3bX/VnY2aefgBBmiAHPL4tyDbUOe7+KpYA4AIgL5kU0DFG8szKd+szRzz/OTUWJ0tZqij41h2eU9rSe1IBIQNBB1hy+jKsg1TihMT0dXw7etpu9TkO3NuvhBDFJlBj1cP2AQABAStAQg8AAAAAACIAIE7eX7PgogfhUczQvJR3IFix9tObDY89aU+fYIV5RubUIgICSKJsNs0zFJN58yd2aYQ+C3vhMbi0x7k0FV3wBhR4THlIMEUCIQCPWWWOhs2lThxOq/G8X2fYBRvM9MXSm7qPH+dRVYQZEwIgfut2vx3RvwZWcgEj4ohQJD5lNJlwOkA4PAiN1fjx6dABIgID3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACpHMEQCICZNR+0/1hPkrDQwPFmg5VjUHkh6aK9cXUu3kPbM8hirAiAyE/5NUXKfmFKij30isuyysJbq8HrURjivd+S9vdRGKQEBBZNSIQJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeSEC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8FSrnNkUSED3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACohA+ECH+HlR+8Sf3pumaXH3IwSsoqSLCH7H1THiBP93z3ZUq9SsmgiBgJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeRxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAABAAAAIgYC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8Ec/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAQAAACIGA95r49c3q2SqITlYSgorJGJPt6qwrpujyMA4UNenjwAqHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAEAAAAiBgPhAh/h5UfvEn96bpmlx9yMErKKkiwh+x9Ux4gT/d892Rz/1jyNMAAAgAEAAIABAACAAgAAgAAAAAABAAAAACICAlBQ7gGocg7eF3sXrCio+zusAC9+xfoyIV95AeR69DWvHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAMAAAAiAgMvVy984eg8Kgvj058PBHetFayWbRGb7L0DMnS9KHSJzBxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgIDSRIG1dn6njdjsDXenHa2lUvQHWGPLKBVrSzbQOhiIxgc/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAwAAACICA0/epE59sVEj7Et0I4R9qJQNuX23RNvDZKCRL7eUps9FHP/WPI0wAACAAQAAgAEAAIACAACAAAAAAAMAAAAAIgICgldCOK6iHscv//2NipgaMABLV5TICU/zlP7HlQmlg08cY2rfPzAAAIABAACAAQAAgAIAAIABAAAAAQAAACICApb0p9rfpJshB3J186PGWrvzQdixcwQZWmebOUMdkquZHP/WPI0wAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgLY5q+unoDxC/HI5BaNiPq12ei1REZIcUAN304JfKXUwxz/1jyNMAAAgAEAAIABAACAAgAAgAEAAAABAAAAIgIDg6cUVCJB79cMcofiURHojxFARWyS4YEhJNRixuOZZRgcY2rfPzAAAIABAACAAAAAgAIAAIABAAAAAQAAAAA=");
))].iter().cloned().collect());
let mut psbt = psbt_from_str("cHNidP8BAIkCAAAAAWi3OFgkj1CqCDT3Swm8kbxZS9lxz4L3i4W2v9KGC7nqAQAAAAD9////AkANAwAAAAAAIgAg27lNc1rog+dOq80ohRuds4Hgg/RcpxVun2XwgpuLSrFYMwwAAAAAACIAIDyWveqaElWmFGkTbFojg1zXWHODtiipSNjfgi2DqBy9AAAAAAABAOoCAAAAAAEBsRWl70USoAFFozxc86pC7Dovttdg4kvja//3WMEJskEBAAAAAP7///8CWKmCIk4GAAAWABRKBWYWkCNS46jgF0r69Ehdnq+7T0BCDwAAAAAAIgAgTt5fs+CiB+FRzNC8lHcgWLH205sNjz1pT59ghXlG5tQCRzBEAiBXK9MF8z3bX/VnY2aefgBBmiAHPL4tyDbUOe7+KpYA4AIgL5kU0DFG8szKd+szRzz/OTUWJ0tZqij41h2eU9rSe1IBIQNBB1hy+jKsg1TihMT0dXw7etpu9TkO3NuvhBDFJlBj1cP2AQABAStAQg8AAAAAACIAIE7eX7PgogfhUczQvJR3IFix9tObDY89aU+fYIV5RubUIgICSKJsNs0zFJN58yd2aYQ+C3vhMbi0x7k0FV3wBhR4THlIMEUCIQCPWWWOhs2lThxOq/G8X2fYBRvM9MXSm7qPH+dRVYQZEwIgfut2vx3RvwZWcgEj4ohQJD5lNJlwOkA4PAiN1fjx6dABIgID3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACpHMEQCICZNR+0/1hPkrDQwPFmg5VjUHkh6aK9cXUu3kPbM8hirAiAyE/5NUXKfmFKij30isuyysJbq8HrURjivd+S9vdRGKQEBBZNSIQJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeSEC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8FSrnNkUSED3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACohA+ECH+HlR+8Sf3pumaXH3IwSsoqSLCH7H1THiBP93z3ZUq9SsmgiBgJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeRxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAABAAAAIgYC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8Ec/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAQAAACIGA95r49c3q2SqITlYSgorJGJPt6qwrpujyMA4UNenjwAqHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAEAAAAiBgPhAh/h5UfvEn96bpmlx9yMErKKkiwh+x9Ux4gT/d892Rz/1jyNMAAAgAEAAIABAACAAgAAgAAAAAABAAAAACICAlBQ7gGocg7eF3sXrCio+zusAC9+xfoyIV95AeR69DWvHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAMAAAAiAgMvVy984eg8Kgvj058PBHetFayWbRGb7L0DMnS9KHSJzBxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgIDSRIG1dn6njdjsDXenHa2lUvQHWGPLKBVrSzbQOhiIxgc/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAwAAACICA0/epE59sVEj7Et0I4R9qJQNuX23RNvDZKCRL7eUps9FHP/WPI0wAACAAQAAgAEAAIACAACAAAAAAAMAAAAAIgICgldCOK6iHscv//2NipgaMABLV5TICU/zlP7HlQmlg08cY2rfPzAAAIABAACAAQAAgAIAAIABAAAAAQAAACICApb0p9rfpJshB3J186PGWrvzQdixcwQZWmebOUMdkquZHP/WPI0wAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgLY5q+unoDxC/HI5BaNiPq12ei1REZIcUAN304JfKXUwxz/1jyNMAAAgAEAAIABAACAAgAAgAEAAAABAAAAIgIDg6cUVCJB79cMcofiURHojxFARWyS4YEhJNRixuOZZRgcY2rfPzAAAIABAACAAAAAgAIAAIABAAAAAQAAAAA=");
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.primary_path.threshold, 2);
assert_eq!(partial_info.primary_path.sigs_count, 1);
assert_eq!(partial_info.primary_path.signed_pubkeys.len(), 1);
assert!(partial_info.recovery_path.is_none());
assert!(partial_info.recovery_paths.is_empty());
// A not very well thought-out decaying multisig.
let prim_path = PathInfo::Multi(3, (0..3).map(|_| random_desc_key(&secp)).collect());
let first_reco_path = PathInfo::Multi(3, (0..5).map(|_| random_desc_key(&secp)).collect());
let sec_reco_path = PathInfo::Multi(2, (0..5).map(|_| random_desc_key(&secp)).collect());
let third_reco_path = PathInfo::Multi(1, (0..5).map(|_| random_desc_key(&secp)).collect());
let liana_policy = LianaPolicy::new(
prim_path.clone(),
[
(26784, first_reco_path.clone()),
(53568, sec_reco_path.clone()),
(62496, third_reco_path.clone()),
]
.iter()
.cloned()
.collect(),
)
.unwrap();
let desc = LianaDescriptor::new(liana_policy.clone());
let policy = desc.policy();
assert_eq!(policy, liana_policy);
let empty_partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(empty_partial_info.primary_path.threshold, 3);
assert_eq!(empty_partial_info.primary_path.sigs_count, 0);
assert_eq!(
empty_partial_info.primary_path.sigs_count,
empty_partial_info.primary_path.signed_pubkeys.len()
);
assert!(empty_partial_info.recovery_paths.is_empty());
// Now set a signature for the primary path. All recovery paths still empty, a signature is
// present for the primary path.
let dummy_pubkey = bitcoin::PublicKey::from_str(
"0282574238aea21ec72ffffd8d8a981a30004b5794c8094ff394fec79509a5834f",
)
.unwrap();
let dummy_sig = bitcoin::EcdsaSig::from_str ("30440220264d47ed3fd613e4ac34303c59a0e558d41e487a68af5c5d4bb790f6ccf218ab02203213fe4d51729f9852a28f7d22b2ecb2b096eaf07ad44638af77e4bdbdd4462901").unwrap();
let dummy_der_path = bip32::DerivationPath::from_str("m/0/1").unwrap();
let fingerprint = prim_path.thresh_origins().1.into_iter().next().unwrap().0;
psbt.inputs[0]
.bip32_derivation
.insert(dummy_pubkey.inner, (fingerprint, dummy_der_path));
psbt.inputs[0].partial_sigs.insert(dummy_pubkey, dummy_sig);
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.primary_path.threshold, 3);
assert_eq!(partial_info.primary_path.sigs_count, 1);
assert_eq!(
partial_info.primary_path.sigs_count,
partial_info.primary_path.signed_pubkeys.len()
);
assert!(partial_info.recovery_paths.is_empty());
// Now enable the first recovery path and make the signature be for this path.
let fingerprint = first_reco_path
.thresh_origins()
.1
.into_iter()
.next()
.unwrap()
.0;
psbt.inputs[0]
.bip32_derivation
.get_mut(&dummy_pubkey.inner)
.unwrap()
.0 = fingerprint;
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.primary_path.threshold, 3);
assert_eq!(partial_info.primary_path.sigs_count, 0);
assert_eq!(
partial_info.primary_path.sigs_count,
partial_info.primary_path.signed_pubkeys.len()
);
assert!(partial_info.recovery_paths.is_empty());
psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(26784);
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.recovery_paths.len(), 1);
assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 1);
assert_eq!(
partial_info.recovery_paths[&26784].signed_pubkeys.len(),
partial_info.recovery_paths[&26784].sigs_count
);
// Now enable the second recovery path and make the signature be for this path.
let fingerprint = sec_reco_path
.thresh_origins()
.1
.into_iter()
.next()
.unwrap()
.0;
psbt.inputs[0]
.bip32_derivation
.get_mut(&dummy_pubkey.inner)
.unwrap()
.0 = fingerprint;
psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(53568);
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.primary_path.threshold, 3);
assert_eq!(partial_info.primary_path.sigs_count, 0);
assert_eq!(
partial_info.primary_path.sigs_count,
partial_info.primary_path.signed_pubkeys.len()
);
assert_eq!(partial_info.recovery_paths.len(), 2);
assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0);
assert_eq!(partial_info.recovery_paths[&53568].threshold, 2);
assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 1);
for rec_path in partial_info.recovery_paths.values() {
assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len());
}
// Finally do the same for the third recovery path.
let fingerprint = third_reco_path
.thresh_origins()
.1
.into_iter()
.next()
.unwrap()
.0;
psbt.inputs[0]
.bip32_derivation
.get_mut(&dummy_pubkey.inner)
.unwrap()
.0 = fingerprint;
psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(62496);
let partial_info = desc.partial_spend_info(&psbt).unwrap();
assert_eq!(partial_info.primary_path.threshold, 3);
assert_eq!(partial_info.primary_path.sigs_count, 0);
assert_eq!(
partial_info.primary_path.sigs_count,
partial_info.primary_path.signed_pubkeys.len()
);
assert_eq!(partial_info.recovery_paths.len(), 3);
assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0);
assert_eq!(partial_info.recovery_paths[&53568].threshold, 2);
assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 0);
assert_eq!(partial_info.recovery_paths[&62496].threshold, 1);
assert_eq!(partial_info.recovery_paths[&62496].sigs_count, 1);
for rec_path in partial_info.recovery_paths.values() {
assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len());
}
}
// TODO: test error conditions of deserialization.

View File

@ -148,8 +148,16 @@ fn create_recovery(control: &DaemonControl, params: Params) -> Result<serde_json
.ok_or_else(|| Error::invalid_params("Missing 'feerate' parameter."))?
.as_u64()
.ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?;
let timelock: Option<u16> = params
.get(2, "timelock")
.map(|tl| {
tl.as_u64()
.and_then(|tl| tl.try_into().ok())
.ok_or_else(|| Error::invalid_params("Invalid 'timelock' parameter."))
})
.transpose()?;
let res = control.create_recovery(address, feerate)?;
let res = control.create_recovery(address, feerate, timelock)?;
Ok(serde_json::json!(&res))
}

View File

@ -423,7 +423,9 @@ mod tests {
wildcard: Wildcard::Unhardened,
});
let recov_keys = descriptors::PathInfo::Single(recov_key);
let policy = descriptors::LianaPolicy::new(prim_keys, recov_keys, 42).unwrap();
let policy =
descriptors::LianaPolicy::new(prim_keys, [(46, recov_keys)].iter().cloned().collect())
.unwrap();
let desc = descriptors::LianaDescriptor::new(policy);
// Create a dummy PSBT spending a coin from this descriptor with a single input and single

View File

@ -402,7 +402,11 @@ impl DummyLiana {
let owner_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap());
let heir_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap());
let policy = descriptors::LianaPolicy::new(owner_key, heir_key, 10_000).unwrap();
let policy = descriptors::LianaPolicy::new(
owner_key,
[(10_000, heir_key)].iter().cloned().collect(),
)
.unwrap();
let desc = descriptors::LianaDescriptor::new(policy);
let config = Config {
bitcoin_config,

View File

@ -163,11 +163,11 @@ def lianad_multisig(bitcoind, directory):
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
# A 3-of-4 that degrades into a 2-of-5 after 10 blocks
signer = MultiSigner(3, 4, 2, 5)
csv_value = 10
signer = MultiSigner(4, {csv_value: 5})
prim_multi, recov_multi = (
multi_expression(signer.prim_thresh, signer.prim_hds),
multi_expression(signer.recov_thresh, signer.recov_hds),
multi_expression(3, signer.prim_hds),
multi_expression(2, signer.recov_hds[csv_value]),
)
main_desc = Descriptor.from_str(
f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
@ -189,3 +189,37 @@ def lianad_multisig(bitcoind, directory):
raise
lianad.cleanup()
@pytest.fixture
def lianad_multipath(bitcoind, directory):
datadir = os.path.join(directory, "lianad")
os.makedirs(datadir, exist_ok=True)
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
# A 3-of-4 that degrades into a 3-of-5 after 10 blocks and into a 1-of-10 after 20 blocks.
csv_values = [10, 20]
signer = MultiSigner(4, {csv_values[0]: 5, csv_values[1]: 10})
prim_multi = multi_expression(3, signer.prim_hds)
first_recov_multi = multi_expression(3, signer.recov_hds[csv_values[0]])
second_recov_multi = multi_expression(1, signer.recov_hds[csv_values[1]])
main_desc = Descriptor.from_str(
f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
)
lianad = Lianad(
datadir,
signer,
main_desc,
bitcoind.rpcport,
bitcoind_cookie,
)
try:
lianad.start()
yield lianad
except Exception:
lianad.cleanup()
raise
lianad.cleanup()

View File

@ -59,6 +59,8 @@ def sign_psbt(psbt, hds):
class SingleSigner:
"""Assumes a simple 1-primary path 1-recovery path Liana descriptor."""
def __init__(self):
self.primary_hd = BIP32.from_seed(os.urandom(32), network="test")
self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
@ -76,22 +78,31 @@ class SingleSigner:
class MultiSigner:
def __init__(
self, primary_thresh, primary_hds_count, recovery_thresh, recovery_hds_count
):
self.prim_thresh = primary_thresh
"""A signer that has multiple keys and may have multiple recovery path."""
def __init__(self, primary_hds_count, recovery_hds_counts):
self.prim_hds = [
BIP32.from_seed(os.urandom(32), network="test")
for _ in range(primary_hds_count)
]
self.recov_thresh = recovery_thresh
self.recov_hds = [
BIP32.from_seed(os.urandom(32), network="test")
for _ in range(recovery_hds_count)
]
self.recov_hds = {}
for timelock, count in recovery_hds_counts.items():
self.recov_hds[timelock] = [
BIP32.from_seed(os.urandom(32), network="test") for _ in range(count)
]
def sign_psbt(self, psbt, key_indices, recovery=False):
"""Sign a transaction with the keys at the specified indices."""
hds = self.recov_hds if recovery else self.prim_hds
hds = [hds[i] for i in key_indices]
def sign_psbt(self, psbt, key_indices):
"""Sign a transaction with the keys at the specified indices.
The key indices may be specified as a mapping from timelock value to list of
indices to sign with the keys of a specific recovery path.
"""
if isinstance(key_indices, dict):
hds = [
self.recov_hds[timelock][i]
for timelock, indices in key_indices.items()
for i in indices
]
else:
hds = [self.prim_hds[i] for i in key_indices]
return sign_psbt(psbt, hds)

View File

@ -5,11 +5,7 @@ from test_framework.serializations import PSBT
from test_framework.utils import wait_for, RpcError
def test_multisig(lianad_multisig, bitcoind):
"""Test using lianad with a descriptor that contains multiple keys for both
the primary and recovery paths."""
lianad = lianad_multisig
def receive_and_send(lianad, bitcoind):
# Receive 3 coins in different blocks on different addresses.
for _ in range(3):
addr = lianad.rpc.getnewaddress()["address"]
@ -17,7 +13,6 @@ def test_multisig(lianad_multisig, bitcoind):
bitcoind.generate_block(1, wait_for_mempool=txid)
wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3)
print(lianad.rpc.listcoins())
# Create a spend that will create a change output, sign and broadcast it.
outpoints = [lianad.rpc.listcoins()["coins"][0]["outpoint"]]
destinations = {
@ -49,7 +44,9 @@ def test_multisig(lianad_multisig, bitcoind):
psbt = PSBT.from_base64(res["psbt"])
txid = psbt.tx.txid().hex()
# If we sign only with two keys it won't be able to finalize
with pytest.raises(RpcError, match="Miniscript Error: could not satisfy at index 0"):
with pytest.raises(
RpcError, match="Miniscript Error: could not satisfy at index 0"
):
signed_psbt = lianad.signer.sign_psbt(psbt, range(2))
lianad.rpc.updatespend(signed_psbt.to_base64())
lianad.rpc.broadcastspend(txid)
@ -57,21 +54,120 @@ def test_multisig(lianad_multisig, bitcoind):
signed_psbt = lianad.signer.sign_psbt(psbt, range(1, 4))
lianad.rpc.updatespend(signed_psbt.to_base64())
lianad.rpc.broadcastspend(txid)
bitcoind.generate_block(1, wait_for_mempool=txid)
def test_multisig(lianad_multisig, bitcoind):
"""Test using lianad with a descriptor that contains multiple keys for both
the primary and recovery paths."""
receive_and_send(lianad_multisig, bitcoind)
# Generate 10 blocks to test the recovery path
bitcoind.generate_block(10, wait_for_mempool=txid)
bitcoind.generate_block(10)
wait_for(
lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount()
lambda: lianad_multisig.rpc.getinfo()["block_height"]
== bitcoind.rpc.getblockcount()
)
# Sweep all coins through the recovery path. It needs 2 signatures out of
# 5 keys. Sign with the second and the fifth ones.
res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
res = lianad_multisig.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
reco_psbt = PSBT.from_base64(res["psbt"])
txid = reco_psbt.tx.txid().hex()
signed_psbt = lianad.signer.sign_psbt(reco_psbt, [1, 4], recovery=True)
lianad.rpc.updatespend(signed_psbt.to_base64())
lianad.rpc.broadcastspend(txid)
signed_psbt = lianad_multisig.signer.sign_psbt(reco_psbt, {10: [1, 4]})
lianad_multisig.rpc.updatespend(signed_psbt.to_base64())
lianad_multisig.rpc.broadcastspend(txid)
def test_multipath(lianad_multipath, bitcoind):
"""Exercise various commands as well as recovery with a descriptor with multiple
recovery paths."""
receive_and_send(lianad_multipath, bitcoind)
# Generate 10 blocks to test the recovery path
bitcoind.generate_block(10)
wait_for(
lambda: lianad_multipath.rpc.getinfo()["block_height"]
== bitcoind.rpc.getblockcount()
)
# We can't create a recovery tx for the second recovery path, as all coins were confirmed
# within the last 19 blocks.
with pytest.raises(
RpcError,
match="No coin currently spendable through this timelocked recovery path",
):
lianad_multipath.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2, 20)
# Sweep all coins through the first recovery path (that is available after 10 blocks).
# It needs 3 signatures out of 5 keys.
res = lianad_multipath.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
reco_psbt = PSBT.from_base64(res["psbt"])
txid = reco_psbt.tx.txid().hex()
# Try to sign with the keys for the next recovery spending path, it'll fail.
signed_psbt = lianad_multipath.signer.sign_psbt(reco_psbt, {20: range(3)})
lianad_multipath.rpc.updatespend(signed_psbt.to_base64())
with pytest.raises(RpcError, match="Failed to finalize"):
lianad_multipath.rpc.broadcastspend(txid)
# Try to sign with the right keys but only two of them, it'll fail.
signed_psbt = lianad_multipath.signer.sign_psbt(reco_psbt, {10: range(2)})
lianad_multipath.rpc.updatespend(signed_psbt.to_base64())
with pytest.raises(RpcError, match="Failed to finalize"):
lianad_multipath.rpc.broadcastspend(txid)
# Finally add one more signature with an unused key from the right keyset.
signed_psbt = lianad_multipath.signer.sign_psbt(reco_psbt, {10: [2]})
lianad_multipath.rpc.updatespend(signed_psbt.to_base64())
lianad_multipath.rpc.broadcastspend(txid)
# Receive 3 more coins and make the second recovery path (20 blocks) available.
txids = []
for _ in range(3):
addr = lianad_multipath.rpc.getnewaddress()["address"]
txids.append(bitcoind.rpc.sendtoaddress(addr, 0.42))
bitcoind.generate_block(20, wait_for_mempool=txids)
wait_for(
lambda: lianad_multipath.rpc.getinfo()["block_height"]
== bitcoind.rpc.getblockcount()
)
# We can create a recovery transaction for an earlier timelock.
lianad_multipath.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)
# Sweep all coins through the second recovery path (that is available after 20 blocks).
# It needs 3 signatures out of 5 keys.
res = lianad_multipath.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2, 20)
reco_psbt = PSBT.from_base64(res["psbt"])
txid = reco_psbt.tx.txid().hex()
# We can sign with any keys for the second recovery path (we need only 1 out of 10)
signed_psbt = lianad_multipath.signer.sign_psbt(reco_psbt, {20: [8]})
lianad_multipath.rpc.updatespend(signed_psbt.to_base64())
lianad_multipath.rpc.broadcastspend(txid)
# Now do this again but with signing using keys for the first recovery path.
# Receive 3 more coins and make the second recovery path (20 blocks) available. Note this
# is possible since the CSV checks the nSequence is >= to the value, not ==.
txids = []
for _ in range(3):
addr = lianad_multipath.rpc.getnewaddress()["address"]
txids.append(bitcoind.rpc.sendtoaddress(addr, 0.398))
bitcoind.generate_block(20, wait_for_mempool=txids)
wait_for(
lambda: lianad_multipath.rpc.getinfo()["block_height"]
== bitcoind.rpc.getblockcount()
)
# Sweep all coins through the second recovery path (that is available after 20 blocks).
# It needs 3 signatures out of 5 keys.
res = lianad_multipath.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2, 20)
reco_psbt = PSBT.from_base64(res["psbt"])
txid = reco_psbt.tx.txid().hex()
# We can sign with keys for the first recovery path (we need 3 out of 5)
signed_psbt = lianad_multipath.signer.sign_psbt(reco_psbt, {10: range(2, 5)})
lianad_multipath.rpc.updatespend(signed_psbt.to_base64())
lianad_multipath.rpc.broadcastspend(txid)
def test_coinbase_deposit(lianad, bitcoind):

View File

@ -554,7 +554,7 @@ def test_create_recovery(lianad, bitcoind):
# There's nothing to sweep
with pytest.raises(
RpcError,
match="No coin currently available through the timelocked recovery path",
match="No coin currently spendable through this timelocked recovery path",
):
lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2)