← back

secp256k1 NUMS Public Keys

What does NUMS even mean?

NUMS stands for "Nothing Up My Sleeve". The name comes from the magician's gesture of rolling up their sleeves to prove there's nothing hidden in them before a trick. In cryptography we borrow the idea to talk about constants that are built in such an obvious, boring way that nobody could have rigged them.

If you want a great primer on the idea, Computerphile has a video on it: Magic "Nothing Up My Sleeve" Numbers.

Whenever an algorithm needs a "random looking" constant, the person choosing it could, in theory, pick a value that secretly gives them an advantage (a backdoor, a shortcut, a way to cheat later). A NUMS value sidesteps that suspicion by being derived from something everyone already trusts, like the digits of pi or the hash of a well known string. The construction is the proof: anyone can recompute it and see there was no room to cheat.

A NUMS key on secp256k1

secp256k1 is the elliptic curve Bitcoin uses. On that curve, a private key is just a number (technically a scalar in the range 1 to n - 1, where n is the order of the curve), and the matching public key is that number multiplied by a fixed point called the generator G. The whole thing is secure because going backwards (figuring out the private key from the public key) is computationally hopeless. That's the discrete logarithm problem.

A NUMS key flips the usual goal on its head. Normally you want a public key whose private key only you know. A NUMS key is a public key whose private key nobody knows, including the person who created it.

How do you get a point on the curve that no one has the private key for? You don't pick a number and multiply. Instead you take some unquestionable value, treat it as if it were the x coordinate of a point, and find the matching point on the curve (thanks to secp256k1's equation y² = x³ + 7, that just means solving for y = √(x³ + 7) mod p, when a square root exists). And since the value came from something public and arbitrary, you couldn't have worked backwards from a private key you secretly kept either. There is still some private key behind that point (every point on the curve has one), but the only way to find it is to solve that discrete logarithm problem, so your odds of landing on a matching key are about 1 in n, the curve order, or roughly 1 in 2²⁵⁶.

So how is this useful?

The cleanest real world example is Taproot.

A Taproot output can be spent two ways: the "key path" (a normal signature) or the "script path" (one of several spending conditions hidden in a tree). Sometimes you want a coin that can only be spent through the scripts, with the key path completely disabled.

Diagram of how a Taproot output is built: a script tree of leaves and branches reduces to a merkle root, which is combined with a public key to produce a tweak and a final tweaked public key.

Taproot Summary, from learnmeabitcoin.com.

To do that, you need a public key that genuinely has no known private key, otherwise whoever knows that key could bypass all your carefully written scripts. So BIP-341 specifies a NUMS point for exactly this purpose. It's built by hashing the standard generator point and lifting the result to the curve:

text
H = lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0)

That hex value is the SHA256 of the encoded generator G. Anyone can recompute it in a couple of lines and confirm it wasn't cherry picked. Because it came out of a hash, no one knows (or can know) the corresponding private key, so the key path is provably dead and only the scripts can move the coins.

Here's the whole thing in SageMath: derive the base point H from G, add r * G for a scalar r that itself comes from hashing a string, and you get a NUMS point that nobody holds the key for.

python
import hashlib
from sage.all import FiniteField, EllipticCurve, Integer

# secp256k1 parameters
F = FiniteField(Integer(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F))
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141  # curve order
E = EllipticCurve([F(Integer(0)), F(Integer(7))])

# Original generator point G in uncompressed format
G_DER = '0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'

# Get H point by hashing G
H = E.lift_x(F(int(hashlib.sha256(bytes.fromhex(G_DER)).hexdigest(), 16)))
print("H (NUMS base point) = ")
print('%x %x' % H.xy())

# Generate scalar from string "unspendable"
r = Integer(int(hashlib.sha256("unspendable".encode()).hexdigest(), 16) % n)
print("\nScalar r = ")
print(hex(r))

# Get the original G point
G = E.lift_x(F(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798))

# Calculate rG
rG = r * G

# Calculate final NUMS point: H + rG
final_nums = H + rG
print("\nFinal NUMS point (H + rG) = ")
print('%x %x' % final_nums.xy())

# NUMS = 8335b42143dd67da2ec8cb8b9108777c351b47993bba2a537a04bf72eb7396a

Our first example is Labitbu. Rather than lifting one fixed hash to the curve, it hashes a tag together with a counter and reads the digest straight off as an x-only key: PK = SHA256(tag || ctr). The tag is just a label string (here, Labitbu, so the call is nums_from_tag(b"Labitbu")) that domain-separates the key so it can't coincide with one derived for some other purpose, and the counter is bumped until the digest lands on a valid x coordinate. You can check the resulting point in the NUMS explorer.

rust
fn nums_from_tag(tag: &[u8]) -> XOnlyPublicKey {
    let mut ctr = 0u32;
    loop {
        let mut eng = sha256::Hash::engine();
        eng.input(tag);
        eng.input(&ctr.to_le_bytes());
        let candidate = sha256::Hash::from_engine(eng);

        if let Ok(pk) = XOnlyPublicKey::from_slice(&candidate[..]) {
            return pk;
        }
        ctr += 1;
    }
}

Source: labitbu/src/lib.rs.

Another project leaning on the same trick is Strata, from Alpen Labs. It takes the most minimal route of all: no counter, no tweak, not even an explicit curve lift. It hashes the label Strata unspendable with plain SHA256 and feeds the 32-byte digest straight in as an x-only public key. An x-only key is an x coordinate, so this works as long as the digest lands on the curve, and from_slice would reject it (panicking on the expect) if it hadn't:

rust
pub const UNSPENDABLE_PUBLIC_KEY_INPUT: &[u8] = b"Strata unspendable";
pub static UNSPENDABLE_PUBLIC_KEY: LazyLock<XOnlyPublicKey> = LazyLock::new(|| {
    XOnlyPublicKey::from_slice(sha256::Hash::hash(UNSPENDABLE_PUBLIC_KEY_INPUT).as_byte_array())
        .expect("valid xonly public key")
});

Source: strata-common/crates/crypto/src/keys/constants.rs.

The takeaway

Most public keys matter because someone holds the secret behind them. A NUMS key matters for the opposite reason: nobody does, and anyone can check that for themselves.

That second half is the whole game. The three constructions above look nothing alike (a hash lifted to the curve, a tagged hash with a counter, a bare SHA256 read straight off as an x coordinate), yet they earn the same property the same way: the recipe is public, so the result couldn't have been rigged. No trusted setup to believe in, no "just take our word for it." The construction is the proof.

So when a protocol needs "nobody should be able to do this" to be a fact instead of a promise, a NUMS key is how you write it down.

PS: until a quantum computer breaks this shit. :D