DEV Community

Aturo Phil
Aturo Phil

Posted on

Taproot for Developers: What Changed and What You Can Build

Taproot activated on Bitcoin at block 709,632 in November 2021. Three and a half years later, adoption is growing but developer understanding of what Taproot actually enables, at the code level, is still shallow in most technical content.

This post is about what Taproot changed in the Bitcoin protocol, what Schnorr signatures unlock, what MAST (Merkelized Abstract Syntax Trees) means for script design, and what you can build today that was not possible or practical before.


What Taproot Is, Precisely

Taproot is a combination of three improvements shipped together in BIP 340, BIP 341, and BIP 342:

  • BIP 340: Schnorr signatures — a new signature scheme replacing ECDSA for Taproot outputs
  • BIP 341: Taproot itself — a new output type (P2TR) that can be spent either by a key path or a script path
  • BIP 342: Tapscript — modifications to Bitcoin Script that apply when spending via the script path

They are inseparable in practice. You need all three to use Taproot correctly.


Schnorr Signatures: What They Change

Bitcoin used ECDSA for signatures from 2009 through 2021. ECDSA works, but it has properties that limit what you can build efficiently:

  • Signatures are not linearly composable, you cannot add two ECDSA signatures together to produce a valid combined signature
  • Signature size varies (71–72 bytes DER-encoded)
  • Batch verification is not possible

Schnorr signatures (BIP 340) fix all three:

  • They are linearly composable — you can add public keys and add signatures, and the result is a valid signature for the combined key
  • Fixed 64-byte size
  • Batch verification of N signatures is faster than N sequential verifications

The linear composability property is what enables MuSig2 and key aggregation. If Alice has key A and Bob has key B, they can produce a combined key C = A + B. A Schnorr signature from C is indistinguishable from a single-key signature. No one observing the blockchain can tell that C is a multisig key.

BIP 340 specifies the exact scheme. The signature is (R, s) where:

R = k·G  (nonce point)
s = k + e·x  (mod n)
e = H_tag(R || P || m)  (tagged hash)
Enter fullscreen mode Exit fullscreen mode

P is the public key, m is the message (transaction hash), x is the private key scalar, and k is the nonce.

Verification checks s·G = R + e·P.

In Python, using the bip340 reference implementation:

import hashlib
import secrets

def tagged_hash(tag: str, msg: bytes) -> bytes:
    tag_hash = hashlib.sha256(tag.encode()).digest()
    return hashlib.sha256(tag_hash + tag_hash + msg).digest()

def schnorr_sign(private_key: int, message: bytes) -> bytes:
    """
    BIP 340 Schnorr signature.
    Returns 64-byte signature (R.x || s).
    """
    from secp256k1 import G, n, point_mul, point_add

    P = point_mul(G, private_key)
    # BIP 340: negate private key if P.y is odd
    if P.y % 2 != 0:
        private_key = n - private_key

    k = int.from_bytes(secrets.token_bytes(32), 'big') % n
    R = point_mul(G, k)
    if R.y % 2 != 0:
        k = n - k

    P_bytes = P.x.to_bytes(32, 'big')
    R_bytes = R.x.to_bytes(32, 'big')
    e = int.from_bytes(tagged_hash("BIP0340/challenge", R_bytes + P_bytes + message), 'big') % n
    s = (k + e * private_key) % n

    return R_bytes + s.to_bytes(32, 'big')
Enter fullscreen mode Exit fullscreen mode

In practice, you use a library. Python: python-bitcoinlib or coincurve. Go: btcec/v2. Rust: secp256k1.


Key Path vs. Script Path Spending

A Pay-to-Taproot (P2TR) output encodes a single 32-byte x-only public key:

OP_1 <32-byte-x-only-pubkey>
Enter fullscreen mode Exit fullscreen mode

This output can be spent two ways:

Key path: Provide a Schnorr signature for the encoded public key. The key is called the "tweaked key" because it commits to a Merkle root of spending conditions.

Script path: Reveal one of the scripts in the Merkle tree, prove its position in the tree (Merkle proof), and satisfy that script.

