From 9c18d1a34f43a521715347157f35005419e103f1 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 03:45:06 -0500 Subject: [PATCH 1/6] expression: parse descriptor key subtrees Teach the expression tree to preserve derivation suffixes after function-style key nodes, such as musig(A,B)/0/*. The parser records postfix data on the node that owns the closing parenthesis. Key-expression callers now serialize and parse the whole subtree instead of requiring every key to be terminal text. Taproot internal keys and Tap miniscript key arguments use these helpers. Non-key consumers reject postfixes through the standard node validation helpers, including threshold parsing, so Miniscript fragments cannot silently accept key-derivation syntax. --- src/descriptor/tr/mod.rs | 2 +- src/expression/error.rs | 37 +++++- src/expression/mod.rs | 191 ++++++++++++++++++++++++++++-- src/miniscript/mod.rs | 244 +++++++++++++++++++++++---------------- 4 files changed, 360 insertions(+), 114 deletions(-) diff --git a/src/descriptor/tr/mod.rs b/src/descriptor/tr/mod.rs index 47a72cfff..48956744f 100644 --- a/src/descriptor/tr/mod.rs +++ b/src/descriptor/tr/mod.rs @@ -356,7 +356,7 @@ impl crate::expression::FromTree for Tr { let internal_key: Pk = root_children .next() .unwrap() // `verify_toplevel` above checked that first child existed - .verify_terminal("internal key") + .verify_key_expression("internal key") .map_err(Error::Parse)?; let tap_tree = match root_children.next() { diff --git a/src/expression/error.rs b/src/expression/error.rs index bd62863d4..5fb0f267b 100644 --- a/src/expression/error.rs +++ b/src/expression/error.rs @@ -83,6 +83,15 @@ pub enum ParseTreeError { /// The position of the second separator. pos: usize, }, + /// A descriptor key expression suffix appeared where only a script expression is allowed. + UnexpectedKeyExpressionSuffix { + /// The expression name. + name: String, + /// The suffix following the expression. + suffix: String, + /// The position of the suffix. + pos: usize, + }, /// Data occurred after the final ). TrailingCharacter { /// The first trailing character. @@ -163,6 +172,13 @@ impl fmt::Display for ParseTreeError { separator, pos ) } + ParseTreeError::UnexpectedKeyExpressionSuffix { name, suffix, pos } => { + write!( + f, + "unexpected key expression suffix '{}' after '{}' (position {})", + suffix, name, pos + ) + } ParseTreeError::TrailingCharacter { ch, pos } => { write!(f, "trailing data `{}...` (position {})", ch, pos) } @@ -184,6 +200,7 @@ impl std::error::Error for ParseTreeError { | ParseTreeError::IncorrectName { .. } | ParseTreeError::IncorrectNumberOfChildren { .. } | ParseTreeError::MultipleSeparators { .. } + | ParseTreeError::UnexpectedKeyExpressionSuffix { .. } | ParseTreeError::TrailingCharacter { .. } | ParseTreeError::UnknownName { .. } => None, } @@ -242,6 +259,15 @@ pub enum ParseThresholdError { IllegalOr, /// A n-of-n threshold was used in a context it was not allowed. IllegalAnd, + /// A descriptor key expression suffix appeared where only a script expression is allowed. + UnexpectedKeyExpressionSuffix { + /// The expression name. + name: String, + /// The suffix following the expression. + suffix: String, + /// The position of the suffix. + pos: usize, + }, /// Failed to parse the threshold value. ParseK(ParseNumError), /// Threshold parameters were invalid. @@ -261,6 +287,11 @@ impl fmt::Display for ParseThresholdError { IllegalAnd => f.write_str( "n-of-n thresholds not allowed here; please use an 'and' fragment instead", ), + UnexpectedKeyExpressionSuffix { ref name, ref suffix, pos } => write!( + f, + "unexpected key expression suffix '{}' after '{}' (position {})", + suffix, name, pos + ), ParseK(ref x) => write!(f, "failed to parse threshold value: {}", x), Threshold(ref e) => e.fmt(f), } @@ -273,7 +304,11 @@ impl std::error::Error for ParseThresholdError { use ParseThresholdError::*; match *self { - NoChildren | KNotTerminal | IllegalOr | IllegalAnd => None, + NoChildren + | KNotTerminal + | IllegalOr + | IllegalAnd + | UnexpectedKeyExpressionSuffix { .. } => None, ParseK(ref e) => Some(e), Threshold(ref e) => Some(e), } diff --git a/src/expression/mod.rs b/src/expression/mod.rs index 6bfdfd055..e6d2f058f 100644 --- a/src/expression/mod.rs +++ b/src/expression/mod.rs @@ -27,8 +27,8 @@ mod error; -use core::ops; use core::str::FromStr; +use core::{fmt, ops}; pub use self::error::{ParseNumError, ParseThresholdError, ParseTreeError}; use crate::blanket_traits::StaticDebugAndDisplay; @@ -48,6 +48,8 @@ pub const INPUT_CHARSET: &str = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVW struct TreeNode<'s> { name: &'s str, name_pos: usize, + postfix: &'s str, + postfix_pos: usize, parens: Parens, n_children: usize, index: usize, @@ -61,6 +63,8 @@ impl TreeNode<'_> { TreeNode { name: "", name_pos: 0, + postfix: "", + postfix_pos: 0, parens: Parens::None, n_children: 0, index, @@ -182,6 +186,9 @@ impl<'s> TreeIterItem<'s> { /// The name of this tree node. pub fn name(self) -> &'s str { self.nodes[self.index].name } + /// The suffix following a function-style key expression, such as `/0/*` in `musig(A,B)/0/*`. + pub fn postfix(self) -> &'s str { self.nodes[self.index].postfix } + /// The 0-indexed byte-position of the name in the original expression tree. pub fn name_pos(self) -> usize { self.nodes[self.index].name_pos } @@ -267,6 +274,14 @@ impl<'s> TreeIterItem<'s> { self, separator: char, ) -> Result<(Option<&'s str>, &'s str), ParseTreeError> { + if !self.postfix().is_empty() { + return Err(ParseTreeError::UnexpectedKeyExpressionSuffix { + name: self.name().to_owned(), + suffix: self.postfix().to_owned(), + pos: self.nodes[self.index].postfix_pos, + }); + } + let mut name_split = self.name().splitn(3, separator); match (name_split.next(), name_split.next(), name_split.next()) { (None, _, _) => unreachable!("'split' always yields at least one element"), @@ -288,6 +303,14 @@ impl<'s> TreeIterItem<'s> { description: &'static str, n_children: impl ops::RangeBounds, ) -> Result<(), ParseTreeError> { + if !self.postfix().is_empty() { + return Err(ParseTreeError::UnexpectedKeyExpressionSuffix { + name: self.name().to_owned(), + suffix: self.postfix().to_owned(), + pos: self.nodes[self.index].postfix_pos, + }); + } + if n_children.contains(&self.n_children()) { Ok(()) } else { @@ -330,6 +353,12 @@ impl<'s> TreeIterItem<'s> { if self.name() != name { Err(ParseTreeError::IncorrectName { actual: self.name().to_owned(), expected: name }) + } else if !self.postfix().is_empty() { + Err(ParseTreeError::UnexpectedKeyExpressionSuffix { + name: self.name().to_owned(), + suffix: self.postfix().to_owned(), + pos: self.nodes[self.index].postfix_pos, + }) } else if self.parens() == Parens::Curly { Err(ParseTreeError::IllegalCurlyBrace { pos: self.children_pos() }) } else { @@ -410,6 +439,69 @@ impl<'s> TreeIterItem<'s> { .verify_terminal(inner_description) } + /// Check that a tree node has exactly one child, which is a descriptor key expression. + /// + /// If so, serialize the child expression and parse it from a string. + pub fn verify_key_expression_parent( + &self, + description: &'static str, + inner_description: &'static str, + ) -> Result + where + T: FromStr, + T::Err: StaticDebugAndDisplay, + { + self.verify_n_children(description, 1..=1) + .map_err(ParseError::Tree)?; + self.first_child() + .unwrap() + .verify_key_expression(inner_description) + } + + /// Check that a tree node is a descriptor key expression. + /// + /// If so, serialize the expression and parse it from a string. + pub fn verify_key_expression(&self, description: &'static str) -> Result + where + T: FromStr, + T::Err: StaticDebugAndDisplay, + { + let _ = description; + let key = self.expression_string(); + T::from_str(&key).map_err(ParseError::box_from_str) + } + + /// Serialize this tree node and all descendants back to expression syntax. + pub fn expression_string(&self) -> String { + let mut ret = String::new(); + self.fmt_expression(&mut ret) + .expect("writing to a string cannot fail"); + ret + } + + fn fmt_expression(&self, f: &mut W) -> fmt::Result { + f.write_str(self.name())?; + match self.parens() { + Parens::None => {} + Parens::Round | Parens::Curly => { + let (open, close) = match self.parens() { + Parens::Round => ('(', ')'), + Parens::Curly => ('{', '}'), + Parens::None => unreachable!(), + }; + f.write_char(open)?; + for (i, child) in self.children().enumerate() { + if i > 0 { + f.write_char(',')?; + } + child.fmt_expression(f)?; + } + f.write_char(close)?; + } + } + f.write_str(self.postfix()) + } + /// Check that a tree node has exactly two children. /// /// If so, return them. @@ -444,6 +536,15 @@ impl<'s> TreeIterItem<'s> { &'s self, mut map_child: F, ) -> Result, E> { + if !self.postfix().is_empty() { + return Err(ParseThresholdError::UnexpectedKeyExpressionSuffix { + name: self.name().to_owned(), + suffix: self.postfix().to_owned(), + pos: self.nodes[self.index].postfix_pos, + } + .into()); + } + let mut child_iter = self.children(); let kchild = match child_iter.next() { Some(k) => k, @@ -516,7 +617,10 @@ impl<'a> Tree<'a> { let mut n_nodes = 1; let mut max_depth = 0; let mut open_paren_stack = Vec::with_capacity(128); - for (pos, ch) in s.bytes().enumerate() { + let mut pos = 0; + let bytes = s.as_bytes(); + while pos < s.len() { + let ch = bytes[pos]; if ch == b'(' || ch == b'{' { open_paren_stack.push((ch, pos)); if max_depth < open_paren_stack.len() { @@ -533,30 +637,31 @@ impl<'a> Tree<'a> { }); } + let next_pos = scan_key_expression_postfix(s, pos + 1)?; if let Some(&(paren_ch, paren_pos)) = open_paren_stack.last() { // not last paren; this should not be the end of the string, // and the next character should be a , ) or }. - if pos == s.len() - 1 { + if next_pos == s.len() { return Err(ParseTreeError::UnmatchedOpenParen { ch: paren_ch.into(), pos: paren_pos, }); } else { - let next_byte = s.as_bytes()[pos + 1]; + let next_byte = s.as_bytes()[next_pos]; if next_byte != b')' && next_byte != b'}' && next_byte != b',' { return Err(ParseTreeError::ExpectedParenOrComma { ch: next_byte.into(), - pos: pos + 1, + pos: next_pos, }); // } } } else { // last paren; this SHOULD be the end of the string - if pos < s.len() - 1 { + if next_pos < s.len() { return Err(ParseTreeError::TrailingCharacter { - ch: s.as_bytes()[pos + 1].into(), - pos: pos + 1, + ch: s.as_bytes()[next_pos].into(), + pos: next_pos, }); } } @@ -573,6 +678,8 @@ impl<'a> Tree<'a> { } n_nodes += 1; + pos = scan_key_expression_postfix(s, pos + 1)?; + continue; } else if ch == b',' { if open_paren_stack.is_empty() { // We consider commas outside of the tree to be "trailing characters" @@ -581,6 +688,7 @@ impl<'a> Tree<'a> { n_nodes += 1; } + pos += 1; } // Catch "early end of string" if let Some((ch, pos)) = open_paren_stack.pop() { @@ -630,7 +738,10 @@ impl<'a> Tree<'a> { // as the string serialization lists all the nodes in pre-order. let mut parent_stack = Vec::with_capacity(max_depth); let mut current_node = Some(TreeNode::null(0)); - for (pos, ch) in s.bytes().enumerate() { + let mut pos = 0; + let bytes = s.as_bytes(); + while pos < s.len() { + let ch = bytes[pos]; if ch == b'(' || ch == b'{' { let mut current = current_node.expect("'(' only occurs after a node name"); current.name = &s[current.name_pos..pos]; @@ -662,8 +773,16 @@ impl<'a> Tree<'a> { } current_node = None; - parent_stack.pop(); + let closed_idx = parent_stack.pop().expect("checked by parse_pre_check"); + let suffix_end = scan_key_expression_postfix(s, pos + 1)?; + if suffix_end > pos + 1 { + nodes[closed_idx].postfix = &s[pos + 1..suffix_end]; + nodes[closed_idx].postfix_pos = pos + 1; + pos = suffix_end; + continue; + } } + pos += 1; } if let Some(mut current) = current_node { current.name = &s[current.name_pos..]; @@ -678,6 +797,26 @@ impl<'a> Tree<'a> { } } +fn scan_key_expression_postfix(s: &str, mut pos: usize) -> Result { + let bytes = s.as_bytes(); + if bytes.get(pos) != Some(&b'/') { + return Ok(pos); + } + + while pos < s.len() { + match bytes[pos] { + b'0'..=b'9' | b'\'' | b'*' | b'/' | b';' | b'<' | b'>' | b'h' | b'H' => { + pos += 1; + } + b',' | b')' | b'}' => break, + ch => { + return Err(ParseTreeError::ExpectedParenOrComma { ch: ch.into(), pos }); + } + } + } + Ok(pos) +} + /// Parse a string as a u32, forbidding zero. pub fn parse_num_nonzero(s: &str, context: &'static str) -> Result { if s == "0" { @@ -898,6 +1037,38 @@ mod tests { ); } + #[test] + fn parse_key_expression_postfix() { + let expr = Tree::from_str("pk(musig(A,B)/0/*)").unwrap(); + let key_expr = expr.root().first_child().unwrap(); + assert_eq!(key_expr.name(), "musig"); + assert_eq!(key_expr.postfix(), "/0/*"); + assert_eq!(key_expr.expression_string(), "musig(A,B)/0/*"); + } + + #[test] + fn non_key_expression_postfix_rejected() { + let expr = Tree::from_str("or_b(pk(A),pk(B))/0/*").unwrap(); + let root = expr.root(); + + assert!(matches!( + root.name_separated(':').unwrap_err(), + ParseTreeError::UnexpectedKeyExpressionSuffix { .. } + )); + assert!(matches!( + root.verify_n_children("or_b", 2..=2).unwrap_err(), + ParseTreeError::UnexpectedKeyExpressionSuffix { .. } + )); + + let expr = Tree::from_str("multi_a(1,A,B)/0/*").unwrap(); + let root = expr.root(); + assert!(matches!( + root.verify_threshold::<20, _, (), ParseThresholdError>(|_| Ok(())) + .unwrap_err(), + ParseThresholdError::UnexpectedKeyExpressionSuffix { .. } + )); + } + #[test] fn parse_tree_desc() { let keys = [ diff --git a/src/miniscript/mod.rs b/src/miniscript/mod.rs index f56230014..49a994889 100644 --- a/src/miniscript/mod.rs +++ b/src/miniscript/mod.rs @@ -877,6 +877,31 @@ impl FromTree for Miniscript { .map_err(From::from) .map_err(Error::Parse)?; + fn is_key_expression_node(node: expression::TreeIterItem) -> bool { + let mut child = node; + while let Some(parent) = child.parent() { + let parent_name = parent + .name() + .rsplit_once(':') + .map(|(_, name)| name) + .unwrap_or(parent.name()); + + if matches!(parent_name, "pk" | "pkh" | "pk_k" | "pk_h") && parent.n_children() == 1 + { + return true; + } + + if matches!(parent_name, "multi" | "sortedmulti" | "multi_a" | "sortedmulti_a") + && !child.is_first_child() + { + return true; + } + + child = parent; + } + false + } + let mut stack = Vec::with_capacity(128); for (n, node) in root.pre_order_iter().enumerate().rev() { // Before doing anything else, check if this is the inner value of a terminal. @@ -890,6 +915,10 @@ impl FromTree for Miniscript { // We do not do this check on the root node, because its parent might be wsh or // sh or something, and actually these ARE single-child combinators, but we don't // want to skip their children. + if n > 0 && is_key_expression_node(node) { + continue; + } + if n > 0 && node.n_children() == 0 { let parent = node.parent().unwrap(); if parent.n_children() == 1 { @@ -915,108 +944,119 @@ impl FromTree for Miniscript { .map_err(Error::Parse)?; // "pk" and "pkh" are aliases for "c:pk_k" and "c:pk_h" respectively. - let new = match frag_name { - "expr_raw_pkh" => node - .verify_terminal_parent("expr_raw_pkh", "public key hash") - .map(Miniscript::expr_raw_pkh) - .map_err(Error::Parse), - "pk" => node - .verify_terminal_parent("pk", "public key") - .map(Miniscript::pk) - .map_err(Error::Parse), - "pkh" => node - .verify_terminal_parent("pkh", "public key") - .map(Miniscript::pkh) - .map_err(Error::Parse), - "pk_k" => node - .verify_terminal_parent("pk_k", "public key") - .map(Miniscript::pk_k) - .map_err(Error::Parse), - "pk_h" => node - .verify_terminal_parent("pk_h", "public key") - .map(Miniscript::pk_h) - .map_err(Error::Parse), - "after" => node - .verify_after() - .map(Miniscript::after) - .map_err(Error::Parse), - "older" => node - .verify_older() - .map(Miniscript::older) - .map_err(Error::Parse), - "sha256" => node - .verify_terminal_parent("sha256", "hash") - .map(Miniscript::sha256) - .map_err(Error::Parse), - "hash256" => node - .verify_terminal_parent("hash256", "hash") - .map(Miniscript::hash256) - .map_err(Error::Parse), - "ripemd160" => node - .verify_terminal_parent("ripemd160", "hash") - .map(Miniscript::ripemd160) - .map_err(Error::Parse), - "hash160" => node - .verify_terminal_parent("hash160", "hash") - .map(Miniscript::hash160) - .map_err(Error::Parse), - "1" => { - node.verify_n_children("1", 0..=0) - .map_err(From::from) - .map_err(Error::Parse)?; - Ok(Miniscript::TRUE) - } - "0" => { - node.verify_n_children("0", 0..=0) - .map_err(From::from) - .map_err(Error::Parse)?; - Ok(Miniscript::FALSE) - } - "and_v" => binary(node, &mut stack, "and_v", Terminal::AndV), - "and_b" => binary(node, &mut stack, "and_b", Terminal::AndB), - "and_n" => binary(node, &mut stack, "and_n", |x, y| { - Terminal::AndOr(x, y, Arc::new(Miniscript::FALSE)) - }), - "andor" => { - node.verify_n_children("andor", 3..=3) - .map_err(From::from) - .map_err(Error::Parse)?; - Miniscript::from_ast(Terminal::AndOr( - stack.pop().unwrap(), - stack.pop().unwrap(), - stack.pop().unwrap(), - )) - } - "or_b" => binary(node, &mut stack, "or_b", Terminal::OrB), - "or_d" => binary(node, &mut stack, "or_d", Terminal::OrD), - "or_c" => binary(node, &mut stack, "or_c", Terminal::OrC), - "or_i" => binary(node, &mut stack, "or_i", Terminal::OrI), - "thresh" => node - .verify_threshold(|_| Ok(stack.pop().unwrap())) - .map(Terminal::Thresh) - .and_then(Miniscript::from_ast), - "multi" => node - .verify_threshold(|sub| sub.verify_terminal("public_key").map_err(Error::Parse)) - .map(Terminal::Multi) - .and_then(Miniscript::from_ast), - "sortedmulti" => node - .verify_threshold(|sub| sub.verify_terminal("public_key").map_err(Error::Parse)) - .map(Terminal::SortedMulti) - .and_then(Miniscript::from_ast), - "multi_a" => node - .verify_threshold(|sub| sub.verify_terminal("public_key").map_err(Error::Parse)) - .map(Terminal::MultiA) - .and_then(Miniscript::from_ast), - "sortedmulti_a" => node - .verify_threshold(|sub| sub.verify_terminal("public_key").map_err(Error::Parse)) - .map(Terminal::SortedMultiA) - .and_then(Miniscript::from_ast), - x => { - Err(Error::Parse(crate::ParseError::Tree(crate::ParseTreeError::UnknownName { - name: x.to_owned(), - }))) - } - }?; + let new = + match frag_name { + "expr_raw_pkh" => node + .verify_terminal_parent("expr_raw_pkh", "public key hash") + .map(Miniscript::expr_raw_pkh) + .map_err(Error::Parse), + "pk" => node + .verify_key_expression_parent("pk", "public key") + .map(Miniscript::pk) + .map_err(Error::Parse), + "pkh" => node + .verify_key_expression_parent("pkh", "public key") + .map(Miniscript::pkh) + .map_err(Error::Parse), + "pk_k" => node + .verify_key_expression_parent("pk_k", "public key") + .map(Miniscript::pk_k) + .map_err(Error::Parse), + "pk_h" => node + .verify_key_expression_parent("pk_h", "public key") + .map(Miniscript::pk_h) + .map_err(Error::Parse), + "after" => node + .verify_after() + .map(Miniscript::after) + .map_err(Error::Parse), + "older" => node + .verify_older() + .map(Miniscript::older) + .map_err(Error::Parse), + "sha256" => node + .verify_terminal_parent("sha256", "hash") + .map(Miniscript::sha256) + .map_err(Error::Parse), + "hash256" => node + .verify_terminal_parent("hash256", "hash") + .map(Miniscript::hash256) + .map_err(Error::Parse), + "ripemd160" => node + .verify_terminal_parent("ripemd160", "hash") + .map(Miniscript::ripemd160) + .map_err(Error::Parse), + "hash160" => node + .verify_terminal_parent("hash160", "hash") + .map(Miniscript::hash160) + .map_err(Error::Parse), + "1" => { + node.verify_n_children("1", 0..=0) + .map_err(From::from) + .map_err(Error::Parse)?; + Ok(Miniscript::TRUE) + } + "0" => { + node.verify_n_children("0", 0..=0) + .map_err(From::from) + .map_err(Error::Parse)?; + Ok(Miniscript::FALSE) + } + "and_v" => binary(node, &mut stack, "and_v", Terminal::AndV), + "and_b" => binary(node, &mut stack, "and_b", Terminal::AndB), + "and_n" => binary(node, &mut stack, "and_n", |x, y| { + Terminal::AndOr(x, y, Arc::new(Miniscript::FALSE)) + }), + "andor" => { + node.verify_n_children("andor", 3..=3) + .map_err(From::from) + .map_err(Error::Parse)?; + Miniscript::from_ast(Terminal::AndOr( + stack.pop().unwrap(), + stack.pop().unwrap(), + stack.pop().unwrap(), + )) + } + "or_b" => binary(node, &mut stack, "or_b", Terminal::OrB), + "or_d" => binary(node, &mut stack, "or_d", Terminal::OrD), + "or_c" => binary(node, &mut stack, "or_c", Terminal::OrC), + "or_i" => binary(node, &mut stack, "or_i", Terminal::OrI), + "thresh" => node + .verify_threshold(|_| Ok(stack.pop().unwrap())) + .map(Terminal::Thresh) + .and_then(Miniscript::from_ast), + "multi" => node + .verify_threshold(|sub| { + sub.verify_key_expression("public_key") + .map_err(Error::Parse) + }) + .map(Terminal::Multi) + .and_then(Miniscript::from_ast), + "sortedmulti" => node + .verify_threshold(|sub| { + sub.verify_key_expression("public_key") + .map_err(Error::Parse) + }) + .map(Terminal::SortedMulti) + .and_then(Miniscript::from_ast), + "multi_a" => node + .verify_threshold(|sub| { + sub.verify_key_expression("public_key") + .map_err(Error::Parse) + }) + .map(Terminal::MultiA) + .and_then(Miniscript::from_ast), + "sortedmulti_a" => node + .verify_threshold(|sub| { + sub.verify_key_expression("public_key") + .map_err(Error::Parse) + }) + .map(Terminal::SortedMultiA) + .and_then(Miniscript::from_ast), + x => Err(Error::Parse(crate::ParseError::Tree( + crate::ParseTreeError::UnknownName { name: x.to_owned() }, + ))), + }?; let mut new = Arc::new(new); if let Some(frag_wrap) = frag_wrap { From 516861b22bd3f273e84568b06e3b4a26ad2fee73 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 03:53:24 -0500 Subject: [PATCH 2/6] descriptor: add musig key model Add DescriptorPublicKey::Musig and DescriptorMusigKey so descriptors can retain participant keys, aggregate-level derivation, and wildcard state. Route construction through DescriptorMusigKey::new. That keeps the parser's BIP390 invariants attached to the type itself: no empty or nested aggregates, no invalid aggregate-level derivation, no hardened aggregate derivation, and no mismatched participant multipath lengths. BIP390 forbids empty aggregates but does not require at least two participants. Keep musig(K) valid to match Bitcoin Core's musig() descriptor behavior. Extend the DescriptorPublicKey helper surface to the new variant so musig() round-trips and can participate in descriptor derivation before aggregate key computation is wired in. The network helper distinguishes mixed participant xpub networks, and musig() reports no synthetic master fingerprint. Context helpers describe the aggregate key shape, so uncompressed participant KEYs do not make musig() itself an uncompressed key. --- src/descriptor/key.rs | 515 ++++++++++++++++++++++++++++++++++++++++-- src/descriptor/mod.rs | 60 ++++- 2 files changed, 545 insertions(+), 30 deletions(-) diff --git a/src/descriptor/key.rs b/src/descriptor/key.rs index 3c726501a..0afb39e8d 100644 --- a/src/descriptor/key.rs +++ b/src/descriptor/key.rs @@ -27,6 +27,8 @@ pub enum DescriptorPublicKey { XPub(DescriptorXKey), /// Multiple extended public keys. MultiXPub(DescriptorMultiXKey), + /// MuSig2 aggregate key expression. + Musig(DescriptorMusigKey), } /// The descriptor secret key, either a single private key or an xprv. @@ -105,6 +107,19 @@ pub struct DescriptorMultiXKey { pub wildcard: Wildcard, } +/// Instance of a BIP390 `musig()` aggregate key expression. +/// +/// X-only participants are lifted with an even y-coordinate before aggregation. +#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] +pub struct DescriptorMusigKey { + /// Participant keys. + participants: Vec, + /// Derivation paths applied to the aggregate key. Never empty. + derivation_paths: DerivPaths, + /// Whether the aggregate key expression is wildcard. + wildcard: Wildcard, +} + /// Single public key without any origin or range information. #[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] pub enum SinglePubKey { @@ -119,7 +134,7 @@ pub enum SinglePubKey { pub struct DefiniteDescriptorKey(DescriptorPublicKey); /// Network information extracted from extended keys in a descriptor. -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum XKeyNetwork { /// No extended keys are present in the descriptor. NoXKeys, @@ -466,10 +481,17 @@ pub enum MalformedKeyDataKind { InvalidFullPublicKeyPrefix, InvalidMasterFingerprintLength, InvalidMultiIndexStep, + InvalidMusigAggregateDerivation, + InvalidMusigExpression, + InvalidMusigParticipant, InvalidMultiXKeyDerivation, InvalidPublicKeyLength, InvalidWildcardInDerivationPath, KeyTooShort, + MusigEmpty, + MusigHardenedDerivation, + MusigMultipathLenMismatch, + MusigNested, MultipleFingerprintsInPublicKey, MultipleDerivationPathIndexSteps, NoKeyAfterOrigin, @@ -485,10 +507,17 @@ impl fmt::Display for MalformedKeyDataKind { Self::InvalidFullPublicKeyPrefix => "only full public keys with prefixes '02', '03' or '04' are allowed", Self::InvalidMasterFingerprintLength => "master fingerprint should be 8 characters long", Self::InvalidMultiIndexStep => "invalid multi index step in multipath descriptor", + Self::InvalidMusigAggregateDerivation => "musig aggregate-level derivation requires non-ranged xpub participants", + Self::InvalidMusigExpression => "invalid musig key expression", + Self::InvalidMusigParticipant => "invalid musig participant key", Self::InvalidMultiXKeyDerivation => "can't make a multi-xpriv with hardened derivation steps that are not shared among all paths into a public key", Self::InvalidPublicKeyLength => "public keys must be 64, 66 or 130 characters in size", Self::InvalidWildcardInDerivationPath => "'*' may only appear as last element in a derivation path", Self::KeyTooShort => "key too short", + Self::MusigEmpty => "musig key expression must contain at least one participant", + Self::MusigHardenedDerivation => "musig aggregate-level derivation cannot contain hardened steps", + Self::MusigMultipathLenMismatch => "musig participant multipath lengths must match", + Self::MusigNested => "musig key expressions cannot be nested", Self::MultipleFingerprintsInPublicKey => "multiple ']' in Descriptor Public Key", Self::MultipleDerivationPathIndexSteps => "'<' may only appear once in a derivation path", Self::NoKeyAfterOrigin => "no key after origin", @@ -611,7 +640,23 @@ impl fmt::Display for DescriptorPublicKey { Self::Single(ref pk) => pk.fmt(f), Self::XPub(ref xpub) => xpub.fmt(f), Self::MultiXPub(ref xpub) => xpub.fmt(f), + Self::Musig(ref musig) => musig.fmt(f), + } + } +} + +impl fmt::Display for DescriptorMusigKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("musig(")?; + for (i, participant) in self.participants.iter().enumerate() { + if i > 0 { + f.write_str(",")?; + } + participant.fmt(f)?; } + f.write_str(")")?; + fmt_derivation_paths(f, self.derivation_paths.paths())?; + self.wildcard.fmt(f) } } @@ -726,6 +771,10 @@ impl FromStr for DescriptorPublicKey { type Err = DescriptorKeyParseError; fn from_str(s: &str) -> Result { + if s.starts_with("musig(") { + return parse_musig_key(s).map(DescriptorPublicKey::Musig); + } + // A "raw" public key without any origin is the least we accept. if s.len() < 64 { return Err(DescriptorKeyParseError::MalformedKeyData( @@ -783,6 +832,47 @@ impl FromStr for DescriptorPublicKey { } } +fn parse_musig_key(s: &str) -> Result { + let tree = crate::expression::Tree::from_str(s).map_err(|_| { + DescriptorKeyParseError::MalformedKeyData(MalformedKeyDataKind::InvalidMusigExpression) + })?; + let root = tree.root(); + if root.name() != "musig" { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::InvalidMusigExpression, + )); + } + if root.n_children() == 0 { + return Err(DescriptorKeyParseError::MalformedKeyData(MalformedKeyDataKind::MusigEmpty)); + } + + let mut participants = Vec::with_capacity(root.n_children()); + for child in root.children() { + let participant = DescriptorPublicKey::from_str(&child.expression_string())?; + if matches!(participant, DescriptorPublicKey::Musig(_)) { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigNested, + )); + } + participants.push(participant); + } + + let (mut derivation_paths, wildcard) = parse_derivation_suffix(root.postfix())?; + if derivation_paths.is_empty() { + derivation_paths.push(bip32::DerivationPath::from(Vec::new())); + } + + DescriptorMusigKey::new( + participants, + DerivPaths::new(derivation_paths).expect("not empty"), + wildcard, + ) +} + +fn has_hardened_step(path: &bip32::DerivationPath) -> bool { + path.into_iter().any(|step| step.is_hardened()) +} + impl From for DescriptorPublicKey { fn from(key: XOnlyPublicKey) -> Self { DescriptorPublicKey::Single(SinglePub { origin: None, key: SinglePubKey::XOnly(key) }) @@ -831,6 +921,7 @@ impl DescriptorPublicKey { ) } } + DescriptorPublicKey::Musig(_) => bip32::Fingerprint::default(), } } @@ -858,7 +949,7 @@ impl DescriptorPublicKey { bip32::DerivationPath::from(vec![]) }) } - DescriptorPublicKey::MultiXPub(_) => None, + DescriptorPublicKey::MultiXPub(_) | DescriptorPublicKey::Musig(_) => None, } } @@ -883,6 +974,7 @@ impl DescriptorPublicKey { .map(|p| origin_path.extend(p)) .collect() } + DescriptorPublicKey::Musig(musig) => musig.derivation_paths.paths().clone(), _ => vec![self .full_derivation_path() .expect("Must be Some for non-multipath keys")], @@ -900,7 +992,7 @@ impl DescriptorPublicKey { match *self { DescriptorPublicKey::XPub(ref xpub) => Some(xpub.derivation_path.clone()), DescriptorPublicKey::Single(_) => Some(bip32::DerivationPath::from(vec![])), - DescriptorPublicKey::MultiXPub(_) => None, + DescriptorPublicKey::MultiXPub(_) | DescriptorPublicKey::Musig(_) => None, } } @@ -918,6 +1010,7 @@ impl DescriptorPublicKey { vec![bip32::DerivationPath::from(vec![])] } DescriptorPublicKey::MultiXPub(xpub) => xpub.derivation_paths.paths().clone(), + DescriptorPublicKey::Musig(musig) => musig.derivation_paths.paths().clone(), } } @@ -927,6 +1020,13 @@ impl DescriptorPublicKey { DescriptorPublicKey::Single(..) => false, DescriptorPublicKey::XPub(ref xpub) => xpub.wildcard != Wildcard::None, DescriptorPublicKey::MultiXPub(ref xpub) => xpub.wildcard != Wildcard::None, + DescriptorPublicKey::Musig(ref musig) => { + musig.wildcard != Wildcard::None + || musig + .participants + .iter() + .any(DescriptorPublicKey::has_wildcard) + } } } @@ -936,6 +1036,7 @@ impl DescriptorPublicKey { DescriptorPublicKey::Single(..) => None, DescriptorPublicKey::XPub(ref xpub) => Some(xpub.wildcard), DescriptorPublicKey::MultiXPub(ref xpub) => Some(xpub.wildcard), + DescriptorPublicKey::Musig(ref musig) => Some(musig.wildcard), } } @@ -945,6 +1046,15 @@ impl DescriptorPublicKey { DescriptorPublicKey::Single(..) => &[], DescriptorPublicKey::XPub(xpub) => core::slice::from_ref(&xpub.derivation_path), DescriptorPublicKey::MultiXPub(xpub) => &xpub.derivation_paths.paths()[..], + DescriptorPublicKey::Musig(musig) => { + if musig.derivation_paths.paths().iter().any(has_hardened_step) { + return true; + } + return musig + .participants + .iter() + .any(DescriptorPublicKey::has_hardened_step); + } }; for p in paths { for step in p.into_iter() { @@ -998,6 +1108,47 @@ impl DescriptorPublicKey { }) } DescriptorPublicKey::MultiXPub(_) => return Err(NonDefiniteKeyError::Multipath), + DescriptorPublicKey::Musig(musig) => { + let participants = musig + .participants + .into_iter() + .map(|participant| { + if participant.has_wildcard() { + participant + .at_derivation_index(index) + .map(DefiniteDescriptorKey::into_descriptor_public_key) + } else if participant.is_multipath() { + Err(NonDefiniteKeyError::Multipath) + } else { + Ok(participant) + } + }) + .collect::, _>>()?; + + let derivation_paths = musig + .derivation_paths + .into_paths() + .into_iter() + .map(|path| match musig.wildcard { + Wildcard::None => Ok(path), + Wildcard::Unhardened => Ok(path.into_child( + bip32::ChildNumber::from_normal_idx(index) + .ok() + .ok_or(NonDefiniteKeyError::HardenedStep)?, + )), + Wildcard::Hardened => Err(NonDefiniteKeyError::HardenedStep), + }) + .collect::, _>>()?; + + DescriptorPublicKey::Musig( + DescriptorMusigKey::new( + participants, + DerivPaths::new(derivation_paths).expect("not empty"), + Wildcard::None, + ) + .expect("deriving a valid musig key preserves musig invariants"), + ) + } }; DefiniteDescriptorKey::new(definite) @@ -1008,6 +1159,13 @@ impl DescriptorPublicKey { match *self { DescriptorPublicKey::Single(..) | DescriptorPublicKey::XPub(..) => false, DescriptorPublicKey::MultiXPub(_) => true, + DescriptorPublicKey::Musig(ref musig) => { + musig.derivation_paths.paths().len() > 1 + || musig + .participants + .iter() + .any(DescriptorPublicKey::is_multipath) + } } } @@ -1034,6 +1192,7 @@ impl DescriptorPublicKey { }) .collect() } + DescriptorPublicKey::Musig(musig) => musig.into_single_keys(), } } @@ -1041,14 +1200,188 @@ impl DescriptorPublicKey { /// /// Returns `None` for single keys (non-extended keys), `Some(NetworkKind)` for extended keys. pub fn xkey_network(&self) -> Option { + match self.xkey_network_summary() { + XKeyNetwork::Single(network) => Some(network), + XKeyNetwork::NoXKeys | XKeyNetwork::Mixed => None, + } + } + + pub(crate) fn xkey_network_summary(&self) -> XKeyNetwork { match self { - DescriptorPublicKey::Single(_) => None, - DescriptorPublicKey::XPub(xpub) => Some(xpub.xkey.network), - DescriptorPublicKey::MultiXPub(multi_xpub) => Some(multi_xpub.xkey.network), + DescriptorPublicKey::Single(_) => XKeyNetwork::NoXKeys, + DescriptorPublicKey::XPub(xpub) => XKeyNetwork::Single(xpub.xkey.network), + DescriptorPublicKey::MultiXPub(multi_xpub) => { + XKeyNetwork::Single(multi_xpub.xkey.network) + } + DescriptorPublicKey::Musig(musig) => musig.xkey_network_summary(), } } } +impl DescriptorMusigKey { + /// Creates a `musig()` aggregate key expression after validating BIP390 invariants. + pub fn new( + participants: Vec, + derivation_paths: DerivPaths, + wildcard: Wildcard, + ) -> Result { + Self::validate(&participants, &derivation_paths, wildcard)?; + Ok(Self { participants, derivation_paths, wildcard }) + } + + /// Participant keys in this aggregate. + pub fn participants(&self) -> &[DescriptorPublicKey] { &self.participants } + + /// Derivation paths applied to the aggregate key. + pub fn derivation_paths(&self) -> &DerivPaths { &self.derivation_paths } + + /// Wildcard applied to the aggregate key expression. + pub fn wildcard(&self) -> Wildcard { self.wildcard } + + /// Get the network of the participant xpubs, if all present xpubs agree. + pub fn xkey_network(&self) -> Option { + match self.xkey_network_summary() { + XKeyNetwork::Single(network) => Some(network), + XKeyNetwork::NoXKeys | XKeyNetwork::Mixed => None, + } + } + + pub(crate) fn xkey_network_summary(&self) -> XKeyNetwork { + let mut network = None; + for participant in &self.participants { + match participant.xkey_network_summary() { + XKeyNetwork::NoXKeys => {} + XKeyNetwork::Single(found) => match network { + None => network = Some(found), + Some(current) if current == found => {} + Some(_) => return XKeyNetwork::Mixed, + }, + XKeyNetwork::Mixed => return XKeyNetwork::Mixed, + } + } + + match network { + Some(network) => XKeyNetwork::Single(network), + None => XKeyNetwork::NoXKeys, + } + } + + fn validate( + participants: &[DescriptorPublicKey], + derivation_paths: &DerivPaths, + wildcard: Wildcard, + ) -> Result<(), DescriptorKeyParseError> { + if participants.is_empty() { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigEmpty, + )); + } + + if participants + .iter() + .any(|participant| matches!(participant, DescriptorPublicKey::Musig(_))) + { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigNested, + )); + } + + if Self::has_aggregate_derivation(derivation_paths, wildcard) { + if wildcard == Wildcard::Hardened + || derivation_paths.paths().iter().any(has_hardened_step) + { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigHardenedDerivation, + )); + } + + if participants.iter().any(|p| { + !matches!(p, DescriptorPublicKey::XPub(_)) || p.has_wildcard() || p.is_multipath() + }) { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::InvalidMusigAggregateDerivation, + )); + } + } else { + let mut multipath_len = None; + for participant in participants { + let n_paths = participant.num_der_paths(); + if n_paths > 1 { + match multipath_len { + None => multipath_len = Some(n_paths), + Some(len) if len == n_paths => {} + Some(_) => { + return Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigMultipathLenMismatch, + )) + } + } + } + } + } + + Ok(()) + } + + fn has_aggregate_derivation(derivation_paths: &DerivPaths, wildcard: Wildcard) -> bool { + wildcard != Wildcard::None + || derivation_paths.paths().len() > 1 + || derivation_paths.paths().iter().any(|path| !path.is_empty()) + } + + fn into_single_keys(self) -> Vec { + let DescriptorMusigKey { participants, derivation_paths, wildcard } = self; + let derivation_paths = derivation_paths.into_paths(); + if derivation_paths.len() > 1 { + return derivation_paths + .into_iter() + .map(|derivation_path| { + DescriptorPublicKey::Musig( + DescriptorMusigKey::new( + participants.clone(), + DerivPaths::new(vec![derivation_path]).expect("not empty"), + wildcard, + ) + .expect("single aggregate paths preserve musig invariants"), + ) + }) + .collect(); + } + + let participant_keys = participants + .into_iter() + .map(DescriptorPublicKey::into_single_keys) + .collect::>(); + let n_keys = participant_keys.iter().map(Vec::len).max().unwrap_or(1); + debug_assert!(participant_keys + .iter() + .all(|keys| keys.len() == 1 || keys.len() == n_keys)); + + (0..n_keys) + .map(|i| { + let participants = participant_keys + .iter() + .map(|keys| { + if keys.len() == 1 { + keys[0].clone() + } else { + keys[i].clone() + } + }) + .collect(); + DescriptorPublicKey::Musig( + DescriptorMusigKey::new( + participants, + DerivPaths::new(derivation_paths.clone()).expect("not empty"), + wildcard, + ) + .expect("single participant paths preserve musig invariants"), + ) + }) + .collect() + } +} + impl FromStr for DescriptorSecretKey { type Err = DescriptorKeyParseError; @@ -1150,8 +1483,8 @@ where Key: FromStr, E: Into, { - let mut key_deriv = key_deriv.split('/'); - let xkey_str = key_deriv + let mut key_deriv_split = key_deriv.split('/'); + let xkey_str = key_deriv_split .next() .ok_or(DescriptorKeyParseError::MalformedKeyData( MalformedKeyDataKind::NoKeyAfterOrigin, @@ -1160,6 +1493,28 @@ where let xkey = Key::from_str(xkey_str).map_err(|e| DescriptorKeyParseError::XKeyParseError(e.into()))?; + let (derivation_paths, wildcard) = parse_derivation_steps(key_deriv_split)?; + + Ok((xkey, derivation_paths, wildcard)) +} + +fn parse_derivation_suffix( + suffix: &str, +) -> Result<(Vec, Wildcard), DescriptorKeyParseError> { + if suffix.is_empty() { + Ok((Vec::new(), Wildcard::None)) + } else if let Some(suffix) = suffix.strip_prefix('/') { + parse_derivation_steps(suffix.split('/')) + } else { + Err(DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::InvalidMusigExpression, + )) + } +} + +fn parse_derivation_steps<'a>( + key_deriv: impl Iterator, +) -> Result<(Vec, Wildcard), DescriptorKeyParseError> { let mut wildcard = Wildcard::None; let mut multipath = false; let derivation_paths = key_deriv @@ -1253,7 +1608,7 @@ where .map(|index_list| index_list.into_iter().collect::()) .collect::>(); - Ok((xkey, derivation_paths, wildcard)) + Ok((derivation_paths, wildcard)) } impl DescriptorXKey { @@ -1358,6 +1713,7 @@ impl MiniscriptKey for DescriptorPublicKey { fn is_x_only_key(&self) -> bool { match self { DescriptorPublicKey::Single(single_pub) => single_pub.is_x_only_key(), + DescriptorPublicKey::Musig(_) => true, _ => false, } } @@ -1367,6 +1723,16 @@ impl MiniscriptKey for DescriptorPublicKey { DescriptorPublicKey::Single(single) => single.num_der_paths(), DescriptorPublicKey::XPub(xpub) => xpub.num_der_paths(), DescriptorPublicKey::MultiXPub(xpub) => xpub.num_der_paths(), + DescriptorPublicKey::Musig(musig) => { + let aggregate_paths = musig.derivation_paths.paths().len(); + let participant_paths = musig + .participants + .iter() + .map(DescriptorPublicKey::num_der_paths) + .max() + .unwrap_or(0); + aggregate_paths.max(participant_paths) + } } } } @@ -1400,6 +1766,9 @@ impl DefiniteDescriptorKey { DescriptorPublicKey::MultiXPub(_) => { unreachable!("impossible by construction of DefiniteDescriptorKey") } + DescriptorPublicKey::Musig(_) => { + unimplemented!("MuSig aggregate key derivation is implemented in a later commit") + } } } @@ -1524,6 +1893,11 @@ mod test { use crate::prelude::*; use crate::DefiniteDescriptorKey; + const PUBKEY_1: &str = "021d4ea7132d4e1a362ee5efd8d0b59dd4d1fe8906eefa7dd812b05a46b73d829b"; + const PUBKEY_2: &str = "02a489e0ea42b56148d212d325b7c67c6460483ff931c303ea311edfef667c8f35"; + const UNCOMPRESSED_PUBKEY: &str = "0414fc03b8df87cd7b872996810db8458d61da8448e531569c8517b469a119d267be5645686309c6e6736dbd93940707cc9143d3cf29f1b877ff340e2cb2d259cf"; + const TPUB: &str = "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi"; + #[test] fn parse_descriptor_key_errors() { // And ones with misplaced wildcard @@ -1599,6 +1973,95 @@ mod test { assert_eq!(DescriptorSecretKey::from_str(desc).unwrap_err().to_string(), "invalid base58"); } + #[test] + fn parse_musig_descriptor_public_keys() { + let desc = format!("musig({},{})", PUBKEY_1, PUBKEY_2); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + assert_eq!(key.to_string(), desc); + assert!(key.is_x_only_key()); + assert!(!key.has_wildcard()); + assert_eq!(key.num_der_paths(), 1); + assert!(matches!(DefiniteDescriptorKey::new(key), Ok(..))); + + // Bitcoin Core PR #31244 accepts a single musig() participant, and BIP390 does not + // forbid it. + let desc = format!("musig({})", PUBKEY_1); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + assert_eq!(key.to_string(), desc); + assert!(key.is_x_only_key()); + assert_eq!(key.num_der_paths(), 1); + assert!(matches!(DefiniteDescriptorKey::new(key), Ok(..))); + + let desc = format!("musig({},{})", UNCOMPRESSED_PUBKEY, PUBKEY_2); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + assert!(key.is_x_only_key()); + assert!(!key.is_uncompressed()); + + let desc = format!("musig({},{})/0/*", TPUB, TPUB); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + assert_eq!(key.to_string(), desc); + assert!(key.has_wildcard()); + assert_eq!(key.wildcard(), Some(Wildcard::Unhardened)); + assert_eq!(key.derivation_paths(), vec![bip32::DerivationPath::from_str("m/0").unwrap()]); + assert!(matches!( + DefiniteDescriptorKey::new(key.clone()), + Err(NonDefiniteKeyError::Wildcard) + )); + assert_eq!( + key.at_derivation_index(7) + .unwrap() + .into_descriptor_public_key() + .to_string(), + format!("musig({},{})/0/7", TPUB, TPUB) + ); + } + + #[test] + fn parse_musig_descriptor_public_key_errors() { + for desc in [ + "musig()", + &format!("musig(musig({},{}),{})", PUBKEY_1, PUBKEY_2, PUBKEY_1), + &format!("musig({},{})/0", PUBKEY_1, PUBKEY_2), + &format!("musig({0}/*,{0})/0", TPUB), + &format!("musig({0},{0})/0'", TPUB), + &format!("musig({0},{0})/0/*h", TPUB), + &format!("musig({0}/<0;1>,{0}/<0;1;2>)", TPUB), + &format!("musig({0}/<0;1>,{0})/<2;3>", TPUB), + ] { + DescriptorPublicKey::from_str(desc).unwrap_err(); + } + } + + #[test] + fn musig_into_single_keys() { + let key = + DescriptorPublicKey::from_str(&format!("musig({0}/<0;1>,{0}/<2;3>)", TPUB)).unwrap(); + assert_eq!(key.num_der_paths(), 2); + assert_eq!( + key.into_single_keys() + .into_iter() + .map(|key| key.to_string()) + .collect::>(), + vec![ + format!("musig({0}/0,{0}/2)", TPUB), + format!("musig({0}/1,{0}/3)", TPUB) + ] + ); + + let key = DescriptorPublicKey::from_str(&format!("musig({0},{0})/<0;1>/*", TPUB)).unwrap(); + assert_eq!(key.num_der_paths(), 2); + assert_eq!( + key.into_single_keys() + .into_iter() + .map(|key| key.to_string()) + .collect::>(), + vec![ + format!("musig({0},{0})/0/*", TPUB), + format!("musig({0},{0})/1/*", TPUB) + ] + ); + } + #[test] fn test_wildcard() { let public_key = DescriptorPublicKey::from_str("[abcdef00/0'/1']tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2").unwrap(); @@ -1664,6 +2127,10 @@ mod test { .as_bytes(), b"\xb0\x59\x11\x6a" ); + + let musig = + DescriptorPublicKey::from_str(&format!("musig({},{})", PUBKEY_1, PUBKEY_2)).unwrap(); + assert_eq!(musig.master_fingerprint(), bip32::Fingerprint::default()); } fn get_multipath_xpub(key_str: &str, num_paths: usize) -> DescriptorMultiXKey { @@ -1934,22 +2401,34 @@ mod test { fn test_xkey_network() { use bitcoin::NetworkKind; + let mainnet_xpub_str = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + let testnet_xpub_str = "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi"; + let single_key_str = "021d4ea7132d4e1a362ee5efd8d0b59dd4d1fe8906eefa7dd812b05a46b73d829b"; + // Test mainnet xpub - let mainnet_xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" - .parse::() - .unwrap(); + let mainnet_xpub = mainnet_xpub_str.parse::().unwrap(); assert_eq!(mainnet_xpub.xkey_network(), Some(NetworkKind::Main)); // Test testnet xpub - let testnet_xpub = "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi" - .parse::() - .unwrap(); + let testnet_xpub = testnet_xpub_str.parse::().unwrap(); assert_eq!(testnet_xpub.xkey_network(), Some(NetworkKind::Test)); // Test single public key (no extended key) - let single_key = "021d4ea7132d4e1a362ee5efd8d0b59dd4d1fe8906eefa7dd812b05a46b73d829b" - .parse::() - .unwrap(); + let single_key = single_key_str.parse::().unwrap(); assert_eq!(single_key.xkey_network(), None); + + let musig = DescriptorPublicKey::from_str(&format!( + "musig({},{})", + single_key_str, mainnet_xpub_str + )) + .unwrap(); + assert_eq!(musig.xkey_network(), Some(NetworkKind::Main)); + + let musig = DescriptorPublicKey::from_str(&format!( + "musig({},{})", + mainnet_xpub_str, testnet_xpub_str + )) + .unwrap(); + assert_eq!(musig.xkey_network(), None); } } diff --git a/src/descriptor/mod.rs b/src/descriptor/mod.rs index 5dc6e7592..0805e2345 100644 --- a/src/descriptor/mod.rs +++ b/src/descriptor/mod.rs @@ -55,8 +55,9 @@ mod wallet_policy; pub use self::key::{ DefiniteDescriptorKey, DerivPaths, DescriptorKeyParseError, DescriptorMultiXKey, - DescriptorPublicKey, DescriptorSecretKey, DescriptorXKey, InnerXKey, MalformedKeyDataKind, - NonDefiniteKeyError, SinglePriv, SinglePub, SinglePubKey, Wildcard, XKeyNetwork, + DescriptorMusigKey, DescriptorPublicKey, DescriptorSecretKey, DescriptorXKey, InnerXKey, + MalformedKeyDataKind, NonDefiniteKeyError, SinglePriv, SinglePub, SinglePubKey, Wildcard, + XKeyNetwork, }; pub use self::key_map::KeyMap; pub use self::wallet_policy::{WalletPolicy, WalletPolicyError}; @@ -962,6 +963,17 @@ impl Descriptor { } true } + DescriptorPublicKey::Musig(musig) => { + let n_paths = key.num_der_paths(); + if n_paths > 1 || musig.derivation_paths().paths().len() > 1 { + for _ in 0..n_paths { + descriptors.push(self.clone()); + } + true + } else { + false + } + } } }) { // If there is no multipath key, return early. @@ -986,6 +998,12 @@ impl Descriptor { .get(self.0) .cloned() .ok_or(Error::MultipathDescLenMismatch), + DescriptorPublicKey::Musig(_) => pk + .clone() + .into_single_keys() + .get(self.0) + .cloned() + .ok_or(Error::MultipathDescLenMismatch), } } translate_hash_clone!(DescriptorPublicKey); @@ -1013,12 +1031,14 @@ impl Descriptor { let mut first_network = None; for key in self.iter_pk() { - if let Some(network) = key.xkey_network() { - match first_network { + match key.xkey_network_summary() { + XKeyNetwork::NoXKeys => {} + XKeyNetwork::Single(network) => match first_network { None => first_network = Some(network), - Some(ref n) if *n != network => return XKeyNetwork::Mixed, - _ => continue, - } + Some(n) if n != network => return XKeyNetwork::Mixed, + Some(_) => {} + }, + XKeyNetwork::Mixed => return XKeyNetwork::Mixed, } } @@ -1103,12 +1123,14 @@ impl Descriptor { let mut first_network = None; for key in self.iter_pk() { - if let Some(network) = key.as_descriptor_public_key().xkey_network() { - match first_network { + match key.as_descriptor_public_key().xkey_network_summary() { + XKeyNetwork::NoXKeys => {} + XKeyNetwork::Single(network) => match first_network { None => first_network = Some(network), - Some(ref n) if *n != network => return XKeyNetwork::Mixed, - _ => continue, - } + Some(n) if n != network => return XKeyNetwork::Mixed, + Some(_) => {} + }, + XKeyNetwork::Mixed => return XKeyNetwork::Mixed, } } @@ -2740,6 +2762,16 @@ pk(03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8))"; let complex_tests = vec![ // Taproot with mixed networks (format!("tr({},pk({}))", mainnet_xpubs[0], testnet_tpubs[0]), XKeyNetwork::Mixed), + // MuSig with mixed networks + ( + format!("tr(musig({},{}))", mainnet_xpubs[0], testnet_tpubs[0]), + XKeyNetwork::Mixed, + ), + // MuSig with raw keys and one xpub + ( + format!("tr(musig({},{},{}))", single_keys[0], mainnet_xpubs[0], single_keys[1]), + XKeyNetwork::Single(NetworkKind::Main), + ), // Taproot with consistent mainnet keys (format!("tr({},pk({}))", mainnet_xpubs[0], mainnet_xpubs[1]), XKeyNetwork::Single(NetworkKind::Main)), // HTLC-like pattern with mixed networks @@ -2806,6 +2838,10 @@ pk(03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8))"; format!("tr({},pk({}/0))", mainnet_xpubs[0], testnet_tpubs[0]), XKeyNetwork::Mixed, ), + ( + format!("tr(musig({},{}))", mainnet_xpubs[0], testnet_tpubs[0]), + XKeyNetwork::Mixed, + ), ]; for (desc_str, expected) in definite_key_tests { From c34cd79ff3946f68be861f90ba899d303f99d442 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 04:00:45 -0500 Subject: [PATCH 3/6] descriptor: derive musig aggregate keys Implement the descriptor-facing subset of BIP327 key aggregation and BIP328 synthetic xpub derivation in a private descriptor::musig module. Definite musig() keys derive participant keys, sort them as BIP390 requires, aggregate them with BIP327 coefficients, and apply aggregate-level unhardened derivation through the BIP328 synthetic xpub when needed. Document BIP327's infinity-aggregate failure as a panic because derive_public_key is infallible today. The failure remains cryptographically unreachable for non-adversarial participants. Tests pin BIP328 aggregate/xpub vectors, synthetic xpub metadata, x-only participant lifting, uncompressed participant compression, and BIP390 scriptPubKey vectors including duplicated participants with aggregate derivation. --- src/descriptor/key.rs | 10 +- src/descriptor/mod.rs | 93 ++++++++++++++++++ src/descriptor/musig.rs | 213 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/descriptor/musig.rs diff --git a/src/descriptor/key.rs b/src/descriptor/key.rs index 0afb39e8d..7ea059fe1 100644 --- a/src/descriptor/key.rs +++ b/src/descriptor/key.rs @@ -1745,6 +1745,12 @@ impl DefiniteDescriptorKey { /// /// Will return an error if the descriptor key has any hardened derivation steps in its path. To /// avoid this error you should replace any such public keys first with [`crate::Descriptor::translate_pk`]. + /// + /// # Panics + /// + /// Panics if a musig aggregate public key is the point at infinity. BIP327 specifies this as a + /// key aggregation failure, but descriptor public key derivation is currently an infallible API + /// and this failure is cryptographically unreachable for non-adversarial participant keys. pub fn derive_public_key(&self, secp: &Secp256k1) -> bitcoin::PublicKey { match self.0 { DescriptorPublicKey::Single(ref pk) => match pk.key { @@ -1766,9 +1772,7 @@ impl DefiniteDescriptorKey { DescriptorPublicKey::MultiXPub(_) => { unreachable!("impossible by construction of DefiniteDescriptorKey") } - DescriptorPublicKey::Musig(_) => { - unimplemented!("MuSig aggregate key derivation is implemented in a later commit") - } + DescriptorPublicKey::Musig(ref musig) => super::musig::derive_public_key(secp, musig), } } diff --git a/src/descriptor/mod.rs b/src/descriptor/mod.rs index 0805e2345..4c871a85f 100644 --- a/src/descriptor/mod.rs +++ b/src/descriptor/mod.rs @@ -51,6 +51,7 @@ pub use self::tr::{ pub mod checksum; mod key; mod key_map; +mod musig; mod wallet_policy; pub use self::key::{ @@ -2153,6 +2154,98 @@ pk(03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8))"; .unwrap_err(); } + #[test] + fn bip390_musig_script_pubkey_vectors() { + let secp = secp256k1::Secp256k1::verification_only(); + let descriptor = Descriptor::::from_str( + "tr(musig(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659,023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66))", + ) + .unwrap(); + assert_eq!( + descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + ScriptBuf::from_hex( + "512079e6c3e628c9bfbce91de6b7fb28e2aec7713d377cf260ab599dcbc40e542312" + ) + .unwrap() + ); + + let descriptor = Descriptor::::from_str( + "tr(musig(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659,023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66))", + ) + .unwrap(); + assert_eq!( + descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + ScriptBuf::from_hex( + "512079e6c3e628c9bfbce91de6b7fb28e2aec7713d377cf260ab599dcbc40e542312" + ) + .unwrap() + ); + + let uncompressed_descriptor = Descriptor::::from_str( + "tr(musig(0414fc03b8df87cd7b872996810db8458d61da8448e531569c8517b469a119d267be5645686309c6e6736dbd93940707cc9143d3cf29f1b877ff340e2cb2d259cf,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659))", + ) + .unwrap(); + let compressed_descriptor = Descriptor::::from_str( + "tr(musig(0314fc03b8df87cd7b872996810db8458d61da8448e531569c8517b469a119d267,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659))", + ) + .unwrap(); + assert_eq!( + uncompressed_descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + compressed_descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey() + ); + + let descriptor = Descriptor::::from_str( + "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1)/2)", + ) + .unwrap(); + assert_eq!( + descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + ScriptBuf::from_hex( + "5120a17ceacd6422bd5ffd9f165807b254b7d68ad39f179cc4f11545a6835227e97c" + ) + .unwrap() + ); + + let descriptor = Descriptor::::from_str( + "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*,pk(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9))", + ) + .unwrap(); + for (index, expected) in [ + (0, "51201d377b637b5c73f670f5c8a96a2c0bb0d1a682a1fca6aba91fe673501a189782"), + (1, "51208950c83b117a6c208d5205ffefcf75b187b32512eb7f0d8577db8d9102833036"), + (2, "5120a49a477c61df73691b77fcd563a80a15ea67bb9c75470310ce5c0f25918db60d"), + ] { + assert_eq!( + descriptor + .derive_at_index(index) + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + ScriptBuf::from_hex(expected).unwrap() + ); + } + } + #[test] fn test_find_derivation_index_for_spk() { let secp = secp256k1::Secp256k1::verification_only(); diff --git a/src/descriptor/musig.rs b/src/descriptor/musig.rs new file mode 100644 index 000000000..5e82e17fe --- /dev/null +++ b/src/descriptor/musig.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: CC0-1.0 + +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::{constants, PublicKey, Scalar, Secp256k1, Verification}; +use bitcoin::{bip32, NetworkKind}; + +use super::key::{DefiniteDescriptorKey, DescriptorMusigKey}; +use crate::prelude::*; + +const SYNTHETIC_XPUB_CHAIN_CODE: [u8; 32] = [ + 0x86, 0x80, 0x87, 0xca, 0x02, 0xa6, 0xf9, 0x74, 0xc4, 0x59, 0x89, 0x24, 0xc3, 0x6b, 0x57, 0x76, + 0x2d, 0x32, 0xcb, 0x45, 0x71, 0x71, 0x67, 0xe3, 0x00, 0x62, 0x2c, 0x71, 0x67, 0xe3, 0x89, 0x65, +]; + +pub(super) fn derive_public_key( + secp: &Secp256k1, + musig: &DescriptorMusigKey, +) -> bitcoin::PublicKey { + let aggregate = aggregate_public_key( + secp, + musig.participants().iter().map(|participant| { + DefiniteDescriptorKey::new(participant.clone()) + .expect("musig participants are definite") + .derive_public_key(secp) + .inner + }), + ); + + let derivation_path = &musig.derivation_paths().paths()[0]; + if derivation_path.is_empty() { + bitcoin::PublicKey::new(aggregate) + } else { + let network = musig.xkey_network().unwrap_or(NetworkKind::Main); + let xpub = synthetic_xpub(aggregate, network); + bitcoin::PublicKey::new( + xpub.derive_pub(secp, derivation_path) + .expect("definite musig aggregate derivation path is unhardened") + .public_key, + ) + } +} + +fn aggregate_public_key(secp: &Secp256k1, keys: I) -> PublicKey +where + C: Verification, + I: IntoIterator, +{ + let mut keys = keys.into_iter().collect::>(); + keys.sort_by_key(|key| key.serialize()); + key_agg_public_key(secp, keys) +} + +fn key_agg_public_key(secp: &Secp256k1, keys: I) -> PublicKey +where + C: Verification, + I: IntoIterator, +{ + let keys = keys.into_iter().collect::>(); + let key_bytes = keys.iter().map(PublicKey::serialize).collect::>(); + let second_key = second_distinct_key(&key_bytes); + let key_hash = hash_keys(&key_bytes); + + let mut weighted_keys = Vec::with_capacity(keys.len()); + for (key, key_bytes) in keys.into_iter().zip(key_bytes.iter()) { + let coeff = if Some(key_bytes) == second_key { + Scalar::ONE + } else { + key_agg_coeff(&key_hash, key_bytes) + }; + // A zero coefficient is cryptographically unreachable for honest inputs. `mul_tweak` + // rejects zero, while adding the identity point would not change the aggregate. + if coeff != Scalar::ZERO { + weighted_keys.push( + key.mul_tweak(secp, &coeff) + .expect("musig key coefficient produces a valid public key"), + ); + } + } + + let refs = weighted_keys.iter().collect::>(); + let aggregate = PublicKey::combine_keys(&refs); + debug_assert!(aggregate.is_ok(), "BIP327 KeyAgg output is not infinity"); + // BIP327 specifies infinity as a KeyAgg failure. Descriptor public key derivation is + // currently infallible, matching the surrounding BIP32 derivation APIs after + // DefiniteDescriptorKey has ruled out malformed paths, so we treat this cryptographic edge as + // unreachable here. + aggregate.expect("BIP327 KeyAgg output is not infinity") +} + +fn synthetic_xpub(public_key: PublicKey, network: NetworkKind) -> bip32::Xpub { + bip32::Xpub { + network, + depth: 0, + parent_fingerprint: Default::default(), + child_number: bip32::ChildNumber::from_normal_idx(0).expect("0 is a valid child number"), + public_key, + chain_code: SYNTHETIC_XPUB_CHAIN_CODE.into(), + } +} + +fn second_distinct_key(key_bytes: &[[u8; 33]]) -> Option<&[u8; 33]> { + key_bytes.iter().find(|key| *key != &key_bytes[0]) +} + +fn hash_keys(key_bytes: &[[u8; 33]]) -> [u8; 32] { + let mut bytes = Vec::with_capacity(key_bytes.len() * 33); + for key in key_bytes { + bytes.extend_from_slice(key); + } + tagged_hash("KeyAgg list", &bytes) +} + +fn key_agg_coeff(key_hash: &[u8; 32], key_bytes: &[u8; 33]) -> Scalar { + let mut bytes = Vec::with_capacity(65); + bytes.extend_from_slice(key_hash); + bytes.extend_from_slice(key_bytes); + scalar_from_hash_mod_n(tagged_hash("KeyAgg coefficient", &bytes)) +} + +fn tagged_hash(tag: &str, bytes: &[u8]) -> [u8; 32] { + let tag_hash = sha256::Hash::hash(tag.as_bytes()); + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(bytes); + sha256::Hash::from_engine(engine).to_byte_array() +} + +fn scalar_from_hash_mod_n(mut bytes: [u8; 32]) -> Scalar { + // A SHA256 digest is below 2^256, which is below twice the secp256k1 group order. + // One conditional subtraction is therefore enough to reduce this 32-byte value. + if bytes >= constants::CURVE_ORDER { + sub_assign_32(&mut bytes, &constants::CURVE_ORDER); + } + debug_assert!(bytes < constants::CURVE_ORDER); + Scalar::from_be_bytes(bytes).expect("hash reduced modulo curve order") +} + +fn sub_assign_32(lhs: &mut [u8; 32], rhs: &[u8; 32]) { + let mut borrow = 0u16; + for (a, b) in lhs.iter_mut().zip(rhs.iter()).rev() { + let subtrahend = *b as u16 + borrow; + let minuend = *a as u16; + if minuend >= subtrahend { + *a = (minuend - subtrahend) as u8; + borrow = 0; + } else { + *a = (minuend + 256 - subtrahend) as u8; + borrow = 1; + } + } + debug_assert_eq!(borrow, 0); +} + +#[cfg(test)] +mod tests { + use core::str::FromStr; + + use bitcoin::bip32; + use bitcoin::secp256k1::Secp256k1; + + use super::*; + + #[test] + fn bip328_aggregate_key_vectors() { + for (keys, expected_aggregate, xpub) in [ + ( + &[ + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + ][..], + "0354240c76b8f2999143301a99c7f721ee57eee0bce401df3afeaa9ae218c70f23", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwXEKGEouzXE6QLLRxjatMcLLzJ5LV5Nib1BN7vJg6yp45yHHRbm", + ), + ( + &[ + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66", + ][..], + "0290539eede565f5d054f32cc0c220126889ed1e5d193baf15aef344fe59d4610c", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwVk5TFJk8Tw5WAdV3DhrGfbFA216sE9BsQQiSFTdudkETnKdg8k", + ), + ( + &[ + "02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + ][..], + "022479f134cdb266141dab1a023cbba30a870f8995b95a91fc8464e56a7d41f8ea", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwUvaZYpysLX4wN59tjwU5pBuDjNrPEJbfxjLwn7ruzbXTcUTHkZ", + ), + ] { + let secp = Secp256k1::verification_only(); + let keys = keys + .iter() + .map(|key| PublicKey::from_str(key).unwrap()) + .collect::>(); + let aggregate = key_agg_public_key(&secp, keys); + assert_eq!(aggregate.to_string(), expected_aggregate); + let synthetic = synthetic_xpub(aggregate, NetworkKind::Main); + assert_eq!(synthetic, bip32::Xpub::from_str(xpub).unwrap()); + assert_eq!(synthetic.depth, 0); + assert_eq!(synthetic.parent_fingerprint, bip32::Fingerprint::default()); + assert_eq!( + synthetic.child_number, + bip32::ChildNumber::from_normal_idx(0).unwrap() + ); + assert_eq!(synthetic.chain_code, SYNTHETIC_XPUB_CHAIN_CODE.into()); + } + } +} From d994c7b442c192889290d2bfa1adcdcca78bca3a Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 22:15:53 -0500 Subject: [PATCH 4/6] descriptor: expose musig helper APIs Add public helpers that let wallet integrations inspect how a musig() key expression derives, walk participant leaf keys, and derive participant keys at an index. Expose fallible aggregate-key and final-key derivation through DescriptorMusigKey. DefiniteDescriptorKey derivation remains infallible, but callers can now handle BIP327 key-aggregation failure explicitly. --- src/descriptor/key.rs | 206 +++++++++++++++++++++++++++++++++++++++- src/descriptor/mod.rs | 4 +- src/descriptor/musig.rs | 53 ++++++----- 3 files changed, 233 insertions(+), 30 deletions(-) diff --git a/src/descriptor/key.rs b/src/descriptor/key.rs index 7ea059fe1..9f7ef1732 100644 --- a/src/descriptor/key.rs +++ b/src/descriptor/key.rs @@ -120,6 +120,20 @@ pub struct DescriptorMusigKey { wildcard: Wildcard, } +/// The way a `musig()` key expression applies derivation. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MusigDerivationKind<'a> { + /// Participant keys are derived first, then aggregated. + DeriveThenAggregate, + /// Participant xpubs are aggregated first, then the BIP328 synthetic xpub is derived. + AggregateThenDerive { + /// Derivation paths applied to the aggregate key. + derivation_paths: &'a DerivPaths, + /// Wildcard applied to the aggregate key. + wildcard: Wildcard, + }, +} + /// Single public key without any origin or range information. #[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] pub enum SinglePubKey { @@ -471,6 +485,43 @@ impl fmt::Display for NonDefiniteKeyError { #[cfg(feature = "std")] impl error::Error for NonDefiniteKeyError {} +/// Error deriving a `musig()` aggregate public key. +#[derive(Debug, PartialEq, Eq, Clone)] +#[non_exhaustive] +pub enum MusigKeyAggError { + /// The `musig()` key expression is not definite. + NonDefiniteKey(NonDefiniteKeyError), + /// BIP32 aggregate-level derivation failed. + AggregateDerivation(bip32::Error), + /// BIP327 key aggregation produced the point at infinity. + Infinity, +} + +impl fmt::Display for MusigKeyAggError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonDefiniteKey(err) => err.fmt(f), + Self::AggregateDerivation(err) => err.fmt(f), + Self::Infinity => f.write_str("musig aggregate public key is infinity"), + } + } +} + +#[cfg(feature = "std")] +impl error::Error for MusigKeyAggError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::NonDefiniteKey(err) => Some(err), + Self::AggregateDerivation(err) => Some(err), + Self::Infinity => None, + } + } +} + +impl From for MusigKeyAggError { + fn from(err: NonDefiniteKeyError) -> Self { Self::NonDefiniteKey(err) } +} + /// Kinds of malformed key data #[derive(Debug, PartialEq, Eq, Clone)] #[non_exhaustive] @@ -1040,6 +1091,30 @@ impl DescriptorPublicKey { } } + /// Applies `pred` to this key and any underlying `musig()` participant keys. + /// + /// For a `musig()` key expression, this walks the participant keys and does not call `pred` + /// on the aggregate key expression itself. + pub fn for_each_leaf_key<'a, F>(&'a self, mut pred: F) -> bool + where + F: FnMut(&'a DescriptorPublicKey) -> bool, + { + self.for_each_leaf_key_inner(&mut pred) + } + + fn for_each_leaf_key_inner<'a, F>(&'a self, pred: &mut F) -> bool + where + F: FnMut(&'a DescriptorPublicKey) -> bool, + { + match self { + DescriptorPublicKey::Musig(musig) => musig + .participants + .iter() + .all(|participant| participant.for_each_leaf_key_inner(pred)), + _ => pred(self), + } + } + /// Whether or not the key has a hardened step in path pub fn has_hardened_step(&self) -> bool { let paths = match self { @@ -1238,6 +1313,70 @@ impl DescriptorMusigKey { /// Wildcard applied to the aggregate key expression. pub fn wildcard(&self) -> Wildcard { self.wildcard } + /// Returns whether this `musig()` derives participants before aggregation or derives the + /// aggregate through the BIP328 synthetic xpub. + pub fn derivation_kind(&self) -> MusigDerivationKind<'_> { + if Self::has_aggregate_derivation(&self.derivation_paths, self.wildcard) { + MusigDerivationKind::AggregateThenDerive { + derivation_paths: &self.derivation_paths, + wildcard: self.wildcard, + } + } else { + MusigDerivationKind::DeriveThenAggregate + } + } + + /// Returns the BIP327 aggregate public key before aggregate-level BIP328 derivation. + /// + /// This requires all participants to be definite. If participant keys are ranged, first call + /// [`DescriptorPublicKey::at_derivation_index`] or + /// [`DescriptorMusigKey::derived_participants_at_index`]. + pub fn aggregate_public_key( + &self, + secp: &Secp256k1, + ) -> Result { + super::musig::aggregate_public_key(secp, self) + } + + /// Returns the BIP328 synthetic xpub for aggregate-level derivation, if this `musig()` uses it. + pub fn synthetic_xpub(&self, aggregate: PublicKey) -> Option { + if Self::has_aggregate_derivation(&self.derivation_paths, self.wildcard) { + let network = self.xkey_network().unwrap_or(NetworkKind::Main); + Some(super::musig::synthetic_xpub(aggregate.inner, network)) + } else { + None + } + } + + /// Derives the final aggregate public key for a definite `musig()` key expression. + pub fn try_derive_public_key( + &self, + secp: &Secp256k1, + ) -> Result { + DefiniteDescriptorKey::new(DescriptorPublicKey::Musig(self.clone()))?; + super::musig::derive_public_key(secp, self) + } + + /// Derives each participant key at `index`. + /// + /// Non-ranged participants are returned as-is. Ranged participants are derived at `index`. + pub fn derived_participants_at_index( + &self, + index: u32, + ) -> Result, NonDefiniteKeyError> { + self.participants + .iter() + .cloned() + .map(|participant| { + if participant.has_wildcard() { + participant.at_derivation_index(index) + } else { + DefiniteDescriptorKey::new(participant) + } + }) + .collect() + } + /// Get the network of the participant xpubs, if all present xpubs agree. pub fn xkey_network(&self) -> Option { match self.xkey_network_summary() { @@ -1743,8 +1882,9 @@ impl DefiniteDescriptorKey { /// and returns the obtained full [`bitcoin::PublicKey`]. All BIP32 derivations /// always return a compressed key /// - /// Will return an error if the descriptor key has any hardened derivation steps in its path. To - /// avoid this error you should replace any such public keys first with [`crate::Descriptor::translate_pk`]. + /// Hardened derivation steps are rejected when constructing a `DefiniteDescriptorKey`. To + /// handle a `musig()` key aggregation error explicitly, use + /// [`DescriptorMusigKey::try_derive_public_key`]. /// /// # Panics /// @@ -1772,7 +1912,9 @@ impl DefiniteDescriptorKey { DescriptorPublicKey::MultiXPub(_) => { unreachable!("impossible by construction of DefiniteDescriptorKey") } - DescriptorPublicKey::Musig(ref musig) => super::musig::derive_public_key(secp, musig), + DescriptorPublicKey::Musig(ref musig) => musig + .try_derive_public_key(secp) + .expect("definite musig aggregate public key derivation succeeds"), } } @@ -1891,7 +2033,8 @@ mod test { use serde_test::{assert_tokens, Token}; use super::{ - DescriptorMultiXKey, DescriptorPublicKey, DescriptorSecretKey, MiniscriptKey, Wildcard, + DescriptorMultiXKey, DescriptorPublicKey, DescriptorSecretKey, MiniscriptKey, + MusigDerivationKind, MusigKeyAggError, PublicKey, Wildcard, }; use crate::descriptor::key::NonDefiniteKeyError; use crate::prelude::*; @@ -2020,6 +2163,61 @@ mod test { ); } + #[test] + fn musig_helper_apis() { + let secp = bitcoin::secp256k1::Secp256k1::verification_only(); + + let desc = format!("musig({0}/*,{0})", TPUB); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + let musig = match &key { + DescriptorPublicKey::Musig(ref musig) => musig, + _ => unreachable!("parsed musig key"), + }; + assert_eq!(musig.derivation_kind(), MusigDerivationKind::DeriveThenAggregate); + assert!(musig + .synthetic_xpub(PublicKey::from_str(PUBKEY_1).unwrap()) + .is_none()); + assert!(matches!( + musig.try_derive_public_key(&secp), + Err(MusigKeyAggError::NonDefiniteKey(NonDefiniteKeyError::Wildcard)) + )); + assert_eq!( + musig + .derived_participants_at_index(7) + .unwrap() + .into_iter() + .map(|key| key.to_string()) + .collect::>(), + vec![format!("{}/7", TPUB), TPUB.to_owned()] + ); + + let mut leaves = Vec::new(); + key.for_each_leaf_key(|key| { + leaves.push(key.to_string()); + true + }); + assert_eq!(leaves, vec![format!("{}/{}", TPUB, "*"), TPUB.to_owned()]); + + let desc = format!("musig({0},{0})/2", TPUB); + let key = DescriptorPublicKey::from_str(&desc).unwrap(); + let musig = match &key { + DescriptorPublicKey::Musig(ref musig) => musig, + _ => unreachable!("parsed musig key"), + }; + assert!(matches!( + musig.derivation_kind(), + MusigDerivationKind::AggregateThenDerive { wildcard: Wildcard::None, .. } + )); + let aggregate = musig.aggregate_public_key(&secp).unwrap(); + assert_eq!(musig.synthetic_xpub(aggregate).unwrap().depth, 0); + assert_eq!( + musig.try_derive_public_key(&secp).unwrap(), + DefiniteDescriptorKey::new(key) + .unwrap() + .derive_public_key(&secp) + ); + } + #[test] fn parse_musig_descriptor_public_key_errors() { for desc in [ diff --git a/src/descriptor/mod.rs b/src/descriptor/mod.rs index 4c871a85f..85bc0d9bd 100644 --- a/src/descriptor/mod.rs +++ b/src/descriptor/mod.rs @@ -57,8 +57,8 @@ mod wallet_policy; pub use self::key::{ DefiniteDescriptorKey, DerivPaths, DescriptorKeyParseError, DescriptorMultiXKey, DescriptorMusigKey, DescriptorPublicKey, DescriptorSecretKey, DescriptorXKey, InnerXKey, - MalformedKeyDataKind, NonDefiniteKeyError, SinglePriv, SinglePub, SinglePubKey, Wildcard, - XKeyNetwork, + MalformedKeyDataKind, MusigDerivationKind, MusigKeyAggError, NonDefiniteKeyError, SinglePriv, + SinglePub, SinglePubKey, Wildcard, XKeyNetwork, }; pub use self::key_map::KeyMap; pub use self::wallet_policy::{WalletPolicy, WalletPolicyError}; diff --git a/src/descriptor/musig.rs b/src/descriptor/musig.rs index 5e82e17fe..cd3e489e1 100644 --- a/src/descriptor/musig.rs +++ b/src/descriptor/musig.rs @@ -4,7 +4,7 @@ use bitcoin::hashes::{sha256, Hash, HashEngine}; use bitcoin::secp256k1::{constants, PublicKey, Scalar, Secp256k1, Verification}; use bitcoin::{bip32, NetworkKind}; -use super::key::{DefiniteDescriptorKey, DescriptorMusigKey}; +use super::key::{DefiniteDescriptorKey, DescriptorMusigKey, MusigKeyAggError}; use crate::prelude::*; const SYNTHETIC_XPUB_CHAIN_CODE: [u8; 32] = [ @@ -15,32 +15,41 @@ const SYNTHETIC_XPUB_CHAIN_CODE: [u8; 32] = [ pub(super) fn derive_public_key( secp: &Secp256k1, musig: &DescriptorMusigKey, -) -> bitcoin::PublicKey { - let aggregate = aggregate_public_key( - secp, - musig.participants().iter().map(|participant| { - DefiniteDescriptorKey::new(participant.clone()) - .expect("musig participants are definite") - .derive_public_key(secp) - .inner - }), - ); +) -> Result { + let aggregate = aggregate_public_key(secp, musig)?.inner; let derivation_path = &musig.derivation_paths().paths()[0]; if derivation_path.is_empty() { - bitcoin::PublicKey::new(aggregate) + Ok(bitcoin::PublicKey::new(aggregate)) } else { let network = musig.xkey_network().unwrap_or(NetworkKind::Main); let xpub = synthetic_xpub(aggregate, network); - bitcoin::PublicKey::new( + Ok(bitcoin::PublicKey::new( xpub.derive_pub(secp, derivation_path) - .expect("definite musig aggregate derivation path is unhardened") + .map_err(MusigKeyAggError::AggregateDerivation)? .public_key, - ) + )) } } -fn aggregate_public_key(secp: &Secp256k1, keys: I) -> PublicKey +pub(super) fn aggregate_public_key( + secp: &Secp256k1, + musig: &DescriptorMusigKey, +) -> Result { + let keys = musig + .participants() + .iter() + .map(|participant| { + Ok(DefiniteDescriptorKey::new(participant.clone())? + .derive_public_key(secp) + .inner) + }) + .collect::, MusigKeyAggError>>()?; + + aggregate_keys(secp, keys).map(bitcoin::PublicKey::new) +} + +fn aggregate_keys(secp: &Secp256k1, keys: I) -> Result where C: Verification, I: IntoIterator, @@ -50,7 +59,7 @@ where key_agg_public_key(secp, keys) } -fn key_agg_public_key(secp: &Secp256k1, keys: I) -> PublicKey +fn key_agg_public_key(secp: &Secp256k1, keys: I) -> Result where C: Verification, I: IntoIterator, @@ -80,14 +89,10 @@ where let refs = weighted_keys.iter().collect::>(); let aggregate = PublicKey::combine_keys(&refs); debug_assert!(aggregate.is_ok(), "BIP327 KeyAgg output is not infinity"); - // BIP327 specifies infinity as a KeyAgg failure. Descriptor public key derivation is - // currently infallible, matching the surrounding BIP32 derivation APIs after - // DefiniteDescriptorKey has ruled out malformed paths, so we treat this cryptographic edge as - // unreachable here. - aggregate.expect("BIP327 KeyAgg output is not infinity") + aggregate.map_err(|_| MusigKeyAggError::Infinity) } -fn synthetic_xpub(public_key: PublicKey, network: NetworkKind) -> bip32::Xpub { +pub(super) fn synthetic_xpub(public_key: PublicKey, network: NetworkKind) -> bip32::Xpub { bip32::Xpub { network, depth: 0, @@ -197,7 +202,7 @@ mod tests { .iter() .map(|key| PublicKey::from_str(key).unwrap()) .collect::>(); - let aggregate = key_agg_public_key(&secp, keys); + let aggregate = key_agg_public_key(&secp, keys).unwrap(); assert_eq!(aggregate.to_string(), expected_aggregate); let synthetic = synthetic_xpub(aggregate, NetworkKind::Main); assert_eq!(synthetic, bip32::Xpub::from_str(xpub).unwrap()); From 2efb22e7c9c839436aea08d2d5d0209698c83fc8 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 04:03:42 -0500 Subject: [PATCH 5/6] descriptor: parse musig secret participants Make Descriptor::parse_descriptor recurse into musig() participant expressions instead of treating the aggregate as one opaque key string. Each secret participant is converted to its public descriptor key and inserted into the KeyMap individually. There is no aggregate secret key to store. Nested musig() expressions are rejected before the recursive secret-key walk so malformed input does not repeatedly reparse nested aggregate strings. to_string_with_secret reconstructs the musig() expression with secret participants when the KeyMap has them. It renders the aggregate derivation suffix from DescriptorMusigKey directly, preserving round-trip serialization without reparsing the public key string. Tests cover BIP32 xprv and WIF participants because those pass through different DescriptorSecretKey parsing paths. --- src/descriptor/key.rs | 10 +++++++ src/descriptor/mod.rs | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/descriptor/key.rs b/src/descriptor/key.rs index 9f7ef1732..f1fee030b 100644 --- a/src/descriptor/key.rs +++ b/src/descriptor/key.rs @@ -1377,6 +1377,16 @@ impl DescriptorMusigKey { .collect() } + pub(crate) fn derivation_suffix_string(&self) -> String { + use core::fmt::Write as _; + + let mut suffix = String::new(); + fmt_derivation_paths(&mut suffix, self.derivation_paths.paths()) + .expect("writing to a string cannot fail"); + write!(&mut suffix, "{}", self.wildcard).expect("writing to a string cannot fail"); + suffix + } + /// Get the network of the participant xpubs, if all present xpubs agree. pub fn xkey_network(&self) -> Option { match self.xkey_network_summary() { diff --git a/src/descriptor/mod.rs b/src/descriptor/mod.rs index 85bc0d9bd..4714103b7 100644 --- a/src/descriptor/mod.rs +++ b/src/descriptor/mod.rs @@ -798,6 +798,35 @@ impl Descriptor { key_map: &mut KeyMap, secp: &secp256k1::Secp256k1, ) -> Result { + if s.starts_with("musig(") { + let tree = expression::Tree::from_str(s)?; + let root = tree.root(); + let mut public_participants = Vec::with_capacity(root.n_children()); + for child in root.children() { + if child.name() == "musig" { + return Err(Error::Parse(ParseError::box_from_str( + DescriptorKeyParseError::MalformedKeyData( + MalformedKeyDataKind::MusigNested, + ), + ))); + } + public_participants.push(parse_key(&child.expression_string(), key_map, secp)?); + } + + let mut public_musig = String::from("musig("); + for (i, participant) in public_participants.iter().enumerate() { + if i > 0 { + public_musig.push(','); + } + public_musig.push_str(&participant.to_string()); + } + public_musig.push(')'); + public_musig.push_str(root.postfix()); + return public_musig + .parse() + .map_err(|e| Error::Parse(ParseError::box_from_str(e))); + } + match DescriptorSecretKey::from_str(s) { Ok(sk) => { let pk = key_map @@ -893,6 +922,19 @@ impl Descriptor { pk: &DescriptorPublicKey, key_map: &KeyMap, ) -> Result { + if let DescriptorPublicKey::Musig(ref musig) = *pk { + let mut musig_string = String::from("musig("); + for (i, participant) in musig.participants().iter().enumerate() { + if i > 0 { + musig_string.push(','); + } + musig_string.push_str(&key_to_string(participant, key_map)?); + } + musig_string.push(')'); + musig_string.push_str(&musig.derivation_suffix_string()); + return Ok(musig_string); + } + Ok(match key_map.get(pk) { Some(secret) => secret.to_string(), None => pk.to_string(), @@ -2128,6 +2170,33 @@ pk(03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8))"; assert_eq!(descriptor_str, descriptor.to_string_with_secret(&keymap)); } + #[test] + fn parse_musig_with_secrets() { + let secp = &secp256k1::Secp256k1::signing_only(); + let descriptor_str = "tr(musig(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1)/2)"; + let descriptor_str = Descriptor::::from_str(descriptor_str) + .unwrap() + .to_string(); + let (descriptor, keymap) = + Descriptor::::parse_descriptor(secp, &descriptor_str).unwrap(); + + assert_eq!(keymap.len(), 1); + assert!(descriptor + .to_string() + .contains("tr(musig([a12b02f4/44'/0'/0']")); + assert_eq!(descriptor_str, descriptor.to_string_with_secret(&keymap)); + + let descriptor_str = "tr(musig(KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU74sHUHy8S,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659,023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66))"; + let descriptor_str = Descriptor::::from_str(descriptor_str) + .unwrap() + .to_string(); + let (descriptor, keymap) = + Descriptor::::parse_descriptor(secp, &descriptor_str).unwrap(); + + assert_eq!(keymap.len(), 1); + assert_eq!(descriptor_str, descriptor.to_string_with_secret(&keymap)); + } + #[test] fn checksum_for_nested_sh() { let descriptor_str = "sh(wpkh(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL))"; From 1b1f458e1695d93a95fd7851553cb21f9fdad3ee Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 17 May 2026 04:04:32 -0500 Subject: [PATCH 6/6] descriptor: test musig tap placement rules Add descriptor-level coverage for musig() in Taproot script key positions. The tests exercise pk(musig(...)), multi_a(..., musig(...)), and sortedmulti_a(..., musig(...)), including a ranged aggregate derivation case. Add invalid placement coverage for legacy and Segwit v0 contexts, top-level musig(), musig() as a Taproot script branch, and descriptor level nested musig(). This keeps musig() accepted only as Taproot key material. These tests document the intended BIP390 boundary: musig() is a key expression for Taproot descriptors, not a replacement for legacy public-key expressions or Miniscript fragments. --- src/descriptor/mod.rs | 71 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/descriptor/mod.rs b/src/descriptor/mod.rs index 4714103b7..0118752b6 100644 --- a/src/descriptor/mod.rs +++ b/src/descriptor/mod.rs @@ -2313,6 +2313,77 @@ pk(03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8))"; ScriptBuf::from_hex(expected).unwrap() ); } + + let descriptor = Descriptor::::from_str( + "tr(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,pk(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*))", + ) + .unwrap(); + for (index, expected) in [ + (0, "512068983d461174afc90c26f3b2821d8a9ced9534586a756763b68371a404635cc8"), + (1, "5120368e2d864115181bdc8bb5dc8684be8d0760d5c33315570d71a21afce4afd43e"), + (2, "512097a1e6270b33ad85744677418bae5f59ea9136027223bc6e282c47c167b471d5"), + ] { + assert_eq!( + descriptor + .derive_at_index(index) + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(), + ScriptBuf::from_hex(expected).unwrap() + ); + } + } + + #[test] + fn bip390_musig_tap_miniscript_key_positions() { + let secp = secp256k1::Secp256k1::verification_only(); + let musig = "musig(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659,023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66)"; + let other_key = "02c2fd50ceae468857bb7eb32ae9cd4083e6c7e42fbbec179d81134b3e3830586c"; + + for desc in [ + format!( + "tr(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,multi_a(1,{},{}))", + musig, other_key + ), + format!( + "tr(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,sortedmulti_a(1,{},{}))", + other_key, musig + ), + ] { + let descriptor = Descriptor::::from_str(&desc).unwrap(); + assert!(descriptor.to_string().contains("musig(")); + descriptor + .into_definite() + .unwrap() + .derived_descriptor(&secp) + .script_pubkey(); + } + } + + #[test] + fn bip390_musig_invalid_placements() { + let musig = "musig(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659,023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66)"; + for desc in [ + musig.to_owned(), + format!("pk({})", musig), + format!("pkh({})", musig), + format!("wpkh({})", musig), + format!("sh(wpkh({}))", musig), + format!("wsh(pk({}))", musig), + format!("sh(wsh(pk({})))", musig), + format!("wsh({})", musig), + format!("sh({})", musig), + format!( + "tr(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,{})", + musig + ), + format!( + "tr(musig({},02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9))", + musig + ), + ] { + Descriptor::::from_str(&desc).unwrap_err(); + } } #[test]