You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add taproot privacy optimisations in policy compiler #244
bdk-cli compile --type tr "$POL" currently only calls compile and manually wraps the result into a single taproot leaf, instead of using compile_tr from rust-miniscript that splits disjunctions into separate leaves and Huffman-optimises the tree. Using compile_tr would be a good compromise, though compile_tr_private_experimental exists and goes one step further in privacy optimisation.
Use case
When compiling a disjunctive policy to a taproot descriptor, each spending condition should ideally be its own leaf so that only the used path is revealed on-chain. Currently all conditions are bundled into a single leaf, missing the privacy benefits of taproot's script tree.
Additional context
The following shows the output of all three rust-miniscript functions for reference:
Example 1:or(pk(A),pk(B))
compile:
or_b(pk(A),s:pk(B))
compile_tr:
internal key : B
tap tree leaves (1 total):
depth=0 script=pk(A)
compile_tr_private_experimental:
internal key : B
tap tree leaves (1 total):
depth=0 script=pk(A)
Example 2:thresh(1,pk(A),pk(B),pk(C))
compile:
multi_a(1,A,B,C)
compile_tr:
internal key : C
tap tree leaves (2 total):
depth=1 script=pk(B)
depth=1 script=pk(A)
compile_tr_private_experimental:
internal key : C
tap tree leaves (2 total):
depth=1 script=pk(B)
depth=1 script=pk(A)
Example 3:thresh(2,pk(A),pk(B),pk(C))
compile:
multi_a(2,A,B,C)
compile_tr:
internal key : UNSPENDABLE
tap tree leaves (1 total):
depth=0 script=multi_a(2,A,B,C)
compile_tr_private_experimental:
internal key : UNSPENDABLE
tap tree leaves (3 total):
depth=1 script=and_v(v:pk(A),pk(B))
depth=2 script=and_v(v:pk(B),pk(C))
depth=2 script=and_v(v:pk(A),pk(C))
compile_tr_private_experimental panics with an assertion failure
Expected result in both cases is tr(A), extract_key correctly promotes A to the taproot
internal key, but the remaining Policy::Unsatisfiable is not handled the same as Policy::Trivial,
causing it to fall through to leaf compilation with nothing to compile.
compile_tr_private_experimental also panics for and(pk(A),pk(B)) (compile_tr is fine there).
Expected tr(UNSPEND,and_v(v:pk(A),pk(B))).
Looked into switching to compile_tr for the trbranch.
All compile_tr variants internally call extract_key() which picks the most probable key from the policy as internal key. The provided unspendable_key is only a fallback:
fnextract_key(self,unspendable_key:Option<Pk>) -> Result<(Pk,Policy<Pk>), ...>{let internal_key = /* find key with highest probability in policy */;match(internal_key, unspendable_key){(Some(ref key), _) => Ok((key.clone(), ...)),// policy key wins(_,Some(key)) => Ok((key,self)),// unspendable is fallback only
_ => Err(...)}}
This conflicts with #218 - we need the internal key to always be our verifiable NUMS (H + rG) so that r can be used to prove key-path spending is impossible. With compile_tr, for any policy where a key can satisfy it on its own, that key becomes the internal key and our NUMS is ignored.
This behavior is the same in miniscript 12.3.5 (used by bdk-cli as the time of writing) and latest dev (13.0.0+). All variants (compile_tr, compile_tr_native, compile_tr_private_experimental) share the same extract_key logic.
The trade-off: compile_tr gives better taptree optimization (Huffman tree, separate leaves per spending path), but our manual approach guarantees a verifiable unspendable internal key. Given #218, keeping the current approach is the right call.
Separately, compile_tr / compile_tr_private_experimental have bugs with simple policies - will continue investigating upstream in rust-bitcoin/rust-miniscript#902.
Separately, compile_tr / compile_tr_private_experimental have bugs with simple policies - will continue investigating upstream in rust-bitcoin/rust-miniscript#902.
I'd like to pick this up, but first I want to check the approach with you guys.
Where things stand
compile --type tr still builds a single leaf (TapTree::Leaf(Arc::new(policy.compile()?))), so a disjunctive policy reveals all its branches on spend. As @vadim-anfv noted, switching to compile_tr doesn't work, because extract_key() makes a policy key the internal key and only uses the unspendable key as a fallback. I reproduced this on miniscript 12.3.7 (the version in the bdk-cli master branch's Cargo.lock):
Policy
compile_tr(Some(NUMS))
or(pk(A),pk(B))
tr(A,pk(B))
thresh(1,pk(A),pk(B),pk(C))
tr(A,{pk(B),pk(C)})
or(99@pk(A),1@and(pk(B),pk(C)))
tr(A,and_v(v:pk(B),pk(C)))
pk(A)
Err(Empty Miniscript compilation)
The first three would make r meaningless. The last is the bug tracked in rust-miniscript#902; it still occurs on 12.3.7. @vadim-anfv mentioned #917 fixes it, but I haven't checked if it has been added to a release yet.
Proposal
I propose that we Keep the current H + rG internal key and build the tree ourselves, using only public miniscript APIs:
Flatten root-level or and thresh(1,..) into (probability, branch) pairs (the same splitting compile_tr does).
Compile each branch with compile::<Tap>() and run sanity_check().
Combine the leaves into a Huffman tree with TapTree::combine.
Pass the NUMS-derived key to Descriptor::new_tr as today.
I tried this in a standalone crate against 12.3.7, using the plain NUMS key and String placeholder keys, and I got the following results:
and(..) and thresh(2,..) -> identical to stock compile_tr (single leaf)
Since the internal key is unchanged, I'd expect the r verification in #368 to be unaffected, but I haven't tested that yet. This approach also doesn't depend on the upstream fix.
Should I limit scope to what compile_tr does (root-level or / thresh(1,..)), leaving the compile_tr_private_experimental-style expansion of thresh(k,n) for later?
If the approach sounds right, I'll open a PR to fix it.
Activity
simongltr commentedon Feb 25, 2026
Seems like it's not that simple.
Both
compile_trandcompile_tr_private_experimentalfail forpk(A):compile_trreturnsErr("Empty Miniscript compilation")compile_tr_private_experimentalpanics with an assertion failureExpected result in both cases is
tr(A),extract_keycorrectly promotesAto the taprootinternal key, but the remaining
Policy::Unsatisfiableis not handled the same asPolicy::Trivial,causing it to fall through to leaf compilation with nothing to compile.
compile_tr_private_experimentalalso panics forand(pk(A),pk(B))(compile_tris fine there).Expected
tr(UNSPEND,and_v(v:pk(A),pk(B))).vadim-anfv commentedon Mar 25, 2026
Looked into switching to
compile_trfor thetrbranch.All
compile_trvariants internally callextract_key()which picks the most probable key from the policy as internal key. The providedunspendable_keyis only a fallback:This conflicts with #218 - we need the internal key to always be our verifiable NUMS (
H + rG) so thatrcan be used to prove key-path spending is impossible. Withcompile_tr, for any policy where a key can satisfy it on its own, that key becomes the internal key and our NUMS is ignored.This behavior is the same in miniscript 12.3.5 (used by bdk-cli as the time of writing) and latest dev (13.0.0+). All variants (
compile_tr,compile_tr_native,compile_tr_private_experimental) share the sameextract_keylogic.The trade-off:
compile_trgives better taptree optimization (Huffman tree, separate leaves per spending path), but our manual approach guarantees a verifiable unspendable internal key. Given #218, keeping the current approach is the right call.Separately,
compile_tr/compile_tr_private_experimentalhave bugs with simple policies - will continue investigating upstream in rust-bitcoin/rust-miniscript#902.vadim-anfv commentedon May 4, 2026
Fixed in rust-bitcoin/rust-miniscript#917.
Musab1258 commentedon Nov 2, 2026
I'd like to pick this up, but first I want to check the approach with you guys.
Where things stand
compile --type trstill builds a single leaf (TapTree::Leaf(Arc::new(policy.compile()?))), so a disjunctive policy reveals all its branches on spend. As @vadim-anfv noted, switching tocompile_trdoesn't work, becauseextract_key()makes a policy key the internal key and only uses the unspendable key as a fallback. I reproduced this on miniscript 12.3.7 (the version in the bdk-cli master branch'sCargo.lock):compile_tr(Some(NUMS))or(pk(A),pk(B))tr(A,pk(B))thresh(1,pk(A),pk(B),pk(C))tr(A,{pk(B),pk(C)})or(99@pk(A),1@and(pk(B),pk(C)))tr(A,and_v(v:pk(B),pk(C)))pk(A)Err(Empty Miniscript compilation)The first three would make
rmeaningless. The last is the bug tracked in rust-miniscript#902; it still occurs on 12.3.7. @vadim-anfv mentioned #917 fixes it, but I haven't checked if it has been added to a release yet.Proposal
I propose that we Keep the current
H + rGinternal key and build the tree ourselves, using only public miniscript APIs:orandthresh(1,..)into(probability, branch)pairs (the same splittingcompile_trdoes).compile::<Tap>()and runsanity_check().TapTree::combine.Descriptor::new_tras today.I tried this in a standalone crate against 12.3.7, using the plain NUMS key and
Stringplaceholder keys, and I got the following results:or(pk(A),pk(B))->tr(NUMS,{pk(B),pk(A)})thresh(1,A,B,C)->tr(NUMS,{pk(C),{pk(B),pk(A)}})or(99@pk(A),1@and(pk(B),pk(C)))->tr(NUMS,{and_v(v:pk(B),pk(C)),pk(A)})pk(A)->tr(NUMS,pk(A))and(..)andthresh(2,..)-> identical to stockcompile_tr(single leaf)Since the internal key is unchanged, I'd expect the
rverification in #368 to be unaffected, but I haven't tested that yet. This approach also doesn't depend on the upstream fix.Questions
compile_trdoes (root-levelor/thresh(1,..)), leaving thecompile_tr_private_experimental-style expansion ofthresh(k,n)for later?If the approach sounds right, I'll open a PR to fix it.