The tweaked key is computed as:

Q = P + H_taptweak(P || merkle_root) · G
Enter fullscreen mode Exit fullscreen mode

Where P is the "internal key" and merkle_root is the Merkle root of all the spending scripts (or a zero hash if there are no scripts).

If you only need a single key to spend (no scripts), the key path is a single 64-byte signature, cheaper than any script-based spend. If you need complex conditions, the script path lets you commit to many conditions but only reveal the one you use.


MAST: Merkelized Script Trees

Before Taproot, if you wanted "Alice can spend, or Bob and Carol can spend together, or time-locked refund", you had to use OP_IF branches in a single script. Every branch was visible when you spent. The script revealed all conditions.

With Taproot, each condition is a leaf in a Merkle tree. When you spend, you reveal only the leaf you use and the Merkle proof connecting it to the committed root. The other branches are never published.

Building a script tree in Python using python-bitcoinlib:

from bitcoin.core.script import CScript, OP_CHECKSIG, OP_CHECKLOCKTIMEVERIFY, OP_DROP
from bitcoin.core.key import CPubKey
import hashlib

def tapbranch_hash(left: bytes, right: bytes) -> bytes:
    """BIP 341 TapBranch hash."""
    tag = "TapBranch"
    tag_hash = hashlib.sha256(tag.encode()).digest()
    if left > right:
        left, right = right, left
    return hashlib.sha256(tag_hash + tag_hash + left + right).digest()

def tapleaf_hash(script: bytes, version: int = 0xc0) -> bytes:
    """BIP 341 TapLeaf hash."""
    tag = "TapLeaf"
    tag_hash = hashlib.sha256(tag.encode()).digest()
    leaf_data = bytes([version]) + compact_size(len(script)) + script
    return hashlib.sha256(tag_hash + tag_hash + leaf_data).digest()

# Three spending conditions
alice_pubkey = CPubKey(bytes.fromhex("02..."))
bob_pubkey = CPubKey(bytes.fromhex("03..."))

# Leaf A: Alice spends
leaf_a_script = CScript([alice_pubkey, OP_CHECKSIG])
leaf_a_hash = tapleaf_hash(bytes(leaf_a_script))

# Leaf B: Bob spends after block 800000
leaf_b_script = CScript([800000, OP_CHECKLOCKTIMEVERIFY, OP_DROP, bob_pubkey, OP_CHECKSIG])
leaf_b_hash = tapleaf_hash(bytes(leaf_b_script))

# Merkle root of the two leaves
merkle_root = tapbranch_hash(leaf_a_hash, leaf_b_hash)
Enter fullscreen mode Exit fullscreen mode

When Alice spends via Leaf A:

Witness stack:
  <alice_signature>          # satisfies the script
  <leaf_a_script>            # the script being executed
  <leaf_b_hash>              # Merkle proof: sibling hash
  <control_block>            # version byte + internal key + parity bit
Enter fullscreen mode Exit fullscreen mode

Bob's leaf is never revealed. Its existence is committed but private.


MuSig2: Practical n-of-n Multisig

MuSig2 (BIP 327) is the key aggregation scheme that makes n-of-n multisig look like single-sig on-chain.

The core operations:

  1. Key aggregation: Each signer contributes their public key. The aggregate key is derived deterministically.
  2. Nonce exchange: Each signer generates two nonces and shares the commitments.
  3. Partial signing: Each signer produces a partial signature.
  4. Signature aggregation: Partial signatures are summed to produce the final Schnorr signature.
from coincurve import PublicKey, PrivateKey

def aggregate_pubkeys(pubkeys: list[bytes]) -> bytes:
    """
    MuSig2 key aggregation (simplified).
    In production, use a correct MuSig2 implementation — the actual
    scheme includes key aggregation coefficients to prevent rogue key attacks.
    """
    # Each key is weighted by H(L || P_i) where L = hash of all keys
    L = hashlib.sha256(b''.join(sorted(pubkeys))).digest()

    # In the real scheme, each key is multiplied by its coefficient before summing
    # This is the conceptual structure; use bitcoin-core's secp256k1-zkp for production
    aggregate = None
    for pk_bytes in pubkeys:
        pk = PublicKey(pk_bytes)
        # ... apply coefficient multiplication
        aggregate = pk if aggregate is None else aggregate.combine([pk])
    return aggregate.format(compressed=True)
