From 6080513d4a4e8f3dcc78cfb4c2ef5c1ed6705651 Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Fri, 29 May 2026 19:35:48 +0200 Subject: [PATCH 1/4] Add support for and_v(v:$sub1,$sub2) patterns While 'or' in taptrees can easily be split into different leaves, the same is not easily possible for policies that are composed with a conjunction (except with DNF reductions that incur in an exponential cost in the number of leaves, which is not practical). This adds minimal support for matching pattern with disjunctive policies, even just at a single level of nesting. --- apps/bitcoin/bip388/build.rs | 286 +++++++++++++++++- apps/bitcoin/bip388/src/cleartext/decode.rs | 27 ++ apps/bitcoin/bip388/src/cleartext/mod.rs | 8 + .../bip388/src/cleartext/specs/cleartext.toml | 5 + .../src/cleartext/specs/test_vectors.toml | 65 ++++ 5 files changed, 378 insertions(+), 13 deletions(-) diff --git a/apps/bitcoin/bip388/build.rs b/apps/bitcoin/bip388/build.rs index 5f60a8f8..565494c7 100644 --- a/apps/bitcoin/bip388/build.rs +++ b/apps/bitcoin/bip388/build.rs @@ -136,6 +136,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)] @@ -150,6 +154,9 @@ enum BindingKind { /// 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 @@ -213,6 +220,12 @@ impl BindingKind { cursor_method: None, range: None, }, + BindingKind::Subpolicy => KindInfo { + rust_type: "alloc::boxed::Box", + cleartext_variant: Some("Subpolicy"), + cursor_method: Some("subpolicy"), + range: None, + }, } } } @@ -230,6 +243,7 @@ fn binding_name_kind(name: &str) -> Option { "block_height" => BindingKind::BlockHeight, "timestamp" => BindingKind::Timestamp, "leaves" => BindingKind::Leaves, + "sub" => BindingKind::Subpolicy, _ => return None, }) } @@ -487,6 +501,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, @@ -507,7 +534,8 @@ fn check_kind_matches(name: &str, binding: BindingKind, positional: ArgKind) -> | BindingKind::Timestamp, ArgKind::Num, ) - | (BindingKind::Leaves, ArgKind::Tree) => Ok(()), + | (BindingKind::Leaves, ArgKind::Tree) + | (BindingKind::Subpolicy, ArgKind::Sub) => Ok(()), _ => Err(format!( "binding '${}' (kind {:?}) doesn't match the AST position kind {:?}", name, binding, positional @@ -532,6 +560,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 +575,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, }) } @@ -827,6 +858,15 @@ enum MatchStep { lo: u32, hi: Option, }, + /// 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, + }, } struct Counter(usize); @@ -846,18 +886,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); @@ -891,6 +937,18 @@ fn lower(pat: &Pattern, matchee: TokenStream, c: &mut Counter, l: &mut Lowered) 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)); + } ArgKind::Sub | ArgKind::Tree => { l.bindings.insert(name.clone(), quote!(#tv)); } @@ -940,7 +998,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)); } } } @@ -975,6 +1059,22 @@ fn fold_steps(steps: &[MatchStep], inner: TokenStream) -> TokenStream { Some(h) => quote!(if *#expr >= #lo && *#expr < #h { #code }), None => quote!(if *#expr >= #lo { #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 @@ -1006,6 +1106,7 @@ fn build_innermost( 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 +1124,19 @@ 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 +1261,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 +1347,30 @@ 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 +1550,117 @@ 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(); + + // 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 +1672,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,6 +1775,9 @@ fn build_construction_expr(pat: &Pattern, owned: bool) -> TokenStream { fn build_arg_expr(arg: &PatternArg, kind: ArgKind, owned: bool) -> TokenStream { match arg { + PatternArg::SubpolicyRef { .. } => { + panic!("SubpolicyRef must be handled by build_subpolicy_arg_expr, not build_arg_expr") + } PatternArg::Binding { name, .. } => { let n = id(name); match kind { diff --git a/apps/bitcoin/bip388/src/cleartext/decode.rs b/apps/bitcoin/bip388/src/cleartext/decode.rs index cd922561..033167cf 100644 --- a/apps/bitcoin/bip388/src/cleartext/decode.rs +++ b/apps/bitcoin/bip388/src/cleartext/decode.rs @@ -144,6 +144,13 @@ impl CleartextValueCursor { } } + fn subpolicy(&mut self) -> Option> { + match self.values.next()? { + CleartextValue::Subpolicy(value) => Some(value), + _ => None, + } + } + fn finish(mut self) -> Option<()> { if self.values.next().is_none() { Some(()) @@ -153,6 +160,23 @@ 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, @@ -165,6 +189,9 @@ fn parse_cleartext_value(part: CleartextPart, input: &str) -> Option { parse_utc_date_to_timestamp(input).map(CleartextValue::Timestamp) } + 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..c30b0422 100644 --- a/apps/bitcoin/bip388/src/cleartext/mod.rs +++ b/apps/bitcoin/bip388/src/cleartext/mod.rs @@ -66,6 +66,7 @@ pub(super) enum CleartextPart { RelativeTime, BlockHeight, Timestamp, + Subpolicy, } pub(super) struct CleartextSpec { @@ -82,6 +83,7 @@ enum CleartextValue { RelativeTime(u32), BlockHeight(u32), Timestamp(u32), + Subpolicy(alloc::boxed::Box), } /// Compares two key placeholders for canonical display ordering: @@ -183,6 +185,9 @@ impl TapleafClass { 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::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, @@ -264,6 +269,9 @@ fn format_cleartext_value( (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::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..9ef2a57f 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml @@ -160,3 +160,8 @@ patterns = [ "and_v(v:pk(musig($threshold, $keys)), after($timestamp))", ] cleartext = ["$threshold", " of ", "$keys", " after date ", "$timestamp"] + +[[tapleaf]] +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..59502bee 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml @@ -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 (order=0) sorts before AndV (order=16) +[[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`). From b0a0da716226a1076cb10219e88e4905b1b103d4 Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:31:46 +0200 Subject: [PATCH 2/4] Use the $sub pattern for time- and height-locks This avoids a proliferation of patterns: if a certain policy is supported as a pattern, prior to this change, 4 additional patterns with the various time-/height-locks would have to be added as well. Instead, we make the locks generic, so that the combinations are rather handled by composition (that is, recursion). --- apps/bitcoin/bip388/src/cleartext/mod.rs | 62 ++++---------- .../bip388/src/cleartext/specs/cleartext.toml | 81 +++++-------------- .../src/cleartext/specs/test_vectors.toml | 55 ++++++++----- 3 files changed, 68 insertions(+), 130 deletions(-) diff --git a/apps/bitcoin/bip388/src/cleartext/mod.rs b/apps/bitcoin/bip388/src/cleartext/mod.rs index c30b0422..225aa6e7 100644 --- a/apps/bitcoin/bip388/src/cleartext/mod.rs +++ b/apps/bitcoin/bip388/src/cleartext/mod.rs @@ -109,8 +109,10 @@ 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 + /// - lock variants (`RelativeHeightlock`, `RelativeTimelock`, + /// `AbsoluteHeightlock`, `AbsoluteTimelock`): 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 { @@ -138,53 +140,21 @@ 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::RelativeHeightlock { sub: s1, blocks: b1 }, + TC::RelativeHeightlock { sub: s2, blocks: b2 }, + ) => s1.display_cmp(s2).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::RelativeTimelock { sub: s1, relative_time: t1 }, + TC::RelativeTimelock { sub: s2, relative_time: t2 }, + ) => s1.display_cmp(s2).then(t1.cmp(t2)), ( - 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::AbsoluteHeightlock { sub: s1, block_height: h1 }, + TC::AbsoluteHeightlock { sub: s2, block_height: h2 }, + ) => s1.display_cmp(s2).then(h1.cmp(h2)), ( - 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::AbsoluteTimelock { sub: s1, timestamp: ts1 }, + TC::AbsoluteTimelock { sub: s2, timestamp: ts2 }, + ) => s1.display_cmp(s2).then(ts1.cmp(ts2)), (TC::AndV { sub1: a1, sub2: a2 }, TC::AndV { sub1: b1, sub2: b2 }) => { a1.display_cmp(b1).then_with(|| a2.display_cmp(b2)) } diff --git a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml index 9ef2a57f..5812a8a7 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml @@ -89,77 +89,32 @@ patterns = [ ] cleartext = ["$threshold", " of ", "$keys"] -[[tapleaf]] -name = "RelativeHeightlockSingleSig" -patterns = ["and_v(v:pk($key), older($blocks))"] -cleartext = ["$key", " after ", "$blocks", " blocks"] - -[[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"] +# Timelock / heightlock 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 kind is fixed +# by the pattern and disambiguated by the binding's implicit value range (e.g. +# `older($blocks)` vs `older($relative_time)`). This single shape per lock kind +# replaces the former 3-signers x 4-locks cross-product. [[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"] +name = "RelativeHeightlock" +patterns = ["and_v(v:$sub, older($blocks))"] +cleartext = ["$sub", " after ", "$blocks", " blocks"] [[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"] +name = "RelativeTimelock" +patterns = ["and_v(v:$sub, older($relative_time))"] +cleartext = ["$sub", " after ", "$relative_time"] [[tapleaf]] -name = "AbsoluteHeightlockSingleSig" -patterns = ["and_v(v:pk($key), after($block_height))"] -cleartext = ["$key", " after block height ", "$block_height"] +name = "AbsoluteHeightlock" +patterns = ["and_v(v:$sub, after($block_height))"] +cleartext = ["$sub", " 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 = "AbsoluteTimelock" +patterns = ["and_v(v:$sub, after($timestamp))"] +cleartext = ["$sub", " after date ", "$timestamp"] [[tapleaf]] name = "AndV" diff --git a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml index 59502bee..cf7cb767 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 @@ -575,7 +575,7 @@ cleartext = [ ] has_cleartext = true -# AndV leaf alongside a normal leaf: SingleSig (order=0) sorts before AndV (order=16) +# AndV leaf alongside a normal leaf: SingleSig (order=0) sorts before AndV (order=8) [[vector]] template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/**),sortedmulti_a(2,@3/**,@4/**))})" confusion_score = 1 @@ -586,6 +586,19 @@ cleartext = [ ] has_cleartext = true +# ============================================================================= +# Lock with a sorted-multisig signer: newly representable as +# RelativeHeightlock{sub: SortedMultisig}. Before unifying the lock leaves into +# `and_v(v:$sub, LOCK)` there was no sortedmulti+lock entry, so this fell back +# to the raw descriptor template. +# ============================================================================= + +[[vector]] +template = "tr(@0/**,and_v(v:sortedmulti_a(2,@1/**,@2/**),older(1000)))" +confusion_score = 1 +cleartext = ["Primary path: @0", "2 of @1 and @2 (sorted) after 1000 blocks"] +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`). From 41dbb37d1cb78a27962a5587061b73af0f19792b Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:22:34 +0200 Subject: [PATCH 3/4] Further simplify timelock based policies --- apps/bitcoin/bip388/build.rs | 200 +++++++++--------- apps/bitcoin/bip388/src/cleartext/decode.rs | 73 ++++--- apps/bitcoin/bip388/src/cleartext/mod.rs | 93 +++++--- .../bip388/src/cleartext/specs/cleartext.toml | 34 +-- .../src/cleartext/specs/test_vectors.toml | 15 +- 5 files changed, 212 insertions(+), 203 deletions(-) diff --git a/apps/bitcoin/bip388/build.rs b/apps/bitcoin/bip388/build.rs index 565494c7..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 //! @@ -147,10 +148,11 @@ 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, @@ -160,13 +162,12 @@ enum BindingKind { } /// 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 { @@ -176,55 +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"), - range: None, }, } } @@ -238,10 +215,7 @@ 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, @@ -526,16 +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::Threshold, ArgKind::Num) | (BindingKind::Leaves, ArgKind::Tree) - | (BindingKind::Subpolicy, ArgKind::Sub) => Ok(()), + | (BindingKind::Subpolicy | BindingKind::Timelock, ArgKind::Sub) => Ok(()), _ => Err(format!( "binding '${}' (kind {:?}) doesn't match the AST position kind {:?}", name, binding, positional @@ -852,12 +819,10 @@ 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 @@ -924,13 +889,6 @@ fn lower( 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 => { @@ -949,6 +907,17 @@ fn lower( }); 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)); } @@ -1055,9 +1024,17 @@ 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, @@ -1098,11 +1075,7 @@ 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()) } @@ -1128,7 +1101,12 @@ fn emit_classify(entries: &[ProcessedEntry], ck: ClassKind, fn_name: &str) -> To // 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)) + .filter(|e| { + e.fields + .kinds + .values() + .any(|k| *k == BindingKind::Subpolicy) + }) .map(|e| id(&e.name)) .collect(); @@ -1365,9 +1343,10 @@ fn emit_score_arm(entry: &ProcessedEntry, class_enum: &str) -> TokenStream { 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())) - }); + let product = sub_idents.iter().fold( + quote!(1u64), + |acc, n| quote!(#acc.saturating_mul(#n.per_leaf_score())), + ); return quote!(#class::#variant #destructure => #product,); } @@ -1573,6 +1552,13 @@ fn emit_subpolicy_construction_block( }) .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]`. @@ -1593,13 +1579,10 @@ fn emit_subpolicy_construction_block( // 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 }) - }); + 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(); @@ -1613,21 +1596,20 @@ fn emit_subpolicy_construction_block( /// 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 { +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)); + 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),*)) } @@ -1640,7 +1622,10 @@ fn build_subpolicy_arg_expr( // 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()), + 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), }; @@ -1651,7 +1636,7 @@ fn build_subpolicy_arg_expr( .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()); + let mut expr = quote!((*#var).clone()); // Apply wrappers from innermost to outermost. for w in wrappers.iter().rev() { let wv = id(w); @@ -1778,7 +1763,7 @@ fn build_arg_expr(arg: &PatternArg, kind: ArgKind, owned: bool) -> TokenStream { PatternArg::SubpolicyRef { .. } => { panic!("SubpolicyRef must be handled by build_subpolicy_arg_expr, not build_arg_expr") } - PatternArg::Binding { name, .. } => { + PatternArg::Binding { name, kind: bkind } => { let n = id(name); match kind { ArgKind::Key | ArgKind::KeyList => quote!(#n.clone()), @@ -1789,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 033167cf..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,9 @@ impl CleartextValueCursor { } } - fn blocks(&mut self) -> Option { - match self.values.next()? { - CleartextValue::Blocks(value) => Some(value), - _ => None, - } - } - - fn relative_time(&mut self) -> Option { + fn timelock(&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::Timelock(value) => Some(value), _ => None, } } @@ -183,12 +201,7 @@ fn parse_cleartext_value(part: CleartextPart, input: &str) -> Option 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 225aa6e7..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,7 @@ pub(super) enum CleartextPart { Threshold, KeyIndex, KeyIndices, - Blocks, - RelativeTime, - BlockHeight, - Timestamp, + Timelock, Subpolicy, } @@ -79,10 +112,7 @@ enum CleartextValue { Threshold(u32), KeyIndex(KeyExpression), KeyIndices(Vec), - Blocks(u32), - RelativeTime(u32), - BlockHeight(u32), - Timestamp(u32), + Timelock(Timelock), Subpolicy(alloc::boxed::Box), } @@ -109,9 +139,7 @@ impl TapleafClass { /// - `SingleSig`: key_index /// - `BothMustSign`: key_index1, then key_index2 /// - `SortedMultisig` / `Multisig`: number of keys, then threshold - /// - lock variants (`RelativeHeightlock`, `RelativeTimelock`, - /// `AbsoluteHeightlock`, `AbsoluteTimelock`): signer sub-policy - /// (recursively), then the lock value + /// - `Timelocked`: signer sub-policy (recursively), then the lock value /// - `AndV`: sub1 (recursively), then sub2 (recursively) /// - `Other`: lexicographic by descriptor string #[rustfmt::skip] @@ -140,21 +168,9 @@ impl TapleafClass { TC::Multisig { threshold: t2, keys: k2 }, ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)), ( - TC::RelativeHeightlock { sub: s1, blocks: b1 }, - TC::RelativeHeightlock { sub: s2, blocks: b2 }, - ) => s1.display_cmp(s2).then(b1.cmp(b2)), - ( - TC::RelativeTimelock { sub: s1, relative_time: t1 }, - TC::RelativeTimelock { sub: s2, relative_time: t2 }, + TC::Timelocked { sub: s1, timelock: t1 }, + TC::Timelocked { sub: s2, timelock: t2 }, ) => s1.display_cmp(s2).then(t1.cmp(t2)), - ( - TC::AbsoluteHeightlock { sub: s1, block_height: h1 }, - TC::AbsoluteHeightlock { sub: s2, block_height: h2 }, - ) => s1.display_cmp(s2).then(h1.cmp(h2)), - ( - TC::AbsoluteTimelock { sub: s1, timestamp: ts1 }, - TC::AbsoluteTimelock { sub: s2, timestamp: ts2 }, - ) => s1.display_cmp(s2).then(ts1.cmp(ts2)), (TC::AndV { sub1: a1, sub2: a2 }, TC::AndV { sub1: b1, sub2: b2 }) => { a1.display_cmp(b1).then_with(|| a2.display_cmp(b2)) } @@ -205,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 { @@ -235,10 +273,7 @@ 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)? } diff --git a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml index 5812a8a7..f4e30477 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml @@ -89,32 +89,16 @@ patterns = [ ] cleartext = ["$threshold", " of ", "$keys"] -# Timelock / heightlock 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 kind is fixed -# by the pattern and disambiguated by the binding's implicit value range (e.g. -# `older($blocks)` vs `older($relative_time)`). This single shape per lock kind -# replaces the former 3-signers x 4-locks cross-product. - -[[tapleaf]] -name = "RelativeHeightlock" -patterns = ["and_v(v:$sub, older($blocks))"] -cleartext = ["$sub", " after ", "$blocks", " blocks"] - -[[tapleaf]] -name = "RelativeTimelock" -patterns = ["and_v(v:$sub, older($relative_time))"] -cleartext = ["$sub", " after ", "$relative_time"] - -[[tapleaf]] -name = "AbsoluteHeightlock" -patterns = ["and_v(v:$sub, after($block_height))"] -cleartext = ["$sub", " after block height ", "$block_height"] - +# 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 = "AbsoluteTimelock" -patterns = ["and_v(v:$sub, after($timestamp))"] -cleartext = ["$sub", " after date ", "$timestamp"] +name = "Timelocked" +patterns = ["and_v(v:$sub, $timelock)"] +cleartext = ["$sub", " after ", "$timelock"] [[tapleaf]] name = "AndV" diff --git a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml index cf7cb767..00f41bb1 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml @@ -575,7 +575,7 @@ cleartext = [ ] has_cleartext = true -# AndV leaf alongside a normal leaf: SingleSig (order=0) sorts before AndV (order=8) +# 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 @@ -586,19 +586,6 @@ cleartext = [ ] has_cleartext = true -# ============================================================================= -# Lock with a sorted-multisig signer: newly representable as -# RelativeHeightlock{sub: SortedMultisig}. Before unifying the lock leaves into -# `and_v(v:$sub, LOCK)` there was no sortedmulti+lock entry, so this fell back -# to the raw descriptor template. -# ============================================================================= - -[[vector]] -template = "tr(@0/**,and_v(v:sortedmulti_a(2,@1/**,@2/**),older(1000)))" -confusion_score = 1 -cleartext = ["Primary path: @0", "2 of @1 and @2 (sorted) after 1000 blocks"] -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`). From 86428c00b6d25c9f2d67124d1cd29a52f1020e63 Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:54:05 +0200 Subject: [PATCH 4/4] Use Oxford comma to disambiguate 'AndV' in cleartext instead of capitalized AND --- .../bip388/src/cleartext/specs/cleartext.toml | 2 +- .../bip388/src/cleartext/specs/test_vectors.toml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml index f4e30477..f84b8906 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml @@ -103,4 +103,4 @@ cleartext = ["$sub", " after ", "$timelock"] [[tapleaf]] name = "AndV" patterns = ["and_v(v:$sub1, $sub2)"] -cleartext = ["$sub1", " AND ", "$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 00f41bb1..53bc1768 100644 --- a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml +++ b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml @@ -531,7 +531,7 @@ 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)", + "Single-signature (@1), and 2 of @2 and @3 (sorted)", ] has_cleartext = true @@ -541,7 +541,7 @@ 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", + "Single-signature (@1), and 2 of @2 and @3", ] has_cleartext = true @@ -551,7 +551,7 @@ 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", + "2 of @1 and @2 (sorted), and 2 of @3 and @4", ] has_cleartext = true @@ -561,7 +561,7 @@ 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", + "2 of @1 and @2, and 2 of @3 and @4", ] has_cleartext = true @@ -571,7 +571,7 @@ template = "tr(@0/**,and_v(v:and_v(v:pk(@1/**),pk(@2/**)),sortedmulti_a(2,@3/**, confusion_score = 1 cleartext = [ "Primary path: @0", - "Both @1 and @2 must sign AND 2 of @3 and @4 (sorted)", + "Both @1 and @2 must sign, and 2 of @3 and @4 (sorted)", ] has_cleartext = true @@ -582,7 +582,7 @@ confusion_score = 1 cleartext = [ "Primary path: @0", "Single-signature (@1)", - "Single-signature (@2) AND 2 of @3 and @4 (sorted)", + "Single-signature (@2), and 2 of @3 and @4 (sorted)", ] has_cleartext = true