Why using a fixed NUMS key for taproot descriptors leaks privacy, and how to randomize it.

This post is a follow-up to Adding taproot descriptor compilation to bdk-cli.

The problem with a fixed NUMS key

In the previous post we used the BIP-341 NUMS point as the internal key:

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

This works - the key path is unspendable, funds can only move through the script tree. But every bdk-cli user gets the exact same internal key. An on-chain observer can scan for this well-known point and immediately learn:

  • The key path is intentionally disabled (script-path-only spending)
  • The output was created by one of many tools that follow the BIP-341 recommendation for unspendable keys
  • The user is using a specific wallet construction pattern

A fixed NUMS key acts as a fingerprint. These outputs become distinguishable from taproot outputs that use real key-path spending, which defeats the privacy benefit of taproot making all outputs look the same.

Randomization approach

Instead of using H directly, we compute H + rG where:

  • H - the BIP-341 NUMS point (no known discrete logarithm)
  • r - a freshly generated random scalar
  • G - the secp256k1 generator point

Each compilation produces a unique internal key. Since the discrete log of H is unknown, H + rG is equally unspendable for any r - you'd need to know the discrete log of H to spend via the key path.

The r value is returned alongside the descriptor so the user can later verify the key is indeed derived from the NUMS point (by recomputing H + rG and checking it matches the internal key).

Implementation

let secp = Secp256k1::new();
let r_secret = SecretKey::new(&mut rand::thread_rng());

let nums_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)?;
let nums_point = PublicKey::from_x_only_public_key(nums_key, Parity::Even);

let internal_key_point = nums_point
    .add_exp_tweak(&secp, &Scalar::from(r_secret))?;
let (xonly_internal_key, _) = internal_key_point.x_only_public_key();

add_exp_tweak computes P + scalar*G in one step - cleaner than generating r*G as a separate public key and combining two points.

The output now includes r:

% bdk-cli compile "or(pk(A),pk(B))" -t tr
{
  "descriptor": "tr(2dd09dd0...,{pk(A),pk(B)})#anvu48aj",
  "r": "275a5882...380cdcfd"
}

Each invocation gives a different descriptor and r. Non-taproot types are unchanged.

Well, bdk-cli can't verify r out of the box just yet - but I'm working on it, stay tuned.

The PR: bitcoindevkit/bdk-cli#225.