How I added taproot support to bdk-cli's compile command.

What compile does

bdk-cli's compile takes a miniscript policy and turns it into a Bitcoin descriptor:

% bdk-cli compile "and(pk(A),pk(B))" -t sh
{
  "descriptor": "sh(and_v(v:pk(A),pk(B)))#0s43jst8"
}

It supported sh, wsh, and sh-wsh — but not taproot. Issue #204 asked for it.

Why taproot is different

A taproot descriptor is tr(INTERNAL_KEY, TREE). The internal key enables key-path spending, the tree holds script-path conditions.

When compiling from a policy, all keys live in the tree. There's no "real" internal key — so we need an unspendable one to disable the key path entirely.

NUMS key from BIP-341

BIP-341 defines a Nothing Up My Sleeve (NUMS) point — a point on secp256k1 for which nobody knows the discrete logarithm:

const NUMS_UNSPENDABLE_KEY_HEX: &str =
    "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0";

Implementation

Compile the policy into taproot miniscript, wrap it in a taptree leaf, use the NUMS key as internal key:

"tr" => {
    let taproot_policy: Miniscript<String, Tap> = policy
        .compile()
        .map_err(|e| Error::Generic(e.to_string()))?;

    let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)
        .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?;

    let tree = TapTree::Leaf(Arc::new(taproot_policy));
    Descriptor::new_tr(xonly_public_key.to_string(), Some(tree))
}

Result for or(pk(A),pk(B)):

tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{pk(A),pk(B)})

Why not compile_tr?

You might notice that rust-miniscript already has policy.compile_tr() which does all of this — takes a policy and an optional unspendable key, and returns a taproot descriptor directly. Why not use it? That's a good question, and I'll cover it in a separate post.

What's next

A fixed NUMS key works but leaks privacy — all compiled descriptors share the same internal key, which is a fingerprint. The follow-up is randomizing it per compilation.