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)
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')
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>
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
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)
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
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:
- Key aggregation: Each signer contributes their public key. The aggregate key is derived deterministically.
- Nonce exchange: Each signer generates two nonces and shares the commitments.
- Partial signing: Each signer produces a partial signature.
- 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)
For production MuSig2, use:
- Go:
btcec/v2/schnorr/musig2 - Rust:
secp256k1-zkpwith 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
)
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
- BIP 340 (Schnorr) — https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
- BIP 341 (Taproot) — https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki
- BIP 342 (Tapscript) — https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki
- BIP 327 (MuSig2) — https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki
- Bitcoin Optech Taproot Workshop — https://bitcoinops.org/en/schorr-taproot-workshop/
- btcec/v2 MuSig2 (Go) — https://pkg.go.dev/github.com/btcsuite/btcd/btcec/v2/schnorr/musig2
Top comments (0)