diff --git a/apps/bitcoin/bip388/build.rs b/apps/bitcoin/bip388/build.rs index 5f60a8f8..060d7bac 100644 --- a/apps/bitcoin/bip388/build.rs +++ b/apps/bitcoin/bip388/build.rs @@ -48,20 +48,21 @@ //! kind. `$key`, `$key1`, `$key2` all resolve to single-key placeholders; the //! digit only disambiguates two occurrences within one pattern. //! -//! | base name | kind | host type | implicit range | -//! |-----------------------|---------------|----------------------|-------------------------| -//! | `key`, `internal_key` | Key | `KeyExpression` | — | -//! | `keys` | KeyList | `Vec` | — | -//! | `threshold` | Threshold | `u32` | — | -//! | `blocks` | Blocks | `u32` | `1..65_536` | -//! | `relative_time` | RelativeTime | `u32` (BIP-68 form) | `4_194_305..4_259_840` | -//! | `block_height` | BlockHeight | `u32` | `1..500_000_000` | -//! | `timestamp` | Timestamp | `u32` (Unix seconds) | `500_000_000..` | -//! | `leaves` | Leaves | `Vec` | `tr(...)` only | +//! | base name | kind | host type | notes | +//! |-----------------------|------------|----------------------|---------------------------| +//! | `key`, `internal_key` | Key | `KeyExpression` | plain key | +//! | `keys` | KeyList | `Vec` | — | +//! | `threshold` | Threshold | `u32` | — | +//! | `timelock` | Timelock | `Timelock` | matches `older`/`after` | +//! | `sub` | Subpolicy | `Box` | classified non-combinator | +//! | `leaves` | Leaves | `Vec` | `tr(...)` only | //! -//! Ranges (where present) become guard clauses in the classifier -//! (`if *expr >= lo && *expr < hi`), so the same keyword — e.g. `older($N)` — -//! routes to different classes depending on `N`. +//! A `$timelock` binding is special: the same binding matches both `older($N)` +//! (relative) and `after($N)` (absolute), and the classifier emits a validity +//! guard (`is_valid_relative_locktime` / `is_valid_absolute_locktime`) together +//! with the matching `Timelock::{Relative,Absolute}` wrapper, chosen by the +//! enclosing keyword. The four display forms (blocks / duration / block height / +//! date) are produced from the `Timelock` value at format time. //! //! # Invariants enforced at build time //! @@ -136,6 +137,10 @@ enum PatternArg { wrappers: Vec, inner: Box, }, + /// `(wrappers:)?$name` where `$name` is a `Subpolicy` binding. + /// The wrappers are unwrapped in the AST before the sub-expression is + /// classified as a `TapleafClass`. + SubpolicyRef { wrappers: Vec, name: String }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -143,23 +148,26 @@ enum BindingKind { Key, KeyList, Threshold, - Blocks, - RelativeTime, - BlockHeight, - Timestamp, + /// A spending timelock: a `$timelock` binding matches both `older(...)` + /// (relative) and `after(...)` (absolute). Host type is the runtime + /// `Timelock` enum; the enclosing keyword selects the variant and the + /// validity guard emitted by the classifier. + Timelock, /// Bound to the `Option` of a `tr(...)` and lowered to /// `Vec` after classification. Leaves, + /// Bound to a sub-expression that is classified as a `TapleafClass`. + /// Host type is `Box`. + Subpolicy, } /// Static metadata for a binding kind: the host-language type, the matching -/// `CleartextPart` / `CleartextValue` variant name, the cursor method that -/// pops a value of this kind, and the implicit value range (if any). +/// `CleartextPart` / `CleartextValue` variant name, and the cursor method that +/// pops a value of this kind. struct KindInfo { rust_type: &'static str, cleartext_variant: Option<&'static str>, cursor_method: Option<&'static str>, - range: Option<(u32, Option)>, } impl BindingKind { @@ -169,49 +177,31 @@ impl BindingKind { rust_type: "KeyExpression", cleartext_variant: Some("KeyIndex"), cursor_method: Some("key_index"), - range: None, }, BindingKind::KeyList => KindInfo { rust_type: "Vec", cleartext_variant: Some("KeyIndices"), cursor_method: Some("key_indices"), - range: None, }, BindingKind::Threshold => KindInfo { rust_type: "u32", cleartext_variant: Some("Threshold"), cursor_method: Some("threshold"), - range: None, }, - BindingKind::Blocks => KindInfo { - rust_type: "u32", - cleartext_variant: Some("Blocks"), - cursor_method: Some("blocks"), - range: Some((1, Some(65_536))), - }, - BindingKind::RelativeTime => KindInfo { - rust_type: "u32", - cleartext_variant: Some("RelativeTime"), - cursor_method: Some("relative_time"), - range: Some((4_194_305, Some(4_259_840))), - }, - BindingKind::BlockHeight => KindInfo { - rust_type: "u32", - cleartext_variant: Some("BlockHeight"), - cursor_method: Some("block_height"), - range: Some((1, Some(500_000_000))), - }, - BindingKind::Timestamp => KindInfo { - rust_type: "u32", - cleartext_variant: Some("Timestamp"), - cursor_method: Some("timestamp"), - range: Some((500_000_000, None)), + BindingKind::Timelock => KindInfo { + rust_type: "Timelock", + cleartext_variant: Some("Timelock"), + cursor_method: Some("timelock"), }, BindingKind::Leaves => KindInfo { rust_type: "Vec", cleartext_variant: None, cursor_method: None, - range: None, + }, + BindingKind::Subpolicy => KindInfo { + rust_type: "alloc::boxed::Box", + cleartext_variant: Some("Subpolicy"), + cursor_method: Some("subpolicy"), }, } } @@ -225,11 +215,9 @@ fn binding_name_kind(name: &str) -> Option { "key" | "internal_key" => BindingKind::Key, "keys" => BindingKind::KeyList, "threshold" => BindingKind::Threshold, - "blocks" => BindingKind::Blocks, - "relative_time" => BindingKind::RelativeTime, - "block_height" => BindingKind::BlockHeight, - "timestamp" => BindingKind::Timestamp, + "timelock" => BindingKind::Timelock, "leaves" => BindingKind::Leaves, + "sub" => BindingKind::Subpolicy, _ => return None, }) } @@ -487,6 +475,19 @@ impl<'a> PatternParser<'a> { expected )); } + // If the next token is `$name` where name resolves to Subpolicy, this + // is a wrapped subpolicy reference rather than a nested pattern. + if self.peek() == Some(b'$') { + let snap = self.pos; + if let Ok(name) = self.parse_binding_name() { + if binding_name_kind(&name) == Some(BindingKind::Subpolicy) { + check_kind_matches(&name, BindingKind::Subpolicy, expected)?; + return Ok(PatternArg::SubpolicyRef { wrappers, name }); + } + } + // Not a Subpolicy binding — rewind and fall through to pattern parsing. + self.pos = snap; + } let inner = self.parse_pattern()?; Ok(PatternArg::Sub { wrappers, @@ -499,15 +500,9 @@ fn check_kind_matches(name: &str, binding: BindingKind, positional: ArgKind) -> match (binding, positional) { (BindingKind::Key, ArgKind::Key) | (BindingKind::KeyList, ArgKind::KeyList) - | ( - BindingKind::Threshold - | BindingKind::Blocks - | BindingKind::RelativeTime - | BindingKind::BlockHeight - | BindingKind::Timestamp, - ArgKind::Num, - ) - | (BindingKind::Leaves, ArgKind::Tree) => Ok(()), + | (BindingKind::Threshold, ArgKind::Num) + | (BindingKind::Leaves, ArgKind::Tree) + | (BindingKind::Subpolicy | BindingKind::Timelock, ArgKind::Sub) => Ok(()), _ => Err(format!( "binding '${}' (kind {:?}) doesn't match the AST position kind {:?}", name, binding, positional @@ -532,6 +527,9 @@ fn pattern_bindings(p: &Pattern) -> Vec<(String, BindingKind)> { out.push((keys.clone(), BindingKind::KeyList)); } PatternArg::Sub { inner, .. } => walk(inner, out), + PatternArg::SubpolicyRef { name, .. } => { + out.push((name.clone(), BindingKind::Subpolicy)) + } } } } @@ -544,7 +542,7 @@ fn pattern_uses_musig(p: &Pattern) -> bool { p.args.iter().any(|a| match a { PatternArg::Musig { .. } => true, PatternArg::Sub { inner, .. } => pattern_uses_musig(inner), - _ => false, + PatternArg::Binding { .. } | PatternArg::SubpolicyRef { .. } => false, }) } @@ -821,11 +819,18 @@ enum MatchStep { PlainKeyList { expr: Ident }, /// `if .is_musig() { let = ; ... }`. MusigKey { expr: Ident, temp: Ident }, - /// `if * >= lo && * < hi` (hi exclusive; None = open-ended). - Range { - expr: Ident, - lo: u32, - hi: Option, + /// Match the whole `older(...)`/`after(...)` node bound to a `$timelock` + /// Sub-position binding into a `Timelock` (rejecting out-of-range or + /// non-lock nodes). `bound` holds the resulting `Timelock`. + ClassifyTimelock { expr: TokenStream, bound: Ident }, + /// Classify `.classify_as_tapleaf()` into ``; + /// fail the match if it is `Other` or any combinator variant. + /// `combinator_variants` is the list of TapleafClass variant names that + /// have Subpolicy fields (used to prevent nesting). + ClassifySubpolicy { + expr: TokenStream, + classified: Ident, + combinator_variants: Vec, }, } @@ -846,18 +851,24 @@ struct Lowered { bindings: BTreeMap, } -fn lower_pattern(pat: &Pattern) -> Lowered { +fn lower_pattern(pat: &Pattern, combinator_variants: &[Ident]) -> Lowered { let mut counter = Counter(0); let mut l = Lowered { steps: Vec::new(), preamble: Vec::new(), bindings: BTreeMap::new(), }; - lower(pat, quote!(__m), &mut counter, &mut l); + lower(pat, quote!(__m), &mut counter, &mut l, combinator_variants); l } -fn lower(pat: &Pattern, matchee: TokenStream, c: &mut Counter, l: &mut Lowered) { +fn lower( + pat: &Pattern, + matchee: TokenStream, + c: &mut Counter, + l: &mut Lowered, + combinator_variants: &[Ident], +) { let variant_str = keyword_to_variant(&pat.keyword).expect("keyword validated"); let variant = id(variant_str); let arg_kinds = variant_arg_kinds(variant_str); @@ -878,19 +889,35 @@ fn lower(pat: &Pattern, matchee: TokenStream, c: &mut Counter, l: &mut Lowered) l.bindings.insert(name.clone(), quote!(#tv)); } ArgKind::Num => { - if let Some((lo, hi)) = bkind.info().range { - l.steps.push(MatchStep::Range { - expr: tv.clone(), - lo, - hi, - }); - } l.bindings.insert(name.clone(), quote!(*#tv)); } ArgKind::KeyList => { l.steps.push(MatchStep::PlainKeyList { expr: tv.clone() }); l.bindings.insert(name.clone(), quote!(#tv)); } + // A Subpolicy binding in a Sub position with no wrappers: + // classify the expression directly (same as SubpolicyRef with empty wrappers). + ArgKind::Sub if *bkind == BindingKind::Subpolicy => { + let classify_expr = quote!(#tv.as_ref()); + let classified = format_ident!("__cls_{}", name); + l.steps.push(MatchStep::ClassifySubpolicy { + expr: classify_expr, + classified: classified.clone(), + combinator_variants: combinator_variants.to_vec(), + }); + l.bindings.insert(name.clone(), quote!(#classified)); + } + // A Timelock binding in a Sub position matches the whole + // `older(...)`/`after(...)` node; `ClassifyTimelock` captures the + // resulting `Timelock` value (or fails the match). + ArgKind::Sub if *bkind == BindingKind::Timelock => { + let bound = format_ident!("__tl_{}", name); + l.steps.push(MatchStep::ClassifyTimelock { + expr: quote!(#tv.as_ref()), + bound: bound.clone(), + }); + l.bindings.insert(name.clone(), quote!(#bound)); + } ArgKind::Sub | ArgKind::Tree => { l.bindings.insert(name.clone(), quote!(#tv)); } @@ -940,7 +967,33 @@ fn lower(pat: &Pattern, matchee: TokenStream, c: &mut Counter, l: &mut Lowered) } else { current }; - lower(inner, next_matchee, c, l); + lower(inner, next_matchee, c, l, combinator_variants); + } + PatternArg::SubpolicyRef { wrappers, name } => { + // Unwrap through each wrapper in the AST (e.g. `v:` → V node). + let mut current: TokenStream = quote!(#tv); + for w in wrappers { + let wv = id(w); + let wt = c.next(); + l.steps.push(MatchStep::Variant { + matchee: quote!(#current.as_ref()), + variant: wv, + temps: vec![wt.clone()], + }); + current = quote!(#wt); + } + // After unwrapping wrappers, `current` is a temp Ident holding a + // `Box`. We classify `current.as_ref()`. + let classify_expr = quote!(#current.as_ref()); + let classified = format_ident!("__cls_{}", name); + l.steps.push(MatchStep::ClassifySubpolicy { + expr: classify_expr, + classified: classified.clone(), + combinator_variants: combinator_variants.to_vec(), + }); + // The binding holds the classified TapleafClass value; build_innermost + // wraps it in Box::new() for the Subpolicy field. + l.bindings.insert(name.clone(), quote!(#classified)); } } } @@ -971,10 +1024,34 @@ fn fold_steps(steps: &[MatchStep], inner: TokenStream) -> TokenStream { #code } }, - MatchStep::Range { expr, lo, hi } => match hi { - Some(h) => quote!(if *#expr >= #lo && *#expr < #h { #code }), - None => quote!(if *#expr >= #lo { #code }), + MatchStep::ClassifyTimelock { expr, bound } => quote! { + let __tl = match #expr { + DescriptorTemplate::Older(__n) if is_valid_relative_locktime(*__n) => { + Some(Timelock::Relative(*__n)) + } + DescriptorTemplate::After(__n) if is_valid_absolute_locktime(*__n) => { + Some(Timelock::Absolute(*__n)) + } + _ => None, + }; + if let Some(#bound) = __tl { #code } }, + MatchStep::ClassifySubpolicy { + expr, + classified, + combinator_variants, + } => { + // Reject Other and all combinator variants (no nesting). + let reject_pat = if combinator_variants.is_empty() { + quote!(TapleafClass::Other(_)) + } else { + quote!(TapleafClass::Other(_) #(| TapleafClass::#combinator_variants { .. })*) + }; + quote! { + let #classified = #expr.classify_as_tapleaf(); + if !matches!(#classified, #reject_pat) { #code } + } + } }; } code @@ -998,14 +1075,11 @@ fn build_innermost( let f = id(fname); let value = match kind { BindingKind::Key | BindingKind::KeyList => quote!(#bound.clone()), - BindingKind::Threshold - | BindingKind::Blocks - | BindingKind::RelativeTime - | BindingKind::BlockHeight - | BindingKind::Timestamp => quote!(#bound), + BindingKind::Threshold | BindingKind::Timelock => quote!(#bound), BindingKind::Leaves => { quote!(#bound.as_ref().map(tree_to_leaves).unwrap_or_default()) } + BindingKind::Subpolicy => quote!(alloc::boxed::Box::new(#bound)), }; quote!(#f: #value) }); @@ -1023,11 +1097,24 @@ fn emit_classify(entries: &[ProcessedEntry], ck: ClassKind, fn_name: &str) -> To let label: TokenStream = format!("'{fn_name}").parse().unwrap(); let other = ck.other_ctor(); + // Collect all TapleafClass variant names that contain Subpolicy fields. + // These are excluded from sub-policy classification to prevent nesting. + let combinator_variants: Vec = entries + .iter() + .filter(|e| { + e.fields + .kinds + .values() + .any(|k| *k == BindingKind::Subpolicy) + }) + .map(|e| id(&e.name)) + .collect(); + let blocks: Vec = entries .iter() .flat_map(|entry| { entry.patterns.iter().map(|pat| { - let l = lower_pattern(pat); + let l = lower_pattern(pat, &combinator_variants); let variant = id(&entry.name); let inner = build_innermost(&l, &entry.fields, ck, &variant, &label); fold_steps(&l.steps, inner) @@ -1152,7 +1239,9 @@ fn emit_cleartext_pattern(entries: &[ProcessedEntry], ck: ClassKind) -> TokenStr let ctor = cleartext_variant(*kind); let n = id(name); let arg: TokenStream = match kind { - BindingKind::Key | BindingKind::KeyList => quote!(#n.clone()), + BindingKind::Key | BindingKind::KeyList | BindingKind::Subpolicy => { + quote!(#n.clone()) + } _ => quote!(*#n), }; Some(quote!(CleartextValue::#ctor(#arg))) @@ -1236,9 +1325,31 @@ fn emit_outer_score(top_level: &[ProcessedEntry]) -> TokenStream { /// number of patterns whose round-trip applies: non-musig patterns always do, /// musig patterns require `threshold == len(keys)` (keys are guaranteed plain /// by classification). +/// +/// For entries with Subpolicy fields the score is the product of the +/// sub-policies' individual per-leaf scores. fn emit_score_arm(entry: &ProcessedEntry, class_enum: &str) -> TokenStream { let class = id(class_enum); let variant = id(&entry.name); + + let subpolicy_names: Vec<&str> = entry + .fields + .order + .iter() + .filter(|n| entry.fields.kinds[*n] == BindingKind::Subpolicy) + .map(String::as_str) + .collect(); + + if !subpolicy_names.is_empty() { + let sub_idents: Vec = subpolicy_names.iter().map(|n| id(n)).collect(); + let destructure = quote!({ #(#sub_idents),*, .. }); + let product = sub_idents.iter().fold( + quote!(1u64), + |acc, n| quote!(#acc.saturating_mul(#n.per_leaf_score())), + ); + return quote!(#class::#variant #destructure => #product,); + } + let plain: u64 = entry .patterns .iter() @@ -1418,6 +1529,123 @@ fn emit_top_level_variants_arm(entry: &ProcessedEntry) -> TokenStream { } } +/// Build the body of a `tapleaf_to_descriptors` arm for an entry that has +/// Subpolicy fields. Generates a Cartesian product over the descriptor-template +/// sets for each sub-policy, wrapping them as required by the single pattern. +/// +/// Assumes `subpolicy_names` is non-empty and `entry.patterns` has exactly one +/// pattern (all current combinator entries have one). +fn emit_subpolicy_construction_block( + entry: &ProcessedEntry, + subpolicy_names: &[&str], +) -> TokenStream { + // For each sub-policy field, emit a `let _descs = tapleaf_to_descriptors(&**)?;`. + // The `&**` is needed because the match arm binds sub-policy fields as + // `&Box` (when matching on `leaf: &TapleafClass`), whereas + // `tapleaf_to_descriptors` expects `&TapleafClass`. + let desc_lets: Vec = subpolicy_names + .iter() + .map(|n| { + let n_ident = id(n); + let d_ident = format_ident!("{}_descs", n); + quote!(let #d_ident = tapleaf_to_descriptors(&**#n_ident)?;) + }) + .collect(); + + assert!( + entry.patterns.len() == 1, + "entry '{}' has Subpolicy fields but {} patterns; expected exactly 1", + entry.name, + entry.patterns.len() + ); + + // Build the single pattern's construction expression, substituting each + // sub-policy binding with its loop variable. + // We derive the construction expression from `entry.patterns[0]`. + let pat = &entry.patterns[0]; + let sub_loop_vars: Vec<(String, Ident)> = subpolicy_names + .iter() + .map(|n| (n.to_string(), format_ident!("__s_{}", n))) + .collect(); + + let construction_expr = build_subpolicy_construction_expr(pat, &sub_loop_vars); + + // Nested for-loops over each sub-policy's descriptor set. + let loop_vars: Vec = sub_loop_vars.iter().map(|(_, v)| v.clone()).collect(); + let desc_idents: Vec = subpolicy_names + .iter() + .map(|n| format_ident!("{}_descs", n)) + .collect(); + + // Build nested loops (last sub-policy is the innermost). + let push_stmt = quote!(__out.push(#construction_expr);); + let loops = desc_idents.iter().zip(loop_vars.iter()).rev().fold( + push_stmt, + |body, (desc, var)| quote!(for #var in &#desc { #body }), + ); + + quote! { + let mut __out: alloc::vec::Vec = alloc::vec::Vec::new(); + #(#desc_lets)* + #loops + Ok(__out) + } +} + +/// Build a `DescriptorTemplate` construction expression for a pattern that +/// contains `SubpolicyRef` args. Each sub-policy arg is replaced by its +/// corresponding loop variable (a `&DescriptorTemplate`), cloned and optionally +/// wrapped. +fn build_subpolicy_construction_expr(pat: &Pattern, sub_vars: &[(String, Ident)]) -> TokenStream { + let variant_str = keyword_to_variant(&pat.keyword).expect("keyword validated"); + let variant = id(variant_str); + let arg_kinds = variant_arg_kinds(variant_str); + if pat.args.is_empty() { + return quote!(DescriptorTemplate::#variant); + } + let args = pat.args.iter().enumerate().map(|(i, a)| { + build_subpolicy_arg_expr( + a, + arg_kinds.get(i).copied().unwrap_or(ArgKind::Sub), + sub_vars, + ) + }); + quote!(DescriptorTemplate::#variant(#(#args),*)) +} + +fn build_subpolicy_arg_expr( + arg: &PatternArg, + kind: ArgKind, + sub_vars: &[(String, Ident)], +) -> TokenStream { + // Both `SubpolicyRef { wrappers, name }` and `Binding { kind: Subpolicy, name }` + // represent sub-policy arguments and are handled identically here. + let (wrappers, name): (&[String], &str) = match arg { + PatternArg::SubpolicyRef { wrappers, name } => (wrappers.as_slice(), name.as_str()), + PatternArg::Binding { + name, + kind: BindingKind::Subpolicy, + } => (&[], name.as_str()), + // Non-subpolicy args delegate to the standard builder. + _ => return build_arg_expr(arg, kind, /*owned=*/ false), + }; + // Find the loop variable for this sub-policy. + let var = sub_vars + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + .expect("sub-policy var present"); + // Start with the cloned descriptor template from the loop variable. + let mut expr = quote!((*#var).clone()); + // Apply wrappers from innermost to outermost. + for w in wrappers.iter().rev() { + let wv = id(w); + expr = quote!(DescriptorTemplate::#wv(alloc::boxed::Box::new(#expr))); + } + // The arg is in a Sub position → box it. + quote!(alloc::boxed::Box::new(#expr)) +} + fn emit_tapleaf_to_descriptors(tapleaf: &[ProcessedEntry]) -> TokenStream { let arms: Vec = tapleaf .iter() @@ -1429,12 +1657,26 @@ fn emit_tapleaf_to_descriptors(tapleaf: &[ProcessedEntry]) -> TokenStream { let fields: Vec = e.fields.order.iter().map(|s| id(s)).collect(); quote!({ #(#fields),* }) }; - let block = emit_pattern_construction_block(e, /*owned=*/ false); + + // For entries with Subpolicy fields, emit a Cartesian product over + // the descriptor-template sets for each sub-policy. + let subpolicy_names: Vec<&str> = e + .fields + .order + .iter() + .filter(|n| e.fields.kinds[*n] == BindingKind::Subpolicy) + .map(String::as_str) + .collect(); + + let block = if !subpolicy_names.is_empty() { + emit_subpolicy_construction_block(e, &subpolicy_names) + } else { + let b = emit_pattern_construction_block(e, /*owned=*/ false); + quote!(#b Ok(__out)) + }; + quote! { - TapleafClass::#variant #destructure => { - #block - Ok(__out) - }, + TapleafClass::#variant #destructure => { #block }, } }) .collect(); @@ -1518,7 +1760,10 @@ fn build_construction_expr(pat: &Pattern, owned: bool) -> TokenStream { fn build_arg_expr(arg: &PatternArg, kind: ArgKind, owned: bool) -> TokenStream { match arg { - PatternArg::Binding { name, .. } => { + PatternArg::SubpolicyRef { .. } => { + panic!("SubpolicyRef must be handled by build_subpolicy_arg_expr, not build_arg_expr") + } + PatternArg::Binding { name, kind: bkind } => { let n = id(name); match kind { ArgKind::Key | ArgKind::KeyList => quote!(#n.clone()), @@ -1529,6 +1774,11 @@ fn build_arg_expr(arg: &PatternArg, kind: ArgKind, owned: bool) -> TokenStream { quote!(*#n) } } + // A `$timelock` Sub-position binding reconstructs to the + // `older(...)`/`after(...)` node it matched. + ArgKind::Sub if *bkind == BindingKind::Timelock => { + quote!(alloc::boxed::Box::new(#n.to_descriptor())) + } ArgKind::Sub => quote!(#n), ArgKind::Tree => quote!(None), } diff --git a/apps/bitcoin/bip388/src/cleartext/decode.rs b/apps/bitcoin/bip388/src/cleartext/decode.rs index cd922561..fcd79658 100644 --- a/apps/bitcoin/bip388/src/cleartext/decode.rs +++ b/apps/bitcoin/bip388/src/cleartext/decode.rs @@ -19,7 +19,8 @@ use super::super::time::{parse_relative_time_to_seconds, parse_utc_date_to_times use super::super::{DescriptorTemplate, KeyExpression, KeyExpressionType, TapTree}; use super::{ CleartextPart, CleartextSpec, CleartextValue, DescriptorClass, TapleafClass, TapleafPattern, - TopLevelPattern, SEQUENCE_LOCKTIME_TYPE_FLAG, TAPLEAF_SPECS, TOP_LEVEL_SPECS, + Timelock, TopLevelPattern, LOCKTIME_THRESHOLD, RELATIVE_LOCK_LIMIT, + SEQUENCE_LOCKTIME_TYPE_FLAG, TAPLEAF_SPECS, TOP_LEVEL_SPECS, }; /// Error type for `from_cleartext`. @@ -84,6 +85,44 @@ fn parse_relative_time(s: &str) -> Option { Some(secs / 512 | SEQUENCE_LOCKTIME_TYPE_FLAG) } +/// Parse the tail of a timelock description (as produced by `format_timelock`) +/// back into a `Timelock`. Each branch accepts only the value range its display +/// form encodes, so it is the exact inverse of `format_timelock`: +/// " blocks" -> relative block count (`1..RELATIVE_LOCK_LIMIT`) +/// "" -> relative 512-second duration (type flag set) +/// "block height " -> absolute block height (`1..LOCKTIME_THRESHOLD`) +/// "date " -> absolute timestamp (`>= LOCKTIME_THRESHOLD`) +fn parse_timelock(s: &str) -> Option { + if let Some(num) = s.strip_suffix(" blocks") { + let n: u32 = num.parse().ok()?; + return (1..RELATIVE_LOCK_LIMIT).contains(&n).then_some(Timelock::Relative(n)); + } + if let Some(num) = s.strip_prefix("block height ") { + let n: u32 = num.parse().ok()?; + return (1..LOCKTIME_THRESHOLD).contains(&n).then_some(Timelock::Absolute(n)); + } + if let Some(date) = s.strip_prefix("date ") { + let n = parse_utc_date_to_timestamp(date)?; + return (n >= LOCKTIME_THRESHOLD).then_some(Timelock::Absolute(n)); + } + let n = parse_relative_time(s)?; + ((SEQUENCE_LOCKTIME_TYPE_FLAG + 1)..(SEQUENCE_LOCKTIME_TYPE_FLAG + RELATIVE_LOCK_LIMIT)) + .contains(&n) + .then_some(Timelock::Relative(n)) +} + +impl Timelock { + /// Reconstruct the descriptor lock node this timelock was matched from: + /// `Relative` -> `older(n)`, `Absolute` -> `after(n)`. Used by the generated + /// `tapleaf_to_descriptors` to rebuild `and_v(v:, )`. + fn to_descriptor(&self) -> DescriptorTemplate { + match self { + Timelock::Relative(n) => DescriptorTemplate::Older(*n), + Timelock::Absolute(n) => DescriptorTemplate::After(*n), + } + } +} + struct CleartextValueCursor { values: alloc::vec::IntoIter, } @@ -116,30 +155,16 @@ impl CleartextValueCursor { } } - fn blocks(&mut self) -> Option { + fn timelock(&mut self) -> Option { match self.values.next()? { - CleartextValue::Blocks(value) => Some(value), + CleartextValue::Timelock(value) => Some(value), _ => None, } } - fn relative_time(&mut self) -> Option { + fn subpolicy(&mut self) -> Option> { match self.values.next()? { - CleartextValue::RelativeTime(value) => Some(value), - _ => None, - } - } - - fn block_height(&mut self) -> Option { - match self.values.next()? { - CleartextValue::BlockHeight(value) => Some(value), - _ => None, - } - } - - fn timestamp(&mut self) -> Option { - match self.values.next()? { - CleartextValue::Timestamp(value) => Some(value), + CleartextValue::Subpolicy(value) => Some(value), _ => None, } } @@ -153,17 +178,32 @@ impl CleartextValueCursor { } } +/// Parse a sub-policy cleartext string back into a `TapleafClass`. +/// Tries all non-combinator tapleaf specs (those without `Subpolicy` parts) +/// to prevent nesting. +fn parse_tapleaf_cleartext(s: &str) -> Option> { + for spec in TAPLEAF_SPECS { + if spec.parts.iter().any(|p| matches!(p, CleartextPart::Subpolicy)) { + continue; // skip combinator specs to prevent nesting + } + for values in parse_with_spec(spec, s) { + if let Some(leaf) = TapleafClass::from_cleartext_pattern(spec.kind, values) { + return Some(alloc::boxed::Box::new(leaf)); + } + } + } + None +} + fn parse_cleartext_value(part: CleartextPart, input: &str) -> Option { match part { CleartextPart::Literal(_) => None, CleartextPart::Threshold => input.parse().ok().map(CleartextValue::Threshold), CleartextPart::KeyIndex => parse_key_index(input).map(CleartextValue::KeyIndex), CleartextPart::KeyIndices => parse_key_indices(input).map(CleartextValue::KeyIndices), - CleartextPart::Blocks => input.parse().ok().map(CleartextValue::Blocks), - CleartextPart::RelativeTime => parse_relative_time(input).map(CleartextValue::RelativeTime), - CleartextPart::BlockHeight => input.parse().ok().map(CleartextValue::BlockHeight), - CleartextPart::Timestamp => { - parse_utc_date_to_timestamp(input).map(CleartextValue::Timestamp) + CleartextPart::Timelock => parse_timelock(input).map(CleartextValue::Timelock), + CleartextPart::Subpolicy => { + parse_tapleaf_cleartext(input).map(CleartextValue::Subpolicy) } } } diff --git a/apps/bitcoin/bip388/src/cleartext/mod.rs b/apps/bitcoin/bip388/src/cleartext/mod.rs index 7fbd4c3e..17d87857 100644 --- a/apps/bitcoin/bip388/src/cleartext/mod.rs +++ b/apps/bitcoin/bip388/src/cleartext/mod.rs @@ -46,6 +46,42 @@ pub const MAX_CONFUSION_SCORE: u64 = 3600; pub(super) const SEQUENCE_LOCKTIME_TYPE_FLAG: u32 = 1 << 22; +/// Absolute locktimes below this are block heights; at or above, Unix +/// timestamps (BIP-65). Relative locktimes use `SEQUENCE_LOCKTIME_TYPE_FLAG` +/// for the same block-vs-time split instead. +pub(super) const LOCKTIME_THRESHOLD: u32 = 500_000_000; + +/// A relative locktime encodes a block count or 512-second interval count in +/// its low 16 bits; valid counts are `1..RELATIVE_LOCK_LIMIT`. +pub(super) const RELATIVE_LOCK_LIMIT: u32 = 1 << 16; + +/// A spending timelock attached to a tapleaf signer, carrying enough to render +/// all four display forms. `Relative` is the raw `older(...)` sequence value +/// (the type flag distinguishes a block count from a 512-second duration); +/// `Absolute` is the raw `after(...)` value (`< 500_000_000` is a block height, +/// otherwise a Unix timestamp). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Timelock { + Relative(u32), + Absolute(u32), +} + +/// `older(n)` is recognized as a timelock iff `n` is either a relative block +/// count or a relative 512-second duration (type flag set) — in both cases with +/// a low-16-bit count in `1..RELATIVE_LOCK_LIMIT`. Other values (e.g. +/// `older(0)`) are left unclassified. +pub(super) fn is_valid_relative_locktime(n: u32) -> bool { + (1..RELATIVE_LOCK_LIMIT).contains(&n) + || ((SEQUENCE_LOCKTIME_TYPE_FLAG + 1)..(SEQUENCE_LOCKTIME_TYPE_FLAG + RELATIVE_LOCK_LIMIT)) + .contains(&n) +} + +/// `after(n)` is recognized as a timelock for any `n >= 1` (a block height when +/// `n < LOCKTIME_THRESHOLD`, otherwise a Unix timestamp). +pub(super) fn is_valid_absolute_locktime(n: u32) -> bool { + n >= 1 +} + // `DescriptorClass`, `TapleafClass`, `TopLevelPattern`, `TapleafPattern`, // the `TOP_LEVEL_SPECS` / `TAPLEAF_SPECS` cleartext templates, and the // always-compiled pattern-matching code (`classify`, `classify_as_tapleaf`, @@ -62,10 +98,8 @@ pub(super) enum CleartextPart { Threshold, KeyIndex, KeyIndices, - Blocks, - RelativeTime, - BlockHeight, - Timestamp, + Timelock, + Subpolicy, } pub(super) struct CleartextSpec { @@ -78,10 +112,8 @@ enum CleartextValue { Threshold(u32), KeyIndex(KeyExpression), KeyIndices(Vec), - Blocks(u32), - RelativeTime(u32), - BlockHeight(u32), - Timestamp(u32), + Timelock(Timelock), + Subpolicy(alloc::boxed::Box), } /// Compares two key placeholders for canonical display ordering: @@ -107,8 +139,8 @@ impl TapleafClass { /// - `SingleSig`: key_index /// - `BothMustSign`: key_index1, then key_index2 /// - `SortedMultisig` / `Multisig`: number of keys, then threshold - /// - `*SingleSig` lock variants: key_index, then lock value - /// - `*MultiSig` lock variants: number of keys, then threshold, then lock value + /// - `Timelocked`: signer sub-policy (recursively), then the lock value + /// - `AndV`: sub1 (recursively), then sub2 (recursively) /// - `Other`: lexicographic by descriptor string #[rustfmt::skip] fn display_cmp(&self, other: &Self) -> core::cmp::Ordering { @@ -136,53 +168,12 @@ impl TapleafClass { TC::Multisig { threshold: t2, keys: k2 }, ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)), ( - TC::RelativeHeightlockSingleSig { key: k1, blocks: b1 }, - TC::RelativeHeightlockSingleSig { key: k2, blocks: b2 }, - ) => cmp_key(k1, k2).then(b1.cmp(b2)), - ( - TC::RelativeHeightlockBothMustSign { key1: a1, key2: b1, blocks: bl1 }, - TC::RelativeHeightlockBothMustSign { key1: a2, key2: b2, blocks: bl2 }, - ) => cmp_key(a1, a2).then(cmp_key(b1, b2)).then(bl1.cmp(bl2)), - ( - TC::RelativeHeightlockMultiSig { threshold: t1, keys: k1, blocks: b1 }, - TC::RelativeHeightlockMultiSig { threshold: t2, keys: k2, blocks: b2 }, - ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)).then(b1.cmp(b2)), - ( - TC::RelativeTimelockSingleSig { key: k1, relative_time: t1 }, - TC::RelativeTimelockSingleSig { key: k2, relative_time: t2 }, - ) => cmp_key(k1, k2).then(t1.cmp(t2)), - ( - TC::RelativeTimelockBothMustSign { key1: a1, key2: b1, relative_time: t1 }, - TC::RelativeTimelockBothMustSign { key1: a2, key2: b2, relative_time: t2 }, - ) => cmp_key(a1, a2).then(cmp_key(b1, b2)).then(t1.cmp(t2)), - ( - TC::RelativeTimelockMultiSig { threshold: t1, keys: k1, relative_time: tm1 }, - TC::RelativeTimelockMultiSig { threshold: t2, keys: k2, relative_time: tm2 }, - ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)).then(tm1.cmp(tm2)), - ( - TC::AbsoluteHeightlockSingleSig { key: k1, block_height: h1 }, - TC::AbsoluteHeightlockSingleSig { key: k2, block_height: h2 }, - ) => cmp_key(k1, k2).then(h1.cmp(h2)), - ( - TC::AbsoluteHeightlockBothMustSign { key1: a1, key2: b1, block_height: h1 }, - TC::AbsoluteHeightlockBothMustSign { key1: a2, key2: b2, block_height: h2 }, - ) => cmp_key(a1, a2).then(cmp_key(b1, b2)).then(h1.cmp(h2)), - ( - TC::AbsoluteHeightlockMultiSig { threshold: t1, keys: k1, block_height: h1 }, - TC::AbsoluteHeightlockMultiSig { threshold: t2, keys: k2, block_height: h2 }, - ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)).then(h1.cmp(h2)), - ( - TC::AbsoluteTimelockSingleSig { key: k1, timestamp: ts1 }, - TC::AbsoluteTimelockSingleSig { key: k2, timestamp: ts2 }, - ) => cmp_key(k1, k2).then(ts1.cmp(ts2)), - ( - TC::AbsoluteTimelockBothMustSign { key1: a1, key2: b1, timestamp: ts1 }, - TC::AbsoluteTimelockBothMustSign { key1: a2, key2: b2, timestamp: ts2 }, - ) => cmp_key(a1, a2).then(cmp_key(b1, b2)).then(ts1.cmp(ts2)), - ( - TC::AbsoluteTimelockMultiSig { threshold: t1, keys: k1, timestamp: ts1 }, - TC::AbsoluteTimelockMultiSig { threshold: t2, keys: k2, timestamp: ts2 }, - ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)).then(ts1.cmp(ts2)), + TC::Timelocked { sub: s1, timelock: t1 }, + TC::Timelocked { sub: s2, timelock: t2 }, + ) => s1.display_cmp(s2).then(t1.cmp(t2)), + (TC::AndV { sub1: a1, sub2: a2 }, TC::AndV { sub1: b1, sub2: b2 }) => { + a1.display_cmp(b1).then_with(|| a2.display_cmp(b2)) + } (TC::Other(s1), TC::Other(s2)) => s1.cmp(s2), // Same order() value implies same variant; this arm is unreachable. _ => Ordering::Equal, @@ -230,6 +221,28 @@ fn format_relative_time(time: u32) -> String { format_seconds((time & !SEQUENCE_LOCKTIME_TYPE_FLAG) * 512) } +/// Render a timelock as the tail of an " after ..." description, picking +/// the form from the lock kind and value: relative block count, relative +/// duration, absolute block height, or absolute date. +fn format_timelock(lock: Timelock) -> String { + match lock { + Timelock::Relative(n) => { + if n & SEQUENCE_LOCKTIME_TYPE_FLAG != 0 { + format_relative_time(n) + } else { + format!("{} blocks", n) + } + } + Timelock::Absolute(n) => { + if n < LOCKTIME_THRESHOLD { + format!("block height {}", n) + } else { + format!("date {}", format_utc_date(n)) + } + } + } +} + /// Classify every leaf of a tap-tree and collect the results in tree-traversal /// order. Used by the generated `classify` for `tr(...)` patterns. fn tree_to_leaves(t: &super::TapTree) -> Vec { @@ -260,10 +273,10 @@ fn format_cleartext_value( (CleartextPart::KeyIndices, CleartextValue::KeyIndices(ks)) => { format_key_indices(ks, canonical) } - (CleartextPart::Blocks, CleartextValue::Blocks(b)) => b.to_string(), - (CleartextPart::RelativeTime, CleartextValue::RelativeTime(t)) => format_relative_time(*t), - (CleartextPart::BlockHeight, CleartextValue::BlockHeight(h)) => h.to_string(), - (CleartextPart::Timestamp, CleartextValue::Timestamp(t)) => format_utc_date(*t), + (CleartextPart::Timelock, CleartextValue::Timelock(lock)) => format_timelock(*lock), + (CleartextPart::Subpolicy, CleartextValue::Subpolicy(leaf)) => { + leaf.to_cleartext_string(canonical)? + } _ => { debug_assert!(false, "cleartext part/value mismatch (codegen invariant violated)"); return None; diff --git a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml index 053f9080..f84b8906 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml @@ -89,74 +89,18 @@ patterns = [ ] cleartext = ["$threshold", " of ", "$keys"] +# Timelocked leaves are `and_v(v:SIGNER, LOCK)`. The signer is a `$sub` +# sub-policy (classified recursively as a non-combinator leaf: SingleSig, +# BothMustSign, SortedMultisig or Multisig). The lock is a single `$timelock` +# binding that matches both `older(...)` (relative) and `after(...)` (absolute); +# the four display forms (blocks / duration / block height / date) are produced +# by the `$timelock` value itself. [[tapleaf]] -name = "RelativeHeightlockSingleSig" -patterns = ["and_v(v:pk($key), older($blocks))"] -cleartext = ["$key", " after ", "$blocks", " blocks"] +name = "Timelocked" +patterns = ["and_v(v:$sub, $timelock)"] +cleartext = ["$sub", " after ", "$timelock"] [[tapleaf]] -name = "RelativeHeightlockBothMustSign" -patterns = ["and_v(v:and_v(v:pk($key1), pk($key2)), older($blocks))"] -cleartext = ["Both ", "$key1", " and ", "$key2", " after ", "$blocks", " blocks"] - -[[tapleaf]] -name = "RelativeHeightlockMultiSig" -patterns = [ - "and_v(v:multi_a($threshold, $keys), older($blocks))", - "and_v(v:pk(musig($threshold, $keys)), older($blocks))", -] -cleartext = ["$threshold", " of ", "$keys", " after ", "$blocks", " blocks"] - -[[tapleaf]] -name = "RelativeTimelockSingleSig" -patterns = ["and_v(v:pk($key), older($relative_time))"] -cleartext = ["$key", " after ", "$relative_time"] - -[[tapleaf]] -name = "RelativeTimelockBothMustSign" -patterns = ["and_v(v:and_v(v:pk($key1), pk($key2)), older($relative_time))"] -cleartext = ["Both ", "$key1", " and ", "$key2", " after ", "$relative_time"] - -[[tapleaf]] -name = "RelativeTimelockMultiSig" -patterns = [ - "and_v(v:multi_a($threshold, $keys), older($relative_time))", - "and_v(v:pk(musig($threshold, $keys)), older($relative_time))", -] -cleartext = ["$threshold", " of ", "$keys", " after ", "$relative_time"] - -[[tapleaf]] -name = "AbsoluteHeightlockSingleSig" -patterns = ["and_v(v:pk($key), after($block_height))"] -cleartext = ["$key", " after block height ", "$block_height"] - -[[tapleaf]] -name = "AbsoluteHeightlockBothMustSign" -patterns = ["and_v(v:and_v(v:pk($key1), pk($key2)), after($block_height))"] -cleartext = ["Both ", "$key1", " and ", "$key2", " after block height ", "$block_height"] - -[[tapleaf]] -name = "AbsoluteHeightlockMultiSig" -patterns = [ - "and_v(v:multi_a($threshold, $keys), after($block_height))", - "and_v(v:pk(musig($threshold, $keys)), after($block_height))", -] -cleartext = ["$threshold", " of ", "$keys", " after block height ", "$block_height"] - -[[tapleaf]] -name = "AbsoluteTimelockSingleSig" -patterns = ["and_v(v:pk($key), after($timestamp))"] -cleartext = ["$key", " after date ", "$timestamp"] - -[[tapleaf]] -name = "AbsoluteTimelockBothMustSign" -patterns = ["and_v(v:and_v(v:pk($key1), pk($key2)), after($timestamp))"] -cleartext = ["Both ", "$key1", " and ", "$key2", " after date ", "$timestamp"] - -[[tapleaf]] -name = "AbsoluteTimelockMultiSig" -patterns = [ - "and_v(v:multi_a($threshold, $keys), after($timestamp))", - "and_v(v:pk(musig($threshold, $keys)), after($timestamp))", -] -cleartext = ["$threshold", " of ", "$keys", " after date ", "$timestamp"] +name = "AndV" +patterns = ["and_v(v:$sub1, $sub2)"] +cleartext = ["$sub1", ", and ", "$sub2"] diff --git a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml index c99f39b2..53bc1768 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml @@ -174,8 +174,8 @@ has_cleartext = true template = "tr(@0/**,{and_v(v:pk(@2/<0;1>/*),older(2000)),and_v(v:pk(@1/<0;1>/*),older(1000))})" cleartext = [ "Primary path: @0", - "@1 after 1000 blocks", - "@2 after 2000 blocks", + "Single-signature (@1) after 1000 blocks", + "Single-signature (@2) after 2000 blocks", ] has_cleartext = true @@ -194,7 +194,7 @@ has_cleartext = true template = "tr(@0/**,{and_v(v:pk(@1/**),older(960)),t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))})" cleartext = [ "Primary path: @0", - "@1 after 960 blocks", + "Single-signature (@1) after 960 blocks", "t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))", ] has_cleartext = false @@ -205,7 +205,7 @@ has_cleartext = false [[vector]] template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))" -cleartext = ["Primary path: @0", "@1 after 52560 blocks"] +cleartext = ["Primary path: @0", "Single-signature (@1) after 52560 blocks"] has_cleartext = true [[vector]] @@ -214,7 +214,7 @@ confusion_score = 1 cleartext = [ "Primary path: @0", "Single-signature (@1)", - "@2 after 52560 blocks", + "Single-signature (@2) after 52560 blocks", ] has_cleartext = true @@ -223,7 +223,7 @@ template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})" cleartext = [ "Primary path: @0", "Single-signature (@1)", - "@2 after 1008 blocks", + "Single-signature (@2) after 1008 blocks", ] has_cleartext = true @@ -233,7 +233,7 @@ has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))" -cleartext = ["Primary path: @0", "@1 after 8m 32s"] +cleartext = ["Primary path: @0", "Single-signature (@1) after 8m 32s"] has_cleartext = true [[vector]] @@ -242,7 +242,7 @@ confusion_score = 1 cleartext = [ "Primary path: @0", "Single-signature (@1)", - "@2 after 8m 32s", + "Single-signature (@2) after 8m 32s", ] has_cleartext = true @@ -251,7 +251,7 @@ template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})" cleartext = [ "Primary path: @0", "Single-signature (@1)", - "@2 after 1d 1h 36m", + "Single-signature (@2) after 1d 1h 36m", ] has_cleartext = true @@ -271,7 +271,7 @@ has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))" -cleartext = ["Primary path: @0", "@1 after block height 840000"] +cleartext = ["Primary path: @0", "Single-signature (@1) after block height 840000"] has_cleartext = true [[vector]] @@ -285,7 +285,7 @@ has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))" -cleartext = ["Primary path: @0", "@1 after date 1985-11-05 00:53:20"] +cleartext = ["Primary path: @0", "Single-signature (@1) after date 1985-11-05 00:53:20"] has_cleartext = true [[vector]] @@ -303,7 +303,7 @@ has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(1008)))" -cleartext = ["Primary path: @0", "Both @1 and @2 after 1008 blocks"] +cleartext = ["Primary path: @0", "Both @1 and @2 must sign after 1008 blocks"] has_cleartext = true [[vector]] @@ -311,23 +311,23 @@ template = "tr(@0/**,{pk(@1/**),and_v(v:and_v(v:pk(@2/<0;1>/*),pk(@3/<0;1>/*)),o cleartext = [ "Primary path: @0", "Single-signature (@1)", - "Both @2 and @3 after 1008 blocks", + "Both @2 and @3 must sign after 1008 blocks", ] has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))" -cleartext = ["Primary path: @0", "Both @1 and @2 after 1d 1h 36m"] +cleartext = ["Primary path: @0", "Both @1 and @2 must sign after 1d 1h 36m"] has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))" -cleartext = ["Primary path: @0", "Both @1 and @2 after block height 840000"] +cleartext = ["Primary path: @0", "Both @1 and @2 must sign after block height 840000"] has_cleartext = true [[vector]] template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))" -cleartext = ["Primary path: @0", "Both @1 and @2 after date 2023-11-14 22:13:20"] +cleartext = ["Primary path: @0", "Both @1 and @2 must sign after date 2023-11-14 22:13:20"] has_cleartext = true # ============================================================================= @@ -375,7 +375,7 @@ has_cleartext = true [[vector]] template = "tr(musig(@0,@1)/**,and_v(v:pk(@2/<0;1>/*),older(1008)))" -cleartext = ["Primary path: 2 of @0 and @1", "@2 after 1008 blocks"] +cleartext = ["Primary path: 2 of @0 and @1", "Single-signature (@2) after 1008 blocks"] has_cleartext = true [[vector]] @@ -383,7 +383,7 @@ template = "tr(musig(@0,@1)/**,{pk(@2/**),and_v(v:pk(@3/<0;1>/*),after(840000))} cleartext = [ "Primary path: 2 of @0 and @1", "Single-signature (@2)", - "@3 after block height 840000", + "Single-signature (@3) after block height 840000", ] has_cleartext = true @@ -460,7 +460,7 @@ template = "tr(@0/<0;1>/*,{and_v(v:pk(@1/<0;1>/*),older(4383)),and_v(v:pk(@2/<0; cleartext = [ "Primary path: @0", "Both @2 and @1 must sign", - "@1 after 4383 blocks", + "Single-signature (@1) after 4383 blocks", ] has_cleartext = true @@ -507,7 +507,7 @@ template = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(4383)),pk(@1/<2;3>/*)})" cleartext = [ "Primary path: @0", "Single-signature (@1)", - "@1 after 4383 blocks", + "Single-signature (@1) after 4383 blocks", ] has_cleartext = true @@ -521,6 +521,71 @@ cleartext = [ ] has_cleartext = true +# ============================================================================= +# Taproot AndV leaves: and_v(v:SUBPOLICY_1, SUBPOLICY_2) +# ============================================================================= + +# SingleSig AND SortedMultisig: score = 1 * 1 = 1 +[[vector]] +template = "tr(@0/**,and_v(v:pk(@1/**),sortedmulti_a(2,@2/**,@3/**)))" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@1), and 2 of @2 and @3 (sorted)", +] +has_cleartext = true + +# SingleSig AND Multisig (threshold == keys): score = 1 * 2 = 2 +[[vector]] +template = "tr(@0/**,and_v(v:pk(@1/**),multi_a(2,@2/**,@3/**)))" +confusion_score = 2 +cleartext = [ + "Primary path: @0", + "Single-signature (@1), and 2 of @2 and @3", +] +has_cleartext = true + +# SortedMultisig AND Multisig (threshold == keys): score = 1 * 2 = 2 +[[vector]] +template = "tr(@0/**,and_v(v:sortedmulti_a(2,@1/**,@2/**),multi_a(2,@3/**,@4/**)))" +confusion_score = 2 +cleartext = [ + "Primary path: @0", + "2 of @1 and @2 (sorted), and 2 of @3 and @4", +] +has_cleartext = true + +# Multisig AND Multisig (both threshold == keys): score = 2 * 2 = 4 +[[vector]] +template = "tr(@0/**,and_v(v:multi_a(2,@1/**,@2/**),multi_a(2,@3/**,@4/**)))" +confusion_score = 4 +cleartext = [ + "Primary path: @0", + "2 of @1 and @2, and 2 of @3 and @4", +] +has_cleartext = true + +# BothMustSign AND SortedMultisig: score = 1 * 1 = 1 +[[vector]] +template = "tr(@0/**,and_v(v:and_v(v:pk(@1/**),pk(@2/**)),sortedmulti_a(2,@3/**,@4/**)))" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Both @1 and @2 must sign, and 2 of @3 and @4 (sorted)", +] +has_cleartext = true + +# AndV leaf alongside a normal leaf: SingleSig sorts before AndV +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/**),sortedmulti_a(2,@3/**,@4/**))})" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Single-signature (@2), and 2 of @3 and @4 (sorted)", +] +has_cleartext = true + # ============================================================================= # Non-canonical key derivations: no cleartext is produced and the encoder # falls back to the raw descriptor template (with `has_cleartext = false`).