Skip to content

feat(compile): give each disjunctive branch its own taproot leaf - #378

Open
Musab1258 wants to merge 2 commits into
bitcoindevkit:masterfrom
Musab1258:feat/compile-tr-split-leaves
Open

Musab1258 wants to merge 2 commits into
bitcoindevkit:masterfrom
Musab1258:feat/compile-tr-split-leaves

Conversation

@Musab1258

@Musab1258 Musab1258 commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Description

compile --type tr put the whole policy into a single taproot leaf, so spending any branch of an or revealed every other branch on-chain. This gives each spending path of a disjunctive policy (or, thresh(1,..)) its own leaf, arranged as a probability-weighted Huffman tree, so only the path that is used is revealed.

The internal key is unchanged: it is still the randomized unspendable key H + rG, and r is still returned (#218, #225). Conjunctions, thresh(k,n) with k > 1, and single keys still compile to one leaf.

or(pk(A),pk(B)) before and after:

before: tr(<H+rG>,or_b(pk(A),s:pk(B)))
after:  tr(<H+rG>,{pk(B),pk(A)})

Closes #244.

Notes to the reviewers

Why not compile_tr? Its extract_key() picks a policy key as the internal key whenever one can satisfy the policy alone, and the unspendable key is only a fallback. For or(pk(A),pk(B)) it returns tr(A,pk(B)), so r would no longer prove anything about the internal key. It also returns Empty Miniscript compilation for pk(A) on miniscript 12.3.7 and 12.3.8 (rust-miniscript#902). So the tree is built here from public miniscript APIs, and only the internal key comes from the existing code.

How it works. compile_tap_tree in src/utils/descriptors.rs:

  1. Validates the whole policy first (is_valid() plus the safety checks, as compile_tr does).
  2. Flattens root-level or and thresh(1,..) into (probability, branch) pairs.
  3. Compiles each branch with compile::<Tap>() and runs sanity_check().
  4. Combines the leaves into a Huffman tree with TapTree::combine, capped at 1024 leaves (miniscript's limit). Ties break deterministically.

The only change to compile_policy is the line that built the single leaf. It works for both DescriptorPublicKey and String (--placeholders) keys.

Whole-policy validation matters. Validating each branch alone accepted or(pk(A),and(pk(A),pk(B))), which wsh rejects with "duplicate keys". Step 1 prevents that, and there is a test for it.

Trade-off. Unlike stock compile_tr, or(A,B) gets no key-path spend through A. That follows from keeping the verifiable unspendable key required by #218.

Commits. There are two:

  1. Tests only, covering behavior that must not change. They pass on the current code.
  2. The change, its tests and the CHANGELOG entry.

Testing.

  • Commit 1 adds 6 integration tests: single key, conjunction and thresh(2) stay one leaf, a key reused across branches is rejected, an unsafe branch is rejected, and the internal key is the unspendable key with r returned.
  • Commit 2 adds 4 integration tests (split or, split thresh(1), 1024 leaves accepted, 1025 rejected) and 8 unit tests on tree shape, including that a likely branch sits nearer the root.
  • The existing test_compile_* tests are untouched and pass.
  • Run against the old code, the 4 new-behavior integration tests fail and the guard tests pass.

Not covered.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo fmt and cargo clippy before committing

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

A following commit changes how `compile --type tr` builds its script
tree (bitcoindevkit#244). Add integration tests for the behavior that must stay the
same, so that commit shows them passing unchanged:

- a single key, a conjunction and a k-of-n threshold with k > 1 still
  compile to one leaf
- a key reused across branches is rejected, as it is for wsh
- a branch that is not safe on every spending path is rejected
- the internal key is the randomized unspendable key, never a key from
  the policy, and `r` is returned

These pass on the current code.
`compile --type tr` put the whole policy in a single leaf, so spending
any branch of an `or` revealed all the others on-chain. Compile each
root-level `or` and `thresh(1, ..)` branch into its own leaf and
arrange the leaves as a Huffman tree weighted by branch probability, so
only the spent path is revealed.

rust-miniscript's `compile_tr` is not used. It takes the internal key
from the policy whenever a key can satisfy it alone, which would
replace the randomized unspendable key `H + rG` and make `r` prove
nothing (bitcoindevkit#218). It also fails on `pk(A)` in miniscript 12.3.7 and
12.3.8 (rust-miniscript#902). Build the tree here from public APIs and
leave the internal key as it is.

Validate the whole policy before splitting it, as `Concrete::compile`
does. Checking branches one at a time would accept a key reused across
two of them. Reject policies that compile to more than 1024 leaves,
miniscript's limit.

Conjunctions, `thresh(k, n)` with k > 1 and single keys still compile
to one leaf. Expanding `thresh(k, n)` is left out.

Cover it with unit tests for the tree shape and integration tests for
the split and the leaf limit.

Closes bitcoindevkit#244
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.92473% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 60.74%. Comparing base (3dec0e0) to head (4b64f52).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/utils/descriptors.rs 98.92% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #378      +/-   ##
==========================================
+ Coverage   60.00%   60.74%   +0.74%     
==========================================
  Files          25       25              
  Lines        4736     4828      +92     
==========================================
+ Hits         2842     2933      +91     
- Misses       1894     1895       +1     
Flag Coverage Δ
rust 60.74% <98.92%> (+0.74%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Musab1258

Copy link
Copy Markdown
Contributor Author

@vadim-anfv, can you please check this out when you are free?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Add taproot privacy optimisations in policy compiler

1 participant