From d84ba95520852b96eec6ed7981e54ab89c68de0e Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Tue, 19 May 2026 15:42:50 +0200 Subject: [PATCH 1/2] Move cleartext toml specifications to /cleartext/specs; crated test vectors as a toml file --- apps/bitcoin/common/Cargo.toml | 2 + apps/bitcoin/common/build.rs | 7 +- .../common/src/bip388/cleartext/decode.rs | 2 +- .../common/src/bip388/cleartext/mod.rs | 666 +++--------------- .../specs/cleartext.toml} | 0 .../bip388/cleartext/specs/test_vectors.toml | 547 ++++++++++++++ 6 files changed, 639 insertions(+), 585 deletions(-) rename apps/bitcoin/common/src/bip388/{cleartext.spec.toml => cleartext/specs/cleartext.toml} (100%) create mode 100644 apps/bitcoin/common/src/bip388/cleartext/specs/test_vectors.toml diff --git a/apps/bitcoin/common/Cargo.toml b/apps/bitcoin/common/Cargo.toml index a943d0dd..2854a80e 100644 --- a/apps/bitcoin/common/Cargo.toml +++ b/apps/bitcoin/common/Cargo.toml @@ -28,6 +28,8 @@ subtle = { version="2.6.1", default-features = false } [dev-dependencies] hex-literal = "0.4.1" sdk = { package = "vanadium-app-sdk", path = "../../../app-sdk", default-features = false, features = ["target_native"] } +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"] } diff --git a/apps/bitcoin/common/build.rs b/apps/bitcoin/common/build.rs index e4b3dfac..3111ac9c 100644 --- a/apps/bitcoin/common/build.rs +++ b/apps/bitcoin/common/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/bip388/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/bip388/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/bip388/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/common/src/bip388/cleartext/decode.rs index 0707f588..bb7fbbd1 100644 --- a/apps/bitcoin/common/src/bip388/cleartext/decode.rs +++ b/apps/bitcoin/common/src/bip388/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/common/src/bip388/cleartext/mod.rs b/apps/bitcoin/common/src/bip388/cleartext/mod.rs index a2f88632..4c81f39c 100644 --- a/apps/bitcoin/common/src/bip388/cleartext/mod.rs +++ b/apps/bitcoin/common/src/bip388/cleartext/mod.rs @@ -50,7 +50,7 @@ pub(super) const SEQUENCE_LOCKTIME_TYPE_FLAG: u32 = 1 << 22; // 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 +// 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")); @@ -472,7 +472,7 @@ impl ClearText for DescriptorTemplate { #[cfg(test)] mod tests { use super::{ClearText, DescriptorTemplate}; - use alloc::{string::ToString, vec::Vec}; + use alloc::{string::String, vec::Vec}; use core::str::FromStr; fn dt(s: &str) -> DescriptorTemplate { @@ -480,629 +480,133 @@ mod tests { .unwrap_or_else(|e| panic!("parse failed for {:?}: {:?}", s, e)) } - fn strs(ss: &[&str]) -> Vec { - ss.iter().map(|s| s.to_string()).collect() + /// 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, } - #[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 - ), - ]; + #[derive(Debug, serde::Deserialize)] + struct TestVectors { + vector: Vec, + } - for &(desc_str, expected) in cases { + 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(desc_str).confusion_score(), + dt(&v.template).confusion_score(), expected, "confusion_score mismatch for {:?}", - desc_str + v.template ); } } #[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 + 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_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 - ); + 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!( - has_cleartext, expected_has_cleartext, - "cleartext flag mismatch for {:?}", - desc_str + dt(&v.template).to_cleartext().1, + expected_hct, + "has_cleartext flag mismatch for {:?}", + v.template ); } } #[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>/*))})", - ]; + 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; + }; - 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 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 {:?}: {:?}", desc_str, e)) + .unwrap_or_else(|e| { + panic!("from_cleartext failed for {:?}: {:?}", v.template, e) + }) .collect(); - // Number of variants must equal confusion_score assert_eq!( variants.len() as u64, - original.confusion_score(), + score, "variant count != confusion_score for {:?}", - desc_str + v.template ); - // 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_ct, *expected_ct, "variant {:?} produces different cleartext for original {:?}", - variant, desc_str + variant, v.template ); - assert_eq!( - variant_clear, has_cleartext, - "variant {:?} has different cleartext flag for original {:?}", - variant, desc_str + assert!( + variant_clear, + "variant {:?} has has_cleartext=false for original {:?}", + variant, v.template ); } - // 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 + i, j, v.template ); } } diff --git a/apps/bitcoin/common/src/bip388/cleartext.spec.toml b/apps/bitcoin/common/src/bip388/cleartext/specs/cleartext.toml similarity index 100% rename from apps/bitcoin/common/src/bip388/cleartext.spec.toml rename to apps/bitcoin/common/src/bip388/cleartext/specs/cleartext.toml diff --git a/apps/bitcoin/common/src/bip388/cleartext/specs/test_vectors.toml b/apps/bitcoin/common/src/bip388/cleartext/specs/test_vectors.toml new file mode 100644 index 00000000..c99f39b2 --- /dev/null +++ b/apps/bitcoin/common/src/bip388/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 From 19b4f265692e8d22730b059406982adbccc9c102 Mon Sep 17 00:00:00 2001 From: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> Date: Tue, 19 May 2026 17:37:06 +0200 Subject: [PATCH 2/2] Move bip388 (and cleartext) module into separate crate --- .github/workflows/ci.yaml | 2 +- apps/bitcoin/bip388/Cargo.toml | 30 +++++++++++++++++++ apps/bitcoin/{common => bip388}/build.rs | 6 ++-- .../bip388 => bip388/src}/cleartext/decode.rs | 0 .../bip388 => bip388/src}/cleartext/mod.rs | 0 .../src}/cleartext/specs/cleartext.toml | 0 .../src}/cleartext/specs/test_vectors.toml | 0 .../src/bip388/mod.rs => bip388/src/lib.rs} | 4 +++ .../{common/src/bip388 => bip388/src}/time.rs | 0 apps/bitcoin/common/Cargo.toml | 12 +------- apps/bitcoin/common/src/lib.rs | 2 +- vanadium.code-workspace | 4 +++ 12 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 apps/bitcoin/bip388/Cargo.toml rename apps/bitcoin/{common => bip388}/build.rs (99%) rename apps/bitcoin/{common/src/bip388 => bip388/src}/cleartext/decode.rs (100%) rename apps/bitcoin/{common/src/bip388 => bip388/src}/cleartext/mod.rs (100%) rename apps/bitcoin/{common/src/bip388 => bip388/src}/cleartext/specs/cleartext.toml (100%) rename apps/bitcoin/{common/src/bip388 => bip388/src}/cleartext/specs/test_vectors.toml (100%) rename apps/bitcoin/{common/src/bip388/mod.rs => bip388/src/lib.rs} (99%) rename apps/bitcoin/{common/src/bip388 => bip388/src}/time.rs (100%) 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 3111ac9c..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 `src/bip388/cleartext/specs/cleartext.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 src/bip388/cleartext/specs/cleartext.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}" ) } @@ -1467,7 +1467,7 @@ 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/specs/cleartext.toml"); + 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 100% rename from apps/bitcoin/common/src/bip388/cleartext/decode.rs rename to apps/bitcoin/bip388/src/cleartext/decode.rs diff --git a/apps/bitcoin/common/src/bip388/cleartext/mod.rs b/apps/bitcoin/bip388/src/cleartext/mod.rs similarity index 100% rename from apps/bitcoin/common/src/bip388/cleartext/mod.rs rename to apps/bitcoin/bip388/src/cleartext/mod.rs diff --git a/apps/bitcoin/common/src/bip388/cleartext/specs/cleartext.toml b/apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml similarity index 100% rename from apps/bitcoin/common/src/bip388/cleartext/specs/cleartext.toml rename to apps/bitcoin/bip388/src/cleartext/specs/cleartext.toml diff --git a/apps/bitcoin/common/src/bip388/cleartext/specs/test_vectors.toml b/apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml similarity index 100% rename from apps/bitcoin/common/src/bip388/cleartext/specs/test_vectors.toml rename to apps/bitcoin/bip388/src/cleartext/specs/test_vectors.toml 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 2854a80e..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,20 +23,11 @@ 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"] } -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 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"