Skip to content

Add taproot privacy optimisations in policy compiler #244

Open

Description

@simongltr

Describe the enhancement

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))

Activity

  1. simongltr commented on Feb 25, 2026

    @simongltr
    Author

    Seems like it's not that simple.

    Both compile_tr and compile_tr_private_experimental fail for pk(A):

    • compile_tr returns Err("Empty Miniscript compilation")
    • 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))).

  2. added this to the CLI 3.0.0 milestone on Feb 26, 2026
  3. moved this from Todo to No status in BDK-CLIon Mar 10, 2026
  4. modified the milestones: CLI 3.0.0, CLI 2.0.0 on Mar 14, 2026
  5. vadim-anfv commented on Mar 25, 2026

    @vadim-anfv
    Collaborator

    Looked into switching to compile_tr for the tr branch.

    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:

    fn extract_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.

  6. moved this to In Progress in BDK-CLIon Mar 25, 2026
  7. vadim-anfv commented on May 4, 2026

    @vadim-anfv
    Collaborator

    Separately, compile_tr / compile_tr_private_experimental have bugs with simple policies - will continue investigating upstream in rust-bitcoin/rust-miniscript#902.

    Fixed in rust-bitcoin/rust-miniscript#917.

  8. added this to the CLI 4.0.0 milestone on May 6, 2026
  9. modified the milestones: CLI 4.0.0, CLI 4.1.0 on Jun 30, 2026
  10. Musab1258 commented on Nov 2, 2026

    @Musab1258
    Contributor

    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:

    1. Flatten root-level or and thresh(1,..) into (probability, branch) pairs (the same splitting compile_tr does).
    2. Compile each branch with compile::<Tap>() and run sanity_check().
    3. Combine the leaves into a Huffman tree with TapTree::combine.
    4. 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:

    • 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(..) 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.

    Questions

    1. @vadim-anfv, does this fit the plan for NUMS_UNSPENDABLE_KEY_HEX is not supposed to be used as is (privacy concerns) #218?
    2. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    • Status
      In Progress

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions