diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 425ca626..7ebdd71f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -323,7 +323,7 @@ jobs: uses: ./.github/workflows/reusable_native_tests.yaml with: vapp_name: "vnd-bitcoin" - test_dirs_json: '["apps/bitcoin/common","apps/bitcoin/app"]' + test_dirs_json: '["apps/bitcoin/bip388","apps/bitcoin/common","apps/bitcoin/app"]' vnd_bitcoin_integration_tests: needs: [build_vanadium_app, build_vnd_bitcoin_autoapprove] diff --git a/apps/bitcoin/bip388/Cargo.toml b/apps/bitcoin/bip388/Cargo.toml new file mode 100644 index 00000000..2a9f1e84 --- /dev/null +++ b/apps/bitcoin/bip388/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bip388" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +cleartext-decode = [] + +[dependencies] +bitcoin = { version = "0.32.0", features = ["serde"], default-features = false } +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } + +[dev-dependencies] +toml = { version = "0.8", default-features = false, features = ["parse"] } +serde = { version = "1.0", features = ["derive"] } + +[build-dependencies] +toml = { version = "0.8", default-features = false, features = ["parse"] } +serde = { version = "1.0", features = ["derive"] } +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } +prettyplease = "0.2" + +[profile.release] +opt-level = 3 +lto = true + +[workspace] diff --git a/apps/bitcoin/common/build.rs b/apps/bitcoin/bip388/build.rs similarity index 99% rename from apps/bitcoin/common/build.rs rename to apps/bitcoin/bip388/build.rs index e4b3dfac..ed53b5ec 100644 --- a/apps/bitcoin/common/build.rs +++ b/apps/bitcoin/bip388/build.rs @@ -1,4 +1,4 @@ -//! Build script: generates the cleartext support files from `cleartext.spec.toml`. +//! Build script: generates the cleartext support files from `src/cleartext/specs/cleartext.toml`. //! //! Emits two files into `OUT_DIR`: //! @@ -1399,7 +1399,7 @@ fn pretty_file(file: TokenStream) -> String { let parsed = syn::parse_file(&file.to_string()).expect("generated code is valid Rust"); let body = prettyplease::unparse(&parsed); format!( - "// AUTO-GENERATED by build.rs from cleartext.spec.toml. Do not edit.\n\ + "// AUTO-GENERATED by build.rs from src/cleartext/specs/cleartext.toml. Do not edit.\n\ // To regenerate: edit the spec and rebuild.\n\n{body}" ) } @@ -1466,7 +1466,8 @@ fn emit_decode(top_level: &[ProcessedEntry], tapleaf: &[ProcessedEntry]) -> Stri fn main() -> Result<(), Box> { let manifest_dir = env::var("CARGO_MANIFEST_DIR")?; - let spec_path = PathBuf::from(&manifest_dir).join("src/bip388/cleartext.spec.toml"); + let spec_path = + PathBuf::from(&manifest_dir).join("src/cleartext/specs/cleartext.toml"); println!("cargo:rerun-if-changed={}", spec_path.display()); println!("cargo:rerun-if-changed=build.rs"); diff --git a/apps/bitcoin/common/src/bip388/cleartext/decode.rs b/apps/bitcoin/bip388/src/cleartext/decode.rs similarity index 99% rename from apps/bitcoin/common/src/bip388/cleartext/decode.rs rename to apps/bitcoin/bip388/src/cleartext/decode.rs index 0707f588..bb7fbbd1 100644 --- a/apps/bitcoin/common/src/bip388/cleartext/decode.rs +++ b/apps/bitcoin/bip388/src/cleartext/decode.rs @@ -39,7 +39,7 @@ pub enum CleartextDecodeError { // `DescriptorClass::from_cleartext_pattern`, `TapleafClass::from_cleartext_pattern`, // `top_level_variants`, and `tapleaf_to_descriptors` are generated from -// `cleartext.spec.toml` by `build.rs` (see `emit_decode` there). +// `specs/cleartext.toml` by `build.rs` (see `emit_decode` there). include!(concat!(env!("OUT_DIR"), "/cleartext_decode_generated.rs")); fn parse_key_index(s: &str) -> Option { diff --git a/apps/bitcoin/bip388/src/cleartext/mod.rs b/apps/bitcoin/bip388/src/cleartext/mod.rs new file mode 100644 index 00000000..4c81f39c --- /dev/null +++ b/apps/bitcoin/bip388/src/cleartext/mod.rs @@ -0,0 +1,740 @@ +//! Bidirectional conversion between BIP388 descriptor templates and human-readable +//! "cleartext" descriptions suitable for display on constrained UIs (e.g. hardware signers). +//! +//! # Architecture +//! +//! 1. **Classification** — [`DescriptorTemplate::classify`] / [`classify_as_tapleaf`] map the +//! full descriptor AST onto a small set of recognized spending-policy shapes +//! ([`DescriptorClass`] / [`TapleafClass`]). Anything unrecognized becomes `Other`. +//! +//! 2. **Spec-driven formatting** — Each recognized shape has a [`CleartextSpec`]: an array of +//! [`CleartextPart`] tokens (literal strings interleaved with typed dynamic fields such as +//! key indices, thresholds, and lock values). Both the encoder ([`to_cleartext`]) and the +//! decoder ([`from_cleartext`]) are driven by the *same* specs (in the [`specs`] sub-module), +//! so the two directions stay structurally consistent by construction. +//! +//! 3. **Confusion score** — A single cleartext string can correspond to multiple distinct +//! descriptor templates (e.g. `wpkh` vs `sh(wpkh)`). [`ClearText::confusion_score`] +//! quantifies this ambiguity; descriptions are only shown when the score is below +//! [`MAX_CONFUSION_SCORE`]. +//! +//! 4. **Reverse parsing** (feature-gated: `cleartext-decode`) — [`ClearText::from_cleartext`] +//! parses a cleartext description back into *all* structurally distinct descriptor template +//! candidates, including enumeration of taproot tree topologies. The full machinery lives +//! in the [`decode`] submodule, compiled only when the feature is active. +//! +//! 5. **Canonical display order** — Taproot leaves are sorted via [`TapleafClass::display_cmp`] +//! so the cleartext output is deterministic regardless of the original tree shape. The number +//! of structurally distinct trees is taken into account in the confusion score. + +use alloc::{format, string::String, string::ToString, vec, vec::Vec}; + +use super::time::{format_seconds, format_utc_date}; +use super::{DescriptorTemplate, KeyExpressionType, KeyPlaceholder}; + +#[cfg(any(test, feature = "cleartext-decode"))] +mod decode; + +#[cfg(any(test, feature = "cleartext-decode"))] +pub use decode::CleartextDecodeError; + +#[cfg(any(test, feature = "cleartext-decode"))] +use alloc::boxed::Box; + +// Maximum confusion score for which cleartext descriptions are shown instead of the raw descriptor template. +pub const MAX_CONFUSION_SCORE: u64 = 3600; + +pub(super) const SEQUENCE_LOCKTIME_TYPE_FLAG: u32 = 1 << 22; + +// `DescriptorClass`, `TapleafClass`, `TopLevelPattern`, `TapleafPattern`, +// the `TOP_LEVEL_SPECS` / `TAPLEAF_SPECS` cleartext templates, and the +// always-compiled pattern-matching code (`classify`, `classify_as_tapleaf`, +// `cleartext_pattern`, `order`, `outer_score`, `per_leaf_score`) are generated +// from `specs/cleartext.toml` by `build.rs`. The decode-side generated code +// lives in `cleartext_decode_generated.rs` and is included from `decode.rs`. +include!(concat!(env!("OUT_DIR"), "/cleartext_generated.rs")); + +// Represents a part of a clear-text representation of a descriptor template or tapleaf. A sequence of cleartext parts +// fully defines the structure of the cleartext representation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CleartextPart { + Literal(&'static str), + Threshold, + KeyIndex, + KeyIndices, + Blocks, + RelativeTime, + BlockHeight, + Timestamp, +} + +pub(super) struct CleartextSpec { + pub(super) kind: K, + pub(super) parts: &'static [CleartextPart], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CleartextValue { + Threshold(u32), + KeyIndex(KeyPlaceholder), + KeyIndices(Vec), + Blocks(u32), + RelativeTime(u32), + BlockHeight(u32), + Timestamp(u32), +} + +/// Compares two key placeholders for canonical display ordering: +/// - plain key vs plain key: ordered by key index +/// - plain key vs musig: plain key comes first +/// - musig vs musig: ordered by number of keys, then left-to-right by key index +fn cmp_key(a: &KeyPlaceholder, b: &KeyPlaceholder) -> core::cmp::Ordering { + match (&a.key_type, &b.key_type) { + (KeyExpressionType::PlainKey(i1), KeyExpressionType::PlainKey(i2)) => i1.cmp(i2), + (KeyExpressionType::PlainKey(_), KeyExpressionType::Musig(_)) => core::cmp::Ordering::Less, + (KeyExpressionType::Musig(_), KeyExpressionType::PlainKey(_)) => { + core::cmp::Ordering::Greater + } + (KeyExpressionType::Musig(i1), KeyExpressionType::Musig(i2)) => { + i1.len().cmp(&i2.len()).then_with(|| i1.cmp(i2)) + } + } +} + +impl TapleafClass { + /// Full canonical display order. Categories come from `order()` (generated); + /// within a category, ties are broken by: + /// - `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 + /// - `Other`: lexicographic by descriptor string + #[rustfmt::skip] + fn display_cmp(&self, other: &Self) -> core::cmp::Ordering { + use core::cmp::Ordering; + use TapleafClass as TC; + let cat = self.order().cmp(&other.order()); + if cat != Ordering::Equal { + return cat; + } + match (self, other) { + ( + TC::SingleSig { key: k1 }, + TC::SingleSig { key: k2 }, + ) => cmp_key(k1, k2), + ( + TC::BothMustSign { key1: a1, key2: b1 }, + TC::BothMustSign { key1: a2, key2: b2 }, + ) => cmp_key(a1, a2).then(cmp_key(b1, b2)), + ( + TC::SortedMultisig { threshold: t1, keys: k1 }, + TC::SortedMultisig { threshold: t2, keys: k2 }, + ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)), + ( + TC::Multisig { threshold: t1, keys: k1 }, + 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::Other(s1), TC::Other(s2)) => s1.cmp(s2), + // Same order() value implies same variant; this arm is unreachable. + _ => Ordering::Equal, + } + } +} + +fn format_key(kp: &KeyPlaceholder, canonical: bool) -> String { + if canonical { + match &kp.key_type { + KeyExpressionType::PlainKey(key_index) => format!("@{}", key_index), + KeyExpressionType::Musig(key_indices) => { + let inner: Vec = + key_indices.iter().map(|idx| format!("@{}", idx)).collect(); + format!("musig({})", inner.join(",")) + } + } + } else { + // Always use explicit derivation form for non-canonical display + match &kp.key_type { + KeyExpressionType::PlainKey(key_index) => { + format!("@{}/<{};{}>/*", key_index, kp.num1, kp.num2) + } + KeyExpressionType::Musig(key_indices) => { + let inner: Vec = + key_indices.iter().map(|idx| format!("@{}", idx)).collect(); + format!("musig({})/<{};{}>/*", inner.join(","), kp.num1, kp.num2) + } + } + } +} + +fn format_key_indices(keys: &[KeyPlaceholder], canonical: bool) -> String { + match keys { + [] => String::new(), + [single] => format_key(single, canonical), + [init @ .., last] => { + let parts: Vec = init.iter().map(|k| format_key(k, canonical)).collect(); + format!("{} and {}", parts.join(", "), format_key(last, canonical)) + } + } +} + +fn format_relative_time(time: u32) -> String { + format_seconds((time & !SEQUENCE_LOCKTIME_TYPE_FLAG) * 512) +} + +/// 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 { + t.tapleaves().map(|l| l.classify_as_tapleaf()).collect() +} + +fn cleartext_spec( + specs: &'static [CleartextSpec], + kind: K, +) -> &'static CleartextSpec { + specs + .iter() + .find(|spec| spec.kind == kind) + .expect("missing cleartext spec") +} + +/// Render a single dynamic cleartext part. `Literal` parts are inlined by +/// `format_with_spec` directly; passing one here returns `None`. Any other +/// (part, value) pairing represents a codegen-side bug since the two are +/// produced in lockstep. +fn format_cleartext_value( + part: CleartextPart, + value: &CleartextValue, + canonical: bool, +) -> Option { + Some(match (part, value) { + (CleartextPart::Literal(_), _) => return None, + (CleartextPart::Threshold, CleartextValue::Threshold(t)) => t.to_string(), + (CleartextPart::KeyIndex, CleartextValue::KeyIndex(k)) => format_key(k, canonical), + (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), + _ => unreachable!("cleartext part/value mismatch (codegen invariant violated)"), + }) +} + +fn format_with_spec( + spec: &CleartextSpec, + values: &[CleartextValue], + canonical: bool, +) -> String { + let mut result = String::new(); + let mut values = values.iter(); + for part in spec.parts { + match *part { + CleartextPart::Literal(literal) => result.push_str(literal), + field => { + let value = values.next().expect("missing cleartext value"); + result.push_str( + &format_cleartext_value(field, value, canonical) + .expect("invalid cleartext value"), + ); + } + } + } + debug_assert!(values.next().is_none(), "unused cleartext values"); + result +} + +impl DescriptorClass { + fn to_cleartext_string(&self, canonical: bool) -> Option { + let (kind, values) = self.cleartext_pattern()?; + Some(format_with_spec( + cleartext_spec(TOP_LEVEL_SPECS, kind), + &values, + canonical, + )) + } +} + +impl TapleafClass { + fn to_cleartext_string(&self, canonical: bool) -> Option { + let (kind, values) = self.cleartext_pattern()?; + Some(format_with_spec( + cleartext_spec(TAPLEAF_SPECS, kind), + &values, + canonical, + )) + } +} + +pub trait ClearText { + /// Returns an upper bound on the number of different descriptor templates + /// that would be mapped to the same cleartext description. u64::MAX is returned + /// if the confusion score is greater than or equal to u64::MAX. + fn confusion_score(&self) -> u64; + /// Returns the cleartext description of the descriptor, For taproot descriptors, + /// the vector contains first the description of the spending policy of the internal key, + /// and all the other elements are the cleartext descriptions of the taproot leaves. + /// Any spending condition that doesn't have a cleartext description is shown as the + /// unchanged descriptor template, with a confusion score of 1. + fn to_cleartext(&self) -> (Vec, bool); + + /// Given cleartext descriptions (as produced by `to_cleartext`), returns a + /// lazy iterator over all structurally distinct instances that would produce + /// the same cleartext output. The number of yielded instances equals + /// `confusion_score()`. + #[cfg(any(test, feature = "cleartext-decode"))] + fn from_cleartext( + descriptions: &[&str], + ) -> Result>, CleartextDecodeError> + where + Self: Sized; +} + +impl DescriptorTemplate { + // Verify that, for each distinct key expression in placeholders, its k occurrences carry derivations + // (in some order) equal to <0;1>/*, <2;3>/*, ..., <2k-2;2k-1>/*. That is, after sorting the (num1, num2) + // pairs for each key, they must be exactly (0,1), (2,3), .... This guarantees that no information on + // the derivations is lost when omitting this part in the cleartext representation, up to the + // permutation of pair assignments to occurrences (which is accounted for in the confusion score). + fn are_key_derivations_canonical(&self) -> bool { + let mut pairs_per_key: alloc::collections::BTreeMap< + super::KeyExpressionType, + Vec<(u32, u32)>, + > = alloc::collections::BTreeMap::new(); + + for (kp, _) in self.placeholders() { + pairs_per_key + .entry(kp.key_type.clone()) + .or_default() + .push((kp.num1, kp.num2)); + } + + for pairs in pairs_per_key.values_mut() { + pairs.sort(); + for (i, &(n1, n2)) in pairs.iter().enumerate() { + let expected = (2 * i as u32, 2 * i as u32 + 1); + if (n1, n2) != expected { + return false; + } + } + } + + true + } + + // For each distinct key expression that appears k times in the placeholders, returns the product of + // k! across all keys. This is the number of distinct ways the canonical derivation pairs + // (0,1), (2,3), ... can be permuted across the k occurrences. + fn key_derivation_orderings_count(&self) -> u64 { + let mut counts: alloc::collections::BTreeMap = + alloc::collections::BTreeMap::new(); + for (kp, _) in self.placeholders() { + *counts.entry(kp.key_type.clone()).or_insert(0) += 1; + } + let mut product = 1u64; + for &k in counts.values() { + let mut f = 1u64; + for i in 1..=k as u64 { + f = f.saturating_mul(i); + } + product = product.saturating_mul(f); + } + product + } +} + +impl ClearText for DescriptorTemplate { + fn confusion_score(&self) -> u64 { + let class = self.classify(); + let base = match &class { + DescriptorClass::Taproot { leaves, .. } + | DescriptorClass::TaprootMusig { leaves, .. } => { + // The confusion score of a taproot descriptor is the product of the + // outer score and the per-leaf scores, multiplied by the number T(n) + // of distinct unordered tap-tree shapes. + let mut score = class.outer_score(); + let n_leaves = leaves.len(); + for leaf in leaves { + score = score.saturating_mul(leaf.per_leaf_score()); + } + // T(n) = (2n - 3)!! = 1 * 3 * 5 * ... * (2n - 3) for n > 1, and T(1) = 1. + if n_leaves > 1 { + for i in (1..=(2 * n_leaves - 3)).step_by(2) { + score = score.saturating_mul(i as u64); + } + } + score + } + _ => class.outer_score(), + }; + // For each key expression that appears k times in the descriptor template, + // multiply by k! to account for the possible re-orderings of the canonical + // derivation pairs across its occurrences (root-level only). + base.saturating_mul(self.key_derivation_orderings_count()) + } + + fn to_cleartext(&self) -> (Vec, bool) { + if !self.are_key_derivations_canonical() { + return (vec![self.to_string()], false); + } + match self.classify() { + class @ DescriptorClass::LegacySingleSig { .. } + | class @ DescriptorClass::SegwitSingleSig { .. } + | class @ DescriptorClass::SegwitMultisig { .. } => ( + vec![class.to_cleartext_string(true).expect("missing cleartext")], + true, + ), + class @ DescriptorClass::Taproot { .. } + | class @ DescriptorClass::TaprootMusig { .. } => { + let primary_path = class.to_cleartext_string(true).expect("missing cleartext"); + let mut leaves = match class { + DescriptorClass::Taproot { leaves, .. } => leaves, + DescriptorClass::TaprootMusig { leaves, .. } => leaves, + _ => unreachable!(), + }; + leaves.sort_by(|a, b| a.display_cmp(b)); + let mut descriptions = vec![primary_path]; + let mut all_leaves_have_cleartext = true; + for leaf in leaves { + if let Some(description) = leaf.to_cleartext_string(true) { + descriptions.push(description); + } else { + let TapleafClass::Other(raw) = leaf else { + unreachable!(); + }; + descriptions.push(raw); + all_leaves_have_cleartext = false; + } + } + (descriptions, all_leaves_have_cleartext) + } + DescriptorClass::Other => (vec![self.to_string()], false), + } + } + + #[cfg(any(test, feature = "cleartext-decode"))] + fn from_cleartext( + descriptions: &[&str], + ) -> Result>, CleartextDecodeError> { + decode::from_cleartext_impl(descriptions) + } +} + +#[cfg(test)] +mod tests { + use super::{ClearText, DescriptorTemplate}; + use alloc::{string::String, vec::Vec}; + use core::str::FromStr; + + fn dt(s: &str) -> DescriptorTemplate { + DescriptorTemplate::from_str(s) + .unwrap_or_else(|e| panic!("parse failed for {:?}: {:?}", s, e)) + } + + /// One entry from `specs/test_vectors.toml`. Every field except + /// `template` is optional so the same data file can carry partial + /// vectors (e.g. confusion-score-only) for cases that historically + /// asserted only one property. + #[derive(Debug, serde::Deserialize)] + struct Vector { + template: String, + #[serde(default)] + confusion_score: Option, + #[serde(default)] + cleartext: Option>, + #[serde(default)] + has_cleartext: Option, + } + + #[derive(Debug, serde::Deserialize)] + struct TestVectors { + vector: Vec, + } + + fn load_vectors() -> Vec { + const RAW: &str = include_str!("specs/test_vectors.toml"); + let parsed: TestVectors = + toml::from_str(RAW).expect("failed to parse specs/test_vectors.toml"); + parsed.vector + } + + #[test] + fn test_vectors_confusion_score() { + for v in load_vectors() { + let Some(expected) = v.confusion_score else { + continue; + }; + assert_eq!( + dt(&v.template).confusion_score(), + expected, + "confusion_score mismatch for {:?}", + v.template + ); + } + } + + #[test] + fn test_vectors_to_cleartext() { + for v in load_vectors() { + let (Some(expected_ct), Some(expected_hct)) = (&v.cleartext, v.has_cleartext) else { + continue; + }; + let (actual_ct, actual_hct) = dt(&v.template).to_cleartext(); + assert_eq!( + actual_ct, *expected_ct, + "cleartext mismatch for {:?}", + v.template + ); + assert_eq!( + actual_hct, expected_hct, + "has_cleartext flag mismatch for {:?}", + v.template + ); + } + } + + /// Covers vectors that pin only the `has_cleartext` flag without an + /// explicit `cleartext` array (currently none in the data file, but + /// kept so partial vectors remain useful). + #[test] + fn test_vectors_has_cleartext() { + for v in load_vectors() { + if v.cleartext.is_some() { + continue; + } + let Some(expected_hct) = v.has_cleartext else { + continue; + }; + assert_eq!( + dt(&v.template).to_cleartext().1, + expected_hct, + "has_cleartext flag mismatch for {:?}", + v.template + ); + } + } + + #[test] + fn test_vectors_from_cleartext_roundtrip() { + for v in load_vectors() { + if v.has_cleartext != Some(true) { + continue; + } + let (Some(expected_ct), Some(score)) = (&v.cleartext, v.confusion_score) else { + continue; + }; + + let cleartext_refs: Vec<&str> = expected_ct.iter().map(|s| s.as_str()).collect(); + let variants: Vec<_> = DescriptorTemplate::from_cleartext(&cleartext_refs) + .unwrap_or_else(|e| { + panic!("from_cleartext failed for {:?}: {:?}", v.template, e) + }) + .collect(); + + assert_eq!( + variants.len() as u64, + score, + "variant count != confusion_score for {:?}", + v.template + ); + + for variant in &variants { + let (variant_ct, variant_clear) = variant.to_cleartext(); + assert_eq!( + variant_ct, *expected_ct, + "variant {:?} produces different cleartext for original {:?}", + variant, v.template + ); + assert!( + variant_clear, + "variant {:?} has has_cleartext=false for original {:?}", + variant, v.template + ); + } + + for i in 0..variants.len() { + for j in (i + 1)..variants.len() { + assert_ne!( + variants[i], variants[j], + "duplicate variants at indices {} and {} for {:?}", + i, j, v.template + ); + } + } + } + } + + #[test] + fn test_spec_shape_uniqueness() { + // For each spec, build a "shape string" by concatenating its parts, replacing + // literals with their text and every dynamic field with a fixed non-ASCII + // placeholder. Two specs that map to the same shape string would be + // indistinguishable by the parser. + fn shape_string(parts: &[super::CleartextPart]) -> alloc::string::String { + const PLACEHOLDER: char = '\u{A7}'; // '§' + let mut s = alloc::string::String::new(); + for part in parts { + match part { + super::CleartextPart::Literal(lit) => s.push_str(lit), + _ => s.push(PLACEHOLDER), + } + } + s + } + + fn check_unique(part_slices: &[&'static [super::CleartextPart]], label: &str) { + let shapes: Vec = part_slices + .iter() + .map(|parts| shape_string(parts)) + .collect(); + for i in 0..shapes.len() { + for j in (i + 1)..shapes.len() { + assert_ne!( + shapes[i], shapes[j], + "{} entries at indices {} and {} have the same shape: {:?}", + label, i, j, shapes[i] + ); + } + } + } + + check_unique( + &super::TOP_LEVEL_SPECS + .iter() + .map(|s| s.parts) + .collect::>(), + "TOP_LEVEL_SPECS", + ); + check_unique( + &super::TAPLEAF_SPECS + .iter() + .map(|s| s.parts) + .collect::>(), + "TAPLEAF_SPECS", + ); + } + + /// Verify that the `musig` spec primitive preserves the full key-expression + /// derivation paths by propagating them onto each plain key in the resulting + /// class instance. Both `DescriptorClass::TaprootMusig` (musig as the + /// taproot internal key) and `TapleafClass::Multisig` (musig as a tapleaf + /// signer) flatten the shared derivation onto each plain key in `keys`. + #[test] + fn test_musig_classify_preserves_derivations() { + use super::DescriptorClass; + + // musig internal key with non-standard derivation <2;3>: each plain key + // in `keys` carries (num1=2, num2=3). + let desc = dt("tr(musig(@0,@1)/<2;3>/*,pk(@2/**))"); + let class = desc.classify(); + match class { + DescriptorClass::TaprootMusig { + threshold, + keys, + leaves, + } => { + assert_eq!(threshold, 2); + assert_eq!(keys.len(), 2); + for k in &keys { + assert!(k.is_plain()); + assert_eq!(k.num1, 2); + assert_eq!(k.num2, 3); + } + assert_eq!(keys[0].plain_key_index(), Some(0)); + assert_eq!(keys[1].plain_key_index(), Some(1)); + assert_eq!(leaves.len(), 1); + } + other => panic!("expected TaprootMusig, got {:?}", other), + } + + // musig in tapleaf with non-standard derivation <4;5> + let desc2 = dt("tr(@0/**,pk(musig(@1,@2)/<4;5>/*))"); + let class2 = desc2.classify(); + match class2 { + DescriptorClass::Taproot { leaves, .. } => { + assert_eq!(leaves.len(), 1); + match &leaves[0] { + super::TapleafClass::Multisig { threshold, keys } => { + assert_eq!(*threshold, 2); + assert_eq!(keys.len(), 2); + for k in keys { + assert_eq!(k.num1, 4); + assert_eq!(k.num2, 5); + } + } + other => panic!("expected Multisig tapleaf, got {:?}", other), + } + } + other => panic!("expected Taproot, got {:?}", other), + } + + // Standard derivation musig internal key (sanity check: num1=0, num2=1). + let desc3 = dt("tr(musig(@0,@1)/**)"); + let class3 = desc3.classify(); + match class3 { + DescriptorClass::TaprootMusig { + threshold, + keys, + leaves, + } => { + assert_eq!(threshold, 2); + assert_eq!(keys.len(), 2); + for k in &keys { + assert_eq!(k.num1, 0); + assert_eq!(k.num2, 1); + } + assert!(leaves.is_empty()); + } + other => panic!("expected TaprootMusig, got {:?}", other), + } + } +} diff --git a/apps/bitcoin/common/src/bip388/cleartext.spec.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml similarity index 100% rename from apps/bitcoin/common/src/bip388/cleartext.spec.toml rename to apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml diff --git a/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml new file mode 100644 index 00000000..c99f39b2 --- /dev/null +++ b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml @@ -0,0 +1,547 @@ +# Test vectors for BIP388 cleartext display. +# +# This file is intended to be shared with other implementations of the BIP388 +# cleartext-display logic (possibly in different languages). The Rust test +# harness in `cleartext/mod.rs` consumes it; reuse the same data in other +# implementations by parsing this TOML and running equivalent assertions. +# +# Schema for each `[[vector]]` entry: +# template (required, string) +# The BIP388 descriptor template under test. +# confusion_score (optional, u64) +# The expected upper bound on the number of structurally distinct +# descriptor templates that share this cleartext rendering. +# cleartext (optional, array of strings) +# The expected output of the encoder. For taproot descriptors the first +# element describes the key-path spending; the remaining elements +# describe the leaves in canonical display order. When `has_cleartext` +# is `false`, `cleartext[0]` is the raw descriptor template -- the +# fallback rendering used when no cleartext form is available. +# has_cleartext (optional, bool) +# `true` when every part of the descriptor has a cleartext description. +# `false` for descriptors with non-canonical key derivations or with +# tapleaves that don't match any recognised pattern. +# +# An implementation should run these checks for each entry: +# 1. when `confusion_score` is set: the encoder's confusion-score function +# returns exactly that value. +# 2. when `cleartext` and `has_cleartext` are both set: encoding `template` +# produces exactly `(cleartext, has_cleartext)`. +# 3. when only `has_cleartext` is set: the boolean part of the encoder's +# output matches. +# 4. when `has_cleartext == true` AND both `confusion_score` and `cleartext` +# are set: decoding `cleartext` yields exactly `confusion_score` +# structurally distinct descriptor templates, each of which re-encodes +# to `cleartext`, and no two of which are equal. + +# ============================================================================= +# Legacy / SegWit single-signature +# ============================================================================= + +[[vector]] +template = "pkh(@0/**)" +confusion_score = 1 +cleartext = ["Legacy single-signature (@0)"] +has_cleartext = true + +[[vector]] +template = "wpkh(@0/**)" +confusion_score = 2 +cleartext = ["Segwit single-signature (@0)"] +has_cleartext = true + +[[vector]] +template = "sh(wpkh(@0/**))" +confusion_score = 2 +cleartext = ["Segwit single-signature (@0)"] +has_cleartext = true + +# ============================================================================= +# SegWit multisig (wsh + multi/sortedmulti, optionally wrapped in sh) +# ============================================================================= + +[[vector]] +template = "wsh(sortedmulti(2,@0/**,@1/**))" +confusion_score = 4 +cleartext = ["2 of @0 and @1 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "wsh(sortedmulti(2,@0/**,@1/**,@2/**))" +confusion_score = 4 +cleartext = ["2 of @0, @1 and @2 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "wsh(sortedmulti(3,@0/**,@1/**,@2/**))" +confusion_score = 4 +cleartext = ["3 of @0, @1 and @2 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "wsh(multi(2,@0/**,@1/**))" +confusion_score = 4 +cleartext = ["2 of @0 and @1 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "sh(wsh(multi(2,@0/**,@1/**)))" +confusion_score = 4 +cleartext = ["2 of @0 and @1 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "sh(wsh(sortedmulti(2,@0/**,@1/**)))" +confusion_score = 4 +cleartext = ["2 of @0 and @1 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "sh(wsh(multi(2,@0/**,@1/**,@2/**)))" +confusion_score = 4 +cleartext = ["2 of @0, @1 and @2 (SegWit)"] +has_cleartext = true + +[[vector]] +template = "sh(wsh(sortedmulti(3,@0/**,@1/**,@2/**)))" +confusion_score = 4 +cleartext = ["3 of @0, @1 and @2 (SegWit)"] +has_cleartext = true + +# ============================================================================= +# Taproot (no musig); key-path only +# ============================================================================= + +[[vector]] +template = "tr(@0/**)" +confusion_score = 1 +cleartext = ["Primary path: @0"] +has_cleartext = true + +# ============================================================================= +# Taproot (no musig); script-path leaves +# ============================================================================= + +[[vector]] +template = "tr(@0/**,pk(@1/**))" +confusion_score = 1 +cleartext = ["Primary path: @0", "Single-signature (@1)"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),pk(@2/**)})" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Single-signature (@2)", +] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@3)", + "2 of @1 and @2 (sorted)", +] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})" +confusion_score = 3 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Single-signature (@2)", + "Single-signature (@3)", +] +has_cleartext = true + +# Tie-break: two SingleSig leaves given in reverse key_index order +[[vector]] +template = "tr(@0/**,{pk(@2/**),pk(@1/**)})" +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Single-signature (@2)", +] +has_cleartext = true + +# Tie-break: two RelativeHeightlockSingleSig leaves -- sort by key_index, then blocks +[[vector]] +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", +] +has_cleartext = true + +# Tie-break: two Multisig leaves -- fewer keys first, then smaller threshold +[[vector]] +template = "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**)})" +cleartext = [ + "Primary path: @0", + "2 of @4 and @5", + "2 of @1, @2 and @3", +] +has_cleartext = true + +# Recognised first leaf + unrecognised second leaf (complex miniscript) +[[vector]] +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", + "t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))", +] +has_cleartext = false + +# ============================================================================= +# Taproot relative-heightlock leaves +# ============================================================================= + +[[vector]] +template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))" +cleartext = ["Primary path: @0", "@1 after 52560 blocks"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(52560))})" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "@2 after 52560 blocks", +] +has_cleartext = true + +[[vector]] +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", +] +has_cleartext = true + +# ============================================================================= +# Taproot relative-timelock leaves (older() with the SEQUENCE flag set) +# ============================================================================= + +[[vector]] +template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))" +cleartext = ["Primary path: @0", "@1 after 8m 32s"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194305))})" +confusion_score = 1 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "@2 after 8m 32s", +] +has_cleartext = true + +[[vector]] +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", +] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})" +confusion_score = 2 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "2 of @2 and @3 after 1d 1h 36m", +] +has_cleartext = true + +# ============================================================================= +# Taproot absolute-heightlock / -timelock leaves (after()) +# ============================================================================= + +[[vector]] +template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))" +cleartext = ["Primary path: @0", "@1 after block height 840000"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})" +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "2 of @2 and @3 after block height 840000", +] +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"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})" +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "2 of @2 and @3 after date 2023-11-14 22:13:20", +] +has_cleartext = true + +# ============================================================================= +# Taproot BothMustSign + locks +# ============================================================================= + +[[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"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),and_v(v:and_v(v:pk(@2/<0;1>/*),pk(@3/<0;1>/*)),older(1008))})" +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Both @2 and @3 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"] +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"] +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"] +has_cleartext = true + +# ============================================================================= +# Taproot with musig() as internal key +# ============================================================================= + +[[vector]] +template = "tr(musig(@0,@1)/**)" +confusion_score = 1 +cleartext = ["Primary path: 2 of @0 and @1"] +has_cleartext = true + +[[vector]] +template = "tr(musig(@0,@1,@2)/**)" +confusion_score = 1 +cleartext = ["Primary path: 3 of @0, @1 and @2"] +has_cleartext = true + +[[vector]] +template = "tr(musig(@0,@1)/**,pk(@2/**))" +confusion_score = 1 +cleartext = ["Primary path: 2 of @0 and @1", "Single-signature (@2)"] +has_cleartext = true + +[[vector]] +template = "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})" +confusion_score = 1 +cleartext = [ + "Primary path: 2 of @0 and @1", + "Single-signature (@2)", + "Single-signature (@3)", +] +has_cleartext = true + +[[vector]] +template = "tr(musig(@0,@1)/**,{{pk(@2/**),pk(@3/**)},pk(@4/**)})" +confusion_score = 3 +cleartext = [ + "Primary path: 2 of @0 and @1", + "Single-signature (@2)", + "Single-signature (@3)", + "Single-signature (@4)", +] +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"] +has_cleartext = true + +[[vector]] +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", +] +has_cleartext = true + +# ============================================================================= +# Taproot with musig() inside a tapleaf (pk(musig(...)) -> Multisig) +# ============================================================================= + +[[vector]] +template = "tr(@0/**,pk(musig(@1,@2)/**))" +confusion_score = 2 +cleartext = ["Primary path: @0", "2 of @1 and @2"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,pk(musig(@1,@2,@3)/**))" +confusion_score = 2 +cleartext = ["Primary path: @0", "3 of @1, @2 and @3"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))" +confusion_score = 2 +cleartext = ["Primary path: @0", "2 of @1 and @2 after 1008 blocks"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))" +confusion_score = 2 +cleartext = ["Primary path: @0", "2 of @1 and @2 after 1d 1h 36m"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))" +confusion_score = 2 +cleartext = ["Primary path: @0", "2 of @1 and @2 after block height 840000"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))" +confusion_score = 2 +cleartext = ["Primary path: @0", "2 of @1 and @2 after date 2023-11-14 22:13:20"] +has_cleartext = true + +[[vector]] +template = "tr(@0/**,{pk(@1/**),pk(musig(@2,@3)/**)})" +confusion_score = 2 +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "2 of @2 and @3", +] +has_cleartext = true + +# multi_a(threshold < n) alongside pk(musig) +[[vector]] +template = "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),pk(musig(@4,@5)/**)})" +confusion_score = 2 +cleartext = [ + "Primary path: @0", + "2 of @4 and @5", + "2 of @1, @2 and @3", +] +has_cleartext = true + +# ============================================================================= +# Key derivation orderings: same key appears multiple times with canonical +# derivations (0;1), (2;3), (4;5), ... in some order. The confusion score +# picks up an extra factor of k! per key (where k is its multiplicity). +# ============================================================================= + +# @1 appears twice (<0;1>, <2;3>) -> factor 2! = 2 +[[vector]] +template = "tr(@0/<0;1>/*,{and_v(v:pk(@1/<0;1>/*),older(4383)),and_v(v:pk(@2/<0;1>/*),pk(@1/<2;3>/*))})" +cleartext = [ + "Primary path: @0", + "Both @2 and @1 must sign", + "@1 after 4383 blocks", +] +has_cleartext = true + +# Leaves are unambiguous but @1 and @2 each appear twice -> 2! * 2! = 4 +[[vector]] +template = "tr(@0/<0;1>/*,{and_v(v:multi_a(2,@1/<0;1>/*,@2/<0;1>/*,@3/<0;1>/*),older(144)),and_v(v:pk(@1/<2;3>/*),pk(@2/<2;3>/*))})" +confusion_score = 4 +cleartext = [ + "Primary path: @0", + "Both @1 and @2 must sign", + "2 of @1, @2 and @3 after 144 blocks", +] +has_cleartext = true + +# @0 appears twice (key path + leaf) -> 2! = 2 +[[vector]] +template = "tr(@0/<0;1>/*,pk(@0/<2;3>/*))" +cleartext = ["Primary path: @0", "Single-signature (@0)"] +has_cleartext = true + +# @0 appears three times -> 3! = 6 +[[vector]] +template = "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),pk(@0/<4;5>/*)})" +cleartext = [ + "Primary path: @0", + "Single-signature (@0)", + "Single-signature (@0)", +] +has_cleartext = true + +# @1 appears twice in two leaves -> 2! = 2 +[[vector]] +template = "tr(@0/**,{pk(@1/<0;1>/*),pk(@1/<2;3>/*)})" +cleartext = [ + "Primary path: @0", + "Single-signature (@1)", + "Single-signature (@1)", +] +has_cleartext = true + +# @1 appears twice across distinct leaf shapes -> 2! = 2 +[[vector]] +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", +] +has_cleartext = true + +# @0 twice, @1 twice -> 2! * 2! = 4 +[[vector]] +template = "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),and_v(v:pk(@1/<0;1>/*),pk(@1/<2;3>/*))})" +cleartext = [ + "Primary path: @0", + "Single-signature (@0)", + "Both @1 and @1 must sign", +] +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`). +# ============================================================================= + +[[vector]] +template = "pkh(@0/<2;3>/*)" +cleartext = ["pkh(@0/<2;3>/*)"] +has_cleartext = false + +[[vector]] +template = "wpkh(@0/<0;2>/*)" +cleartext = ["wpkh(@0/<0;2>/*)"] +has_cleartext = false + +[[vector]] +template = "tr(@0/<4;5>/*)" +cleartext = ["tr(@0/<4;5>/*)"] +has_cleartext = false + +[[vector]] +template = "tr(@0/**,pk(@1/<2;3>/*))" +cleartext = ["tr(@0/**,pk(@1/<2;3>/*))"] +has_cleartext = false diff --git a/apps/bitcoin/common/src/bip388/mod.rs b/apps/bitcoin/bip388/src/lib.rs similarity index 99% rename from apps/bitcoin/common/src/bip388/mod.rs rename to apps/bitcoin/bip388/src/lib.rs index 4073dae8..ef4f7d90 100644 --- a/apps/bitcoin/common/src/bip388/mod.rs +++ b/apps/bitcoin/bip388/src/lib.rs @@ -1,3 +1,7 @@ +#![cfg_attr(not(test), no_std)] + +extern crate alloc; + // TODO: // - add type checks // - add malleability checks diff --git a/apps/bitcoin/common/src/bip388/time.rs b/apps/bitcoin/bip388/src/time.rs similarity index 100% rename from apps/bitcoin/common/src/bip388/time.rs rename to apps/bitcoin/bip388/src/time.rs diff --git a/apps/bitcoin/common/Cargo.toml b/apps/bitcoin/common/Cargo.toml index a943d0dd..9cdeb772 100644 --- a/apps/bitcoin/common/Cargo.toml +++ b/apps/bitcoin/common/Cargo.toml @@ -11,7 +11,6 @@ target_native = [ target_vanadium_ledger = [ "sdk/target_vanadium_ledger", ] -cleartext-decode = [] [dependencies] bitcoin = { version = "0.32.0", features = ["serde"], default-features = false } @@ -24,19 +23,12 @@ hex = { version = "0.4.3", default-features = false, features = ["alloc"] } minicbor = { version = "2.2", default-features = false, features = ["alloc", "derive"] } sdk = { package = "vanadium-app-sdk", path = "../../../app-sdk", default-features = false } subtle = { version="2.6.1", default-features = false } +bip388 = { path = "../bip388", default-features = false } [dev-dependencies] hex-literal = "0.4.1" sdk = { package = "vanadium-app-sdk", path = "../../../app-sdk", default-features = false, features = ["target_native"] } -[build-dependencies] -toml = { version = "0.8", default-features = false, features = ["parse"] } -serde = { version = "1.0", features = ["derive"] } -proc-macro2 = "1.0" -quote = "1.0" -syn = { version = "2.0", features = ["full"] } -prettyplease = "0.2" - [profile.release] opt-level = 3 lto = true diff --git a/apps/bitcoin/common/src/bip388/cleartext/mod.rs b/apps/bitcoin/common/src/bip388/cleartext/mod.rs deleted file mode 100644 index a2f88632..00000000 --- a/apps/bitcoin/common/src/bip388/cleartext/mod.rs +++ /dev/null @@ -1,1236 +0,0 @@ -//! Bidirectional conversion between BIP388 descriptor templates and human-readable -//! "cleartext" descriptions suitable for display on constrained UIs (e.g. hardware signers). -//! -//! # Architecture -//! -//! 1. **Classification** — [`DescriptorTemplate::classify`] / [`classify_as_tapleaf`] map the -//! full descriptor AST onto a small set of recognized spending-policy shapes -//! ([`DescriptorClass`] / [`TapleafClass`]). Anything unrecognized becomes `Other`. -//! -//! 2. **Spec-driven formatting** — Each recognized shape has a [`CleartextSpec`]: an array of -//! [`CleartextPart`] tokens (literal strings interleaved with typed dynamic fields such as -//! key indices, thresholds, and lock values). Both the encoder ([`to_cleartext`]) and the -//! decoder ([`from_cleartext`]) are driven by the *same* specs (in the [`specs`] sub-module), -//! so the two directions stay structurally consistent by construction. -//! -//! 3. **Confusion score** — A single cleartext string can correspond to multiple distinct -//! descriptor templates (e.g. `wpkh` vs `sh(wpkh)`). [`ClearText::confusion_score`] -//! quantifies this ambiguity; descriptions are only shown when the score is below -//! [`MAX_CONFUSION_SCORE`]. -//! -//! 4. **Reverse parsing** (feature-gated: `cleartext-decode`) — [`ClearText::from_cleartext`] -//! parses a cleartext description back into *all* structurally distinct descriptor template -//! candidates, including enumeration of taproot tree topologies. The full machinery lives -//! in the [`decode`] submodule, compiled only when the feature is active. -//! -//! 5. **Canonical display order** — Taproot leaves are sorted via [`TapleafClass::display_cmp`] -//! so the cleartext output is deterministic regardless of the original tree shape. The number -//! of structurally distinct trees is taken into account in the confusion score. - -use alloc::{format, string::String, string::ToString, vec, vec::Vec}; - -use super::time::{format_seconds, format_utc_date}; -use super::{DescriptorTemplate, KeyExpressionType, KeyPlaceholder}; - -#[cfg(any(test, feature = "cleartext-decode"))] -mod decode; - -#[cfg(any(test, feature = "cleartext-decode"))] -pub use decode::CleartextDecodeError; - -#[cfg(any(test, feature = "cleartext-decode"))] -use alloc::boxed::Box; - -// Maximum confusion score for which cleartext descriptions are shown instead of the raw descriptor template. -pub const MAX_CONFUSION_SCORE: u64 = 3600; - -pub(super) const SEQUENCE_LOCKTIME_TYPE_FLAG: u32 = 1 << 22; - -// `DescriptorClass`, `TapleafClass`, `TopLevelPattern`, `TapleafPattern`, -// the `TOP_LEVEL_SPECS` / `TAPLEAF_SPECS` cleartext templates, and the -// always-compiled pattern-matching code (`classify`, `classify_as_tapleaf`, -// `cleartext_pattern`, `order`, `outer_score`, `per_leaf_score`) are generated -// from `cleartext.spec.toml` by `build.rs`. The decode-side generated code -// lives in `cleartext_decode_generated.rs` and is included from `decode.rs`. -include!(concat!(env!("OUT_DIR"), "/cleartext_generated.rs")); - -// Represents a part of a clear-text representation of a descriptor template or tapleaf. A sequence of cleartext parts -// fully defines the structure of the cleartext representation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum CleartextPart { - Literal(&'static str), - Threshold, - KeyIndex, - KeyIndices, - Blocks, - RelativeTime, - BlockHeight, - Timestamp, -} - -pub(super) struct CleartextSpec { - pub(super) kind: K, - pub(super) parts: &'static [CleartextPart], -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum CleartextValue { - Threshold(u32), - KeyIndex(KeyPlaceholder), - KeyIndices(Vec), - Blocks(u32), - RelativeTime(u32), - BlockHeight(u32), - Timestamp(u32), -} - -/// Compares two key placeholders for canonical display ordering: -/// - plain key vs plain key: ordered by key index -/// - plain key vs musig: plain key comes first -/// - musig vs musig: ordered by number of keys, then left-to-right by key index -fn cmp_key(a: &KeyPlaceholder, b: &KeyPlaceholder) -> core::cmp::Ordering { - match (&a.key_type, &b.key_type) { - (KeyExpressionType::PlainKey(i1), KeyExpressionType::PlainKey(i2)) => i1.cmp(i2), - (KeyExpressionType::PlainKey(_), KeyExpressionType::Musig(_)) => core::cmp::Ordering::Less, - (KeyExpressionType::Musig(_), KeyExpressionType::PlainKey(_)) => { - core::cmp::Ordering::Greater - } - (KeyExpressionType::Musig(i1), KeyExpressionType::Musig(i2)) => { - i1.len().cmp(&i2.len()).then_with(|| i1.cmp(i2)) - } - } -} - -impl TapleafClass { - /// Full canonical display order. Categories come from `order()` (generated); - /// within a category, ties are broken by: - /// - `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 - /// - `Other`: lexicographic by descriptor string - #[rustfmt::skip] - fn display_cmp(&self, other: &Self) -> core::cmp::Ordering { - use core::cmp::Ordering; - use TapleafClass as TC; - let cat = self.order().cmp(&other.order()); - if cat != Ordering::Equal { - return cat; - } - match (self, other) { - ( - TC::SingleSig { key: k1 }, - TC::SingleSig { key: k2 }, - ) => cmp_key(k1, k2), - ( - TC::BothMustSign { key1: a1, key2: b1 }, - TC::BothMustSign { key1: a2, key2: b2 }, - ) => cmp_key(a1, a2).then(cmp_key(b1, b2)), - ( - TC::SortedMultisig { threshold: t1, keys: k1 }, - TC::SortedMultisig { threshold: t2, keys: k2 }, - ) => k1.len().cmp(&k2.len()).then(t1.cmp(t2)), - ( - TC::Multisig { threshold: t1, keys: k1 }, - 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::Other(s1), TC::Other(s2)) => s1.cmp(s2), - // Same order() value implies same variant; this arm is unreachable. - _ => Ordering::Equal, - } - } -} - -fn format_key(kp: &KeyPlaceholder, canonical: bool) -> String { - if canonical { - match &kp.key_type { - KeyExpressionType::PlainKey(key_index) => format!("@{}", key_index), - KeyExpressionType::Musig(key_indices) => { - let inner: Vec = - key_indices.iter().map(|idx| format!("@{}", idx)).collect(); - format!("musig({})", inner.join(",")) - } - } - } else { - // Always use explicit derivation form for non-canonical display - match &kp.key_type { - KeyExpressionType::PlainKey(key_index) => { - format!("@{}/<{};{}>/*", key_index, kp.num1, kp.num2) - } - KeyExpressionType::Musig(key_indices) => { - let inner: Vec = - key_indices.iter().map(|idx| format!("@{}", idx)).collect(); - format!("musig({})/<{};{}>/*", inner.join(","), kp.num1, kp.num2) - } - } - } -} - -fn format_key_indices(keys: &[KeyPlaceholder], canonical: bool) -> String { - match keys { - [] => String::new(), - [single] => format_key(single, canonical), - [init @ .., last] => { - let parts: Vec = init.iter().map(|k| format_key(k, canonical)).collect(); - format!("{} and {}", parts.join(", "), format_key(last, canonical)) - } - } -} - -fn format_relative_time(time: u32) -> String { - format_seconds((time & !SEQUENCE_LOCKTIME_TYPE_FLAG) * 512) -} - -/// 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 { - t.tapleaves().map(|l| l.classify_as_tapleaf()).collect() -} - -fn cleartext_spec( - specs: &'static [CleartextSpec], - kind: K, -) -> &'static CleartextSpec { - specs - .iter() - .find(|spec| spec.kind == kind) - .expect("missing cleartext spec") -} - -/// Render a single dynamic cleartext part. `Literal` parts are inlined by -/// `format_with_spec` directly; passing one here returns `None`. Any other -/// (part, value) pairing represents a codegen-side bug since the two are -/// produced in lockstep. -fn format_cleartext_value( - part: CleartextPart, - value: &CleartextValue, - canonical: bool, -) -> Option { - Some(match (part, value) { - (CleartextPart::Literal(_), _) => return None, - (CleartextPart::Threshold, CleartextValue::Threshold(t)) => t.to_string(), - (CleartextPart::KeyIndex, CleartextValue::KeyIndex(k)) => format_key(k, canonical), - (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), - _ => unreachable!("cleartext part/value mismatch (codegen invariant violated)"), - }) -} - -fn format_with_spec( - spec: &CleartextSpec, - values: &[CleartextValue], - canonical: bool, -) -> String { - let mut result = String::new(); - let mut values = values.iter(); - for part in spec.parts { - match *part { - CleartextPart::Literal(literal) => result.push_str(literal), - field => { - let value = values.next().expect("missing cleartext value"); - result.push_str( - &format_cleartext_value(field, value, canonical) - .expect("invalid cleartext value"), - ); - } - } - } - debug_assert!(values.next().is_none(), "unused cleartext values"); - result -} - -impl DescriptorClass { - fn to_cleartext_string(&self, canonical: bool) -> Option { - let (kind, values) = self.cleartext_pattern()?; - Some(format_with_spec( - cleartext_spec(TOP_LEVEL_SPECS, kind), - &values, - canonical, - )) - } -} - -impl TapleafClass { - fn to_cleartext_string(&self, canonical: bool) -> Option { - let (kind, values) = self.cleartext_pattern()?; - Some(format_with_spec( - cleartext_spec(TAPLEAF_SPECS, kind), - &values, - canonical, - )) - } -} - -pub trait ClearText { - /// Returns an upper bound on the number of different descriptor templates - /// that would be mapped to the same cleartext description. u64::MAX is returned - /// if the confusion score is greater than or equal to u64::MAX. - fn confusion_score(&self) -> u64; - /// Returns the cleartext description of the descriptor, For taproot descriptors, - /// the vector contains first the description of the spending policy of the internal key, - /// and all the other elements are the cleartext descriptions of the taproot leaves. - /// Any spending condition that doesn't have a cleartext description is shown as the - /// unchanged descriptor template, with a confusion score of 1. - fn to_cleartext(&self) -> (Vec, bool); - - /// Given cleartext descriptions (as produced by `to_cleartext`), returns a - /// lazy iterator over all structurally distinct instances that would produce - /// the same cleartext output. The number of yielded instances equals - /// `confusion_score()`. - #[cfg(any(test, feature = "cleartext-decode"))] - fn from_cleartext( - descriptions: &[&str], - ) -> Result>, CleartextDecodeError> - where - Self: Sized; -} - -impl DescriptorTemplate { - // Verify that, for each distinct key expression in placeholders, its k occurrences carry derivations - // (in some order) equal to <0;1>/*, <2;3>/*, ..., <2k-2;2k-1>/*. That is, after sorting the (num1, num2) - // pairs for each key, they must be exactly (0,1), (2,3), .... This guarantees that no information on - // the derivations is lost when omitting this part in the cleartext representation, up to the - // permutation of pair assignments to occurrences (which is accounted for in the confusion score). - fn are_key_derivations_canonical(&self) -> bool { - let mut pairs_per_key: alloc::collections::BTreeMap< - super::KeyExpressionType, - Vec<(u32, u32)>, - > = alloc::collections::BTreeMap::new(); - - for (kp, _) in self.placeholders() { - pairs_per_key - .entry(kp.key_type.clone()) - .or_default() - .push((kp.num1, kp.num2)); - } - - for pairs in pairs_per_key.values_mut() { - pairs.sort(); - for (i, &(n1, n2)) in pairs.iter().enumerate() { - let expected = (2 * i as u32, 2 * i as u32 + 1); - if (n1, n2) != expected { - return false; - } - } - } - - true - } - - // For each distinct key expression that appears k times in the placeholders, returns the product of - // k! across all keys. This is the number of distinct ways the canonical derivation pairs - // (0,1), (2,3), ... can be permuted across the k occurrences. - fn key_derivation_orderings_count(&self) -> u64 { - let mut counts: alloc::collections::BTreeMap = - alloc::collections::BTreeMap::new(); - for (kp, _) in self.placeholders() { - *counts.entry(kp.key_type.clone()).or_insert(0) += 1; - } - let mut product = 1u64; - for &k in counts.values() { - let mut f = 1u64; - for i in 1..=k as u64 { - f = f.saturating_mul(i); - } - product = product.saturating_mul(f); - } - product - } -} - -impl ClearText for DescriptorTemplate { - fn confusion_score(&self) -> u64 { - let class = self.classify(); - let base = match &class { - DescriptorClass::Taproot { leaves, .. } - | DescriptorClass::TaprootMusig { leaves, .. } => { - // The confusion score of a taproot descriptor is the product of the - // outer score and the per-leaf scores, multiplied by the number T(n) - // of distinct unordered tap-tree shapes. - let mut score = class.outer_score(); - let n_leaves = leaves.len(); - for leaf in leaves { - score = score.saturating_mul(leaf.per_leaf_score()); - } - // T(n) = (2n - 3)!! = 1 * 3 * 5 * ... * (2n - 3) for n > 1, and T(1) = 1. - if n_leaves > 1 { - for i in (1..=(2 * n_leaves - 3)).step_by(2) { - score = score.saturating_mul(i as u64); - } - } - score - } - _ => class.outer_score(), - }; - // For each key expression that appears k times in the descriptor template, - // multiply by k! to account for the possible re-orderings of the canonical - // derivation pairs across its occurrences (root-level only). - base.saturating_mul(self.key_derivation_orderings_count()) - } - - fn to_cleartext(&self) -> (Vec, bool) { - if !self.are_key_derivations_canonical() { - return (vec![self.to_string()], false); - } - match self.classify() { - class @ DescriptorClass::LegacySingleSig { .. } - | class @ DescriptorClass::SegwitSingleSig { .. } - | class @ DescriptorClass::SegwitMultisig { .. } => ( - vec![class.to_cleartext_string(true).expect("missing cleartext")], - true, - ), - class @ DescriptorClass::Taproot { .. } - | class @ DescriptorClass::TaprootMusig { .. } => { - let primary_path = class.to_cleartext_string(true).expect("missing cleartext"); - let mut leaves = match class { - DescriptorClass::Taproot { leaves, .. } => leaves, - DescriptorClass::TaprootMusig { leaves, .. } => leaves, - _ => unreachable!(), - }; - leaves.sort_by(|a, b| a.display_cmp(b)); - let mut descriptions = vec![primary_path]; - let mut all_leaves_have_cleartext = true; - for leaf in leaves { - if let Some(description) = leaf.to_cleartext_string(true) { - descriptions.push(description); - } else { - let TapleafClass::Other(raw) = leaf else { - unreachable!(); - }; - descriptions.push(raw); - all_leaves_have_cleartext = false; - } - } - (descriptions, all_leaves_have_cleartext) - } - DescriptorClass::Other => (vec![self.to_string()], false), - } - } - - #[cfg(any(test, feature = "cleartext-decode"))] - fn from_cleartext( - descriptions: &[&str], - ) -> Result>, CleartextDecodeError> { - decode::from_cleartext_impl(descriptions) - } -} - -#[cfg(test)] -mod tests { - use super::{ClearText, DescriptorTemplate}; - use alloc::{string::ToString, vec::Vec}; - use core::str::FromStr; - - fn dt(s: &str) -> DescriptorTemplate { - DescriptorTemplate::from_str(s) - .unwrap_or_else(|e| panic!("parse failed for {:?}: {:?}", s, e)) - } - - fn strs(ss: &[&str]) -> Vec { - ss.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn test_confusion_score() { - // (descriptor_template, expected_confusion_score) - let cases: &[(&str, u64)] = &[ - // Legacy single-sig - ("pkh(@0/**)", 1), - // Segwit single-sig - ("wpkh(@0/**)", 2), - ("sh(wpkh(@0/**))", 2), // wrapped - // Segwit multi-sig (wsh + sortedmulti / multi) - ("wsh(sortedmulti(2,@0/**,@1/**))", 4), - ("wsh(sortedmulti(2,@0/**,@1/**,@2/**))", 4), - ("wsh(sortedmulti(3,@0/**,@1/**,@2/**))", 4), - ("wsh(multi(2,@0/**,@1/**))", 4), - ("sh(wsh(multi(2,@0/**,@1/**)))", 4), // wrapped - ("sh(wsh(sortedmulti(2,@0/**,@1/**)))", 4), // wrapped - ("sh(wsh(multi(2,@0/**,@1/**,@2/**)))", 4), // wrapped - ("sh(wsh(sortedmulti(3,@0/**,@1/**,@2/**)))", 4), // wrapped - // Taproot with 1 SingleSig leaf (score 1) - ("tr(@0/**,pk(@1/**))", 1), - // Taproot with 2 leaves: both SingleSig (score 1 each), T(2)=1 → 1×1×1=1 - ("tr(@0/**,{pk(@1/**),pk(@2/**)})", 1), - // Taproot with 2 leaves: SortedMultisig (score 1) + SingleSig (score 1), T(2)=1 → 1×1×1=1 - ("tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})", 1), - // Taproot with 3 leaves: 3×SingleSig (1×1×1=1), T(3)=3 → 1×3=3 - ("tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})", 3), - // Taproot with 2 leaves: RelativeHeightlockSingleSig (score 1) + SingleSig (score 1), T(2)=1 → 1 - ( - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(52560))})", - 1, - ), - // Taproot with 2 leaves: RelativeTimelockSingleSig (score 1) + SingleSig (score 1), T(2)=1 → 1 - ( - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194305))})", - 1, - ), - // Taproot with 2 leaves: RelativeTimelockMultiSig (score 2, threshold==keys) + SingleSig (score 1), T(2)=1 → 2 - ( - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})", - 2, - ), - // Taproot with musig internal key: key-path only (score 1) - ("tr(musig(@0,@1)/**)", 1), - // Taproot with musig internal key: 3 keys (score 1) - ("tr(musig(@0,@1,@2)/**)", 1), - // Taproot with musig internal key + 1 SingleSig leaf (score 1) - ("tr(musig(@0,@1)/**,pk(@2/**))", 1), - // Taproot with musig internal key + 2 SingleSig leaves: T(2)=1 → 1 - ("tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})", 1), - // Taproot with musig internal key + 3 SingleSig leaves: T(3)=3 → 3 - ("tr(musig(@0,@1)/**,{{pk(@2/**),pk(@3/**)},pk(@4/**)})", 3), - // -------------------------------------------------------------------------------------- - // Taproot with musig() tapleaf (pk(musig(...)) maps to Multisig) - // -------------------------------------------------------------------------------------- - // pk(musig) leaf: 2-of-2 (threshold==keys, score 2) - ("tr(@0/**,pk(musig(@1,@2)/**))", 2), - // pk(musig) leaf: 3-of-3 (threshold==keys, score 2) - ("tr(@0/**,pk(musig(@1,@2,@3)/**))", 2), - // pk(musig) + relative heightlock (threshold==keys, score 2) - ("tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))", 2), - // pk(musig) + relative timelock (threshold==keys, score 2) - ("tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))", 2), - // pk(musig) + absolute heightlock (threshold==keys, score 2) - ("tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))", 2), - // pk(musig) + absolute timelock (threshold==keys, score 2) - ( - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))", - 2, - ), - // pk(musig) alongside SingleSig: 1*2*T(2)=2 - ("tr(@0/**,{pk(@1/**),pk(musig(@2,@3)/**)})", 2), - // multi_a with threshold < keys.len() alongside pk(musig): 1*2*T(2)=2 - ( - "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),pk(musig(@4,@5)/**)})", - 2, - ), - ( - // leaves are unambiguous, but keys @1 and @2 appear twice each, so the result is multiplied by 2! * 2! - "tr(@0/<0;1>/*,{and_v(v:multi_a(2,@1/<0;1>/*,@2/<0;1>/*,@3/<0;1>/*),older(144)),and_v(v:pk(@1/<2;3>/*),pk(@2/<2;3>/*))})", - 4 - ), - ]; - - for &(desc_str, expected) in cases { - assert_eq!( - dt(desc_str).confusion_score(), - expected, - "confusion_score mismatch for {:?}", - desc_str - ); - } - } - - #[test] - fn test_has_cleartext() { - // list of descriptor templates that should have a cleartext description - let cases = &[ - "pkh(@0/**)", - "wpkh(@0/**)", - "wsh(sortedmulti(2,@0/**,@1/**))", - "wsh(sortedmulti(2,@0/**,@1/**,@2/**))", - "wsh(sortedmulti(3,@0/**,@1/**,@2/**))", - "wsh(multi(2,@0/**,@1/**))", - "tr(@0/**)", - "tr(@0/**,pk(@1/**))", - "tr(@0/**,{pk(@1/**),pk(@2/**)})", - "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})", - "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})", - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))", - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})", - "tr(@0/<0;1>/*,{and_v(v:pk(@1/<0;1>/*),older(4383)),and_v(v:pk(@2/<0;1>/*),pk(@1/<2;3>/*))})", - "tr(@0/<0;1>/*,{and_v(v:multi_a(2,@1/<0;1>/*,@2/<0;1>/*,@3/<0;1>/*),older(144)),and_v(v:pk(@1/<2;3>/*),pk(@2/<2;3>/*))})", - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))", - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(1008)))", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))", - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})", - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})", - // Taproot with musig internal key - "tr(musig(@0,@1)/**)", - "tr(musig(@0,@1,@2)/**)", - "tr(musig(@0,@1)/**,pk(@2/**))", - "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})", - // Taproot with musig tapleaf - "tr(@0/**,pk(musig(@1,@2)/**))", - "tr(@0/**,pk(musig(@1,@2,@3)/**))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))", - // Key derivation ordering tests (keys appear multiple times with canonical derivations) - "tr(@0/<0;1>/*,pk(@0/<2;3>/*))", - "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),pk(@0/<4;5>/*)})", - "tr(@0/**,{pk(@1/<0;1>/*),pk(@1/<2;3>/*)})", - "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(4383)),pk(@1/<2;3>/*)})", - "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),and_v(v:pk(@1/<0;1>/*),pk(@1/<2;3>/*))})", - ]; - for &desc_str in cases { - assert!( - dt(desc_str).to_cleartext().1, - "expected to have cleartext description: {:?}", - desc_str - ); - } - } - - #[test] - fn test_to_cleartext() { - // (descriptor_template, expected_descriptions, expected_all_have_cleartext) - let cases: &[(&str, &[&str], bool)] = &[ - // Legacy single-sig - ("pkh(@0/**)", &["Legacy single-signature (@0)"], true), - // Segwit single-sig - ("wpkh(@0/**)", &["Segwit single-signature (@0)"], true), - // Multisig: 2-of-2 sortedmulti - ( - "wsh(sortedmulti(2,@0/**,@1/**))", - &["2 of @0 and @1 (SegWit)"], - true, - ), - // Multisig: 2-of-3 sortedmulti (format_key_indices with 3 keys) - ( - "wsh(sortedmulti(2,@0/**,@1/**,@2/**))", - &["2 of @0, @1 and @2 (SegWit)"], - true, - ), - // Multisig: 3-of-3 sortedmulti - ( - "wsh(sortedmulti(3,@0/**,@1/**,@2/**))", - &["3 of @0, @1 and @2 (SegWit)"], - true, - ), - // Multisig: multi (non-sorted) - ( - "wsh(multi(2,@0/**,@1/**))", - &["2 of @0 and @1 (SegWit)"], - true, - ), - // Taproot: key-path only (no leaves) - ("tr(@0/**)", &["Primary path: @0"], true), - // Taproot: single pk leaf - ( - "tr(@0/**,pk(@1/**))", - &["Primary path: @0", "Single-signature (@1)"], - true, - ), - // Taproot: two SingleSig leaves - ( - "tr(@0/**,{pk(@1/**),pk(@2/**)})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "Single-signature (@2)", - ], - true, - ), - // Taproot: SortedMultisig leaf + SingleSig leaf (SingleSig sorts first) - ( - "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})", - &[ - "Primary path: @0", - "Single-signature (@3)", - "2 of @1 and @2 (sorted)", - ], - true, - ), - // Taproot: three SingleSig leaves - ( - "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "Single-signature (@2)", - "Single-signature (@3)", - ], - true, - ), - // Taproot: relative timelock single-sig leaf - ( - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))", - &["Primary path: @0", "@1 after 52560 blocks"], - true, - ), - // Taproot: relative timelock single-sig alongside a plain single-sig - ( - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "@2 after 1008 blocks", - ], - true, - ), - // Taproot: relative time-lock single-sig (1 unit = 512s = 8m 32s) - ( - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))", - &["Primary path: @0", "@1 after 8m 32s"], - true, - ), - // Taproot: relative time-lock single-sig (180 units = 92160s = 1d 1h 36m) - ( - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "@2 after 1d 1h 36m", - ], - true, - ), - // Taproot: relative time-lock multisig (180 units = 92160s = 1d 1h 36m) - ( - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "2 of @2 and @3 after 1d 1h 36m", - ], - true, - ), - // Taproot: absolute heightlock single-sig (block height 840000) - ( - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))", - &["Primary path: @0", "@1 after block height 840000"], - true, - ), - // Taproot: absolute heightlock multisig alongside a plain single-sig - ( - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "2 of @2 and @3 after block height 840000", - ], - true, - ), - // Taproot: absolute timelock single-sig (timestamp 500000000 = 1985-11-05T00:53:20) - ( - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))", - &["Primary path: @0", "@1 after date 1985-11-05 00:53:20"], - true, - ), - // Taproot: absolute timelock multisig (timestamp 1700000000 = 2023-11-14 22:13:20) - ( - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "2 of @2 and @3 after date 2023-11-14 22:13:20", - ], - true, - ), - // Taproot: relative heightlock both-must-sign - ( - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(1008)))", - &["Primary path: @0", "Both @1 and @2 after 1008 blocks"], - true, - ), - // Taproot: relative heightlock both-must-sign alongside a plain single-sig - ( - "tr(@0/**,{pk(@1/**),and_v(v:and_v(v:pk(@2/<0;1>/*),pk(@3/<0;1>/*)),older(1008))})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "Both @2 and @3 after 1008 blocks", - ], - true, - ), - // Taproot: relative timelock both-must-sign (180 units = 92160s = 1d 1h 36m) - ( - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))", - &["Primary path: @0", "Both @1 and @2 after 1d 1h 36m"], - true, - ), - // Taproot: absolute heightlock both-must-sign (block height 840000) - ( - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))", - &["Primary path: @0", "Both @1 and @2 after block height 840000"], - true, - ), - // Taproot: absolute timelock both-must-sign (timestamp 1700000000 = 2023-11-14 22:13:20) - ( - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))", - &["Primary path: @0", "Both @1 and @2 after date 2023-11-14 22:13:20"], - true, - ), - // Taproot: first leaf recognized (heightlock single-sig), second leaf unrecognized (complex miniscript) - ( - "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))))})", - &[ - "Primary path: @0", - "@1 after 960 blocks", - "t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))", - ], - false, - ), - // Tie-break: two SingleSig leaves given in reverse key_index order → sorted by key_index - ( - "tr(@0/**,{pk(@2/**),pk(@1/**)})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "Single-signature (@2)", - ], - true, - ), - // Tie-break: two RelativeHeightlockSingleSig → sorted by key_index, then blocks - ( - "tr(@0/**,{and_v(v:pk(@2/<0;1>/*),older(2000)),and_v(v:pk(@1/<0;1>/*),older(1000))})", - &[ - "Primary path: @0", - "@1 after 1000 blocks", - "@2 after 2000 blocks", - ], - true, - ), - // Tie-break: two Multisig leaves → fewer keys first, then smaller threshold - ( - "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**)})", - &[ - "Primary path: @0", - "2 of @4 and @5", - "2 of @1, @2 and @3", - ], - true, - ), - // -------------------------------------------------------------------------------------- - // Taproot with musig() internal key - // -------------------------------------------------------------------------------------- - // Taproot: musig key-path only (2-of-2) - ( - "tr(musig(@0,@1)/**)", - &["Primary path: 2 of @0 and @1"], - true, - ), - // Taproot: musig key-path only (3-of-3) - ( - "tr(musig(@0,@1,@2)/**)", - &["Primary path: 3 of @0, @1 and @2"], - true, - ), - // Taproot: musig key-path + single leaf - ( - "tr(musig(@0,@1)/**,pk(@2/**))", - &[ - "Primary path: 2 of @0 and @1", - "Single-signature (@2)", - ], - true, - ), - // Taproot: musig key-path + two leaves - ( - "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})", - &[ - "Primary path: 2 of @0 and @1", - "Single-signature (@2)", - "Single-signature (@3)", - ], - true, - ), - // Taproot: musig key-path + relative heightlock leaf - ( - "tr(musig(@0,@1)/**,and_v(v:pk(@2/<0;1>/*),older(1008)))", - &[ - "Primary path: 2 of @0 and @1", - "@2 after 1008 blocks", - ], - true, - ), - // -------------------------------------------------------------------------------------- - // Taproot with musig() tapleaf (pk(musig(...)) maps to Multisig) - // -------------------------------------------------------------------------------------- - // pk(musig) leaf: 2-of-2 - ( - "tr(@0/**,pk(musig(@1,@2)/**))", - &["Primary path: @0", "2 of @1 and @2"], - true, - ), - // pk(musig) leaf: 3-of-3 - ( - "tr(@0/**,pk(musig(@1,@2,@3)/**))", - &["Primary path: @0", "3 of @1, @2 and @3"], - true, - ), - // pk(musig) + relative heightlock - ( - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))", - &["Primary path: @0", "2 of @1 and @2 after 1008 blocks"], - true, - ), - // pk(musig) + relative timelock (180 units = 92160s = 1d 1h 36m) - ( - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))", - &["Primary path: @0", "2 of @1 and @2 after 1d 1h 36m"], - true, - ), - // pk(musig) + absolute heightlock (block 840000) - ( - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))", - &["Primary path: @0", "2 of @1 and @2 after block height 840000"], - true, - ), - // pk(musig) + absolute timelock (timestamp 1700000000 = 2023-11-14 22:13:20) - ( - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))", - &["Primary path: @0", "2 of @1 and @2 after date 2023-11-14 22:13:20"], - true, - ), - // pk(musig) alongside a plain single-sig - ( - "tr(@0/**,{pk(@1/**),pk(musig(@2,@3)/**)})", - &[ - "Primary path: @0", - "Single-signature (@1)", - "2 of @2 and @3", - ], - true, - ), - // -------------------------------------------------------------------------------------- - // Non-canonical key derivations: no cleartext representation; raw to_string() is returned. - // -------------------------------------------------------------------------------------- - // Legacy single-sig: num1 is not 0 for the first (and only) occurrence - ( - "pkh(@0/<2;3>/*)", - &["pkh(@0/<2;3>/*)"], - false, - ), - // Segwit single-sig: num2 is not num1+1 - ( - "wpkh(@0/<0;2>/*)", - &["wpkh(@0/<0;2>/*)"], - false, - ), - // Taproot, key-path only: internal key has non-canonical derivation - ( - "tr(@0/<4;5>/*)", - &["tr(@0/<4;5>/*)"], - false, - ), - // Taproot single leaf: internal key canonical, leaf key non-canonical - ( - "tr(@0/**,pk(@1/<2;3>/*))", - &["tr(@0/**,pk(@1/<2;3>/*))"], - false, - ), - ]; - - for &(desc_str, expected_txts, expected_has_cleartext) in cases { - let (txts, has_cleartext) = dt(desc_str).to_cleartext(); - assert_eq!( - txts, - strs(expected_txts), - "cleartext descriptions mismatch for {:?}", - desc_str - ); - assert_eq!( - has_cleartext, expected_has_cleartext, - "cleartext flag mismatch for {:?}", - desc_str - ); - } - } - - #[test] - fn test_from_cleartext_roundtrip() { - // All descriptors from test_to_cleartext and test_confusion_score that - // have a cleartext representation (has_cleartext == true). - let cases: &[&str] = &[ - // Legacy single-sig - "pkh(@0/**)", - // Segwit single-sig - "wpkh(@0/**)", - // Multisig variants - "wsh(sortedmulti(2,@0/**,@1/**))", - "wsh(sortedmulti(2,@0/**,@1/**,@2/**))", - "wsh(sortedmulti(3,@0/**,@1/**,@2/**))", - "wsh(multi(2,@0/**,@1/**))", - // Taproot: key-path only - "tr(@0/**)", - // Taproot: single leaf - "tr(@0/**,pk(@1/**))", - // Taproot: two leaves - "tr(@0/**,{pk(@1/**),pk(@2/**)})", - "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})", - // Taproot: three leaves - "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})", - // Taproot: relative heightlock - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))", - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})", - // Taproot: relative timelock - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))", - "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})", - // Taproot: absolute heightlock - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})", - // Taproot: absolute timelock - "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))", - "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})", - // Taproot: BothMustSign + locks - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(1008)))", - "tr(@0/**,{pk(@1/**),and_v(v:and_v(v:pk(@2/<0;1>/*),pk(@3/<0;1>/*)),older(1008))})", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))", - "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))", - // Taproot: BothMustSign (key repeated across leaves with canonical derivations) - "tr(@0/<0;1>/*,{and_v(v:pk(@1/<0;1>/*),older(4383)),and_v(v:pk(@2/<0;1>/*),pk(@1/<2;3>/*))})", - "tr(@0/<0;1>/*,{and_v(v:multi_a(2,@1/<0;1>/*,@2/<0;1>/*,@3/<0;1>/*),older(144)),and_v(v:pk(@1/<2;3>/*),pk(@2/<2;3>/*))})", - // Taproot: musig key-path only - "tr(musig(@0,@1)/**)", - "tr(musig(@0,@1,@2)/**)", - // Taproot: musig key-path with leaves - "tr(musig(@0,@1)/**,pk(@2/**))", - "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})", - "tr(musig(@0,@1)/**,and_v(v:pk(@2/<0;1>/*),older(1008)))", - "tr(musig(@0,@1)/**,{pk(@2/**),and_v(v:pk(@3/<0;1>/*),after(840000))})", - // Taproot: musig tapleaf (pk(musig(...))) - "tr(@0/**,pk(musig(@1,@2)/**))", - "tr(@0/**,pk(musig(@1,@2,@3)/**))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))", - "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))", - // Key derivation ordering roundtrip tests - "tr(@0/<0;1>/*,pk(@0/<2;3>/*))", - "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),pk(@0/<4;5>/*)})", - "tr(@0/**,{pk(@1/<0;1>/*),pk(@1/<2;3>/*)})", - "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(4383)),pk(@1/<2;3>/*)})", - // @0 appears twice, @1 appears twice - "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),and_v(v:pk(@1/<0;1>/*),pk(@1/<2;3>/*))})", - ]; - - for &desc_str in cases { - let original = dt(desc_str); - let (cleartext, has_cleartext) = original.to_cleartext(); - assert!( - has_cleartext, - "expected to have cleartext description for {:?}", - desc_str - ); - let cleartext_refs: Vec<&str> = cleartext.iter().map(|s| s.as_str()).collect(); - let variants: Vec<_> = DescriptorTemplate::from_cleartext(&cleartext_refs) - .unwrap_or_else(|e| panic!("from_cleartext failed for {:?}: {:?}", desc_str, e)) - .collect(); - - // Number of variants must equal confusion_score - assert_eq!( - variants.len() as u64, - original.confusion_score(), - "variant count != confusion_score for {:?}", - desc_str - ); - - // Every variant must produce the same cleartext - for variant in &variants { - let (variant_ct, variant_clear) = variant.to_cleartext(); - assert_eq!( - variant_ct, cleartext, - "variant {:?} produces different cleartext for original {:?}", - variant, desc_str - ); - assert_eq!( - variant_clear, has_cleartext, - "variant {:?} has different cleartext flag for original {:?}", - variant, desc_str - ); - } - - // All variants must be distinct - for i in 0..variants.len() { - for j in (i + 1)..variants.len() { - assert_ne!( - variants[i], variants[j], - "duplicate variants at indices {} and {} for {:?}", - i, j, desc_str - ); - } - } - } - } - - #[test] - fn test_spec_shape_uniqueness() { - // For each spec, build a "shape string" by concatenating its parts, replacing - // literals with their text and every dynamic field with a fixed non-ASCII - // placeholder. Two specs that map to the same shape string would be - // indistinguishable by the parser. - fn shape_string(parts: &[super::CleartextPart]) -> alloc::string::String { - const PLACEHOLDER: char = '\u{A7}'; // '§' - let mut s = alloc::string::String::new(); - for part in parts { - match part { - super::CleartextPart::Literal(lit) => s.push_str(lit), - _ => s.push(PLACEHOLDER), - } - } - s - } - - fn check_unique(part_slices: &[&'static [super::CleartextPart]], label: &str) { - let shapes: Vec = part_slices - .iter() - .map(|parts| shape_string(parts)) - .collect(); - for i in 0..shapes.len() { - for j in (i + 1)..shapes.len() { - assert_ne!( - shapes[i], shapes[j], - "{} entries at indices {} and {} have the same shape: {:?}", - label, i, j, shapes[i] - ); - } - } - } - - check_unique( - &super::TOP_LEVEL_SPECS - .iter() - .map(|s| s.parts) - .collect::>(), - "TOP_LEVEL_SPECS", - ); - check_unique( - &super::TAPLEAF_SPECS - .iter() - .map(|s| s.parts) - .collect::>(), - "TAPLEAF_SPECS", - ); - } - - /// Verify that the `musig` spec primitive preserves the full key-expression - /// derivation paths by propagating them onto each plain key in the resulting - /// class instance. Both `DescriptorClass::TaprootMusig` (musig as the - /// taproot internal key) and `TapleafClass::Multisig` (musig as a tapleaf - /// signer) flatten the shared derivation onto each plain key in `keys`. - #[test] - fn test_musig_classify_preserves_derivations() { - use super::DescriptorClass; - - // musig internal key with non-standard derivation <2;3>: each plain key - // in `keys` carries (num1=2, num2=3). - let desc = dt("tr(musig(@0,@1)/<2;3>/*,pk(@2/**))"); - let class = desc.classify(); - match class { - DescriptorClass::TaprootMusig { - threshold, - keys, - leaves, - } => { - assert_eq!(threshold, 2); - assert_eq!(keys.len(), 2); - for k in &keys { - assert!(k.is_plain()); - assert_eq!(k.num1, 2); - assert_eq!(k.num2, 3); - } - assert_eq!(keys[0].plain_key_index(), Some(0)); - assert_eq!(keys[1].plain_key_index(), Some(1)); - assert_eq!(leaves.len(), 1); - } - other => panic!("expected TaprootMusig, got {:?}", other), - } - - // musig in tapleaf with non-standard derivation <4;5> - let desc2 = dt("tr(@0/**,pk(musig(@1,@2)/<4;5>/*))"); - let class2 = desc2.classify(); - match class2 { - DescriptorClass::Taproot { leaves, .. } => { - assert_eq!(leaves.len(), 1); - match &leaves[0] { - super::TapleafClass::Multisig { threshold, keys } => { - assert_eq!(*threshold, 2); - assert_eq!(keys.len(), 2); - for k in keys { - assert_eq!(k.num1, 4); - assert_eq!(k.num2, 5); - } - } - other => panic!("expected Multisig tapleaf, got {:?}", other), - } - } - other => panic!("expected Taproot, got {:?}", other), - } - - // Standard derivation musig internal key (sanity check: num1=0, num2=1). - let desc3 = dt("tr(musig(@0,@1)/**)"); - let class3 = desc3.classify(); - match class3 { - DescriptorClass::TaprootMusig { - threshold, - keys, - leaves, - } => { - assert_eq!(threshold, 2); - assert_eq!(keys.len(), 2); - for k in &keys { - assert_eq!(k.num1, 0); - assert_eq!(k.num2, 1); - } - assert!(leaves.is_empty()); - } - other => panic!("expected TaprootMusig, got {:?}", other), - } - } -} diff --git a/apps/bitcoin/common/src/lib.rs b/apps/bitcoin/common/src/lib.rs index 89bd0ce1..3011dde4 100644 --- a/apps/bitcoin/common/src/lib.rs +++ b/apps/bitcoin/common/src/lib.rs @@ -3,7 +3,7 @@ extern crate alloc; pub mod account; -pub mod bip388; +pub use bip388; pub mod errors; pub mod fastpsbt; pub mod identity; diff --git a/vanadium.code-workspace b/vanadium.code-workspace index 428eb6ea..fd26c4e2 100644 --- a/vanadium.code-workspace +++ b/vanadium.code-workspace @@ -50,6 +50,10 @@ "path": "apps/bitcoin/common", "name": "₿ vnd-bitcoin-common" }, + { + "path": "apps/bitcoin/bip388", + "name": "₿ bip388" + }, { "path": "apps/bitcoin/client", "name": "₿ vnd-bitcoin-client"