Enter fullscreen mode Exit fullscreen mode

For production MuSig2, use:

  • Go: btcec/v2/schnorr/musig2
  • Rust: secp256k1-zkp with the MuSig module
  • Python: pysecp256k1 (wrapping libsecp256k1 with MuSig2 support)

A 3-of-3 multisig using MuSig2 on-chain is a single key path spend: one signature, 64 bytes, identical to any single-key Taproot output. The threshold structure is invisible.


What You Can Build With Taproot

Collaborative custody without revealing signers: A 2-of-3 setup can be implemented as a Taproot tree where each 2-of-2 combination is a leaf. Normal spends use key path (all three sign via MuSig2). Emergency recovery uses the appropriate script leaf. On-chain, all cooperative spends look like single-key transactions.

Vaults with time-locks: A leaf tree where:

  • Leaf 1: Owner key, no delay
  • Leaf 2: Recovery key, 6-month CLTV
  • Leaf 3: Emergency key, 1-year CLTV

Covenants are not yet possible in Bitcoin Script without soft fork changes (OP_VAULT, CTV), but you can simulate some patterns with pre-signed transactions.

Discreet Log Contracts (DLCs): DLCs allow Bitcoin contracts that resolve based on external oracle attestations. The oracle signs a message with a known key and an adaptor signature mechanism routes funds based on which outcome they attest to. Taproot makes DLC outputs indistinguishable from regular transactions on-chain.

Lightning Taproot channels: LND and CLN both support Taproot-based channels (Simple Taproot Channels, BIP draft). The funding output becomes a MuSig2 key. Force close still reveals the commitment structure, but cooperative closes are indistinguishable from simple transfers.


Practical: Reading a P2TR Address

P2TR addresses use bech32m encoding (distinct from bech32 used for P2WPKH/P2WSH):

import bech32

def decode_p2tr_address(address: str) -> bytes:
    """Decode a P2TR (bech32m) address to the 32-byte x-only pubkey."""
    hrp, data = bech32.bech32_decode(address)
    if data is None:
        raise ValueError("Invalid bech32m address")
    decoded = bech32.convertbits(data[1:], 5, 8, False)
    if len(decoded) != 32:
        raise ValueError(f"Expected 32 bytes, got {len(decoded)}")
    return bytes(decoded)

def create_p2tr_output(internal_key: bytes, merkle_root: bytes = b'') -> bytes:
    """
    Create a P2TR scriptPubKey.
    internal_key: 32-byte x-only public key
    merkle_root: TapTree merkle root, or empty bytes for key-path-only
    """
    tweaked_key = taproot_tweak_pubkey(internal_key, merkle_root)
    return bytes([0x51, 0x20]) + tweaked_key  # OP_1 <32 bytes>

# Checking if a UTXO is P2TR
def is_p2tr(script_pubkey: bytes) -> bool:
    return (
        len(script_pubkey) == 34 and
        script_pubkey[0] == 0x51 and  # OP_1
        script_pubkey[1] == 0x20      # PUSH 32 bytes
    )
Enter fullscreen mode Exit fullscreen mode

The Current State

As of July 2026, approximately 12% of Bitcoin UTXOs use P2TR outputs. Adoption is growing primarily through:

  • Lightning Taproot channels (now default in LND v0.21+)
  • Hardware wallet updates (Ledger and Trezor both support P2TR)
  • Wallet software updates (Sparrow, Electrum, BlueWallet)

The tooling gap that still exists is in educational content about MuSig2 and script tree construction. Most developers know Taproot exists but have not written code that uses the script path. The libraries are mature; the documentation is scattered.


Further Reading

Top comments (0)