cleartext: add BIP388 cleartext engine, codegen and CI check
What changed, and why it matters
This commit adds a new feature to the Ledger Bitcoin app that translates complex wallet policies into plain English for user review, plus a 'confusion score' to warn when a policy's description could match multiple different wallet setups. It is a large, self-contained addition of generated code, a code generator, specs, tests, and a CI check. The feature is not yet connected to any user-facing handler, so it cannot by itself change app behavior or expose secrets. Nothing in the commit message or diff describes this as a security fix or reports a vulnerability.
No immediate security action is required. Treat this as a normal feature commit. When the feature is later wired into a handler, review the integration points for buffer handling, canonicalization correctness, and whether the confusion-score threshold is appropriate before user confirmation.
Security signals we found
New feature code is present but not wired to any handler, so it is not reachable in the current build.
No security bug, buffer overflow, or secret exposure is visible in the diff.
Generated C code includes fixed-size arrays and bounds checks (CT_MAX_BINDINGS, CT_MAX_KEYEXPRS).
A CI check ensures generated files match their TOML sources, reducing supply-chain/drift risk.
The commit message does not frame the change as a security patch or vulnerability fix.
Evidence from the diff
Commit eab93592 introduces the BIP388 ‘cleartext’ engine: a C implementation that renders recognized wallet-policy descriptor templates as human-readable strings and computes a confusion score. It adds specs/bip388/cleartext.toml (the spec), specs/bip388/gen.py (code generator), generated C tables in src/common/cleartext_specs.{h,c} and src/common/cleartext_match.c, the runtime encoder in src/common/cleartext.c, test vectors, and a GitHub Actions job that verifies generated files stay in sync with the TOML specs. The commit explicitly states the engine ‘compiles into the app but is not yet wired into any handler.’ It also exposes are_key_placeholders_identical() from policy.h. The code is described as ported from the Rust bip388 crate with review and refinement.
Changed components
Ledger Bitcoin app (app-bitcoin-new)src/common/cleartext.csrc/common/cleartext.hsrc/common/cleartext_match.csrc/common/cleartext_match.hsrc/common/cleartext_specs.csrc/common/cleartext_specs.hsrc/handler/lib/policy.csrc/handler/lib/policy.hspecs/bip388/cleartext.tomlspecs/bip388/gen.pyspecs/bip388/test_vectors.tomlunit-tests/cleartext_vectors.inc.c.github/workflows/cleartext-gen-check.ymlInspect captured patch +4398 / −2
diff --git a/.github/workflows/cleartext-gen-check.yml b/.github/workflows/cleartext-gen-check.yml
new file mode 100644
index 0000000..a0d50da
--- /dev/null
+++ b/.github/workflows/cleartext-gen-check.yml
@@ -0,0 +1,32 @@
+name: Cleartext codegen check
+
+# Ensures the generated BIP388 cleartext files committed to the repo are in sync
+# with the TOML specs they are derived from. If someone edits
+# specs/bip388/*.toml without re-running specs/bip388/gen.py, this
+# job fails and points at the drifted files.
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - master
+ - develop
+ pull_request:
+
+jobs:
+ check_cleartext_codegen:
+ name: Check generated cleartext files are up to date
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ # 3.11+ for the stdlib tomllib used by gen.py.
+ python-version: '3.12'
+
+ - name: Check codegen is in sync with the TOML specs
+ run: python3 specs/bip388/gen.py --check
diff --git a/specs/bip388/README.md b/specs/bip388/README.md
new file mode 100644
index 0000000..3b4ee00
--- /dev/null
+++ b/specs/bip388/README.md
@@ -0,0 +1,48 @@
+# BIP388 cleartext specs
+
+This folder is the **single source of truth** for the BIP388 "cleartext" display
+feature: the plain-language rendering of a registered wallet policy's descriptor
+template that the device shows to the user (and the confusion score that gates
+it). The C code that implements the feature is *generated* from the TOML files
+here — you edit the specs, regenerate, and commit the result.
+
+The feature is based on the analogous Rust implementation in the [`bip388`](https://crates.io/crates/bip388) crate.
+
+## Files
+
+| File | Role |
+|---------------------|------|
+| `cleartext.toml` | The spec: which descriptor-template shapes have a cleartext display, the cleartext template (literals + dynamic `$field` references) for each, and the reverse-reconstruction data used to compute the confusion score. Read the header comment in the file for the full grammar and wording conventions. |
+| `test_vectors.toml` | Test vectors: descriptor templates paired with their expected cleartext / confusion score / `has_cleartext` flag. |
+| `gen.py` | Code generator. Reads the two TOMLs and (over)writes the generated C files listed below, or verifies that the files are up to date. |
+
+## Generated files
+
+`gen.py` overwrites these; they are committed to the repo but must never be
+edited by hand (each carries a `// Generated by specs/bip388/gen.py. DO NOT EDIT.`
+banner):
+
+- `src/common/cleartext_specs.h` — `descriptor_class_e` / `tapleaf_class_e` enums, the `cleartext_spec_t` shape, and `extern` declarations.
+- `src/common/cleartext_specs.c` — the spec tables (`CT_TOP_LEVEL_SPECS` / `CT_TAPLEAF_SPECS`) and the string pool.
+- `src/common/cleartext_match.c` — the generated AST classifiers (`match_top_level` / `match_tapleaf`), one per `[[top_level]]` / `[[tapleaf]]` entry, tried in order.
+- `unit-tests/cleartext_vectors.inc.c` — the test vectors as a static C array (`CT_VECTORS`), consumed by `unit-tests/test_cleartext.c`.
+
+The hand-written runtime that these generated files plug into lives in
+`src/common/cleartext.c` and `src/common/cleartext_match.h` (stable match-result
+types and primitives) — editing the TOMLs should only ever touch generated files,
+never `cleartext.c`.
+
+## Regenerating
+
+Run from the repository root after editing either TOML, then commit the result:
+
+```
+python3 specs/bip388/gen.py
+```
+
+To verify the committed generated files are in sync with the TOMLs without
+writing anything (this is what CI runs):
+
+```
+python3 specs/bip388/gen.py --check
+```
diff --git a/specs/bip388/cleartext.toml b/specs/bip388/cleartext.toml
new file mode 100644
index 0000000..7a47fc2
--- /dev/null
+++ b/specs/bip388/cleartext.toml
@@ -0,0 +1,189 @@
+# Cleartext-display specification for BIP388 descriptor templates.
+#
+# This file is the single source of truth for:
+# - Which descriptor template shapes have a cleartext display
+# - The cleartext template (literals + dynamic fields) for each shape
+# - The score (number of structurally distinct templates that share a shape's
+# cleartext rendering)
+# - The reverse-direction reconstruction (cleartext -> all DescriptorTemplate
+# candidates)
+#
+# A build script (`build.rs`) parses this file and generates Rust code that
+# implements all of the above. See `cleartext.rs` for how the generated code is
+# integrated.
+#
+# Two tables here:
+# - `[[top_level]]` -- shapes for the root DescriptorTemplate
+# - `[[tapleaf]]` -- shapes for the leaves of a taproot tree
+#
+# Each entry has:
+# - `name` : variant name in the generated DescriptorClass / TapleafClass
+# - `patterns` : list of pattern strings; the generated classifier tries each
+# in order. The score equals the number of patterns that admit
+# a class instance (including round-trip checks performed by
+# primitives such as `musig`).
+# - `cleartext`: list of literal strings and `$field` references. The
+# field's display kind is derived from its binding-name kind.
+#
+# Tapleaf entries also carry their position in this file as their display
+# `order` (used to group leaves when rendering a taptree).
+#
+# Wording conventions (see also `format_timelock` / `format_seconds` in
+# `mod.rs`, which produce the dynamic `$timelock` text):
+# - A taproot's `to_cleartext` returns the key-path description first, then one
+# line per leaf. The leaves are ALTERNATIVE spending paths (any one suffices);
+# conditions *within* a leaf are combined with AND. Hence the consistent
+# "... must sign" phrasing per signer, and " - and also - " for an explicit
+# AND between two sub-policies.
+# - Thresholds read "Any K of <keys> ... must sign" when K < N. When K == N the
+# wording is unnatural, so the n-of-n case renders "Each of <keys> ... must sign"
+# instead. This is selected at encode time from each multisig entry's optional
+# `cleartext_all` template (used when threshold == number of keys); a leaf-less
+# `tr(musig(...))` key-path is inherently n-of-n and is folded into the
+# `Multisig` class, so it renders through that class's `cleartext_all` ("Each
+# of ..."). `TaprootMusig` (a musig key-path *with* a script tree) is not n-of-n
+# selectable and keeps only its "Main path: each of ..." `cleartext`. A
+# `cleartext_all` template omits `$threshold` (it is implied by the key count);
+# the reverse parser re-synthesizes threshold = number of keys.
+# - Timelocks distinguish *relative* locks ("<duration> after receiving",
+# counted from when these coins were received) from *absolute* locks
+# ("not before block N" / "not before <date> UTC", a fixed point).
+# - Address-encoding / script-form qualifiers that don't change who can spend
+# (Legacy / SegWit / sorted) are kept as a trailing parenthetical so the
+# plain-language policy comes first. The `Multisig` top-level class is a
+# deliberate exception: it coalesces the script multisig forms and the
+# leaf-less taproot `musig` key-path into one qualifier-free rendering, so a
+# k-of-n policy reads the same regardless of how it is realized on-chain. The
+# extra on-chain encodings it now stands for are reflected in its score.
+
+# =============================================================================
+# Top-level patterns
+# =============================================================================
+
+[[top_level]]
+name = "LegacySingleSig"
+patterns = ["pkh($key)"]
+cleartext = ["Spendable by ", "$key", " alone (Legacy)"]
+
+[[top_level]]
+name = "SegwitSingleSig"
+patterns = ["wpkh($key)", "sh(wpkh($key))"]
+cleartext = ["Spendable by ", "$key", " alone (SegWit)"]
+
+# A k-of-n multisignature, rendered without an address-encoding qualifier. This
+# class deliberately coalesces every shape that displays the same way to a signer:
+# the script multisig forms (`sh`/`wsh`/`sh(wsh)` over `multi`/`sortedmulti`) and
+# the leaf-less taproot `musig` key-path (`tr(musig(...))`, inherently n-of-n). The
+# `tr(musig($keys))` pattern has no tree argument, so it matches only `tr(key)`
+# (tree == None) and -- like `TaprootKeyOnly` below -- must be tried before the
+# `TaprootMusig` entry (which would otherwise swallow it with empty leaves); its
+# position here, ahead of the taproot tree entries, satisfies that.
+[[top_level]]
+name = "Multisig"
+patterns = [
+ "sh(multi($threshold, $keys))",
+ "sh(sortedmulti($threshold, $keys))",
+ "wsh(multi($threshold, $keys))",
+ "wsh(sortedmulti($threshold, $keys))",
+ "sh(wsh(multi($threshold, $keys)))",
+ "sh(wsh(sortedmulti($threshold, $keys)))",
+ "tr(musig($keys))",
+]
+cleartext = ["Any ", "$threshold", " of ", "$keys", " must sign"]
+cleartext_all = ["Each of ", "$keys", " must sign"]
+
+# Leaf-less taproot, single-key path spend (no script alternatives). This must
+# appear before the `Taproot` entry below: its pattern has no tree argument, so it
+# matches only `tr(key)` (tree == None), and the classifier tries entries in order.
+# Because there is a single spending path, the wording is standalone (no "Main
+# path:" prefix), mirroring the other single-signature choices, with a "(Taproot)"
+# qualifier to keep the rendering distinguishable from the Legacy/SegWit forms when
+# decoding. (The leaf-less *musig* key-path `tr(musig(...))` is handled by the
+# `Multisig` entry above, not here.)
+[[top_level]]
+name = "TaprootKeyOnly"
+patterns = ["tr($internal_key)"]
+cleartext = ["Spendable by ", "$internal_key", " alone (Taproot)"]
+
+[[top_level]]
+name = "Taproot"
+patterns = ["tr($internal_key, $leaves)"]
+cleartext = ["Main path: spendable by ", "$internal_key"]
+
+[[top_level]]
+name = "TaprootMusig"
+patterns = ["tr(musig($keys), $leaves)"]
+# musig is inherently n-of-n: always "each of ...", with `$threshold` omitted.
+cleartext = ["Main path: each of ", "$keys", " must sign"]
+
+# =============================================================================
+# Tapleaf patterns
+#
+# Each tapleaf is an alternative spending path. The wording is chosen so it stays
+# correct when wrapped by a combinator leaf (`Timelocked`, `AndV`): e.g.
+# "@1 must sign" composes as "@1 must sign - and also - ..." whereas
+# "spendable by @1 alone" would not.
+#
+# Tapleaf cleartext is written LOWERCASE (e.g. "any 2 of ...") so it reads
+# correctly when composed mid-sentence inside another leaf. The encoder
+# capitalizes the first letter of each finished top-level element (see
+# `capitalize_first` in `mod.rs`), so a standalone leaf still displays as
+# "Any 2 of ..." while a composed one stays lowercase
+# ("@1 must sign - and also - any 2 of ..."). Decoding is case-insensitive: the
+# reverse parser lower-cases the whole input and the pattern literals before
+# matching (see `decode.rs`), so the patterns must stay unambiguous when
+# lower-cased (enforced by `check_cleartext_uniqueness` and the
+# `test_spec_shape_uniqueness` test).
+# =============================================================================
+
+[[tapleaf]]
+name = "SingleSig"
+patterns = ["pk($key)"]
+cleartext = ["$key", " must sign"]
+
+[[tapleaf]]
+name = "BothMustSign"
+patterns = ["and_v(v:pk($key1), pk($key2))"]
+cleartext = ["$key1", " and ", "$key2", " must both sign"]
+
+[[tapleaf]]
+name = "SortedMultisig"
+patterns = ["sortedmulti_a($threshold, $keys)"]
+cleartext = ["any ", "$threshold", " of ", "$keys", " must sign (sorted)"]
+cleartext_all = ["each of ", "$keys", " must sign (sorted)"]
+
+[[tapleaf]]
+name = "Multisig"
+patterns = [
+ "multi_a($threshold, $keys)",
+ "pk(musig($keys))",
+]
+cleartext = ["any ", "$threshold", " of ", "$keys", " must sign"]
+cleartext_all = ["each of ", "$keys", " must sign"]
+
+# Timelocked leaves are `and_v(v:SIGNER, LOCK)`. The signer is a `$sub`
+# sub-policy (classified recursively as a non-combinator leaf: SingleSig,
+# BothMustSign, SortedMultisig or Multisig). The lock is a single `$timelock`
+# binding that matches both `older(...)` (relative) and `after(...)` (absolute).
+#
+# The `$timelock` value carries its own connective so relative and absolute
+# locks read differently (produced by `format_timelock`):
+# - relative block count : "<n> blocks after receiving"
+# - relative duration : "<duration> after receiving" (e.g. "8 minutes 32 seconds")
+# - absolute block height: "not before block <n>"
+# - absolute date : "not before <YYYY-MM-DD[ HH:MM:SS]> UTC"
+# so the literal between `$sub` and `$timelock` is just ", ".
+#
+# Using ", " as the delimiter is unambiguous for the reverse parser: the only
+# sub-policy with an internal ", " is a 3+-key multisig, and its commas are
+# always followed by more keys (never by a valid `$timelock` tail), so the split
+# only ever succeeds at the real boundary.
+[[tapleaf]]
+name = "Timelocked"
+patterns = ["and_v(v:$sub, $timelock)"]
+cleartext = ["$sub", ", ", "$timelock"]
+
+[[tapleaf]]
+name = "AndV"
+patterns = ["and_v(v:$sub1, $sub2)"]
+cleartext = ["$sub1", " - and also - ", "$sub2"]
diff --git a/specs/bip388/gen.py b/specs/bip388/gen.py
new file mode 100644
index 0000000..bfc9fe9
--- /dev/null
+++ b/specs/bip388/gen.py
@@ -0,0 +1,1091 @@
+#!/usr/bin/env python3
+"""Generate the C cleartext spec tables and unit-test vector arrays from the
+BIP388 cleartext TOML files.
+
+Inputs:
+ specs/bip388/cleartext.toml -- spec for the cleartext encoder
+ specs/bip388/test_vectors.toml -- test vectors
+
+Outputs (all overwritten):
+ src/common/cleartext_specs.h -- enums + extern decls
+ src/common/cleartext_specs.c -- spec tables and string pool
+ src/common/cleartext_match.c -- generated AST classifiers
+ unit-tests/cleartext_vectors.inc.c -- static C array of test vectors
+
+Run this script manually after changing the TOML files and commit the result.
+Pass --check (e.g. in CI) to verify the committed output is up to date instead
+of regenerating it.
+"""
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import re
+import sys
+from pathlib import Path
+from string import digits as _DIGITS
+from typing import Any
+
+try:
+ import tomllib # type: ignore[import-not-found]
+except ModuleNotFoundError: # Python < 3.11
+ import tomli as tomllib # type: ignore[import-not-found,no-redef]
+
+
+# Type aliases for the tuple shapes threaded through spec emission.
+Binding = tuple[str, str] # (name, kind)
+Part = tuple[str, str, str] # (kind, binding_name, literal_text)
+# One entry's built parts: (name, parts, parts_all, bindings). parts_all is None
+# unless the entry declares an n-of-n `cleartext_all` form.
+EntryParts = tuple[str, list[Part], list[Part] | None, list[Binding]]
+
+
+# This script lives alongside the TOML specs it reads, in specs/bip388/.
+SPECS_DIR = Path(__file__).resolve().parent
+REPO_ROOT = SPECS_DIR.parents[1]
+SPEC_FILE = SPECS_DIR / "cleartext.toml"
+VECTORS_FILE = SPECS_DIR / "test_vectors.toml"
+HEADER_OUT = REPO_ROOT / "src" / "common" / "cleartext_specs.h"
+SOURCE_OUT = REPO_ROOT / "src" / "common" / "cleartext_specs.c"
+MATCH_OUT = REPO_ROOT / "src" / "common" / "cleartext_match.c"
+VECTORS_OUT = REPO_ROOT / "unit-tests" / "cleartext_vectors.inc.c"
+
+
+# Mirrors `binding_name_kind` in the Rust build.rs: strip trailing digits,
+# then map to a kind.
+_BINDING_KIND = {
+ "key": "KEY",
+ "internal_key": "KEY",
+ "keys": "KEYS",
+ "threshold": "THRESHOLD",
+ "timelock": "TIMELOCK",
+ "sub": "SUB",
+ "leaves": "LEAVES", # structural; never appears in cleartext output
+}
+
+
+def binding_kind(name: str) -> str | None:
+ return _BINDING_KIND.get(name.rstrip(_DIGITS))
+
+
+# Must match CT_MAX_BINDINGS in src/common/cleartext_match.h: the fixed length
+# of the per-match bindings array the generated classifier writes into. A guard
+# in _emit_match_fn rejects any entry that would overflow it.
+CT_MAX_BINDINGS = 3
+
+
+# --- Small helpers for rendering Python values as C source ------------------
+
+
+def part_kind_c(kind: str) -> str:
+ return f"CT_PART_{kind}"
+
+
+def cbool(b: bool) -> str:
+ """Render a Python bool as a C boolean literal."""
+ return "true" if b else "false"
+
+
+def snake_upper(camel: str) -> str:
+ """Convert CamelCase / PascalCase to UPPER_SNAKE_CASE."""
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", camel)
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).upper()
+
+
+_C_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
+
+
+def c_string_literal(s: str) -> str:
+ """Format a Python string as a C string literal, escaping where needed.
+
+ A `\\xNN` byte escape has no fixed length: the compiler keeps consuming hex
+ digits. So when a non-ASCII byte escape is immediately followed by an ASCII
+ hex-digit character we split the literal (`"\\xc3" "a"`) so the digit can't be
+ folded into the escape. Pure-ASCII input never triggers a split, so its
+ output is unchanged."""
+ result = ['"']
+ prev_was_hex = False
+ for ch in s:
+ o = ord(ch)
+ if ch == "\\":
+ result.append("\\\\")
+ prev_was_hex = False
+ elif ch == '"':
+ result.append('\\"')
+ prev_was_hex = False
+ elif ch == "\n":
+ result.append("\\n")
+ prev_was_hex = False
+ elif ch == "\r":
+ result.append("\\r")
+ prev_was_hex = False
+ elif ch == "\t":
+ result.append("\\t")
+ prev_was_hex = False
+ elif 0x20 <= o < 0x7F:
+ if prev_was_hex and ch in _C_HEX_DIGITS:
+ result.append('" "') # break the string so the escape ends here
+ result.append(ch)
+ prev_was_hex = False
+ else:
+ # Encode as UTF-8 byte escapes
+ for b in ch.encode("utf-8"):
+ result.append(f"\\x{b:02x}")
+ prev_was_hex = True
+ result.append('"')
+ return "".join(result)
+
+
+def c_pool_entry(s: str) -> str:
+ """A C string literal for one pooled string, with its NUL separator baked in
+ (`"foo\\0"`). Concatenated adjacently in the pool array, these reproduce the
+ NUL-separated byte layout that the `lit_off` offsets index into."""
+ inner = c_string_literal(s) # e.g. '"foo"'
+ return inner[:-1] + '\\0"' # -> '"foo\0"'
+
+
+# Pattern parsing -----------------------------------------------------------
+
+_BINDING_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
+# A musig key argument: `musig($keys)`. musig is n-of-n, so the threshold is
+# implied by the key count and is not written explicitly.
+_MUSIG_KEYS_RE = re.compile(r"musig\(\$([A-Za-z_][A-Za-z0-9_]*)\)")
+
+
+def synthesize_threshold_name(keys: str) -> str:
+ """Mirror `synthesize_threshold_name` in the Rust build.rs: swap the "keys"
+ base for "threshold", preserving any trailing digit suffix so it stays
+ consistent with a sibling pattern's explicit `$threshold` (e.g.
+ `keys` -> `threshold`, `keys1` -> `threshold1`)."""
+ base = keys.rstrip(_DIGITS)
+ return "threshold" + keys[len(base):]
+
+
+def pattern_bindings(pattern: str) -> list[Binding]:
+ """Return the list of (binding_name, kind) in the order they appear in
+ the pattern string.
+
+ `musig($keys)` is n-of-n: it carries no explicit threshold, so (as in the
+ Rust build.rs) we synthesize a `$threshold` binding right before its `$keys`.
+ Its value is the key count at match time, and the shared cleartext can still
+ reference `$threshold`."""
+ musig_keys = {m.group(1) for m in _MUSIG_KEYS_RE.finditer(pattern)}
+ bindings: list[Binding] = []
+ seen = set()
+ for m in _BINDING_RE.finditer(pattern):
+ name = m.group(1)
+ if name in seen:
+ raise ValueError(f"binding {name!r} appears twice in pattern {pattern!r}")
+ seen.add(name)
+ kind = binding_kind(name)
+ if kind is None:
+ raise ValueError(f"unknown binding name {name!r} in pattern {pattern!r}")
+ if name in musig_keys:
+ tname = synthesize_threshold_name(name)
+ if tname not in seen:
+ seen.add(tname)
+ bindings.append((tname, "THRESHOLD"))
+ bindings.append((name, kind))
+ return bindings
+
+
+def binding_index(bindings: list[Binding]) -> dict[str, int]:
+ """Map each binding name to its positional index in the bindings list."""
+ return {name: i for i, (name, _) in enumerate(bindings)}
+
+
+def is_musig_pattern(pattern: str) -> bool:
+ """A pattern is a 'musig pattern' when it uses musig(...) as a key
+ expression. Such patterns require the round-trip check
+ `threshold == n_keys` at scoring time."""
+ return "musig(" in pattern
+
+
+def entry_recurses(entry: dict[str, Any]) -> bool:
+ """True iff the entry binds a `$leaves` taptree (i.e. classification
+ recurses into a tr(...) tree). Mirrors `ProcessedEntry::recurses` in the
+ Rust build.rs; drives per-leaf rendering and the taptree score factor."""
+ for p in entry["patterns"]:
+ for _, kind in pattern_bindings(p):
+ if kind == "LEAVES":
+ return True
+ return False
+
+
+# ===========================================================================
+# Classifier codegen (port of build.rs `classify` / `classify_as_tapleaf`).
+#
+# build.rs lowers each `pattern` to a nested `if let` chain over the Rust
+# `DescriptorTemplate` AST. Here we emit the equivalent C: one `do { ... }
+# while (0)` block per pattern that walks the `policy_node_t` AST, `break`s on
+# any mismatch, and on a full match fills the bindings + class and `return`s.
+# ===========================================================================
+
+# keyword -> (PolicyNodeType, C struct type, [positional arg kinds]).
+# Arg kinds: KEY (a keyexpr), NUM (a threshold), KEYLIST (a key array),
+# SUB (a child policy_node), TREE (the tr(...) taptree).
+_KEYWORD_INFO: dict[str, tuple[str, str, list[str]]] = {
+ "pkh": ("TOKEN_PKH", "policy_node_with_key_t", ["KEY"]),
+ "wpkh": ("TOKEN_WPKH", "policy_node_with_key_t", ["KEY"]),
+ "pk": ("TOKEN_PK", "policy_node_with_key_t", ["KEY"]),
+ "pk_k": ("TOKEN_PK_K", "policy_node_with_key_t", ["KEY"]),
+ "pk_h": ("TOKEN_PK_H", "policy_node_with_key_t", ["KEY"]),
+ "multi": ("TOKEN_MULTI", "policy_node_multisig_t", ["NUM", "KEYLIST"]),
+ "multi_a": ("TOKEN_MULTI_A", "policy_node_multisig_t", ["NUM", "KEYLIST"]),
+ "sortedmulti": ("TOKEN_SORTEDMULTI", "policy_node_multisig_t", ["NUM", "KEYLIST"]),
+ "sortedmulti_a": ("TOKEN_SORTEDMULTI_A", "policy_node_multisig_t", ["NUM", "KEYLIST"]),
+ "tr": ("TOKEN_TR", "policy_node_tr_t", ["KEY", "TREE"]),
+ "older": ("TOKEN_OLDER", "policy_node_with_uint32_t", ["NUM"]),
+ "after": ("TOKEN_AFTER", "policy_node_with_uint32_t", ["NUM"]),
+ "sh": ("TOKEN_SH", "policy_node_with_script_t", ["SUB"]),
+ "wsh": ("TOKEN_WSH", "policy_node_with_script_t", ["SUB"]),
+ "and_v": ("TOKEN_AND_V", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "and_b": ("TOKEN_AND_B", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "and_n": ("TOKEN_AND_N", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "or_b": ("TOKEN_OR_B", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "or_c": ("TOKEN_OR_C", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "or_d": ("TOKEN_OR_D", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "or_i": ("TOKEN_OR_I", "policy_node_with_script2_t", ["SUB", "SUB"]),
+ "andor": ("TOKEN_ANDOR", "policy_node_with_script3_t", ["SUB", "SUB", "SUB"]),
+}
+
+# Miniscript wrapper char -> PolicyNodeType. All wrappers are single-script
+# nodes (policy_node_with_script_t).
+_WRAPPER_TOKEN = {
+ "a": "TOKEN_A", "s": "TOKEN_S", "c": "TOKEN_C", "t": "TOKEN_T", "d": "TOKEN_D",
+ "v": "TOKEN_V", "j": "TOKEN_J", "n": "TOKEN_N", "l": "TOKEN_L", "u": "TOKEN_U",
+}
+
+# Allowed (binding-kind, positional-arg-kind) pairs.
+_KIND_POSITION_OK = {
+ ("KEY", "KEY"),
+ ("KEYS", "KEYLIST"),
+ ("THRESHOLD", "NUM"),
+ ("LEAVES", "TREE"),
+ ("SUB", "SUB"),
+ ("TIMELOCK", "SUB"),
+}
+
+
+class Pattern:
+ def __init__(self, keyword: str, args: list[Any]) -> None:
+ self.keyword = keyword
+ self.args = args # list of PatternArg (tuples, see _PatternParser.parse_arg)
+
+
+class _PatternParser:
+ """Recursive-descent parser for the spec pattern language. Mirrors
+ `PatternParser` in build.rs. Produces a `Pattern` AST whose args are tuples:
+ ("binding", name, kind) -- bare `$name`
+ ("musig", threshold_name, keys) -- musig($keys)
+ ("sub", wrappers, inner_Pattern) -- (wrappers:)? nested pattern
+ ("subref", wrappers, name) -- (wrappers:)? $subpolicy
+ """
+
+ def __init__(self, src: str) -> None:
+ self.src = src
+ self.pos = 0
+
+ def skip_ws(self) -> None:
+ while self.pos < len(self.src) and self.src[self.pos].isspace():
+ self.pos += 1
+
+ def peek(self) -> str | None:
+ return self.src[self.pos] if self.pos < len(self.src) else None
+
+ def bump(self, c: str) -> None:
+ self.skip_ws()
+ if self.peek() == c:
+ self.pos += 1
+ else:
+ raise ValueError(f"expected {c!r} at byte {self.pos} in {self.src!r}")
+
+ def try_bump(self, c: str) -> bool:
+ self.skip_ws()
+ if self.peek() == c:
+ self.pos += 1
+ return True
+ return False
+
+ def try_parse_ident(self) -> str | None:
+ self.skip_ws()
+ start = self.pos
+ while self.pos < len(self.src) and (self.src[self.pos].isalnum() or self.src[self.pos] == "_"):
+ self.pos += 1
+ return self.src[start:self.pos] if self.pos > start else None
+
+ def parse_ident(self) -> str:
+ ident = self.try_parse_ident()
+ if ident is None:
+ raise ValueError(f"expected identifier at byte {self.pos} in {self.src!r}")
+ return ident
+
+ def parse_binding_name(self) -> str:
+ self.bump("$")
+ return self.parse_ident()
+
+ def parse_pattern(self) -> Pattern:
+ kw = self.parse_ident()
+ if kw not in _KEYWORD_INFO:
+ raise ValueError(f"unknown descriptor keyword {kw!r} in {self.src!r}")
+ if not self.try_bump("("):
+ return Pattern(kw, [])
+ arg_kinds = _KEYWORD_INFO[kw][2]
+ args: list[Any] = []
+ if not self.try_bump(")"):
+ while True:
+ expected = arg_kinds[len(args)] if len(args) < len(arg_kinds) else "SUB"
+ args.append(self.parse_arg(expected))
+ self.skip_ws()
+ if self.try_bump(")"):
+ break
+ self.bump(",")
+ return Pattern(kw, args)
+
+ def parse_arg(self, expected: str) -> Any:
+ self.skip_ws()
+ if self.peek() == "$":
+ name = self.parse_binding_name()
+ kind = binding_kind(name)
+ if kind is None:
+ raise ValueError(f"unknown binding name ${name!r} in {self.src!r}")
+ _check_kind_position(name, kind, expected)
+ return ("binding", name, kind)
+ saved = self.pos
+ ident = self.try_parse_ident()
+ if ident is not None:
+ if ident == "musig":
+ if expected != "KEY":
+ raise ValueError(f"musig(...) only allowed in a Key position in {self.src!r}")
+ self.bump("(")
+ keys = self.parse_binding_name()
+ if binding_kind(keys) != "KEYS":
+ raise ValueError(f"musig(...) arg must be a $keys binding in {self.src!r}")
+ self.bump(")")
+ return ("musig", synthesize_threshold_name(keys), keys)
+ self.pos = saved # rewind: it's a keyword for a (wrapped) sub-pattern
+ wrappers: list[str] = []
+ while True:
+ snap = self.pos
+ idd = self.try_parse_ident()
+ self.skip_ws()
+ if idd is not None and self.peek() == ":":
+ for ch in idd:
+ if ch not in _WRAPPER_TOKEN:
+ raise ValueError(f"unknown wrapper char {ch!r} in {self.src!r}")
+ wrappers.append(ch)
+ self.pos += 1 # consume ':'
+ continue
+ self.pos = snap
+ break
+ if expected != "SUB" and wrappers:
+ raise ValueError(f"wrappers only allowed in Sub positions in {self.src!r}")
+ if self.peek() == "$":
+ snap = self.pos
+ name = self.parse_binding_name()
+ if binding_kind(name) == "SUB":
+ _check_kind_position(name, "SUB", expected)
+ return ("subref", wrappers, name)
+ self.pos = snap # not a subpolicy binding; fall through to pattern
+ inner = self.parse_pattern()
+ return ("sub", wrappers, inner)
+
+
+def _check_kind_position(name: str, kind: str, expected: str) -> None:
+ if (kind, expected) not in _KIND_POSITION_OK:
+ raise ValueError(f"binding ${name!r} (kind {kind}) not valid in a {expected} position")
+
+
+def parse_pattern_full(src: str) -> Pattern:
+ p = _PatternParser(src)
+ pat = p.parse_pattern()
+ p.skip_ws()
+ if p.pos != len(src):
+ raise ValueError(f"trailing input at byte {p.pos} in pattern {src!r}")
+ return pat
+
+
+# --- C emission for one matcher function -----------------------------------
+
+
+class _Ctx:
+ def __init__(self, bidx: dict[str, int], combinators: list[str]) -> None:
+ self.body: list[str] = []
+ self.binds: list[str] = []
+ self.ctr = 0
+ self.bidx = bidx
+ self.combinators = combinators
+
+ def fresh(self, base: str) -> str:
+ # `ct_`-prefixed (not `__`-prefixed) to avoid C reserved identifiers.
+ self.ctr += 1
+ return f"ct_{base}{self.ctr}"
+
+
+def _sub_child_expr(struct: str, t: str, i: int) -> str:
+ if struct == "policy_node_with_script_t":
+ return f"r_policy_node(&{t}->script)"
+ return f"r_policy_node(&{t}->scripts[{i}])" # script2 / script3
+
+
+def _classify_sub(ctx: _Ctx, node_var: str, name: str) -> None:
+ sm = ctx.fresh("sub")
+ ctx.body.append(f"ct_leaf_match_t {sm};")
+ ctx.body.append(f"if (!match_tapleaf({node_var}, &{sm})) break;")
+ rejects = [f"{sm}.cls == TC_OTHER"] + [f"{sm}.cls == {cc}" for cc in ctx.combinators]
+ ctx.body.append(f"if ({' || '.join(rejects)}) break;")
+ slot = ctx.bidx[name]
+ ctx.binds.append(f"set_binding_sub(&out->bindings, {slot}, {node_var});")
+
+
+def _peel_wrappers(ctx: _Ctx, node_var: str, wrappers: list[str]) -> str:
+ for ch in wrappers:
+ wt = _WRAPPER_TOKEN[ch]
+ ctx.body.append(f"if ({node_var} == NULL || {node_var}->type != {wt}) break;")
+ w = ctx.fresh("w")
+ ctx.body.append(f"const policy_node_with_script_t *{w} = (const policy_node_with_script_t *) {node_var};")
+ c2 = ctx.fresh("c")
+ ctx.body.append(f"const policy_node_t *{c2} = r_policy_node(&{w}->script);")
+ node_var = c2
+ return node_var
+
+
+def _handle_key_arg(ctx: _Ctx, arg: Any, t: str) -> None:
+ key_expr = f"r_policy_node_keyexpr(&{t}->key)"
+ if arg[0] == "binding" and arg[2] == "KEY":
+ k = ctx.fresh("k")
+ ctx.body.append(f"const policy_node_keyexpr_t *{k} = {key_expr};")
+ ctx.body.append(f"if ({k}->type != KEY_EXPRESSION_NORMAL) break;")
+ slot = ctx.bidx[arg[1]]
+ ctx.binds.append(f"set_binding_key(&out->bindings, {slot}, {k});")
+ elif arg[0] == "musig":
+ k = ctx.fresh("k")
+ ctx.body.append(f"const policy_node_keyexpr_t *{k} = {key_expr};")
+ ctx.body.append(f"if ({k}->type != KEY_EXPRESSION_MUSIG) break;")
+ mi = ctx.fresh("mi")
+ ctx.body.append(f"const musig_aggr_key_info_t *{mi} = r_musig_aggr_key_info(&{k}->m.musig_info);")
+ ts, ks = ctx.bidx[arg[1]], ctx.bidx[arg[2]]
+ ctx.binds.append(f"set_binding_number(&out->bindings, {ts}, {mi}->n);")
+ ctx.binds.append(f"set_binding_keys(&out->bindings, {ks}, {k}, 1);")
+ else:
+ raise ValueError(f"unexpected arg {arg!r} in Key position")
+
+
+def _handle_arg(ctx: _Ctx, arg: Any, ak: str, struct: str, t: str, i: int) -> None:
+ if ak == "KEY":
+ _handle_key_arg(ctx, arg, t)
+ elif ak == "NUM":
+ # The only NUM args reached here are multisig thresholds (->k);
+ # older/after thresholds are consumed by match_lock_value, not as args.
+ if not (arg[0] == "binding" and arg[2] == "THRESHOLD"):
+ raise ValueError(f"unexpected arg {arg!r} in Num position")
+ slot = ctx.bidx[arg[1]]
+ ctx.binds.append(f"set_binding_number(&out->bindings, {slot}, {t}->k);")
+ elif ak == "KEYLIST":
+ if not (arg[0] == "binding" and arg[2] == "KEYS"):
+ raise ValueError(f"unexpected arg {arg!r} in KeyList position")
+ ka = ctx.fresh("ka")
+ ctx.body.append(f"const policy_node_keyexpr_t *{ka} = r_policy_node_keyexpr(&{t}->keys);")
+ slot = ctx.bidx[arg[1]]
+ ctx.binds.append(f"set_binding_keys(&out->bindings, {slot}, {ka}, {t}->n);")
+ elif ak == "SUB":
+ child = _sub_child_expr(struct, t, i)
+ c = ctx.fresh("c")
+ ctx.body.append(f"const policy_node_t *{c} = {child};")
+ if arg[0] == "binding" and arg[2] == "TIMELOCK":
+ tl = ctx.fresh("tl")
+ ctx.body.append(f"ct_timelock_t {tl};")
+ ctx.body.append(f"if (!match_lock_value({c}, &{tl})) break;")
+ slot = ctx.bidx[arg[1]]
+ ctx.binds.append(f"set_binding_timelock(&out->bindings, {slot}, {tl});")
+ elif arg[0] == "binding" and arg[2] == "SUB":
+ _classify_sub(ctx, c, arg[1])
+ elif arg[0] == "subref":
+ c = _peel_wrappers(ctx, c, arg[1])
+ _classify_sub(ctx, c, arg[2])
+ elif arg[0] == "sub":
+ c = _peel_wrappers(ctx, c, arg[1])
+ _lower(ctx, arg[2], c)
+ else:
+ raise ValueError(f"unexpected arg {arg!r} in Sub position")
+ else:
+ raise ValueError(f"unexpected arg kind {ak!r}")
+
+
+def _lower(ctx: _Ctx, pat: Pattern, node_expr: str) -> None:
+ token, struct, arg_kinds = _KEYWORD_INFO[pat.keyword]
+ ctx.body.append(f"if ({node_expr} == NULL || {node_expr}->type != {token}) break;")
+ t = ctx.fresh("n")
+ ctx.body.append(f"const {struct} *{t} = (const {struct} *) {node_expr};")
+
+ if pat.keyword == "tr":
+ # tr's first arg is a Key (plain or musig); the optional second arg is
+ # the $leaves taptree. A 1-arg tr matches only a leaf-less taproot.
+ _handle_key_arg(ctx, pat.args[0], t)
+ if len(pat.args) == 1:
+ ctx.body.append(f"if (!isnull_policy_node_tree(&{t}->tree)) break;")
+ else:
+ ctx.body.append(f"if (isnull_policy_node_tree(&{t}->tree)) break;")
+ ctx.binds.append(f"out->taptree = r_policy_node_tree(&{t}->tree);")
+ return
+
+ for i, arg in enumerate(pat.args):
+ ak = arg_kinds[i] if i < len(arg_kinds) else "SUB"
+ _handle_arg(ctx, arg, ak, struct, t, i)
+
+
+def _emit_match_fn(
+ fn_name: str,
+ out_type: str,
+ prefix: str,
+ entries: list[dict[str, Any]],
+ root_var: str,
+ combinators: list[str],
+ is_top: bool,
+) -> str:
+ lines: list[str] = []
+ lines.append(f"bool {fn_name}(const policy_node_t *{root_var}, {out_type} *out) {{")
+ if is_top:
+ lines.append(" out->cls = DC_OTHER;")
+ lines.append(" out->bindings.n = 0;")
+ lines.append(" out->taptree = NULL;")
+ else:
+ lines.append(" out->cls = TC_OTHER;")
+ lines.append(" out->bindings.n = 0;")
+ lines.append(f" out->leaf_script = {root_var};")
+ lines.append(f" if ({root_var} == NULL) return false;")
+ lines.append("")
+ for e in entries:
+ name = e["name"]
+ cls = f"{prefix}_{snake_upper(name)}"
+ bindings = pattern_bindings(e["patterns"][0])
+ bidx = binding_index(bindings)
+ n_nonleaves = sum(1 for _, k in bindings if k != "LEAVES")
+ if n_nonleaves > CT_MAX_BINDINGS:
+ raise ValueError(
+ f"entry {name!r} has {n_nonleaves} non-leaf bindings, which "
+ f"exceeds CT_MAX_BINDINGS ({CT_MAX_BINDINGS}); raise it in "
+ f"src/common/cleartext_match.h and here"
+ )
+ for src in e["patterns"]:
+ ctx = _Ctx(bidx, combinators)
+ _lower(ctx, parse_pattern_full(src), root_var)
+ lines.append(f" // {name}: {src}")
+ lines.append(" do {")
+ for stmt in ctx.body:
+ lines.append(" " + stmt)
+ for stmt in ctx.binds:
+ lines.append(" " + stmt)
+ lines.append(f" out->bindings.n = {n_nonleaves};")
+ lines.append(f" out->cls = {cls};")
+ lines.append(" return true;")
+ lines.append(" } while (0);")
+ lines.append("")
+ lines.append(" return false;")
+ lines.append("}")
+ return "\n".join(lines)
+
+
+def emit_match(spec: dict[str, Any]) -> str:
+ top_level = spec.get("top_level", [])
+ tapleaf = spec.get("tapleaf", [])
+
+ # Combinator tapleaf classes: those that bind a $sub. They are rejected as
+ # sub-policies (no nesting), matching build.rs's `combinator_variants`.
+ combinators: list[str] = []
+ for e in tapleaf:
+ binds = pattern_bindings(e["patterns"][0])
+ if any(k == "SUB" for _, k in binds):
+ combinators.append("TC_" + snake_upper(e["name"]))
+
+ out: list[str] = []
+ out.append("// Generated by specs/bip388/gen.py. DO NOT EDIT.")
+ out.append("// clang-format off")
+ out.append("")
+ out.append('#include "common/cleartext_match.h"')
+ out.append("")
+ out.append("// Classifier for the root descriptor template. Mirrors the `[[top_level]]`")
+ out.append("// patterns of specs/bip388/cleartext.toml, tried in order.")
+ out.append(_emit_match_fn("match_top_level", "ct_top_match_t", "DC", top_level, "root",
+ combinators, is_top=True))
+ out.append("")
+ out.append("// Classifier for a single tap-leaf script. Mirrors the `[[tapleaf]]`")
+ out.append("// patterns of specs/bip388/cleartext.toml, tried in order.")
+ out.append(_emit_match_fn("match_tapleaf", "ct_leaf_match_t", "TC", tapleaf, "leaf_script",
+ combinators, is_top=False))
+ out.append("")
+ return "\n".join(out)
+
+
+# String pool ---------------------------------------------------------------
+
+
+class StringPool:
+ def __init__(self) -> None:
+ self._buf = bytearray()
+ self._offsets: dict[str, int] = {}
+
+ def add(self, s: str) -> int:
+ if s in self._offsets:
+ return self._offsets[s]
+ off = len(self._buf)
+ self._offsets[s] = off
+ self._buf.extend(s.encode("utf-8"))
+ self._buf.append(0)
+ return off
+
+ def offset(self, s: str) -> int:
+ """Return the offset of an already-added string."""
+ return self._offsets[s]
+
+ def items(self) -> list[tuple[str, int]]:
+ """The pooled strings with their byte offsets, in insertion order."""
+ return list(self._offsets.items())
+
+
+# Spec emission -------------------------------------------------------------
+
+
+def load_toml(path: Path) -> dict[str, Any]:
+ with open(path, "rb") as f:
+ return tomllib.load(f)
+
+
+def _cleartext_to_parts(
+ cleartext: list[str],
+ binding_kinds: dict[str, str],
+ entry_name: Any,
+ pool: StringPool,
+) -> list[Part]:
+ """Convert a cleartext template (list of literals and `$field` references)
+ into a list of Parts (kind, binding_name, literal_text). Each part is one of:
+ ("LITERAL", "", text) -- a literal string (also placed in pool)
+ (binding_kind, binding_name, "") -- a dynamic placeholder
+ """
+ parts: list[Part] = []
+ for s in cleartext:
+ if s.startswith("$"):
+ name = s[1:]
+ if name not in binding_kinds:
+ raise ValueError(
+ f"cleartext refers to unknown binding {name!r} in "
+ f"entry {entry_name!r}"
+ )
+ parts.append((binding_kinds[name], name, ""))
+ else:
+ parts.append(("LITERAL", "", s))
+ pool.add(s)
+ return parts
+
+
+def build_parts(
+ entry: dict[str, Any], pool: StringPool
+) -> tuple[list[Part], list[Part] | None, list[Binding]]:
+ """Return (parts, parts_all, bindings).
+
+ `parts` is built from the entry's `cleartext`. `parts_all` is built from the
+ optional `cleartext_all` -- the n-of-n rendering used when
+ `threshold == number of keys` -- or None when the entry has no such form.
+ A `cleartext_all` template must omit `$threshold` (implied by the key count,
+ re-synthesized on decode) and reference the `$keys` list it is derived from.
+
+ Bindings are the ordered list of (name, kind) extracted from the first
+ pattern (assumed identical across all patterns of an entry).
+ """
+ patterns: list[str] = entry["patterns"]
+ cleartext: list[str] = entry["cleartext"]
+
+ if not patterns:
+ raise ValueError(f"entry {entry.get('name')!r} has no patterns")
+ bindings = pattern_bindings(patterns[0])
+
+ # Every pattern within a single entry must share the same bindings
+ # (modulo musig wrapping): a later pattern may only use bindings the
+ # canonical (first) pattern already declares, and their kinds must agree by
+ # name. The musig pattern (e.g. tr(musig($keys))) passes because the
+ # canonical multisig pattern declares $threshold/$keys, which
+ # pattern_bindings() re-synthesizes for musig.
+ binding_kinds = dict(bindings)
+ for p in patterns[1:]:
+ for n, k in pattern_bindings(p):
+ if n not in binding_kinds:
+ raise ValueError(
+ f"binding {n!r} in pattern {p!r} not in canonical pattern "
+ f"{patterns[0]!r}"
+ )
+ if binding_kinds[n] != k:
+ raise ValueError(f"binding {n!r} kind mismatch")
+
+ parts = _cleartext_to_parts(cleartext, binding_kinds, entry.get("name"), pool)
+
+ parts_all: list[Part] | None = None
+ cleartext_all = entry.get("cleartext_all")
+ if cleartext_all is not None:
+ # Validate the n-of-n form, mirroring `parse_cleartext_all` in build.rs.
+ refs = {s[1:] for s in cleartext_all if s.startswith("$")}
+ threshold_names = {n for n, k in bindings if k == "THRESHOLD"}
+ keys_names = {n for n, k in bindings if k == "KEYS"}
+ if not keys_names:
+ raise ValueError(
+ f"entry {entry.get('name')!r}: cleartext_all requires a $keys binding"
+ )
+ if refs & threshold_names:
+ raise ValueError(
+ f"entry {entry.get('name')!r}: cleartext_all must omit $threshold "
+ f"(it is implied by the key count)"
+ )
+ if not (refs & keys_names):
+ raise ValueError(
+ f"entry {entry.get('name')!r}: cleartext_all must reference $keys "
+ f"(threshold is synthesized from it on decode)"
+ )
+ parts_all = _cleartext_to_parts(cleartext_all, binding_kinds, entry.get("name"), pool)
+
+ return parts, parts_all, bindings
+
+
+def build_all_parts(
+ entries: list[dict[str, Any]], pool: StringPool
+) -> list[EntryParts]:
+ """Build the parts of every entry (pooling their literals as a side effect)."""
+ result: list[EntryParts] = []
+ for e in entries:
+ parts, parts_all, bindings = build_parts(e, pool)
+ result.append((e["name"], parts, parts_all, bindings))
+ return result
+
+
+# Static skeleton of cleartext_specs.h. The two enum bodies are the only
+# dynamic parts; they are spliced in by emit_header() over these markers.
+# Substituted with str.replace (not str.format), so the C source's `{`/`}` and
+# the literal `$leaves` in the comments need no escaping.
+_HEADER_TEMPLATE = """\
+// Generated by specs/bip388/gen.py. DO NOT EDIT.
+// clang-format off
+
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+// A part of a cleartext template. Literals reference ct_string_pool
+// at `lit_off`; dynamic placeholders refer to the binding at index
+// `binding_idx` in the matcher's bindings array.
+typedef enum {
+ CT_PART_LITERAL = 0,
+ CT_PART_KEY,
+ CT_PART_KEYS,
+ CT_PART_THRESHOLD,
+ CT_PART_SUB,
+ CT_PART_TIMELOCK,
+} cleartext_part_kind_e;
+
+typedef struct {
+ uint8_t kind; // cleartext_part_kind_e
+ uint8_t binding_idx; // ignored for CT_PART_LITERAL
+ uint16_t lit_off; // valid only for CT_PART_LITERAL
+} cleartext_part_t;
+
+typedef struct {
+ uint8_t n_patterns; // total patterns for the class
+ uint8_t n_musig_patterns; // patterns whose admittance requires
+ // threshold == n_keys
+ uint8_t n_parts;
+ const cleartext_part_t *parts;
+ uint8_t n_parts_all; // 0 if the class has no n-of-n form
+ const cleartext_part_t *parts_all; // alternate template used when
+ // threshold == n_keys; NULL if absent
+ uint8_t recurses; // 1 iff the class binds a $leaves
+ // taptree (drives per-leaf
+ // rendering and the taptree score)
+} cleartext_spec_t;
+
+// Classes of the *root* of a wallet-policy descriptor template. Each named
+// value (the sentinels aside) is one shape the cleartext encoder recognizes,
+// listed in the same order as the `[[top_level]]` entries of
+// specs/bip388/cleartext.toml (the single source of truth for their meaning).
+// match_top_level() in cleartext.c yields one of these, and it indexes
+// CT_TOP_LEVEL_SPECS[] to select that shape's cleartext rendering.
+// DC_OTHER - matches no known shape: no cleartext form, so the UX falls
+// back to showing the raw descriptor template.
+// DC__COUNT - number of recognized classes (== DC_OTHER); sizes
+// CT_TOP_LEVEL_SPECS[], which has no entry for DC_OTHER.
+typedef enum {
+@TOP_LEVEL_ENUM@
+ DC_OTHER,
+ DC__COUNT = DC_OTHER,
+} descriptor_class_e;
+
+// Classes of a single leaf script of a taproot tree. Each named value (the
+// sentinels aside) is one leaf shape the cleartext encoder recognizes, listed
+// in the same order as the `[[tapleaf]]` entries of
+// specs/bip388/cleartext.toml (the single source of truth for their meaning).
+// match_tapleaf() in cleartext.c yields one of these, and it indexes
+// CT_TAPLEAF_SPECS[] to select that leaf's cleartext rendering.
+// TC_OTHER - matches no known shape: no cleartext form, so the leaf is
+// rendered as the fixed "(unknown)" marker.
+// TC__COUNT - number of recognized classes (== TC_OTHER); sizes
+// CT_TAPLEAF_SPECS[], which has no entry for TC_OTHER.
+typedef enum {
+@TAPLEAF_ENUM@
+ TC_OTHER,
+ TC__COUNT = TC_OTHER,
+} tapleaf_class_e;
+
+extern const char ct_string_pool[];
+extern const cleartext_spec_t CT_TOP_LEVEL_SPECS[DC__COUNT];
+extern const cleartext_spec_t CT_TAPLEAF_SPECS[TC__COUNT];
+"""
+
+
+def emit_header(spec: dict[str, Any]) -> str:
+ top_level = spec.get("top_level", [])
+ tapleaf = spec.get("tapleaf", [])
+ top_enum = "\n".join(f" DC_{snake_upper(e['name'])}," for e in top_level)
+ tapleaf_enum = "\n".join(f" TC_{snake_upper(e['name'])}," for e in tapleaf)
+ return (
+ _HEADER_TEMPLATE
+ .replace("@TOP_LEVEL_ENUM@", top_enum)
+ .replace("@TAPLEAF_ENUM@", tapleaf_enum)
+ )
+
+
+def emit_source(spec: dict[str, Any]) -> str:
+ top_level = spec.get("top_level", [])
+ tapleaf = spec.get("tapleaf", [])
+ pool = StringPool()
+
+ # Build the parts of every entry first so that all literals are pooled.
+ top_parts = build_all_parts(top_level, pool)
+ leaf_parts = build_all_parts(tapleaf, pool)
+
+ out: list[str] = []
+ out.append("// Generated by specs/bip388/gen.py. DO NOT EDIT.")
+ out.append("// clang-format off")
+ out.append("")
+ out.append('#include "common/cleartext_specs.h"')
+ out.append("")
+ # Every literal used by the specs below, NUL-separated. Each line is
+ # annotated with its byte offset -- the `lit_off` value a CT_PART_LITERAL
+ # part stores to locate its string here.
+ out.append("const char ct_string_pool[] =")
+ pool_items = pool.items()
+ if not pool_items:
+ out.append(' "";')
+ else:
+ for s, off in pool_items:
+ if off > 0xFFFF:
+ raise ValueError(
+ f"string pool offset {off} for {s!r} does not fit the "
+ f"uint16_t lit_off field"
+ )
+ out.append(f" /* {off:5d} */ {c_pool_entry(s)}")
+ out[-1] += ";" # terminate the initializer on the last literal
+ out.append("")
+
+ def emit_parts_array(arr_name: str, parts: list[Part],
+ bidx: dict[str, int]) -> None:
+ out.append(f"static const cleartext_part_t {arr_name}[] = {{")
+ for kind, bname, lit in parts:
+ kind_str = part_kind_c(kind)
+ if kind == "LITERAL":
+ off = pool.offset(lit)
+ out.append(f' {{ {kind_str:<19s}, 0, {off:4d} }}, // {c_string_literal(lit)}')
+ else:
+ idx = bidx[bname]
+ out.append(f' {{ {kind_str:<19s}, {idx}, {0:4d} }}, // ${bname}')
+ out.append("};")
+ out.append("")
+
+ def emit_entry_parts(prefix: str, name: str, parts: list[Part],
+ parts_all: list[Part] | None,
+ bindings: list[Binding]) -> None:
+ bidx = binding_index(bindings)
+ base = f"{prefix}_{snake_upper(name)}"
+ emit_parts_array(f"{base}_PARTS", parts, bidx)
+ if parts_all is not None:
+ emit_parts_array(f"{base}_PARTS_ALL", parts_all, bidx)
+
+ for name, parts, parts_all, bindings in top_parts:
+ emit_entry_parts("TOP", name, parts, parts_all, bindings)
+ for name, parts, parts_all, bindings in leaf_parts:
+ emit_entry_parts("LEAF", name, parts, parts_all, bindings)
+
+ def spec_initializer(prefix: str, name: str, n_patterns: int, n_musig: int,
+ parts: list[Part], parts_all: list[Part] | None,
+ recurses: bool) -> str:
+ base = f"{prefix}_{snake_upper(name)}"
+ if parts_all is not None:
+ all_fields = f"{len(parts_all)}, {base}_PARTS_ALL"
+ else:
+ all_fields = "0, NULL"
+ return (
+ f"{{ {n_patterns}, {n_musig}, "
+ f"{len(parts)}, {base}_PARTS, {all_fields}, {1 if recurses else 0} }}"
+ )
+
+ def emit_specs_array(decl: str, enum_prefix: str, part_prefix: str,
+ entries: list[dict[str, Any]],
+ parts_list: list[EntryParts]) -> None:
+ out.append(f"const cleartext_spec_t {decl} = {{")
+ for entry, (name, parts, parts_all, _) in zip(entries, parts_list):
+ n_patterns = len(entry["patterns"])
+ n_musig = sum(1 for p in entry["patterns"] if is_musig_pattern(p))
+ out.append(
+ f" [{enum_prefix}_{snake_upper(name)}] = "
+ + spec_initializer(part_prefix, name, n_patterns, n_musig, parts,
+ parts_all, entry_recurses(entry))
+ + ","
+ )
+ out.append("};")
+ out.append("")
+
+ emit_specs_array("CT_TOP_LEVEL_SPECS[DC__COUNT]", "DC", "TOP", top_level, top_parts)
+ emit_specs_array("CT_TAPLEAF_SPECS[TC__COUNT]", "TC", "LEAF", tapleaf, leaf_parts)
+
+ return "\n".join(out)
+
+
+def emit_vectors(vectors_doc: dict[str, Any]) -> str:
+ vectors = vectors_doc.get("vector", [])
+ out: list[str] = []
+ out.append("// Generated by specs/bip388/gen.py. DO NOT EDIT.")
+ out.append("// clang-format off")
+ out.append("")
+
+ # Per-vector cleartext arrays
+ for i, v in enumerate(vectors):
+ if "cleartext" in v:
+ arr_name = f"vec_{i:03d}_ct"
+ out.append(f"static const char *const {arr_name}[] = {{")
+ for s in v["cleartext"]:
+ out.append(f" {c_string_literal(s)},")
+ out.append("};")
+ out.append("")
+
+ out.append("static const ct_vector_t CT_VECTORS[] = {")
+ for i, v in enumerate(vectors):
+ has_score = "confusion_score" in v
+ has_ct = "cleartext" in v
+ has_flag = "has_cleartext" in v
+ template = v["template"]
+ score = v.get("confusion_score", 0)
+ flag = cbool(v.get("has_cleartext", False))
+ if has_ct:
+ ct_n = len(v["cleartext"])
+ ct_ptr = f"vec_{i:03d}_ct"
+ else:
+ ct_n = 0
+ ct_ptr = "NULL"
+ out.append(
+ " { "
+ f".template_str = {c_string_literal(template)},"
+ f" .has_confusion_score = {cbool(has_score)},"
+ f" .confusion_score = {score}ULL,"
+ f" .has_cleartext_array = {cbool(has_ct)},"
+ f" .cleartext_n = {ct_n}, .cleartext = {ct_ptr},"
+ f" .has_has_cleartext = {cbool(has_flag)},"
+ f" .cleartext_flag = {flag} }},"
+ )
+ out.append("};")
+ out.append("static const size_t CT_VECTORS_N = sizeof(CT_VECTORS) / sizeof(CT_VECTORS[0]);")
+ out.append("")
+ return "\n".join(out)
+
+
+def generate_outputs() -> list[tuple[Path, str]]:
+ """Render every generated file in memory, returning (path, content) pairs.
+
+ This is the single source of truth shared by both write and --check modes,
+ so the two can never disagree about what "correct" output is.
+ """
+ spec_doc = load_toml(SPEC_FILE)
+ vectors_doc = load_toml(VECTORS_FILE)
+ return [
+ (HEADER_OUT, emit_header(spec_doc) + "\n"),
+ (SOURCE_OUT, emit_source(spec_doc) + "\n"),
+ (MATCH_OUT, emit_match(spec_doc) + "\n"),
+ (VECTORS_OUT, emit_vectors(vectors_doc) + "\n"),
+ ]
+
+
+def write_outputs(outputs: list[tuple[Path, str]]) -> int:
+ """Write each generated file to disk, creating parent dirs as needed."""
+ for path, content in outputs:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ print(f"wrote {path.relative_to(REPO_ROOT)}")
+ return 0
+
+
+def check_outputs(outputs: list[tuple[Path, str]]) -> int:
+ """Compare each generated file against what is already on disk without
+ modifying anything. Return 0 if everything is up to date, 1 otherwise.
+
+ Prints a unified diff for any file that drifted, so CI logs show exactly
+ what regenerating would change.
+ """
+ stale: list[Path] = []
+ for path, content in outputs:
+ rel = path.relative_to(REPO_ROOT)
+ current = path.read_text() if path.exists() else None
+ if current == content:
+ print(f"ok {rel}")
+ continue
+
+ stale.append(rel)
+ if current is None:
+ print(f"MISSING {rel} (file does not exist)")
+ continue
+ print(f"DRIFT {rel}")
+ diff = difflib.unified_diff(
+ current.splitlines(keepends=True),
+ content.splitlines(keepends=True),
+ fromfile=f"{rel} (committed)",
+ tofile=f"{rel} (generated)",
+ )
+ sys.stdout.writelines(diff)
+
+ if stale:
+ print()
+ print(f"error: {len(stale)} generated file(s) are out of date:")
+ for rel in stale:
+ print(f" - {rel}")
+ print()
+ print("Regenerate them by running:")
+ print(" python3 specs/bip388/gen.py")
+ print("and commit the result.")
+ return 1
+
+ print()
+ print("All generated files are up to date.")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="gen.py",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ description=__doc__,
+ )
+ parser.add_argument(
+ "-c",
+ "--check",
+ action="store_true",
+ help="don't write anything; verify the committed files match what this "
+ "script would generate and exit non-zero on any drift. Use this in "
+ "CI to catch generated files that were not regenerated after the "
+ "TOML spec changed.",
+ )
+ args = parser.parse_args(argv)
+
+ outputs = generate_outputs()
+ if args.check:
+ return check_outputs(outputs)
+ return write_outputs(outputs)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/specs/bip388/test_vectors.toml b/specs/bip388/test_vectors.toml
new file mode 100644
index 0000000..117abfd
--- /dev/null
+++ b/specs/bip388/test_vectors.toml
@@ -0,0 +1,884 @@
+# 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. An
+# unrecognised tap-leaf inside an otherwise-readable taproot is rendered
+# verbatim behind a "Raw policy: " label and always sorts after the
+# recognised leaves.
+# 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 at most `confusion_score`
+# structurally distinct descriptor templates (the score is an upper bound),
+# 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 = ["Spendable by @0 alone (Legacy)"]
+has_cleartext = true
+
+[[vector]]
+template = "wpkh(@0/**)"
+confusion_score = 2
+cleartext = ["Spendable by @0 alone (SegWit)"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(wpkh(@0/**))"
+confusion_score = 2
+cleartext = ["Spendable by @0 alone (SegWit)"]
+has_cleartext = true
+
+# =============================================================================
+# Multisig (k-of-n), coalesced top-level class.
+#
+# The `Multisig` class drops any address-encoding qualifier and stands for every
+# shape that displays identically to a signer: the script multisig forms
+# (`sh`/`wsh`/`sh(wsh)` over `multi`/`sortedmulti`) plus the leaf-less taproot musig
+# key-path (`tr(musig(...))`, see the "musig key-only" section below). The score
+# counts those encodings:
+# - threshold < n : 6 (the six script forms; `tr(musig)` is n-of-n only)
+# - threshold = n : 7 (the six script forms + `tr(musig(...))`)
+# =============================================================================
+
+[[vector]]
+template = "wsh(sortedmulti(2,@0/**,@1/**))"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "wsh(sortedmulti(2,@0/**,@1/**,@2/**))"
+confusion_score = 6
+cleartext = ["Any 2 of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "wsh(sortedmulti(3,@0/**,@1/**,@2/**))"
+confusion_score = 7
+cleartext = ["Each of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "wsh(multi(2,@0/**,@1/**))"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(wsh(multi(2,@0/**,@1/**)))"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(wsh(sortedmulti(2,@0/**,@1/**)))"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(wsh(multi(2,@0/**,@1/**,@2/**)))"
+confusion_score = 6
+cleartext = ["Any 2 of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(wsh(sortedmulti(3,@0/**,@1/**,@2/**)))"
+confusion_score = 7
+cleartext = ["Each of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+# n-of-n via plain `multi` (not sorted): exercises the Multisig "Each of"
+# form for the unsorted keyword too.
+[[vector]]
+template = "wsh(multi(3,@0/**,@1/**,@2/**))"
+confusion_score = 7
+cleartext = ["Each of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+# Legacy P2SH script multisig (`sh(multi)` / `sh(sortedmulti)`): the two forms
+# folded into the coalesced `Multisig` class alongside the SegWit shapes. n-of-n
+# renders "Each of ..." (score 7); threshold < n renders "Any K of ..." (score 6).
+[[vector]]
+template = "sh(multi(2,@0/**,@1/**))"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "sh(sortedmulti(2,@0/**,@1/**,@2/**))"
+confusion_score = 6
+cleartext = ["Any 2 of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+# =============================================================================
+# Taproot (no musig); key-path only
+# =============================================================================
+
+[[vector]]
+template = "tr(@0/**)"
+confusion_score = 1
+cleartext = ["Spendable by @0 alone (Taproot)"]
+has_cleartext = true
+
+# =============================================================================
+# Taproot (no musig); script-path leaves
+# =============================================================================
+
+[[vector]]
+template = "tr(@0/**,pk(@1/**))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),pk(@2/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@3 must sign",
+ "Each of @1 and @2 must sign (sorted)",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})"
+confusion_score = 3
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+]
+has_cleartext = true
+
+# Tie-break: two SingleSig leaves given in reverse key_index order
+[[vector]]
+template = "tr(@0/**,{pk(@2/**),pk(@1/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+]
+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))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@2 must sign, 2000 blocks after receiving",
+]
+has_cleartext = true
+
+# Canonical ordering of multisig tap-leaves. Two same-category multisig leaves
+# are ordered by: number of keys, then threshold, then key indices (the
+# `cmp_keys` tie-break). The vectors below pin each level. In every one the
+# leaves are written in a tap-tree order that differs from the display order, so
+# a regression that made the order depend on tap-tree position (rather than on
+# the leaf contents) would change the rendered output and fail the assertion.
+
+# Fewer keys first: the 2-key leaf sorts before the 3-key leaf even
+# though it is written second in the tree.
+[[vector]]
+template = "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**)})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @4 and @5 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+]
+has_cleartext = true
+
+# Same key count, different threshold: threshold outranks the keys,
+# so the 2-of-3 leaf sorts before the 3-of-3 leaf even though its keys (@4..)
+# are "larger" than the other leaf's (@1..).
+[[vector]]
+template = "tr(@0/**,{multi_a(3,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**,@6/**)})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "Any 2 of @4, @5 and @6 must sign",
+ "Each of @1, @2 and @3 must sign",
+]
+has_cleartext = true
+
+# Same key count and threshold, leaves differ only in their keys:
+# ordered element-by-element by key index (the `cmp_keys` tie-break), so the
+# @1.. leaf displays before the @4.. leaf even though the @4.. leaf is written
+# first in the tree. Without this tie-break the two leaves would compare equal
+# and their order would silently depend on tap-tree position.
+[[vector]]
+template = "tr(@0/**,{multi_a(2,@4/**,@5/**,@6/**),multi_a(2,@1/**,@2/**,@3/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "Any 2 of @1, @2 and @3 must sign",
+ "Any 2 of @4, @5 and @6 must sign",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{sortedmulti_a(2,@4/**,@5/**),sortedmulti_a(2,@1/**,@2/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign (sorted)",
+ "Each of @4 and @5 must sign (sorted)",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{multi_a(3,@7/**,@8/**,@9/**),{multi_a(2,@4/**,@5/**,@6/**),{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@10/**,@11/**)}}})"
+confusion_score = 60
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @10 and @11 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+ "Any 2 of @4, @5 and @6 must sign",
+ "Each of @7, @8 and @9 must sign",
+]
+has_cleartext = true
+
+# Recognised first leaf + unrecognised second leaf (complex miniscript). The
+# unrecognised leaf is rendered verbatim behind the "Raw policy: " label.
+[[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))))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign, 960 blocks after receiving",
+ "Raw policy: t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))",
+]
+has_cleartext = false
+
+# Raw policies always sort last: here the unrecognised leaf is *first* in the
+# tap-tree, but it is displayed after the recognised single-signature leaf.
+[[vector]]
+template = "tr(@0/**,{t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995)))),pk(@1/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Raw policy: 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)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign, 52560 blocks after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(52560))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 52560 blocks after receiving",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 1008 blocks after receiving",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{multi_a(3,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**,@6/**,@7/**,@8/**)})"
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @1, @2 and @3 must sign", # 3-of-3: 3 keys, threshold 3
+ "Any 2 of @4, @5, @6, @7 and @8 must sign", # 2-of-5: 5 keys, threshold 2
+]
+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)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign, 8 minutes 32 seconds after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194305))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 8 minutes 32 seconds after receiving",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 1 day 1 hour 36 minutes after receiving",
+]
+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 = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, 1 day 1 hour 36 minutes after receiving",
+]
+has_cleartext = true
+
+# =============================================================================
+# Taproot absolute-heightlock / -timelock leaves (after())
+# =============================================================================
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign, not before block 840000"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, not before block 840000",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign, not before 1985-11-05 00:53:20 UTC"]
+has_cleartext = true
+
+# Absolute time-lock at exactly midnight UTC: the time-of-day is omitted and only
+# the calendar date is shown. 1577836800 = 2020-01-01 00:00:00 UTC.
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(1577836800)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 must sign, not before 2020-01-01 UTC"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, not before 2023-11-14 22:13:20 UTC",
+]
+has_cleartext = true
+
+# =============================================================================
+# Taproot timelock leaf ordering
+#
+# Two leaves that share the same sub-policy (so the sub-policy comparison ties)
+# and are ordered purely by their timelocks: relative locks sort before absolute
+# locks, and within the same kind by ascending raw value. @1 appears twice
+# (<0;1>, <2;3>) so each vector scores 2! = 2.
+# =============================================================================
+
+# Two relative height-locks: ordered by ascending block count.
+[[vector]]
+template = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),older(2000))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, 2000 blocks after receiving",
+]
+has_cleartext = true
+
+# Relative lock vs absolute lock: the relative one sorts first regardless of raw
+# values.
+[[vector]]
+template = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),after(840000))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, not before block 840000",
+]
+has_cleartext = true
+
+# Identical sub-policy and identical timelock: the two leaves compare fully equal
+# and render identically (they tie).
+[[vector]]
+template = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),older(1000))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, 1000 blocks after receiving",
+]
+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)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 and @2 must both sign, 1008 blocks after receiving"]
+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))})"
+confusion_score = 1
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 and @3 must both sign, 1008 blocks after receiving",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 and @2 must both sign, 1 day 1 hour 36 minutes after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 and @2 must both sign, not before block 840000"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))"
+confusion_score = 1
+cleartext = ["Main path: spendable by @0", "@1 and @2 must both sign, not before 2023-11-14 22:13:20 UTC"]
+has_cleartext = true
+
+# =============================================================================
+# Taproot with musig() as internal key
+# =============================================================================
+
+# Leaf-less musig key-path: an inherently n-of-n multisignature. It is folded into
+# the coalesced `Multisig` class, so it renders without a qualifier and shares its
+# cleartext (and score 7) with the script n-of-n forms above: decoding "Each of @0
+# and @1 must sign" yields sh(multi), sh(sortedmulti), wsh(multi), wsh(sortedmulti),
+# sh(wsh(multi)), sh(wsh(sortedmulti)) and tr(musig(@0,@1)).
+[[vector]]
+template = "tr(musig(@0,@1)/**)"
+confusion_score = 7
+cleartext = ["Each of @0 and @1 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1,@2)/**)"
+confusion_score = 7
+cleartext = ["Each of @0, @1 and @2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1)/**,pk(@2/**))"
+confusion_score = 1
+cleartext = ["Main path: each of @0 and @1 must sign", "@2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})"
+confusion_score = 1
+cleartext = [
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1)/**,{{pk(@2/**),pk(@3/**)},pk(@4/**)})"
+confusion_score = 3
+cleartext = [
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+ "@4 must sign",
+]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1)/**,and_v(v:pk(@2/<0;1>/*),older(1008)))"
+confusion_score = 1
+cleartext = ["Main path: each of @0 and @1 must sign", "@2 must sign, 1008 blocks after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(musig(@0,@1)/**,{pk(@2/**),and_v(v:pk(@3/<0;1>/*),after(840000))})"
+confusion_score = 1
+cleartext = [
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign, not before block 840000",
+]
+has_cleartext = true
+
+# n-of-n via plain `multi_a` (not musig): exercises the Multisig tap-leaf "each of"
+# form. Score 2 = multi_a(3,...) plus the round-tripping pk(musig(...)) (3-of-3).
+[[vector]]
+template = "tr(@0/**,multi_a(3,@1/**,@2/**,@3/**))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1, @2 and @3 must sign"]
+has_cleartext = true
+
+# =============================================================================
+# Taproot with musig() inside a tapleaf (pk(musig(...)) -> Multisig)
+# =============================================================================
+
+[[vector]]
+template = "tr(@0/**,pk(musig(@1,@2)/**))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1 and @2 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,pk(musig(@1,@2,@3)/**))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1, @2 and @3 must sign"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1 and @2 must sign, 1008 blocks after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1 and @2 must sign, 1 day 1 hour 36 minutes after receiving"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1 and @2 must sign, not before block 840000"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "Each of @1 and @2 must sign, not before 2023-11-14 22:13:20 UTC"]
+has_cleartext = true
+
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),pk(musig(@2,@3)/**)})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign",
+]
+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 = [
+ "Main path: spendable by @0",
+ "Each of @4 and @5 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+]
+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>/*))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@2 and @1 must both sign",
+ "@1 must sign, 4383 blocks after receiving",
+]
+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 = [
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign",
+ "Any 2 of @1, @2 and @3 must sign, 144 blocks after receiving",
+]
+has_cleartext = true
+
+# @0 appears twice (key path + leaf) -> 2! = 2
+[[vector]]
+template = "tr(@0/<0;1>/*,pk(@0/<2;3>/*))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "@0 must sign"]
+has_cleartext = true
+
+# Same as above, but the derivation indices are given out of order (key path uses
+# (2,3), leaf uses (0,1)). Canonicalisation must be independent of the order in
+# which occurrences are encountered, so this is still canonical and scores the
+# same 2! = 2 as the in-order version.
+[[vector]]
+template = "tr(@0/<2;3>/*,pk(@0/<0;1>/*))"
+confusion_score = 2
+cleartext = ["Main path: spendable by @0", "@0 must sign"]
+has_cleartext = true
+
+# Two multisig leaves with identical key sets and threshold, distinguished only
+# by their derivations. They render the same and must order deterministically by
+# their keys (here the two leaves tie). Score = 2 (each 2-of-2 leaf admits
+# multi_a and the round-tripping pk(musig(...))) for each leaf, times 2! for @1
+# and 2! for @2, i.e. 2 * 2 * 2! * 2! = 16.
+[[vector]]
+template = "tr(@0/**,{multi_a(2,@1/<0;1>/*,@2/<0;1>/*),multi_a(2,@1/<2;3>/*,@2/<2;3>/*)})"
+confusion_score = 16
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign",
+ "Each of @1 and @2 must sign",
+]
+has_cleartext = true
+
+# @0 appears three times -> 3! = 6
+[[vector]]
+template = "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),pk(@0/<4;5>/*)})"
+confusion_score = 6
+cleartext = [
+ "Main path: spendable by @0",
+ "@0 must sign",
+ "@0 must sign",
+]
+has_cleartext = true
+
+# @1 appears twice in two leaves -> 2! = 2
+[[vector]]
+template = "tr(@0/**,{pk(@1/<0;1>/*),pk(@1/<2;3>/*)})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@1 must sign",
+]
+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>/*)})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@1 must sign, 4383 blocks after receiving",
+]
+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>/*))})"
+confusion_score = 4
+cleartext = [
+ "Main path: spendable by @0",
+ "@0 must sign",
+ "@1 and @1 must both sign",
+]
+has_cleartext = true
+
+# Musig key path with three 2-of-2 musig fallback leaves that share keys: each of
+# @0, @1, @2 appears once in the key path and in two leaves. The confusion score
+# expands every musig group to its plain-key members and counts orderings by key
+# index, so each of @0, @1, @2 has multiplicity 3 -> 3! ^ 3 = 216 derivation
+# orderings. Combined with 2 options per leaf (8) and 3 taptree shapes that gives
+# 8 * 3 * 216 = 5184.
+#
+# This is a deliberate over-count: the true number of distinct decodings is 54.
+# The bound counts the most-permutable (multi_a) interpretation of every key
+# independently, even though the internal key is never a multi_a and not all
+# combinations realize the maximum orderings. Over-counting is the safe direction
+# for the `confusion_score <= MAX_CONFUSION_SCORE` display gate; we keep the
+# formula simple rather than computing the exact figure. The confusion score is
+# therefore an upper bound: decoding this cleartext yields 54 <= 5184 templates.
+[[vector]]
+template = "tr(musig(@0,@1,@2)/**,{and_v(v:pk(musig(@0,@1)/**),older(4194484)),{and_v(v:pk(musig(@0,@2)/**),older(4194484)),and_v(v:pk(musig(@1,@2)/**),older(4194484))}})"
+confusion_score = 5184
+cleartext = [
+ "Main path: each of @0, @1 and @2 must sign",
+ "Each of @0 and @1 must sign, 1 day 1 hour 36 minutes after receiving",
+ "Each of @0 and @2 must sign, 1 day 1 hour 36 minutes after receiving",
+ "Each of @1 and @2 must sign, 1 day 1 hour 36 minutes after receiving",
+]
+has_cleartext = true
+
+# =============================================================================
+# Taproot AndV leaves: and_v(v:SUBPOLICY_1, SUBPOLICY_2)
+# =============================================================================
+
+# SingleSig AND Multisig (plain multisig): score = 1 * 2 = 2
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/**),multi_a(2,@2/**,@3/**)))"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+]
+has_cleartext = true
+
+# SingleSig AND Multisig (using Musig2): score = 1 * 2 = 2
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/**),pk(musig(@2,@3)/**)))"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+]
+has_cleartext = true
+
+
+# SingleSig AND Multisig (threshold == keys): score = 1 * 2 = 2
+[[vector]]
+template = "tr(@0/**,and_v(v:pk(@1/**),multi_a(2,@2/**,@3/**)))"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+]
+has_cleartext = true
+
+# Multisig AND Multisig (both threshold == keys): score = 2 * 2 = 4
+[[vector]]
+template = "tr(@0/**,and_v(v:multi_a(2,@1/**,@2/**),multi_a(2,@3/**,@4/**)))"
+confusion_score = 4
+cleartext = [
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign - and also - each of @3 and @4 must sign",
+]
+has_cleartext = true
+
+# BothMustSign AND Multisig: score = 1 * 2 = 2
+[[vector]]
+template = "tr(@0/**,and_v(v:and_v(v:pk(@1/**),pk(@2/**)),multi_a(2,@3/**,@4/**)))"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign - and also - each of @3 and @4 must sign",
+]
+has_cleartext = true
+
+# AndV leaf alongside a normal leaf: SingleSig sorts before AndV
+[[vector]]
+template = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/**),multi_a(2,@3/**,@4/**))})"
+confusion_score = 2
+cleartext = [
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign - and also - each of @3 and @4 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>/*)"
+confusion_score = 1
+cleartext = ["pkh(@0/<2;3>/*)"]
+has_cleartext = false
+
+[[vector]]
+template = "wpkh(@0/<0;2>/*)"
+confusion_score = 2
+cleartext = ["wpkh(@0/<0;2>/*)"]
+has_cleartext = false
+
+[[vector]]
+template = "tr(@0/<4;5>/*)"
+confusion_score = 1
+cleartext = ["tr(@0/<4;5>/*)"]
+has_cleartext = false
+
+[[vector]]
+template = "tr(@0/**,pk(@1/<2;3>/*))"
+confusion_score = 1
+cleartext = ["tr(@0/**,pk(@1/<2;3>/*))"]
+has_cleartext = false
+
+# =============================================================================
+# Unclassified top-level: a valid descriptor that matches no cleartext class, so
+# no cleartext is produced and the encoder falls back to the raw template. Note
+# `and_v(v:pk, pk)` ("both must sign") is only a recognised shape *inside* a
+# taproot tap-leaf, not as a bare top-level script.
+# =============================================================================
+
+[[vector]]
+template = "wsh(and_v(v:pk(@0/**),pk(@1/**)))"
+confusion_score = 1
+cleartext = ["wsh(and_v(v:pk(@0/**),pk(@1/**)))"]
+has_cleartext = false
diff --git a/src/common/cleartext.c b/src/common/cleartext.c
new file mode 100644
index 0000000..b94b3af
--- /dev/null
+++ b/src/common/cleartext.c
@@ -0,0 +1,921 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *****************************************************************************/
+
+#include <stdint.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <string.h>
+#include <stdio.h>
+
+#include "common/cleartext.h"
+#include "common/cleartext_match.h"
+#include "common/wallet.h"
+#include "constants.h" // SEQUENCE_LOCKTIME_TYPE_FLAG, LOCKTIME_THRESHOLD
+#include "policy.h" // get_keyexpr_by_index
+
+#include "ledger_assert.h"
+#include "os_pic.h"
+
+// Placeholder rendered for a tap-leaf whose spending policy has no cleartext
+// form. We don't have an unparser, nor a pointer from the leaf node to the
+// original substring of the descriptor template. Therefore, we only print the
+// fixed string "(unknown)", and *out_has_cleartext is set to false
+// (see cleartext_encode).
+#define CT_UNKNOWN_LEAF "(unknown)"
+
+// ---------------------------------------------------------------------------
+// Saturating-u64 helpers
+// ---------------------------------------------------------------------------
+
+static inline uint64_t sat_mul_u64(uint64_t a, uint64_t b) {
+ if (a == 0 || b == 0) return 0;
+ if (a > UINT64_MAX / b) return UINT64_MAX;
+ return a * b;
+}
+
+// ---------------------------------------------------------------------------
+// Forward declarations
+// ---------------------------------------------------------------------------
+//
+// The match-result types (ct_value_t, ct_bindings_t, ct_top_match_t,
+// ct_leaf_match_t, ct_timelock_t), the set_binding_* helpers, match_lock_value,
+// and the match_top_level / match_tapleaf classifier (the latter generated into
+// cleartext_match.c from specs/bip388/cleartext.toml) all live in
+// common/cleartext_match.h.
+
+static int append_str(char *out, size_t cap, size_t *off, const char *s);
+static int append_keyexpr(char *out, size_t cap, size_t *off, const policy_node_keyexpr_t *key);
+static int append_keys_list(char *out,
+ size_t cap,
+ size_t *off,
+ const policy_node_keyexpr_t *keys,
+ uint16_t n);
+static int append_uint32(char *out, size_t cap, size_t *off, uint32_t v);
+static int append_timelock(char *out, size_t cap, size_t *off, ct_timelock_t tl);
+
+static int render_spec(const cleartext_spec_t *spec, const ct_bindings_t *b, char *out, size_t cap);
+static int render_spec_at(const cleartext_spec_t *spec,
+ const ct_bindings_t *b,
+ char *out,
+ size_t cap,
+ size_t *off);
+static int leaf_cmp(const ct_leaf_match_t *a, const ct_leaf_match_t *b);
+static uint64_t leaf_score(const ct_leaf_match_t *lm);
+static uint16_t keys_member_count(const ct_value_t *v);
+
+// ---------------------------------------------------------------------------
+// Score helpers (per-class admittance count)
+// ---------------------------------------------------------------------------
+
+// Scans `b` for its first CT_BV_NUMBER (threshold) and first CT_BV_KEYS (keys)
+// bindings — the binding order in our specs is always:
+// - top_level / leaf multisig: [threshold, keys, ...]
+// - top_level Taproot/TaprootMusig: [internal_key or threshold, ...]
+// `keys_member_count` resolves the pk(musig(...)) sentinel (a single musig
+// keyexpr) to its effective member count. Returns whether both were found.
+static bool find_threshold_and_keys(const ct_bindings_t *b,
+ uint32_t *out_threshold,
+ uint32_t *out_n_keys) {
+ bool has_threshold = false;
+ bool has_keys = false;
+ for (uint8_t i = 0; i < b->n; i++) {
+ const ct_value_t *v = &b->v[i];
+ if (v->kind == CT_BV_NUMBER && !has_threshold) {
+ has_threshold = true;
+ *out_threshold = v->u.number;
+ } else if (v->kind == CT_BV_KEYS && !has_keys) {
+ has_keys = true;
+ *out_n_keys = keys_member_count(v);
+ }
+ }
+ return has_threshold && has_keys;
+}
+
+// Returns the number of patterns that admit the matched bindings.
+// For multisig-bearing classes, the musig patterns admit iff
+// threshold == n_keys; otherwise they are excluded from the count.
+static uint8_t admitting_pattern_count(const cleartext_spec_t *spec, const ct_bindings_t *b) {
+ uint8_t n_all = spec->n_patterns;
+
+ // Classes without musig patterns: every pattern always admits.
+ if (spec->n_musig_patterns == 0) return n_all;
+
+ // Classes with musig patterns but no threshold/keys pair to test always
+ // admit them (e.g. TaprootMusig top-level, whose binding is a single KEYS
+ // list).
+ uint32_t threshold, n_keys;
+ if (!find_threshold_and_keys(b, &threshold, &n_keys)) return n_all;
+
+ // Otherwise the musig patterns admit only in the n-of-n case.
+ bool musig_admits = (threshold == n_keys);
+ return musig_admits ? n_all : (uint8_t) (n_all - spec->n_musig_patterns);
+}
+
+// ---------------------------------------------------------------------------
+// Key-derivation helpers (canonical check + ordering factor)
+// ---------------------------------------------------------------------------
+
+// Fixed-size table for grouping equivalent keyexprs. The actual maximum is
+// bounded by total occurrences (taproot can fit a lot in 512-byte templates,
+// but in practice ≤ ~30 occurrences).
+#define CT_MAX_KEYEXPRS 32
+
+typedef struct {
+ // Canonical identity: the first key expression seen in the group. Equality
+ // is determined via are_key_placeholders_identical (from policy.h).
+ const policy_node_keyexpr_t *repr;
+ // Derivation pairs collected for this class.
+ uint32_t pairs[CT_MAX_KEYEXPRS][2];
+ uint8_t n_pairs;
+} ct_keyexpr_class_t;
+
+// Saturating factorial.
+static uint64_t sat_factorial(uint32_t n) {
+ uint64_t f = 1;
+ for (uint32_t i = 2; i <= n; i++) {
+ f = sat_mul_u64(f, i);
+ }
+ return f;
+}
+
+// Sort pairs by (first, second), stable insertion sort.
+static void sort_pairs(uint32_t pairs[][2], uint8_t n) {
+ for (uint8_t i = 1; i < n; i++) {
+ uint32_t a = pairs[i][0], b = pairs[i][1];
+ int8_t j = (int8_t) i - 1;
+ while (j >= 0 && (pairs[j][0] > a || (pairs[j][0] == a && pairs[j][1] > b))) {
+ pairs[j + 1][0] = pairs[j][0];
+ pairs[j + 1][1] = pairs[j][1];
+ j--;
+ }
+ pairs[j + 1][0] = a;
+ pairs[j + 1][1] = b;
+ }
+}
+
+// Returns the orderings count ∏ k!. Sets *out_canonical to true iff for every
+// key, its sorted (num_first, num_second) pairs equal (0,1),(2,3),(4,5),...
+static uint64_t key_orderings_count(const policy_node_t *root, bool *out_canonical) {
+ int n = get_keyexpr_by_index(root, 0, NULL, NULL);
+ if (n < 0 || n > CT_MAX_KEYEXPRS) {
+ // Error, or too many keyexprs to fit — treat as not canonical, and
+ // return saturating value.
+ *out_canonical = false;
+ return UINT64_MAX;
+ }
+ if (n == 0) {
+ *out_canonical = true;
+ return 1;
+ }
+
+ const policy_node_keyexpr_t *kx[CT_MAX_KEYEXPRS];
+ for (int i = 0; i < n; i++) {
+ policy_node_keyexpr_t *k;
+ if (get_keyexpr_by_index(root, i, NULL, &k) < 0) {
+ *out_canonical = false;
+ return UINT64_MAX;
+ }
+ kx[i] = k;
+ }
+
+ // Group by identity. classes[i].repr is the first keyexpr in the group;
+ // classes[i].pairs collects (num_first, num_second) for each occurrence.
+ ct_keyexpr_class_t classes[CT_MAX_KEYEXPRS];
+ int n_classes = 0;
+
+ for (int i = 0; i < n; i++) {
+ const policy_node_keyexpr_t *k = kx[i];
+ int idx = -1;
+ for (int j = 0; j < n_classes; j++) {
+ if (are_key_placeholders_identical(classes[j].repr, k)) {
+ idx = j;
+ break;
+ }
+ }
+ if (idx < 0) {
+ if (n_classes >= CT_MAX_KEYEXPRS) {
+ *out_canonical = false;
+ return UINT64_MAX;
+ }
+ classes[n_classes].repr = k;
+ classes[n_classes].n_pairs = 0;
+ idx = n_classes++;
+ }
+ if (classes[idx].n_pairs >= CT_MAX_KEYEXPRS) {
+ *out_canonical = false;
+ return UINT64_MAX;
+ }
+ classes[idx].pairs[classes[idx].n_pairs][0] = k->num_first;
+ classes[idx].pairs[classes[idx].n_pairs][1] = k->num_second;
+ classes[idx].n_pairs++;
+ }
+
+ // Canonical check: group by full key identity (musig groups stay whole) and
+ // require each group's sorted derivation pairs to be (0,1),(2,3),(4,5),...
+ *out_canonical = true;
+ for (int i = 0; i < n_classes; i++) {
+ sort_pairs(classes[i].pairs, classes[i].n_pairs);
+ for (uint8_t j = 0; j < classes[i].n_pairs; j++) {
+ if (classes[i].pairs[j][0] != (uint32_t) (2 * j) ||
+ classes[i].pairs[j][1] != (uint32_t) (2 * j + 1)) {
+ *out_canonical = false;
+ }
+ }
+ }
+
+ // Compute an upper bound on the possible number of orderings for the
+ // derivation pairs.
+ uint32_t idx_vals[CT_MAX_KEYEXPRS * MAX_PUBKEYS_PER_MUSIG];
+ uint32_t idx_cnts[CT_MAX_KEYEXPRS * MAX_PUBKEYS_PER_MUSIG];
+ int n_idx = 0;
+ for (int i = 0; i < n; i++) {
+ const policy_node_keyexpr_t *k = kx[i];
+ // Build the list of plain key indices contributed by this keyexpr.
+ uint32_t members[MAX_PUBKEYS_PER_MUSIG];
+ uint16_t n_members;
+ if (k->type == KEY_EXPRESSION_NORMAL) {
+ members[0] = k->k.key_index;
+ n_members = 1;
+ } else {
+ const musig_aggr_key_info_t *ai = r_musig_aggr_key_info(&k->m.musig_info);
+ const uint16_t *ak = r_uint16(&ai->key_indexes);
+ n_members = ai->n;
+ for (uint16_t j = 0; j < n_members; j++) members[j] = ak[j];
+ }
+ for (uint16_t j = 0; j < n_members; j++) {
+ int slot = -1;
+ for (int s = 0; s < n_idx; s++) {
+ if (idx_vals[s] == members[j]) {
+ slot = s;
+ break;
+ }
+ }
+ if (slot < 0) {
+ idx_vals[n_idx] = members[j];
+ idx_cnts[n_idx] = 0;
+ slot = n_idx++;
+ }
+ idx_cnts[slot]++;
+ }
+ }
+ uint64_t product = 1;
+ for (int i = 0; i < n_idx; i++) {
+ product = sat_mul_u64(product, sat_factorial(idx_cnts[i]));
+ }
+ return product;
+}
+
+// ---------------------------------------------------------------------------
+// Tap-leaf display ordering (port of mod.rs::display_cmp)
+// ---------------------------------------------------------------------------
+
+// Number of effective member keys in a multisig `$keys` binding. For a
+// multi_a/sortedmulti_a list this is the list length; for the pk(musig(...))
+// sentinel (n == 1, a single musig keyexpr) it is the musig member count.
+static uint16_t keys_member_count(const ct_value_t *v) {
+ if (v->u.keys.n == 1 && v->u.keys.array != NULL &&
+ v->u.keys.array[0].type == KEY_EXPRESSION_MUSIG) {
+ return r_musig_aggr_key_info(&v->u.keys.array[0].m.musig_info)->n;
+ }
+ return v->u.keys.n;
+}
+
+// The j-th effective member key index of a multisig `$keys` binding.
+static uint32_t keys_member_index(const ct_value_t *v, uint16_t j) {
+ if (v->u.keys.n == 1 && v->u.keys.array != NULL &&
+ v->u.keys.array[0].type == KEY_EXPRESSION_MUSIG) {
+ const musig_aggr_key_info_t *mi = r_musig_aggr_key_info(&v->u.keys.array[0].m.musig_info);
+ return r_uint16(&mi->key_indexes)[j];
+ }
+ return v->u.keys.array[j].k.key_index;
+}
+
+static int compare_uint16(uint16_t left, uint16_t right) {
+ if (left == right) return 0;
+ return (left < right) ? -1 : 1;
+}
+
+static int compare_uint32(uint32_t left, uint32_t right) {
+ if (left == right) return 0;
+ return (left < right) ? -1 : 1;
+}
+
+static int compare_musig_keyexprs(const policy_node_keyexpr_t *left,
+ const policy_node_keyexpr_t *right) {
+ const musig_aggr_key_info_t *left_info = r_musig_aggr_key_info(&left->m.musig_info);
+ const musig_aggr_key_info_t *right_info = r_musig_aggr_key_info(&right->m.musig_info);
+ int order = compare_uint16(left_info->n, right_info->n);
+ if (order != 0) return order;
+
+ const uint16_t *left_indexes = r_uint16(&left_info->key_indexes);
+ const uint16_t *right_indexes = r_uint16(&right_info->key_indexes);
+ for (uint16_t i = 0; i < left_info->n; i++) {
+ order = compare_uint16(left_indexes[i], right_indexes[i]);
+ if (order != 0) return order;
+ }
+ return 0;
+}
+
+static int compare_keyexprs(const policy_node_keyexpr_t *left, const policy_node_keyexpr_t *right) {
+ int order = compare_uint32((uint32_t) left->type, (uint32_t) right->type);
+ if (order != 0) return order;
+
+ if (left->type == KEY_EXPRESSION_NORMAL) {
+ return compare_uint32(left->k.key_index, right->k.key_index);
+ }
+ return compare_musig_keyexprs(left, right);
+}
+
+static int compare_timelocks(ct_timelock_t left, ct_timelock_t right) {
+ if (left.is_relative != right.is_relative) return left.is_relative ? -1 : 1;
+ return compare_uint32(left.raw, right.raw);
+}
+
+static int compare_sub_bindings(const policy_node_t *left, const policy_node_t *right) {
+ ct_leaf_match_t left_match, right_match;
+ if (!match_tapleaf(left, &left_match)) left_match.cls = TC_OTHER;
+ if (!match_tapleaf(right, &right_match)) right_match.cls = TC_OTHER;
+ return leaf_cmp(&left_match, &right_match);
+}
+
+static int compare_non_key_list_bindings(const ct_value_t *left, const ct_value_t *right) {
+ switch (left->kind) {
+ case CT_BV_KEY:
+ return compare_keyexprs(left->u.key, right->u.key);
+ case CT_BV_NUMBER:
+ return compare_uint32(left->u.number, right->u.number);
+ case CT_BV_SUB:
+ return compare_sub_bindings(left->u.sub, right->u.sub);
+ case CT_BV_TIMELOCK:
+ return compare_timelocks(left->u.timelock, right->u.timelock);
+ case CT_BV_NONE:
+ case CT_BV_KEYS:
+ return 0;
+ }
+ return 0;
+}
+
+// Compare two multisig `$keys` bindings element by element by key index
+// (derivation-independent). Used as a tie-breaker once the lists are known to
+// be the same length, so that two same-size, same-threshold multisig leaves
+// order deterministically by their keys rather than by tap-tree position
+// (which is unstable across descriptors that share a cleartext rendering).
+// Mirrors `cmp_keys`/`cmp_key` in the reference implementation.
+static int cmp_binding_keys(const ct_value_t *a, const ct_value_t *b) {
+ uint16_t na = keys_member_count(a);
+ uint16_t nb = keys_member_count(b);
+ uint16_t m = (na < nb) ? na : nb;
+ for (uint16_t i = 0; i < m; i++) {
+ uint32_t ia = keys_member_index(a, i);
+ uint32_t ib = keys_member_index(b, i);
+ int order = compare_uint32(ia, ib);
+ if (order != 0) return order;
+ }
+ return 0;
+}
+
+// Compare two matched tapleaves. Order primarily by class enum value, then by
+// binding values.
+//
+// The reference orders multisig leaves by `keys.len() -> threshold -> cmp_keys`
+// (TapleafClass::display_cmp): number of keys first, then the threshold, then
+// the keys element-by-element. A leaf's `$threshold` binding precedes its
+// `$keys` binding, so comparing the bindings in a single positional pass would
+// instead order by threshold before the key count (and by the keys before the
+// threshold) -- a different order whenever the two leaves' key count and
+// threshold disagree (e.g. 3-of-3 vs 2-of-5).
+//
+// To mirror the reference exactly we compare the bindings in three priority
+// tiers, keyed purely on binding *kind* (not class identity):
+// 1. KEYS member count;
+// 2. every other kind, in binding order (KEY/NUMBER/SUB/TIMELOCK);
+// 3. KEYS member indices (the `cmp_keys` tie-break).
+// For non-multisig classes there is no KEYS binding, so tiers 1 and 3 are empty
+// and this reduces to a plain positional comparison. SUB bindings recurse;
+// TIMELOCK bindings compare by is_relative ascending (relative < absolute) then
+// by raw value.
+static int leaf_cmp(const ct_leaf_match_t *a, const ct_leaf_match_t *b) {
+ if (a->cls != b->cls) return (a->cls < b->cls) ? -1 : 1;
+ uint8_t n = (a->bindings.n < b->bindings.n) ? a->bindings.n : b->bindings.n;
+
+ // Tier 1: number of keys (also catches any kind mismatch up front; with
+ // equal classes the kinds always match positionally).
+ for (uint8_t i = 0; i < n; i++) {
+ const ct_value_t *va = &a->bindings.v[i];
+ const ct_value_t *vb = &b->bindings.v[i];
+ int order = compare_uint32(va->kind, vb->kind);
+ if (order != 0) return order;
+ if (va->kind == CT_BV_KEYS) {
+ order = compare_uint16(keys_member_count(va), keys_member_count(vb));
+ if (order != 0) return order;
+ }
+ }
+
+ // Tier 2: every other binding kind, in binding order.
+ for (uint8_t i = 0; i < n; i++) {
+ const ct_value_t *va = &a->bindings.v[i];
+ const ct_value_t *vb = &b->bindings.v[i];
+ int order = compare_non_key_list_bindings(va, vb);
+ if (order != 0) return order;
+ }
+
+ // Tier 3: tie-break by the keys themselves, so multisig leaves that share a
+ // size and threshold still order deterministically rather than by tap-tree
+ // position.
+ for (uint8_t i = 0; i < n; i++) {
+ const ct_value_t *va = &a->bindings.v[i];
+ const ct_value_t *vb = &b->bindings.v[i];
+ if (va->kind == CT_BV_KEYS) {
+ int r = cmp_binding_keys(va, vb);
+ if (r != 0) return r;
+ }
+ }
+ return 0;
+}
+
+// ---------------------------------------------------------------------------
+// Renderer — appends formatted parts to a fixed-size buffer.
+// ---------------------------------------------------------------------------
+
+static int append_str(char *out, size_t cap, size_t *off, const char *s) {
+ size_t l = strlen(s);
+ if (*off + l + 1 > cap) return -1;
+ memcpy(out + *off, s, l);
+ *off += l;
+ out[*off] = 0;
+ return 0;
+}
+
+static int append_keyexpr(char *out, size_t cap, size_t *off, const policy_node_keyexpr_t *key) {
+ if (key->type == KEY_EXPRESSION_NORMAL) {
+ char buf[8];
+ snprintf(buf, sizeof(buf), "@%u", key->k.key_index);
+ return append_str(out, cap, off, buf);
+ }
+ // KEY_EXPRESSION_MUSIG: "musig(@a,@b,@c)"
+ const musig_aggr_key_info_t *mi = r_musig_aggr_key_info(&key->m.musig_info);
+ const uint16_t *idx = r_uint16(&mi->key_indexes);
+ if (append_str(out, cap, off, "musig(") < 0) return -1;
+ for (uint16_t i = 0; i < mi->n; i++) {
+ char buf[8];
+ snprintf(buf, sizeof(buf), "@%u", idx[i]);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ if (i + 1 < mi->n) {
+ if (append_str(out, cap, off, ",") < 0) return -1;
+ }
+ }
+ return append_str(out, cap, off, ")");
+}
+
+static int append_keys_list(char *out,
+ size_t cap,
+ size_t *off,
+ const policy_node_keyexpr_t *keys,
+ uint16_t n) {
+ // Special case: musig list passed through as a single keyexpr (n == 1
+ // sentinel, set by the generated classifier for the pk(musig(...)) /
+ // tr(musig(...)) forms). In that case render the inner keys as a flat
+ // Oxford-comma list (without "musig(...)" wrapping).
+ if (n == 1 && keys->type == KEY_EXPRESSION_MUSIG) {
+ const musig_aggr_key_info_t *mi = r_musig_aggr_key_info(&keys->m.musig_info);
+ const uint16_t *idx = r_uint16(&mi->key_indexes);
+ uint16_t m = mi->n;
+ for (uint16_t i = 0; i < m; i++) {
+ if (i > 0) {
+ if (i == m - 1) {
+ if (append_str(out, cap, off, " and ") < 0) return -1;
+ } else {
+ if (append_str(out, cap, off, ", ") < 0) return -1;
+ }
+ }
+ char buf[8];
+ snprintf(buf, sizeof(buf), "@%u", idx[i]);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ }
+ return 0;
+ }
+
+ // Plain Oxford-comma list of keyexprs.
+ for (uint16_t i = 0; i < n; i++) {
+ if (i > 0) {
+ if (i == n - 1) {
+ if (append_str(out, cap, off, " and ") < 0) return -1;
+ } else {
+ if (append_str(out, cap, off, ", ") < 0) return -1;
+ }
+ }
+ if (append_keyexpr(out, cap, off, &keys[i]) < 0) return -1;
+ }
+ return 0;
+}
+
+static int append_uint32(char *out, size_t cap, size_t *off, uint32_t v) {
+ char buf[12];
+ snprintf(buf, sizeof(buf), "%u", v);
+ return append_str(out, cap, off, buf);
+}
+
+// Civil from days (Howard Hinnant, integer arithmetic only, valid for all
+// reasonable Unix timestamps).
+static void civil_from_unix(uint32_t secs,
+ int *y,
+ unsigned *m,
+ unsigned *d,
+ unsigned *hh,
+ unsigned *mm,
+ unsigned *ss) {
+ int32_t z = (int32_t) (secs / 86400u);
+ uint32_t rem = secs % 86400u;
+ *hh = rem / 3600u;
+ rem %= 3600u;
+ *mm = rem / 60u;
+ *ss = rem % 60u;
+ z += 719468;
+ int32_t era = (z >= 0 ? z : z - 146096) / 146097;
+ uint32_t doe = (uint32_t) (z - era * 146097);
+ uint32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ int32_t yyy = (int32_t) yoe + era * 400;
+ uint32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ uint32_t mp = (5 * doy + 2) / 153;
+ *d = doy - (153 * mp + 2) / 5 + 1;
+ *m = mp < 10 ? mp + 3 : mp - 9;
+ *y = (int) (yyy + (*m <= 2 ? 1 : 0));
+}
+
+// Appends a number of seconds as a human-readable duration with spelled-out,
+// pluralized units (so a non-technical reader can't mistake "m" for months).
+// Renders "0 seconds" for zero. Mirrors `format_seconds` in the reference
+// time.rs. Example: "1 day 2 hours 30 minutes".
+static int append_duration(char *out, size_t cap, size_t *off, uint32_t secs) {
+ uint32_t days = secs / 86400u;
+ uint32_t hours = (secs % 86400u) / 3600u;
+ uint32_t minutes = (secs % 3600u) / 60u;
+ uint32_t seconds = secs % 60u;
+ char buf[16];
+ bool any = false;
+ if (days > 0) {
+ snprintf(buf, sizeof(buf), "%u ", days);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ if (append_str(out, cap, off, days == 1 ? "day" : "days") < 0) return -1;
+ any = true;
+ }
+ if (hours > 0) {
+ if (any && append_str(out, cap, off, " ") < 0) return -1;
+ snprintf(buf, sizeof(buf), "%u ", hours);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ if (append_str(out, cap, off, hours == 1 ? "hour" : "hours") < 0) return -1;
+ any = true;
+ }
+ if (minutes > 0) {
+ if (any && append_str(out, cap, off, " ") < 0) return -1;
+ snprintf(buf, sizeof(buf), "%u ", minutes);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ if (append_str(out, cap, off, minutes == 1 ? "minute" : "minutes") < 0) return -1;
+ any = true;
+ }
+ if (seconds > 0 || !any) {
+ if (any && append_str(out, cap, off, " ") < 0) return -1;
+ snprintf(buf, sizeof(buf), "%u ", seconds);
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ if (append_str(out, cap, off, seconds == 1 ? "second" : "seconds") < 0) return -1;
+ }
+ return 0;
+}
+
+// Unified timelock formatter. Relative locks (counted from when the coins were
+// received) end in "after receiving"; absolute locks (a fixed point) read
+// "not before ...". Produces one of:
+// - "<N> blocks after receiving" — relative height (raw without flag)
+// - "<duration> after receiving" — relative time (raw with flag, ×512s)
+// - "not before block <N>" — absolute height (raw < LOCKTIME_THRESHOLD)
+// - "not before YYYY-MM-DD[ HH:MM:SS] UTC" — absolute timestamp (raw >= threshold)
+static int append_timelock(char *out, size_t cap, size_t *off, ct_timelock_t tl) {
+ char buf[40];
+ if (tl.is_relative) {
+ if (tl.raw & SEQUENCE_LOCKTIME_TYPE_FLAG) {
+ uint32_t units = tl.raw & ~SEQUENCE_LOCKTIME_TYPE_FLAG;
+ if (append_duration(out, cap, off, units * 512u) < 0) return -1;
+ return append_str(out, cap, off, " after receiving");
+ }
+ snprintf(buf, sizeof(buf), "%u blocks after receiving", tl.raw);
+ return append_str(out, cap, off, buf);
+ }
+ // Absolute.
+ if (tl.raw < LOCKTIME_THRESHOLD) {
+ snprintf(buf, sizeof(buf), "not before block %u", tl.raw);
+ return append_str(out, cap, off, buf);
+ }
+ {
+ int y;
+ unsigned mo, d, hh, mm, ss;
+ civil_from_unix(tl.raw, &y, &mo, &d, &hh, &mm, &ss);
+ if (append_str(out, cap, off, "not before ") < 0) return -1;
+ if (hh == 0 && mm == 0 && ss == 0) {
+ snprintf(buf, sizeof(buf), "%04d-%02u-%02u", y, mo, d);
+ } else {
+ snprintf(buf, sizeof(buf), "%04d-%02u-%02u %02u:%02u:%02u", y, mo, d, hh, mm, ss);
+ }
+ if (append_str(out, cap, off, buf) < 0) return -1;
+ return append_str(out, cap, off, " UTC");
+ }
+}
+
+// Returns true iff the bindings carry both a threshold and a keys list with
+// threshold == number of keys (n-of-n). Used to select a multisig class's
+// alternate "each of ..." rendering (`spec->parts_all`).
+static bool bindings_is_n_of_n(const ct_bindings_t *b) {
+ uint32_t threshold, n_keys;
+ return find_threshold_and_keys(b, &threshold, &n_keys) && threshold == n_keys;
+}
+
+// Renders a spec into an existing buffer at offset `*off`, recursing into
+// SUB bindings. `*off` is updated to point past the appended bytes.
+static int render_spec_at(const cleartext_spec_t *spec,
+ const ct_bindings_t *b,
+ char *out,
+ size_t cap,
+ size_t *off) {
+ // Multisig classes carry an alternate "each of ..." template (`parts_all`)
+ // used for the n-of-n case (threshold == number of keys); fall back to the
+ // primary `parts` otherwise. (The musig key-path classes are inherently
+ // n-of-n, so their primary `parts` is already the "each of ..." form and
+ // they declare no `parts_all`.)
+ const cleartext_part_t *parts;
+ uint8_t n_parts;
+ if (spec->parts_all != NULL && bindings_is_n_of_n(b)) {
+ parts = (const cleartext_part_t *) PIC(spec->parts_all);
+ n_parts = spec->n_parts_all;
+ } else {
+ // `spec->parts` is a pointer field stored inside a `const` table in
+ // flash; on Ledger devices flash pointer fields hold link-time addresses
+ // and must be relocated through PIC() before being dereferenced.
+ parts = (const cleartext_part_t *) PIC(spec->parts);
+ n_parts = spec->n_parts;
+ }
+ for (uint8_t i = 0; i < n_parts; i++) {
+ const cleartext_part_t *p = &parts[i];
+ if (p->kind == CT_PART_LITERAL) {
+ if (append_str(out, cap, off, &ct_string_pool[p->lit_off]) < 0) return -1;
+ continue;
+ }
+ const ct_value_t *val = &b->v[p->binding_idx];
+ switch (p->kind) {
+ case CT_PART_KEY:
+ if (append_keyexpr(out, cap, off, val->u.key) < 0) return -1;
+ break;
+ case CT_PART_KEYS:
+ if (append_keys_list(out, cap, off, val->u.keys.array, val->u.keys.n) < 0)
+ return -1;
+ break;
+ case CT_PART_THRESHOLD:
+ if (append_uint32(out, cap, off, val->u.number) < 0) return -1;
+ break;
+ case CT_PART_TIMELOCK:
+ if (append_timelock(out, cap, off, val->u.timelock) < 0) return -1;
+ break;
+ case CT_PART_SUB: {
+ ct_leaf_match_t sub_lm;
+ if (!match_tapleaf(val->u.sub, &sub_lm) || sub_lm.cls == TC_OTHER) {
+ return -1;
+ }
+ const cleartext_spec_t *sub_spec = &CT_TAPLEAF_SPECS[sub_lm.cls];
+ if (render_spec_at(sub_spec, &sub_lm.bindings, out, cap, off) < 0) return -1;
+ break;
+ }
+ default:
+ return -1;
+ }
+ }
+ return 0;
+}
+
+static int render_spec(const cleartext_spec_t *spec,
+ const ct_bindings_t *b,
+ char *out,
+ size_t cap) {
+ size_t off = 0;
+ if (cap == 0) return -1;
+ out[0] = 0;
+ return render_spec_at(spec, b, out, cap, &off);
+}
+
+// ---------------------------------------------------------------------------
+// Taptree iteration: collect all leaves into a flat array.
+// ---------------------------------------------------------------------------
+
+#define CT_MAX_TAP_LEAVES 64 // bound by MAX_TAPTREE_POLICY_DEPTH (=9 -> up to 256, but we cap)
+
+static int collect_leaves(const policy_node_tree_t *tree,
+ const policy_node_t **out,
+ int *n,
+ int max) {
+ if (tree == NULL) return 0;
+ if (tree->is_leaf) {
+ if (*n >= max) return -1;
+ out[(*n)++] = r_policy_node(&tree->script);
+ return 0;
+ }
+ if (collect_leaves(r_policy_node_tree(&tree->left_tree), out, n, max) < 0) return -1;
+ return collect_leaves(r_policy_node_tree(&tree->right_tree), out, n, max);
+}
+
+// ---------------------------------------------------------------------------
+// Confusion score
+// ---------------------------------------------------------------------------
+
+// Recursive per-leaf score: multiplies the leaf's own admitting-pattern count
+// with the per-leaf scores of any SUB-bound sub-policies. Mirrors the
+// upstream Rust `per_leaf_score` generated by build.rs.
+static uint64_t leaf_score(const ct_leaf_match_t *lm) {
+ if (lm == NULL || lm->cls == TC_OTHER) return 1;
+ const cleartext_spec_t *spec = &CT_TAPLEAF_SPECS[lm->cls];
+ uint64_t s = admitting_pattern_count(spec, &lm->bindings);
+ for (uint8_t i = 0; i < lm->bindings.n; i++) {
+ if (lm->bindings.v[i].kind == CT_BV_SUB) {
+ ct_leaf_match_t sub_lm;
+ uint64_t sub_s = 1;
+ if (match_tapleaf(lm->bindings.v[i].u.sub, &sub_lm)) {
+ sub_s = leaf_score(&sub_lm);
+ }
+ s = sat_mul_u64(s, sub_s);
+ }
+ }
+ return s;
+}
+
+uint64_t cleartext_confusion_score(const policy_node_t *root) {
+ ct_top_match_t top;
+ if (!match_top_level(root, &top) || top.cls == DC_OTHER) {
+ return 1;
+ }
+
+ const cleartext_spec_t *top_spec = &CT_TOP_LEVEL_SPECS[top.cls];
+ uint64_t score = admitting_pattern_count(top_spec, &top.bindings);
+
+ if (top_spec->recurses) {
+ // Collect leaves.
+ const policy_node_t *leaves[CT_MAX_TAP_LEAVES];
+ int n_leaves = 0;
+ if (top.taptree != NULL) {
+ if (collect_leaves(top.taptree, leaves, &n_leaves, CT_MAX_TAP_LEAVES) < 0) {
+ return UINT64_MAX;
+ }
+ }
+ for (int i = 0; i < n_leaves; i++) {
+ ct_leaf_match_t lm;
+ if (!match_tapleaf(leaves[i], &lm)) {
+ lm.cls = TC_OTHER;
+ lm.bindings.n = 0;
+ }
+ score = sat_mul_u64(score, leaf_score(&lm));
+ }
+ if (n_leaves > 1) {
+ // (2n - 3)!! = 1 * 3 * 5 * ... * (2n - 3)
+ for (int i = 1; i <= 2 * n_leaves - 3; i += 2) {
+ score = sat_mul_u64(score, (uint64_t) i);
+ }
+ }
+ }
+
+ bool canonical = true;
+ uint64_t orderings = key_orderings_count(root, &canonical);
+ score = sat_mul_u64(score, orderings);
+ return score;
+}
+
+// Capitalize the first character of a finished top-level cleartext line if it is
+// a lowercase ASCII letter. Tapleaf specs are written lowercase so they read
+// correctly when composed mid-sentence inside another leaf (e.g. as the second
+// operand of an `and_v`); this fixes up only the leading character of each fully
+// assembled line, leaving composed sub-policies lowercase. Mirrors
+// `capitalize_first` in the reference mod.rs.
+static void capitalize_first(char *s) {
+ if (s[0] >= 'a' && s[0] <= 'z') {
+ s[0] = (char) (s[0] - 'a' + 'A');
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Top-level encoder
+// ---------------------------------------------------------------------------
+
+// Emits the raw descriptor template as the sole output line. Used as the
+// fallback whenever no cleartext can be produced (non-canonical keys, or an
+// unclassified top level). *out_has_cleartext and *out_class are left as the
+// caller already set them (false / DC_OTHER). Returns cleartext_encode's return
+// value: 0 when there is no template to show, 1 when the template was emitted.
+static int emit_raw_fallback(const char *raw_template,
+ char out_lines[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t *out_n_lines) {
+ if (raw_template == NULL) return 0;
+ size_t l = strlen(raw_template);
+ if (l > CT_MAX_LINE_LEN) l = CT_MAX_LINE_LEN;
+ memcpy(out_lines[0], raw_template, l);
+ out_lines[0][l] = 0;
+ *out_n_lines = 1;
+ return 1;
+}
+
+int cleartext_encode(const policy_node_t *root,
+ const char *raw_template,
+ char out_lines[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t *out_n_lines,
+ bool *out_has_cleartext,
+ descriptor_class_e *out_class) {
+ *out_n_lines = 0;
+ *out_has_cleartext = false;
+ // Stays DC_OTHER on every early-return path (unclassified or raw-template
+ // fallback); set to the matched class only once a cleartext is rendered.
+ *out_class = DC_OTHER;
+
+ bool canonical = true;
+ (void) key_orderings_count(root, &canonical);
+ if (!canonical) {
+ return emit_raw_fallback(raw_template, out_lines, out_n_lines);
+ }
+
+ ct_top_match_t top;
+ if (!match_top_level(root, &top) || top.cls == DC_OTHER) {
+ return emit_raw_fallback(raw_template, out_lines, out_n_lines);
+ }
+
+ *out_class = top.cls;
+
+ // Render the primary line.
+ const cleartext_spec_t *top_spec = &CT_TOP_LEVEL_SPECS[top.cls];
+ if (render_spec(top_spec, &top.bindings, out_lines[0], CT_MAX_LINE_LEN + 1) < 0) return -1;
+ capitalize_first(out_lines[0]);
+ *out_n_lines = 1;
+
+ if (!top_spec->recurses) {
+ *out_has_cleartext = true;
+ return 1;
+ }
+
+ // Collect leaves.
+ const policy_node_t *raw_leaves[CT_MAX_TAP_LEAVES];
+ int n_leaves = 0;
+ if (top.taptree != NULL) {
+ if (collect_leaves(top.taptree, raw_leaves, &n_leaves, CT_MAX_TAP_LEAVES) < 0) return -1;
+ }
+ if (n_leaves == 0) {
+ *out_has_cleartext = true;
+ return 1;
+ }
+ if ((size_t) n_leaves + 1 > CT_MAX_LINES) return -1;
+
+ // Match each leaf and sort by display order.
+ ct_leaf_match_t leaves[CT_MAX_LINES - 1];
+ for (int i = 0; i < n_leaves; i++) {
+ if (!match_tapleaf(raw_leaves[i], &leaves[i])) {
+ leaves[i].cls = TC_OTHER;
+ leaves[i].leaf_script = raw_leaves[i];
+ leaves[i].bindings.n = 0;
+ }
+ }
+
+ // Insertion sort (n small).
+ for (int i = 1; i < n_leaves; i++) {
+ ct_leaf_match_t tmp = leaves[i];
+ int j = i - 1;
+ while (j >= 0 && leaf_cmp(&leaves[j], &tmp) > 0) {
+ leaves[j + 1] = leaves[j];
+ j--;
+ }
+ leaves[j + 1] = tmp;
+ }
+
+ bool all_have_cleartext = true;
+ for (int i = 0; i < n_leaves; i++) {
+ size_t line_idx = (size_t) i + 1;
+ if (leaves[i].cls == TC_OTHER) {
+ // This leaf has no cleartext form. We don't have an in-memory string
+ // for an arbitrary subtree (no AST unparser), so we render the fixed
+ // "(unknown)" marker rather than the leaf's own descriptor, and flag
+ // the overall rendering as incomplete.
+ all_have_cleartext = false;
+ size_t off = 0;
+ out_lines[line_idx][0] = 0;
+ if (append_str(out_lines[line_idx], CT_MAX_LINE_LEN + 1, &off, CT_UNKNOWN_LEAF) < 0) {
+ return -1;
+ }
+ } else {
+ const cleartext_spec_t *leaf_spec = &CT_TAPLEAF_SPECS[leaves[i].cls];
+ if (render_spec(leaf_spec,
+ &leaves[i].bindings,
+ out_lines[line_idx],
+ CT_MAX_LINE_LEN + 1) < 0) {
+ return -1;
+ }
+ capitalize_first(out_lines[line_idx]);
+ }
+ }
+ *out_n_lines = (size_t) n_leaves + 1;
+ *out_has_cleartext = all_have_cleartext;
+ return 1;
+}
diff --git a/src/common/cleartext.h b/src/common/cleartext.h
new file mode 100644
index 0000000..e739b07
--- /dev/null
+++ b/src/common/cleartext.h
@@ -0,0 +1,68 @@
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "common/wallet.h"
+#include "common/cleartext_specs.h"
+
+// Maximum confusion score below which the cleartext representation is shown.
+#define CLEARTEXT_MAX_CONFUSION_SCORE 100000ULL
+
+// Maximum number of cleartext lines we'll produce (1 primary spending path
+// + up to CT_MAX_LINES-1 tapleaves).
+#define CT_MAX_LINES 8
+
+// Maximum length of one rendered cleartext line, not counting the NUL.
+#define CT_MAX_LINE_LEN 160
+
+/**
+ * Computes the confusion score for a wallet policy descriptor.
+ *
+ * The confusion score is an upper bound on the number of structurally distinct
+ * descriptor templates that share the same cleartext rendering. Uses
+ * saturating-u64 arithmetic; returns UINT64_MAX on overflow.
+ *
+ * @param root pointer to the root of the parsed descriptor template
+ * @return the confusion score
+ */
+uint64_t cleartext_confusion_score(const policy_node_t *root);
+
+/**
+ * Encodes a descriptor template into cleartext spending-path lines.
+ *
+ * For top-level shapes other than a taproot with a script tree (including a
+ * leaf-less / key-path-only taproot), exactly one line is produced. For a
+ * taproot with a tree, the first line is the key-path spending description
+ * ("Main path: ..."), followed by one line per leaf (in canonical display
+ * order). A leaf that has no cleartext form is rendered as the fixed
+ * "(unknown)" marker — we don't have an unparser for descriptor templates,
+ * so the leaf's own descriptor fragment cannot be printed — and
+ * *out_has_cleartext is set to false. (Note: this differs from the reference
+ * Rust implementation, whose unparser prints the raw leaf descriptor instead.)
+ *
+ * @param root pointer to the root of the parsed descriptor template
+ * @param raw_template the original descriptor template string (used as
+ * fallback when classification fails or key derivations are
+ * non-canonical); may be NULL — in that case the function returns 0
+ * instead of writing a fallback line.
+ * @param out_lines caller-owned buffer; rendered lines are written here as
+ * NUL-terminated strings.
+ * @param out_n_lines receives the number of lines written.
+ * @param out_has_cleartext receives true iff every part of the descriptor was
+ * rendered through the cleartext path.
+ * @param out_class receives the matched top-level descriptor class when a
+ * cleartext rendering was produced, or DC_OTHER when the descriptor was
+ * unclassified or rendered via the raw-template fallback (non-canonical
+ * derivations). Must not be NULL.
+ * @return 1 if at least one line was written; 0 if the descriptor classifies
+ * as Other and `raw_template` is NULL; -1 on internal error
+ * (e.g. a line did not fit in CT_MAX_LINE_LEN).
+ */
+int cleartext_encode(const policy_node_t *root,
+ const char *raw_template,
+ char out_lines[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t *out_n_lines,
+ bool *out_has_cleartext,
+ descriptor_class_e *out_class);
diff --git a/src/common/cleartext_match.c b/src/common/cleartext_match.c
new file mode 100644
index 0000000..eac43c6
--- /dev/null
+++ b/src/common/cleartext_match.c
@@ -0,0 +1,329 @@
+// Generated by specs/bip388/gen.py. DO NOT EDIT.
+// clang-format off
+
+#include "common/cleartext_match.h"
+
+// Classifier for the root descriptor template. Mirrors the `[[top_level]]`
+// patterns of specs/bip388/cleartext.toml, tried in order.
+bool match_top_level(const policy_node_t *root, ct_top_match_t *out) {
+ out->cls = DC_OTHER;
+ out->bindings.n = 0;
+ out->taptree = NULL;
+ if (root == NULL) return false;
+
+ // LegacySingleSig: pkh($key)
+ do {
+ if (root == NULL || root->type != TOKEN_PKH) break;
+ const policy_node_with_key_t *ct_n1 = (const policy_node_with_key_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_NORMAL) break;
+ set_binding_key(&out->bindings, 0, ct_k2);
+ out->bindings.n = 1;
+ out->cls = DC_LEGACY_SINGLE_SIG;
+ return true;
+ } while (0);
+
+ // SegwitSingleSig: wpkh($key)
+ do {
+ if (root == NULL || root->type != TOKEN_WPKH) break;
+ const policy_node_with_key_t *ct_n1 = (const policy_node_with_key_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_NORMAL) break;
+ set_binding_key(&out->bindings, 0, ct_k2);
+ out->bindings.n = 1;
+ out->cls = DC_SEGWIT_SINGLE_SIG;
+ return true;
+ } while (0);
+ // SegwitSingleSig: sh(wpkh($key))
+ do {
+ if (root == NULL || root->type != TOKEN_SH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_WPKH) break;
+ const policy_node_with_key_t *ct_n3 = (const policy_node_with_key_t *) ct_c2;
+ const policy_node_keyexpr_t *ct_k4 = r_policy_node_keyexpr(&ct_n3->key);
+ if (ct_k4->type != KEY_EXPRESSION_NORMAL) break;
+ set_binding_key(&out->bindings, 0, ct_k4);
+ out->bindings.n = 1;
+ out->cls = DC_SEGWIT_SINGLE_SIG;
+ return true;
+ } while (0);
+
+ // Multisig: sh(multi($threshold, $keys))
+ do {
+ if (root == NULL || root->type != TOKEN_SH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_MULTI) break;
+ const policy_node_multisig_t *ct_n3 = (const policy_node_multisig_t *) ct_c2;
+ const policy_node_keyexpr_t *ct_ka4 = r_policy_node_keyexpr(&ct_n3->keys);
+ set_binding_number(&out->bindings, 0, ct_n3->k);
+ set_binding_keys(&out->bindings, 1, ct_ka4, ct_n3->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: sh(sortedmulti($threshold, $keys))
+ do {
+ if (root == NULL || root->type != TOKEN_SH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_SORTEDMULTI) break;
+ const policy_node_multisig_t *ct_n3 = (const policy_node_multisig_t *) ct_c2;
+ const policy_node_keyexpr_t *ct_ka4 = r_policy_node_keyexpr(&ct_n3->keys);
+ set_binding_number(&out->bindings, 0, ct_n3->k);
+ set_binding_keys(&out->bindings, 1, ct_ka4, ct_n3->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: wsh(multi($threshold, $keys))
+ do {
+ if (root == NULL || root->type != TOKEN_WSH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_MULTI) break;
+ const policy_node_multisig_t *ct_n3 = (const policy_node_multisig_t *) ct_c2;
+ const policy_node_keyexpr_t *ct_ka4 = r_policy_node_keyexpr(&ct_n3->keys);
+ set_binding_number(&out->bindings, 0, ct_n3->k);
+ set_binding_keys(&out->bindings, 1, ct_ka4, ct_n3->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: wsh(sortedmulti($threshold, $keys))
+ do {
+ if (root == NULL || root->type != TOKEN_WSH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_SORTEDMULTI) break;
+ const policy_node_multisig_t *ct_n3 = (const policy_node_multisig_t *) ct_c2;
+ const policy_node_keyexpr_t *ct_ka4 = r_policy_node_keyexpr(&ct_n3->keys);
+ set_binding_number(&out->bindings, 0, ct_n3->k);
+ set_binding_keys(&out->bindings, 1, ct_ka4, ct_n3->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: sh(wsh(multi($threshold, $keys)))
+ do {
+ if (root == NULL || root->type != TOKEN_SH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_WSH) break;
+ const policy_node_with_script_t *ct_n3 = (const policy_node_with_script_t *) ct_c2;
+ const policy_node_t *ct_c4 = r_policy_node(&ct_n3->script);
+ if (ct_c4 == NULL || ct_c4->type != TOKEN_MULTI) break;
+ const policy_node_multisig_t *ct_n5 = (const policy_node_multisig_t *) ct_c4;
+ const policy_node_keyexpr_t *ct_ka6 = r_policy_node_keyexpr(&ct_n5->keys);
+ set_binding_number(&out->bindings, 0, ct_n5->k);
+ set_binding_keys(&out->bindings, 1, ct_ka6, ct_n5->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: sh(wsh(sortedmulti($threshold, $keys)))
+ do {
+ if (root == NULL || root->type != TOKEN_SH) break;
+ const policy_node_with_script_t *ct_n1 = (const policy_node_with_script_t *) root;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->script);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_WSH) break;
+ const policy_node_with_script_t *ct_n3 = (const policy_node_with_script_t *) ct_c2;
+ const policy_node_t *ct_c4 = r_policy_node(&ct_n3->script);
+ if (ct_c4 == NULL || ct_c4->type != TOKEN_SORTEDMULTI) break;
+ const policy_node_multisig_t *ct_n5 = (const policy_node_multisig_t *) ct_c4;
+ const policy_node_keyexpr_t *ct_ka6 = r_policy_node_keyexpr(&ct_n5->keys);
+ set_binding_number(&out->bindings, 0, ct_n5->k);
+ set_binding_keys(&out->bindings, 1, ct_ka6, ct_n5->n);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: tr(musig($keys))
+ do {
+ if (root == NULL || root->type != TOKEN_TR) break;
+ const policy_node_tr_t *ct_n1 = (const policy_node_tr_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_MUSIG) break;
+ const musig_aggr_key_info_t *ct_mi3 = r_musig_aggr_key_info(&ct_k2->m.musig_info);
+ if (!isnull_policy_node_tree(&ct_n1->tree)) break;
+ set_binding_number(&out->bindings, 0, ct_mi3->n);
+ set_binding_keys(&out->bindings, 1, ct_k2, 1);
+ out->bindings.n = 2;
+ out->cls = DC_MULTISIG;
+ return true;
+ } while (0);
+
+ // TaprootKeyOnly: tr($internal_key)
+ do {
+ if (root == NULL || root->type != TOKEN_TR) break;
+ const policy_node_tr_t *ct_n1 = (const policy_node_tr_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_NORMAL) break;
+ if (!isnull_policy_node_tree(&ct_n1->tree)) break;
+ set_binding_key(&out->bindings, 0, ct_k2);
+ out->bindings.n = 1;
+ out->cls = DC_TAPROOT_KEY_ONLY;
+ return true;
+ } while (0);
+
+ // Taproot: tr($internal_key, $leaves)
+ do {
+ if (root == NULL || root->type != TOKEN_TR) break;
+ const policy_node_tr_t *ct_n1 = (const policy_node_tr_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_NORMAL) break;
+ if (isnull_policy_node_tree(&ct_n1->tree)) break;
+ set_binding_key(&out->bindings, 0, ct_k2);
+ out->taptree = r_policy_node_tree(&ct_n1->tree);
+ out->bindings.n = 1;
+ out->cls = DC_TAPROOT;
+ return true;
+ } while (0);
+
+ // TaprootMusig: tr(musig($keys), $leaves)
+ do {
+ if (root == NULL || root->type != TOKEN_TR) break;
+ const policy_node_tr_t *ct_n1 = (const policy_node_tr_t *) root;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_MUSIG) break;
+ const musig_aggr_key_info_t *ct_mi3 = r_musig_aggr_key_info(&ct_k2->m.musig_info);
+ if (isnull_policy_node_tree(&ct_n1->tree)) break;
+ set_binding_number(&out->bindings, 0, ct_mi3->n);
+ set_binding_keys(&out->bindings, 1, ct_k2, 1);
+ out->taptree = r_policy_node_tree(&ct_n1->tree);
+ out->bindings.n = 2;
+ out->cls = DC_TAPROOT_MUSIG;
+ return true;
+ } while (0);
+
+ return false;
+}
+
+// Classifier for a single tap-leaf script. Mirrors the `[[tapleaf]]`
+// patterns of specs/bip388/cleartext.toml, tried in order.
+bool match_tapleaf(const policy_node_t *leaf_script, ct_leaf_match_t *out) {
+ out->cls = TC_OTHER;
+ out->bindings.n = 0;
+ out->leaf_script = leaf_script;
+ if (leaf_script == NULL) return false;
+
+ // SingleSig: pk($key)
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_PK) break;
+ const policy_node_with_key_t *ct_n1 = (const policy_node_with_key_t *) leaf_script;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_NORMAL) break;
+ set_binding_key(&out->bindings, 0, ct_k2);
+ out->bindings.n = 1;
+ out->cls = TC_SINGLE_SIG;
+ return true;
+ } while (0);
+
+ // BothMustSign: and_v(v:pk($key1), pk($key2))
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_AND_V) break;
+ const policy_node_with_script2_t *ct_n1 = (const policy_node_with_script2_t *) leaf_script;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->scripts[0]);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_V) break;
+ const policy_node_with_script_t *ct_w3 = (const policy_node_with_script_t *) ct_c2;
+ const policy_node_t *ct_c4 = r_policy_node(&ct_w3->script);
+ if (ct_c4 == NULL || ct_c4->type != TOKEN_PK) break;
+ const policy_node_with_key_t *ct_n5 = (const policy_node_with_key_t *) ct_c4;
+ const policy_node_keyexpr_t *ct_k6 = r_policy_node_keyexpr(&ct_n5->key);
+ if (ct_k6->type != KEY_EXPRESSION_NORMAL) break;
+ const policy_node_t *ct_c7 = r_policy_node(&ct_n1->scripts[1]);
+ if (ct_c7 == NULL || ct_c7->type != TOKEN_PK) break;
+ const policy_node_with_key_t *ct_n8 = (const policy_node_with_key_t *) ct_c7;
+ const policy_node_keyexpr_t *ct_k9 = r_policy_node_keyexpr(&ct_n8->key);
+ if (ct_k9->type != KEY_EXPRESSION_NORMAL) break;
+ set_binding_key(&out->bindings, 0, ct_k6);
+ set_binding_key(&out->bindings, 1, ct_k9);
+ out->bindings.n = 2;
+ out->cls = TC_BOTH_MUST_SIGN;
+ return true;
+ } while (0);
+
+ // SortedMultisig: sortedmulti_a($threshold, $keys)
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_SORTEDMULTI_A) break;
+ const policy_node_multisig_t *ct_n1 = (const policy_node_multisig_t *) leaf_script;
+ const policy_node_keyexpr_t *ct_ka2 = r_policy_node_keyexpr(&ct_n1->keys);
+ set_binding_number(&out->bindings, 0, ct_n1->k);
+ set_binding_keys(&out->bindings, 1, ct_ka2, ct_n1->n);
+ out->bindings.n = 2;
+ out->cls = TC_SORTED_MULTISIG;
+ return true;
+ } while (0);
+
+ // Multisig: multi_a($threshold, $keys)
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_MULTI_A) break;
+ const policy_node_multisig_t *ct_n1 = (const policy_node_multisig_t *) leaf_script;
+ const policy_node_keyexpr_t *ct_ka2 = r_policy_node_keyexpr(&ct_n1->keys);
+ set_binding_number(&out->bindings, 0, ct_n1->k);
+ set_binding_keys(&out->bindings, 1, ct_ka2, ct_n1->n);
+ out->bindings.n = 2;
+ out->cls = TC_MULTISIG;
+ return true;
+ } while (0);
+ // Multisig: pk(musig($keys))
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_PK) break;
+ const policy_node_with_key_t *ct_n1 = (const policy_node_with_key_t *) leaf_script;
+ const policy_node_keyexpr_t *ct_k2 = r_policy_node_keyexpr(&ct_n1->key);
+ if (ct_k2->type != KEY_EXPRESSION_MUSIG) break;
+ const musig_aggr_key_info_t *ct_mi3 = r_musig_aggr_key_info(&ct_k2->m.musig_info);
+ set_binding_number(&out->bindings, 0, ct_mi3->n);
+ set_binding_keys(&out->bindings, 1, ct_k2, 1);
+ out->bindings.n = 2;
+ out->cls = TC_MULTISIG;
+ return true;
+ } while (0);
+
+ // Timelocked: and_v(v:$sub, $timelock)
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_AND_V) break;
+ const policy_node_with_script2_t *ct_n1 = (const policy_node_with_script2_t *) leaf_script;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->scripts[0]);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_V) break;
+ const policy_node_with_script_t *ct_w3 = (const policy_node_with_script_t *) ct_c2;
+ const policy_node_t *ct_c4 = r_policy_node(&ct_w3->script);
+ ct_leaf_match_t ct_sub5;
+ if (!match_tapleaf(ct_c4, &ct_sub5)) break;
+ if (ct_sub5.cls == TC_OTHER || ct_sub5.cls == TC_TIMELOCKED || ct_sub5.cls == TC_AND_V) break;
+ const policy_node_t *ct_c6 = r_policy_node(&ct_n1->scripts[1]);
+ ct_timelock_t ct_tl7;
+ if (!match_lock_value(ct_c6, &ct_tl7)) break;
+ set_binding_sub(&out->bindings, 0, ct_c4);
+ set_binding_timelock(&out->bindings, 1, ct_tl7);
+ out->bindings.n = 2;
+ out->cls = TC_TIMELOCKED;
+ return true;
+ } while (0);
+
+ // AndV: and_v(v:$sub1, $sub2)
+ do {
+ if (leaf_script == NULL || leaf_script->type != TOKEN_AND_V) break;
+ const policy_node_with_script2_t *ct_n1 = (const policy_node_with_script2_t *) leaf_script;
+ const policy_node_t *ct_c2 = r_policy_node(&ct_n1->scripts[0]);
+ if (ct_c2 == NULL || ct_c2->type != TOKEN_V) break;
+ const policy_node_with_script_t *ct_w3 = (const policy_node_with_script_t *) ct_c2;
+ const policy_node_t *ct_c4 = r_policy_node(&ct_w3->script);
+ ct_leaf_match_t ct_sub5;
+ if (!match_tapleaf(ct_c4, &ct_sub5)) break;
+ if (ct_sub5.cls == TC_OTHER || ct_sub5.cls == TC_TIMELOCKED || ct_sub5.cls == TC_AND_V) break;
+ const policy_node_t *ct_c6 = r_policy_node(&ct_n1->scripts[1]);
+ ct_leaf_match_t ct_sub7;
+ if (!match_tapleaf(ct_c6, &ct_sub7)) break;
+ if (ct_sub7.cls == TC_OTHER || ct_sub7.cls == TC_TIMELOCKED || ct_sub7.cls == TC_AND_V) break;
+ set_binding_sub(&out->bindings, 0, ct_c4);
+ set_binding_sub(&out->bindings, 1, ct_c6);
+ out->bindings.n = 2;
+ out->cls = TC_AND_V;
+ return true;
+ } while (0);
+
+ return false;
+}
+
diff --git a/src/common/cleartext_match.h b/src/common/cleartext_match.h
new file mode 100644
index 0000000..1f09861
--- /dev/null
+++ b/src/common/cleartext_match.h
@@ -0,0 +1,137 @@
+#pragma once
+
+// Shared runtime support for the *generated* cleartext classifier
+// (cleartext_match.c, produced by specs/bip388/gen.py).
+//
+// This header is hand-written and stable: it holds the match-result types and
+// the small runtime primitives that the generated `match_top_level` /
+// `match_tapleaf` functions call. The generated classifier mirrors the
+// `patterns` of specs/bip388/cleartext.toml; editing those patterns regenerates
+// cleartext_match.c without touching this header or cleartext.c.
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "common/wallet.h"
+#include "common/cleartext_specs.h"
+#include "constants.h" // SEQUENCE_LOCKTIME_TYPE_FLAG, LOCKTIME_THRESHOLD
+
+// A matched class instance carries its bindings here. At most 3 dynamic
+// bindings are used by any cleartext spec part (since CT_MAX_BINDINGS == 3).
+#define CT_MAX_BINDINGS 3
+
+typedef enum {
+ CT_BV_NONE = 0,
+ CT_BV_KEY,
+ CT_BV_KEYS,
+ CT_BV_NUMBER,
+ CT_BV_SUB, // a sub-policy (recursively classified at render/score time)
+ CT_BV_TIMELOCK, // unified older()/after() value
+} ct_bind_kind_e;
+
+// A unified timelock value, captured from either older(n) or after(n).
+typedef struct {
+ uint32_t raw;
+ bool is_relative; // true = older(), false = after()
+} ct_timelock_t;
+
+typedef struct {
+ ct_bind_kind_e kind;
+ union {
+ const policy_node_keyexpr_t *key;
+ struct {
+ const policy_node_keyexpr_t *array;
+ uint16_t n;
+ } keys;
+ uint32_t number;
+ const policy_node_t *sub;
+ ct_timelock_t timelock;
+ } u;
+} ct_value_t;
+
+typedef struct {
+ ct_value_t v[CT_MAX_BINDINGS];
+ uint8_t n;
+} ct_bindings_t;
+
+typedef struct {
+ descriptor_class_e cls;
+ ct_bindings_t bindings;
+ // For taproot, the taptree (NULL if no tree).
+ const policy_node_tree_t *taptree;
+} ct_top_match_t;
+
+typedef struct {
+ tapleaf_class_e cls;
+ ct_bindings_t bindings;
+ // The leaf script — when class is TC_OTHER, used for "raw" rendering.
+ const policy_node_t *leaf_script;
+} ct_leaf_match_t;
+
+// ---------------------------------------------------------------------------
+// Binding setters used by the generated classifier.
+// ---------------------------------------------------------------------------
+
+static inline void set_binding_key(ct_bindings_t *b, int i, const policy_node_keyexpr_t *k) {
+ b->v[i].kind = CT_BV_KEY;
+ b->v[i].u.key = k;
+}
+
+static inline void set_binding_keys(ct_bindings_t *b,
+ int i,
+ const policy_node_keyexpr_t *arr,
+ uint16_t n) {
+ b->v[i].kind = CT_BV_KEYS;
+ b->v[i].u.keys.array = arr;
+ b->v[i].u.keys.n = n;
+}
+
+static inline void set_binding_number(ct_bindings_t *b, int i, uint32_t v) {
+ b->v[i].kind = CT_BV_NUMBER;
+ b->v[i].u.number = v;
+}
+
+static inline void set_binding_sub(ct_bindings_t *b, int i, const policy_node_t *sub) {
+ b->v[i].kind = CT_BV_SUB;
+ b->v[i].u.sub = sub;
+}
+
+static inline void set_binding_timelock(ct_bindings_t *b, int i, ct_timelock_t tl) {
+ b->v[i].kind = CT_BV_TIMELOCK;
+ b->v[i].u.timelock = tl;
+}
+
+// Match a unified timelock node: returns true if `node` is `older($n)` or
+// `after($n)`, and fills `*out` with raw + is_relative. The wallet-policy
+// parser already validates locktime ranges, so no extra range guard is needed
+// here (the tree only ever holds in-range older()/after() values).
+static inline bool match_lock_value(const policy_node_t *node, ct_timelock_t *out) {
+ if (node == NULL) return false;
+ if (node->type == TOKEN_OLDER) {
+ const policy_node_with_uint32_t *u = (const policy_node_with_uint32_t *) node;
+ out->raw = u->n;
+ out->is_relative = true;
+ return true;
+ }
+ if (node->type == TOKEN_AFTER) {
+ const policy_node_with_uint32_t *u = (const policy_node_with_uint32_t *) node;
+ out->raw = u->n;
+ out->is_relative = false;
+ return true;
+ }
+ return false;
+}
+
+// ---------------------------------------------------------------------------
+// Generated classifier entry points (defined in cleartext_match.c).
+// ---------------------------------------------------------------------------
+
+// Classify the root of a descriptor template. Returns true and fills *out on a
+// match; on no match returns false with out->cls == DC_OTHER.
+bool match_top_level(const policy_node_t *root, ct_top_match_t *out);
+
+// Classify a single tap-leaf script. Returns true and fills *out on a match;
+// on no match returns false with out->cls == TC_OTHER (out->leaf_script set to
+// the unmatched node for raw rendering).
+bool match_tapleaf(const policy_node_t *leaf_script, ct_leaf_match_t *out);
diff --git a/src/common/cleartext_specs.c b/src/common/cleartext_specs.c
new file mode 100644
index 0000000..98893b7
--- /dev/null
+++ b/src/common/cleartext_specs.c
@@ -0,0 +1,137 @@
+// Generated by specs/bip388/gen.py. DO NOT EDIT.
+// clang-format off
+
+#include "common/cleartext_specs.h"
+
+const char ct_string_pool[] =
+ /* 0 */ "Spendable by \0"
+ /* 14 */ " alone (Legacy)\0"
+ /* 30 */ " alone (SegWit)\0"
+ /* 46 */ "Any \0"
+ /* 51 */ " of \0"
+ /* 56 */ " must sign\0"
+ /* 67 */ "Each of \0"
+ /* 76 */ " alone (Taproot)\0"
+ /* 93 */ "Main path: spendable by \0"
+ /* 118 */ "Main path: each of \0"
+ /* 138 */ " and \0"
+ /* 144 */ " must both sign\0"
+ /* 160 */ "any \0"
+ /* 165 */ " must sign (sorted)\0"
+ /* 185 */ "each of \0"
+ /* 194 */ ", \0"
+ /* 197 */ " - and also - \0";
+
+static const cleartext_part_t TOP_LEGACY_SINGLE_SIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 0 }, // "Spendable by "
+ { CT_PART_KEY , 0, 0 }, // $key
+ { CT_PART_LITERAL , 0, 14 }, // " alone (Legacy)"
+};
+
+static const cleartext_part_t TOP_SEGWIT_SINGLE_SIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 0 }, // "Spendable by "
+ { CT_PART_KEY , 0, 0 }, // $key
+ { CT_PART_LITERAL , 0, 30 }, // " alone (SegWit)"
+};
+
+static const cleartext_part_t TOP_MULTISIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 46 }, // "Any "
+ { CT_PART_THRESHOLD , 0, 0 }, // $threshold
+ { CT_PART_LITERAL , 0, 51 }, // " of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t TOP_MULTISIG_PARTS_ALL[] = {
+ { CT_PART_LITERAL , 0, 67 }, // "Each of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t TOP_TAPROOT_KEY_ONLY_PARTS[] = {
+ { CT_PART_LITERAL , 0, 0 }, // "Spendable by "
+ { CT_PART_KEY , 0, 0 }, // $internal_key
+ { CT_PART_LITERAL , 0, 76 }, // " alone (Taproot)"
+};
+
+static const cleartext_part_t TOP_TAPROOT_PARTS[] = {
+ { CT_PART_LITERAL , 0, 93 }, // "Main path: spendable by "
+ { CT_PART_KEY , 0, 0 }, // $internal_key
+};
+
+static const cleartext_part_t TOP_TAPROOT_MUSIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 118 }, // "Main path: each of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t LEAF_SINGLE_SIG_PARTS[] = {
+ { CT_PART_KEY , 0, 0 }, // $key
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t LEAF_BOTH_MUST_SIGN_PARTS[] = {
+ { CT_PART_KEY , 0, 0 }, // $key1
+ { CT_PART_LITERAL , 0, 138 }, // " and "
+ { CT_PART_KEY , 1, 0 }, // $key2
+ { CT_PART_LITERAL , 0, 144 }, // " must both sign"
+};
+
+static const cleartext_part_t LEAF_SORTED_MULTISIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 160 }, // "any "
+ { CT_PART_THRESHOLD , 0, 0 }, // $threshold
+ { CT_PART_LITERAL , 0, 51 }, // " of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 165 }, // " must sign (sorted)"
+};
+
+static const cleartext_part_t LEAF_SORTED_MULTISIG_PARTS_ALL[] = {
+ { CT_PART_LITERAL , 0, 185 }, // "each of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 165 }, // " must sign (sorted)"
+};
+
+static const cleartext_part_t LEAF_MULTISIG_PARTS[] = {
+ { CT_PART_LITERAL , 0, 160 }, // "any "
+ { CT_PART_THRESHOLD , 0, 0 }, // $threshold
+ { CT_PART_LITERAL , 0, 51 }, // " of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t LEAF_MULTISIG_PARTS_ALL[] = {
+ { CT_PART_LITERAL , 0, 185 }, // "each of "
+ { CT_PART_KEYS , 1, 0 }, // $keys
+ { CT_PART_LITERAL , 0, 56 }, // " must sign"
+};
+
+static const cleartext_part_t LEAF_TIMELOCKED_PARTS[] = {
+ { CT_PART_SUB , 0, 0 }, // $sub
+ { CT_PART_LITERAL , 0, 194 }, // ", "
+ { CT_PART_TIMELOCK , 1, 0 }, // $timelock
+};
+
+static const cleartext_part_t LEAF_AND_V_PARTS[] = {
+ { CT_PART_SUB , 0, 0 }, // $sub1
+ { CT_PART_LITERAL , 0, 197 }, // " - and also - "
+ { CT_PART_SUB , 1, 0 }, // $sub2
+};
+
+const cleartext_spec_t CT_TOP_LEVEL_SPECS[DC__COUNT] = {
+ [DC_LEGACY_SINGLE_SIG] = { 1, 0, 3, TOP_LEGACY_SINGLE_SIG_PARTS, 0, NULL, 0 },
+ [DC_SEGWIT_SINGLE_SIG] = { 2, 0, 3, TOP_SEGWIT_SINGLE_SIG_PARTS, 0, NULL, 0 },
+ [DC_MULTISIG] = { 7, 1, 5, TOP_MULTISIG_PARTS, 3, TOP_MULTISIG_PARTS_ALL, 0 },
+ [DC_TAPROOT_KEY_ONLY] = { 1, 0, 3, TOP_TAPROOT_KEY_ONLY_PARTS, 0, NULL, 0 },
+ [DC_TAPROOT] = { 1, 0, 2, TOP_TAPROOT_PARTS, 0, NULL, 1 },
+ [DC_TAPROOT_MUSIG] = { 1, 1, 3, TOP_TAPROOT_MUSIG_PARTS, 0, NULL, 1 },
+};
+
+const cleartext_spec_t CT_TAPLEAF_SPECS[TC__COUNT] = {
+ [TC_SINGLE_SIG] = { 1, 0, 2, LEAF_SINGLE_SIG_PARTS, 0, NULL, 0 },
+ [TC_BOTH_MUST_SIGN] = { 1, 0, 4, LEAF_BOTH_MUST_SIGN_PARTS, 0, NULL, 0 },
+ [TC_SORTED_MULTISIG] = { 1, 0, 5, LEAF_SORTED_MULTISIG_PARTS, 3, LEAF_SORTED_MULTISIG_PARTS_ALL, 0 },
+ [TC_MULTISIG] = { 2, 1, 5, LEAF_MULTISIG_PARTS, 3, LEAF_MULTISIG_PARTS_ALL, 0 },
+ [TC_TIMELOCKED] = { 1, 0, 3, LEAF_TIMELOCKED_PARTS, 0, NULL, 0 },
+ [TC_AND_V] = { 1, 0, 3, LEAF_AND_V_PARTS, 0, NULL, 0 },
+};
+
diff --git a/src/common/cleartext_specs.h b/src/common/cleartext_specs.h
new file mode 100644
index 0000000..aa465b3
--- /dev/null
+++ b/src/common/cleartext_specs.h
@@ -0,0 +1,87 @@
+// Generated by specs/bip388/gen.py. DO NOT EDIT.
+// clang-format off
+
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+// A part of a cleartext template. Literals reference ct_string_pool
+// at `lit_off`; dynamic placeholders refer to the binding at index
+// `binding_idx` in the matcher's bindings array.
+typedef enum {
+ CT_PART_LITERAL = 0,
+ CT_PART_KEY,
+ CT_PART_KEYS,
+ CT_PART_THRESHOLD,
+ CT_PART_SUB,
+ CT_PART_TIMELOCK,
+} cleartext_part_kind_e;
+
+typedef struct {
+ uint8_t kind; // cleartext_part_kind_e
+ uint8_t binding_idx; // ignored for CT_PART_LITERAL
+ uint16_t lit_off; // valid only for CT_PART_LITERAL
+} cleartext_part_t;
+
+typedef struct {
+ uint8_t n_patterns; // total patterns for the class
+ uint8_t n_musig_patterns; // patterns whose admittance requires
+ // threshold == n_keys
+ uint8_t n_parts;
+ const cleartext_part_t *parts;
+ uint8_t n_parts_all; // 0 if the class has no n-of-n form
+ const cleartext_part_t *parts_all; // alternate template used when
+ // threshold == n_keys; NULL if absent
+ uint8_t recurses; // 1 iff the class binds a $leaves
+ // taptree (drives per-leaf
+ // rendering and the taptree score)
+} cleartext_spec_t;
+
+// Classes of the *root* of a wallet-policy descriptor template. Each named
+// value (the sentinels aside) is one shape the cleartext encoder recognizes,
+// listed in the same order as the `[[top_level]]` entries of
+// specs/bip388/cleartext.toml (the single source of truth for their meaning).
+// match_top_level() in cleartext.c yields one of these, and it indexes
+// CT_TOP_LEVEL_SPECS[] to select that shape's cleartext rendering.
+// DC_OTHER - matches no known shape: no cleartext form, so the UX falls
+// back to showing the raw descriptor template.
+// DC__COUNT - number of recognized classes (== DC_OTHER); sizes
+// CT_TOP_LEVEL_SPECS[], which has no entry for DC_OTHER.
+typedef enum {
+ DC_LEGACY_SINGLE_SIG,
+ DC_SEGWIT_SINGLE_SIG,
+ DC_MULTISIG,
+ DC_TAPROOT_KEY_ONLY,
+ DC_TAPROOT,
+ DC_TAPROOT_MUSIG,
+ DC_OTHER,
+ DC__COUNT = DC_OTHER,
+} descriptor_class_e;
+
+// Classes of a single leaf script of a taproot tree. Each named value (the
+// sentinels aside) is one leaf shape the cleartext encoder recognizes, listed
+// in the same order as the `[[tapleaf]]` entries of
+// specs/bip388/cleartext.toml (the single source of truth for their meaning).
+// match_tapleaf() in cleartext.c yields one of these, and it indexes
+// CT_TAPLEAF_SPECS[] to select that leaf's cleartext rendering.
+// TC_OTHER - matches no known shape: no cleartext form, so the leaf is
+// rendered as the fixed "(unknown)" marker.
+// TC__COUNT - number of recognized classes (== TC_OTHER); sizes
+// CT_TAPLEAF_SPECS[], which has no entry for TC_OTHER.
+typedef enum {
+ TC_SINGLE_SIG,
+ TC_BOTH_MUST_SIGN,
+ TC_SORTED_MULTISIG,
+ TC_MULTISIG,
+ TC_TIMELOCKED,
+ TC_AND_V,
+ TC_OTHER,
+ TC__COUNT = TC_OTHER,
+} tapleaf_class_e;
+
+extern const char ct_string_pool[];
+extern const cleartext_spec_t CT_TOP_LEVEL_SPECS[DC__COUNT];
+extern const cleartext_spec_t CT_TAPLEAF_SPECS[TC__COUNT];
+
diff --git a/src/handler/lib/policy.c b/src/handler/lib/policy.c
index d7734b4..205f870 100644
--- a/src/handler/lib/policy.c
+++ b/src/handler/lib/policy.c
@@ -1906,8 +1906,8 @@ static int compare_uint16(const void *a, const void *b) {
return (num1 > num2) - (num1 < num2);
}
-static bool are_key_placeholders_identical(const policy_node_keyexpr_t *kp1,
- const policy_node_keyexpr_t *kp2) {
+bool are_key_placeholders_identical(const policy_node_keyexpr_t *kp1,
+ const policy_node_keyexpr_t *kp2) {
if (kp1->type != kp2->type) {
return false;
}
diff --git a/src/handler/lib/policy.h b/src/handler/lib/policy.h
index 9c974a6..2bbcf71 100644
--- a/src/handler/lib/policy.h
+++ b/src/handler/lib/policy.h
@@ -218,6 +218,20 @@ __attribute__((warn_unused_result)) int get_keyexpr_by_index(const policy_node_t
const policy_node_t **out_tapleaf_ptr,
policy_node_keyexpr_t **out_keyexpr);
+/**
+ * Determines whether two key expressions refer to the same key, independently of the derivation
+ * steps. Normal key expressions are identical iff they share the same key index; musig key
+ * expressions are identical iff they aggregate exactly the same (unordered) set of key indexes.
+ *
+ * @param[in] kp1
+ * Pointer to the first key expression.
+ * @param[in] kp2
+ * Pointer to the second key expression.
+ * @return true if the two key expressions refer to the same key, false otherwise.
+ */
+bool are_key_placeholders_identical(const policy_node_keyexpr_t *kp1,
+ const policy_node_keyexpr_t *kp2);
+
/**
* Determines the expected number of unique keys in the provided policy's key information.
* The function calculates this by finding the maximum key index from key expressions and increments
diff --git a/unit-tests/cleartext_vectors.inc.c b/unit-tests/cleartext_vectors.inc.c
new file mode 100644
index 0000000..72b1b99
--- /dev/null
+++ b/unit-tests/cleartext_vectors.inc.c
@@ -0,0 +1,459 @@
+// Generated by specs/bip388/gen.py. DO NOT EDIT.
+// clang-format off
+
+static const char *const vec_000_ct[] = {
+ "Spendable by @0 alone (Legacy)",
+};
+static const char *const vec_001_ct[] = {
+ "Spendable by @0 alone (SegWit)",
+};
+static const char *const vec_002_ct[] = {
+ "Spendable by @0 alone (SegWit)",
+};
+static const char *const vec_003_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_004_ct[] = {
+ "Any 2 of @0, @1 and @2 must sign",
+};
+static const char *const vec_005_ct[] = {
+ "Each of @0, @1 and @2 must sign",
+};
+static const char *const vec_006_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_007_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_008_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_009_ct[] = {
+ "Any 2 of @0, @1 and @2 must sign",
+};
+static const char *const vec_010_ct[] = {
+ "Each of @0, @1 and @2 must sign",
+};
+static const char *const vec_011_ct[] = {
+ "Each of @0, @1 and @2 must sign",
+};
+static const char *const vec_012_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_013_ct[] = {
+ "Any 2 of @0, @1 and @2 must sign",
+};
+static const char *const vec_014_ct[] = {
+ "Spendable by @0 alone (Taproot)",
+};
+static const char *const vec_015_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+};
+static const char *const vec_016_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+};
+static const char *const vec_017_ct[] = {
+ "Main path: spendable by @0",
+ "@3 must sign",
+ "Each of @1 and @2 must sign (sorted)",
+};
+static const char *const vec_018_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+};
+static const char *const vec_019_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign",
+};
+static const char *const vec_020_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@2 must sign, 2000 blocks after receiving",
+};
+static const char *const vec_021_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @4 and @5 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+};
+static const char *const vec_022_ct[] = {
+ "Main path: spendable by @0",
+ "Any 2 of @4, @5 and @6 must sign",
+ "Each of @1, @2 and @3 must sign",
+};
+static const char *const vec_023_ct[] = {
+ "Main path: spendable by @0",
+ "Any 2 of @1, @2 and @3 must sign",
+ "Any 2 of @4, @5 and @6 must sign",
+};
+static const char *const vec_024_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign (sorted)",
+ "Each of @4 and @5 must sign (sorted)",
+};
+static const char *const vec_025_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @10 and @11 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+ "Any 2 of @4, @5 and @6 must sign",
+ "Each of @7, @8 and @9 must sign",
+};
+static const char *const vec_026_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 960 blocks after receiving",
+ "Raw policy: t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))",
+};
+static const char *const vec_027_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Raw policy: t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995))))",
+};
+static const char *const vec_028_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 52560 blocks after receiving",
+};
+static const char *const vec_029_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 52560 blocks after receiving",
+};
+static const char *const vec_030_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 1008 blocks after receiving",
+};
+static const char *const vec_031_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1, @2 and @3 must sign",
+ "Any 2 of @4, @5, @6, @7 and @8 must sign",
+};
+static const char *const vec_032_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 8 minutes 32 seconds after receiving",
+};
+static const char *const vec_033_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 8 minutes 32 seconds after receiving",
+};
+static const char *const vec_034_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign, 1 day 1 hour 36 minutes after receiving",
+};
+static const char *const vec_035_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, 1 day 1 hour 36 minutes after receiving",
+};
+static const char *const vec_036_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, not before block 840000",
+};
+static const char *const vec_037_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, not before block 840000",
+};
+static const char *const vec_038_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, not before 1985-11-05 00:53:20 UTC",
+};
+static const char *const vec_039_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, not before 2020-01-01 UTC",
+};
+static const char *const vec_040_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign, not before 2023-11-14 22:13:20 UTC",
+};
+static const char *const vec_041_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, 2000 blocks after receiving",
+};
+static const char *const vec_042_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, not before block 840000",
+};
+static const char *const vec_043_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign, 1000 blocks after receiving",
+ "@1 must sign, 1000 blocks after receiving",
+};
+static const char *const vec_044_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign, 1008 blocks after receiving",
+};
+static const char *const vec_045_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 and @3 must both sign, 1008 blocks after receiving",
+};
+static const char *const vec_046_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign, 1 day 1 hour 36 minutes after receiving",
+};
+static const char *const vec_047_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign, not before block 840000",
+};
+static const char *const vec_048_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign, not before 2023-11-14 22:13:20 UTC",
+};
+static const char *const vec_049_ct[] = {
+ "Each of @0 and @1 must sign",
+};
+static const char *const vec_050_ct[] = {
+ "Each of @0, @1 and @2 must sign",
+};
+static const char *const vec_051_ct[] = {
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+};
+static const char *const vec_052_ct[] = {
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+};
+static const char *const vec_053_ct[] = {
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign",
+ "@4 must sign",
+};
+static const char *const vec_054_ct[] = {
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign, 1008 blocks after receiving",
+};
+static const char *const vec_055_ct[] = {
+ "Main path: each of @0 and @1 must sign",
+ "@2 must sign",
+ "@3 must sign, not before block 840000",
+};
+static const char *const vec_056_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1, @2 and @3 must sign",
+};
+static const char *const vec_057_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign",
+};
+static const char *const vec_058_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1, @2 and @3 must sign",
+};
+static const char *const vec_059_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign, 1008 blocks after receiving",
+};
+static const char *const vec_060_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign, 1 day 1 hour 36 minutes after receiving",
+};
+static const char *const vec_061_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign, not before block 840000",
+};
+static const char *const vec_062_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign, not before 2023-11-14 22:13:20 UTC",
+};
+static const char *const vec_063_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "Each of @2 and @3 must sign",
+};
+static const char *const vec_064_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @4 and @5 must sign",
+ "Any 2 of @1, @2 and @3 must sign",
+};
+static const char *const vec_065_ct[] = {
+ "Main path: spendable by @0",
+ "@2 and @1 must both sign",
+ "@1 must sign, 4383 blocks after receiving",
+};
+static const char *const vec_066_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign",
+ "Any 2 of @1, @2 and @3 must sign, 144 blocks after receiving",
+};
+static const char *const vec_067_ct[] = {
+ "Main path: spendable by @0",
+ "@0 must sign",
+};
+static const char *const vec_068_ct[] = {
+ "Main path: spendable by @0",
+ "@0 must sign",
+};
+static const char *const vec_069_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign",
+ "Each of @1 and @2 must sign",
+};
+static const char *const vec_070_ct[] = {
+ "Main path: spendable by @0",
+ "@0 must sign",
+ "@0 must sign",
+};
+static const char *const vec_071_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@1 must sign",
+};
+static const char *const vec_072_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@1 must sign, 4383 blocks after receiving",
+};
+static const char *const vec_073_ct[] = {
+ "Main path: spendable by @0",
+ "@0 must sign",
+ "@1 and @1 must both sign",
+};
+static const char *const vec_074_ct[] = {
+ "Main path: each of @0, @1 and @2 must sign",
+ "Each of @0 and @1 must sign, 1 day 1 hour 36 minutes after receiving",
+ "Each of @0 and @2 must sign, 1 day 1 hour 36 minutes after receiving",
+ "Each of @1 and @2 must sign, 1 day 1 hour 36 minutes after receiving",
+};
+static const char *const vec_075_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+};
+static const char *const vec_076_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+};
+static const char *const vec_077_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign - and also - each of @2 and @3 must sign",
+};
+static const char *const vec_078_ct[] = {
+ "Main path: spendable by @0",
+ "Each of @1 and @2 must sign - and also - each of @3 and @4 must sign",
+};
+static const char *const vec_079_ct[] = {
+ "Main path: spendable by @0",
+ "@1 and @2 must both sign - and also - each of @3 and @4 must sign",
+};
+static const char *const vec_080_ct[] = {
+ "Main path: spendable by @0",
+ "@1 must sign",
+ "@2 must sign - and also - each of @3 and @4 must sign",
+};
+static const char *const vec_081_ct[] = {
+ "pkh(@0/<2;3>/*)",
+};
+static const char *const vec_082_ct[] = {
+ "wpkh(@0/<0;2>/*)",
+};
+static const char *const vec_083_ct[] = {
+ "tr(@0/<4;5>/*)",
+};
+static const char *const vec_084_ct[] = {
+ "tr(@0/**,pk(@1/<2;3>/*))",
+};
+static const char *const vec_085_ct[] = {
+ "wsh(and_v(v:pk(@0/**),pk(@1/**)))",
+};
+
+static const ct_vector_t CT_VECTORS[] = {
+ { .template_str = "pkh(@0/**)", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_000_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wpkh(@0/**)", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_001_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(wpkh(@0/**))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_002_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wsh(sortedmulti(2,@0/**,@1/**))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_003_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wsh(sortedmulti(2,@0/**,@1/**,@2/**))", .has_confusion_score = true, .confusion_score = 6ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_004_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wsh(sortedmulti(3,@0/**,@1/**,@2/**))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_005_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wsh(multi(2,@0/**,@1/**))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_006_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(wsh(multi(2,@0/**,@1/**)))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_007_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(wsh(sortedmulti(2,@0/**,@1/**)))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_008_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(wsh(multi(2,@0/**,@1/**,@2/**)))", .has_confusion_score = true, .confusion_score = 6ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_009_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(wsh(sortedmulti(3,@0/**,@1/**,@2/**)))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_010_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "wsh(multi(3,@0/**,@1/**,@2/**))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_011_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(multi(2,@0/**,@1/**))", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_012_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "sh(sortedmulti(2,@0/**,@1/**,@2/**))", .has_confusion_score = true, .confusion_score = 6ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_013_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**)", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_014_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,pk(@1/**))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_015_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),pk(@2/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_016_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{sortedmulti_a(2,@1/**,@2/**),pk(@3/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_017_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{{pk(@1/**),pk(@2/**)},pk(@3/**)})", .has_confusion_score = true, .confusion_score = 3ULL, .has_cleartext_array = true, .cleartext_n = 4, .cleartext = vec_018_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@2/**),pk(@1/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_019_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{and_v(v:pk(@2/<0;1>/*),older(2000)),and_v(v:pk(@1/<0;1>/*),older(1000))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_020_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_021_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(3,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**,@6/**)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_022_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(2,@4/**,@5/**,@6/**),multi_a(2,@1/**,@2/**,@3/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_023_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{sortedmulti_a(2,@4/**,@5/**),sortedmulti_a(2,@1/**,@2/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_024_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(3,@7/**,@8/**,@9/**),{multi_a(2,@4/**,@5/**,@6/**),{multi_a(2,@1/**,@2/**,@3/**),multi_a(2,@10/**,@11/**)}}})", .has_confusion_score = true, .confusion_score = 60ULL, .has_cleartext_array = true, .cleartext_n = 5, .cleartext = vec_025_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "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))))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_026_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "tr(@0/**,{t:or_c(pk(@2/**),and_v(v:pk(@3/**),or_c(pk(@4/**),v:ripemd160(907cd521fff981ce4063a4dc43c6f3fd28e08995)))),pk(@1/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_027_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(52560)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_028_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(52560))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_029_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(1008))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_030_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(3,@1/**,@2/**,@3/**),multi_a(2,@4/**,@5/**,@6/**,@7/**,@8/**)})", .has_confusion_score = false, .confusion_score = 0ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_031_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),older(4194305)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_032_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194305))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_033_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/<0;1>/*),older(4194484))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_034_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),older(4194484))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_035_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(840000)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_036_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(840000))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_037_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(500000000)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_038_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/<0;1>/*),after(1577836800)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_039_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:multi_a(2,@2/<0;1>/*,@3/<0;1>/*),after(1700000000))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_040_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),older(2000))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_041_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),after(840000))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_042_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(1000)),and_v(v:pk(@1/<2;3>/*),older(1000))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_043_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(1008)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_044_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:and_v(v:pk(@2/<0;1>/*),pk(@3/<0;1>/*)),older(1008))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_045_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),older(4194484)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_046_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(840000)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_047_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:and_v(v:pk(@1/<0;1>/*),pk(@2/<0;1>/*)),after(1700000000)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_048_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**)", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_049_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1,@2)/**)", .has_confusion_score = true, .confusion_score = 7ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_050_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**,pk(@2/**))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_051_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**,{pk(@2/**),pk(@3/**)})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_052_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**,{{pk(@2/**),pk(@3/**)},pk(@4/**)})", .has_confusion_score = true, .confusion_score = 3ULL, .has_cleartext_array = true, .cleartext_n = 4, .cleartext = vec_053_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**,and_v(v:pk(@2/<0;1>/*),older(1008)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_054_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1)/**,{pk(@2/**),and_v(v:pk(@3/<0;1>/*),after(840000))})", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_055_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,multi_a(3,@1/**,@2/**,@3/**))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_056_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,pk(musig(@1,@2)/**))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_057_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,pk(musig(@1,@2,@3)/**))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_058_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(1008)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_059_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),older(4194484)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_060_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(840000)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_061_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(musig(@1,@2)/**),after(1700000000)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_062_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),pk(musig(@2,@3)/**)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_063_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(2,@1/**,@2/**,@3/**),pk(musig(@4,@5)/**)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_064_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/<0;1>/*,{and_v(v:pk(@1/<0;1>/*),older(4383)),and_v(v:pk(@2/<0;1>/*),pk(@1/<2;3>/*))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_065_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "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>/*))})", .has_confusion_score = true, .confusion_score = 4ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_066_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/<0;1>/*,pk(@0/<2;3>/*))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_067_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/<2;3>/*,pk(@0/<0;1>/*))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_068_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{multi_a(2,@1/<0;1>/*,@2/<0;1>/*),multi_a(2,@1/<2;3>/*,@2/<2;3>/*)})", .has_confusion_score = true, .confusion_score = 16ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_069_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),pk(@0/<4;5>/*)})", .has_confusion_score = true, .confusion_score = 6ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_070_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/<0;1>/*),pk(@1/<2;3>/*)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_071_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{and_v(v:pk(@1/<0;1>/*),older(4383)),pk(@1/<2;3>/*)})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_072_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/<0;1>/*,{pk(@0/<2;3>/*),and_v(v:pk(@1/<0;1>/*),pk(@1/<2;3>/*))})", .has_confusion_score = true, .confusion_score = 4ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_073_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(musig(@0,@1,@2)/**,{and_v(v:pk(musig(@0,@1)/**),older(4194484)),{and_v(v:pk(musig(@0,@2)/**),older(4194484)),and_v(v:pk(musig(@1,@2)/**),older(4194484))}})", .has_confusion_score = true, .confusion_score = 5184ULL, .has_cleartext_array = true, .cleartext_n = 4, .cleartext = vec_074_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/**),multi_a(2,@2/**,@3/**)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_075_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/**),pk(musig(@2,@3)/**)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_076_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:pk(@1/**),multi_a(2,@2/**,@3/**)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_077_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:multi_a(2,@1/**,@2/**),multi_a(2,@3/**,@4/**)))", .has_confusion_score = true, .confusion_score = 4ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_078_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,and_v(v:and_v(v:pk(@1/**),pk(@2/**)),multi_a(2,@3/**,@4/**)))", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 2, .cleartext = vec_079_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "tr(@0/**,{pk(@1/**),and_v(v:pk(@2/**),multi_a(2,@3/**,@4/**))})", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 3, .cleartext = vec_080_ct, .has_has_cleartext = true, .cleartext_flag = true },
+ { .template_str = "pkh(@0/<2;3>/*)", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_081_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "wpkh(@0/<0;2>/*)", .has_confusion_score = true, .confusion_score = 2ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_082_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "tr(@0/<4;5>/*)", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_083_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "tr(@0/**,pk(@1/<2;3>/*))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_084_ct, .has_has_cleartext = true, .cleartext_flag = false },
+ { .template_str = "wsh(and_v(v:pk(@0/**),pk(@1/**)))", .has_confusion_score = true, .confusion_score = 1ULL, .has_cleartext_array = true, .cleartext_n = 1, .cleartext = vec_085_ct, .has_has_cleartext = true, .cleartext_flag = false },
+};
+static const size_t CT_VECTORS_N = sizeof(CT_VECTORS) / sizeof(CT_VECTORS[0]);
+
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.