Chapter 5: Taproot: The Evolution of Bitcoin’s Script System#


Taproot lets complex spending conditions look identical to simple payments on-chain: until the funds are spent, an elaborate script tree and a single-key payment are indistinguishable.

Two pieces of machinery make this possible, and this chapter takes them one at a time:

  1. Schnorr signatures (BIP340) — a new signature scheme replacing ECDSA.

  2. Key tweaking (BIP341) — a way to attach extra information to a public key without changing how it looks on-chain.

A few terms used below — tweak, commitment, Merkle root, script path — are stage-setting for Chapters 6-8. If a phrase looks heavy on first read, skim it. The same sentences will read differently after you’ve worked through Chapters 5-8. This chapter only assumes what Chapters 1-4 already gave you: keys, signatures, scripts, and witnesses.

5.1 Taproot Commitment: Unified Appearance#

Taproot’s core feature is payment uniformity. A transaction may be any of:

  • a simple single-signature payment

  • a complex multi-party contract

  • a Lightning Network channel

  • a treasury with multiple authorization levels

Before it is spent, all of these look identical on the blockchain. That uniformity comes from two mechanisms: Schnorr signatures and key tweaking.

5.2 Schnorr Signatures#

Why Schnorr? The ECDSA Limitations#

Bitcoin shipped with ECDSA in 2009 and has used it ever since. ECDSA is fine for signing and verifying transactions in isolation — but Taproot’s design needs more than that. It needs signatures that don’t drift when copied, that combine cleanly when multiple parties sign together, and that behave predictably under simple algebra. ECDSA was never built for any of those.

The properties of ECDSA that get in Taproot’s way:

  • Malleability: A third party can alter the encoding of a signature without invalidating it — the same signature, two on-chain forms.

  • No aggregation: Two signatures from two parties stay as two separate signatures. They cannot be combined into one.

  • No linearity: Adding two ECDSA signatures does not produce a valid signature for the sum of their keys. There is no clean algebra to build on.

  • Variable size: 71-72 bytes typically, depending on encoding.

BIP340 specifies Schnorr signatures over the same secp256k1 curve, designed around the properties Taproot needs:

  • Non-malleable: Deterministic nonces, x-only public keys, and strict encoding remove the third-party malleability vectors ECDSA suffers from.

  • Aggregatable: Multiple public keys can be combined into one; multiple cooperating signers can produce a single 64-byte signature on-chain.

  • Linear: This is the property that enables Taproot. We unpack it next.

  • Fixed 64 bytes: Smaller and more uniform in size.

Schnorr Linearity#

The property that enables Taproot:

If Alice has signature A for message M
And Bob has signature B for the same message M
Then A + B creates a valid signature for (Alice + Bob)'s combined key

From this, three capabilities follow:

  1. Key Aggregation: Multiple people can combine their public keys into one

  2. Single-signature Output: Multiple parties can cooperatively produce one single unified signature

  3. Key Tweaking: Keys can be deterministically modified with commitments

Note: “Single-signature output” refers to producing one BIP340 signature on-chain via MuSig2 (a wallet-level protocol), not a consensus-level signature aggregation across inputs.

Visual Comparison: ECDSA vs Schnorr#

ECDSA Multisig (3-of-3):
┌─────────────────────────────────────┐
│           Transaction               │
├─────────────────────────────────────┤
│ Alice Signature:   [71 bytes]       │
│ Bob Signature:     [72 bytes]       │
│ Charlie Signature: [70 bytes]       │
├─────────────────────────────────────┤
│ Total Size: ~213 bytes              │
│ Verifications: 3 separate           │
│ Privacy: reveals 3 participants     │
│ Appearance: multi                   │
└─────────────────────────────────────┘

Schnorr Aggregated (3-of-3):
┌─────────────────────────────────────┐
│           Transaction               │
├─────────────────────────────────────┤
│ Aggregated Signature: [64 bytes]    │
├─────────────────────────────────────┤
│ Total Size: 64 bytes                │
│ Verifications: 1 single check       │
│ Privacy: hides participant count    │
│ Appearance: single                  │
└─────────────────────────────────────┘

5.3 Key Tweaking#

Taproot leverages Schnorr’s linearity through key tweaking (also called tweakable commitment in BIP340/341/342).

Conceptually:

t = H("TapTweak" || internal_pubkey || merkle_root)

Formally (BIP341):

t  = int(HashTapTweak(xonly_internal_key || merkle_root_or_empty)) mod n

P' = P + t * G
d' = d + t

Even-Y requirement (BIP340): Taproot uses x-only public keys — but the actual point on secp256k1 still has two possible y values (even / odd). The BIP340 rule is: the final tweaked output key must correspond to an even-y point. If the point ends up odd-y, implementations flip the private key to d' = n d' so that P' = d'*G lands on the even branch.

(Why this matters later: in script-path spending this parity is encoded into the control block’s lowest bit. If you don’t track this now, script-path won’t verify later.)

Tweak Flow#

Internal Key (P) ─────────► + tweak ─────────► Output Key (P')
                              ▲                      │
                              │                      │
                       Merkle Root ◄─────────────────┘
                    script_path_commitment

Where:

  • P = Internal Key (original public key, user controls)

  • M = Merkle Root (commitment to all possible spending conditions)

  • t = Tweak Value (deterministic from P and M)

  • P' = Output Key (final Taproot address, appears on blockchain)

  • d' = Tweaked Private Key (for key path spending)

This relationship ensures:

  1. Anyone can compute P’ from P and commitment (given internal key P and the optional Merkle root M)

  2. Only the key holder can compute d’ from d and tweak

  3. The relation d’ × G = P’ holds, so signature verification works.

Key Code#

# Key-path-only: tree empty, t = HashTapTweak(internal_pubkey || b''), P' = P + t*G
address = pubkey.get_taproot_address([])
# Example 1: Key-path-only Taproot address (btcaaron)
# Reference: examples/ch05_simple_taproot.py

from btcaaron import Key, TapTree

sender = Key.from_wif("cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT")
program = TapTree(internal_key=sender).build()

print("=== KEY-PATH-ONLY TAPROOT ADDRESS ===")
print(f"Internal key (x-only): {sender.xonly}")
print(f"Taproot address:   {program.address}")
print(f"Leaf scripts: {program.leaves} (empty)")
print("btcaaron internally: t=HashTapTweak(x-only||merkle_root), P'=P+t×G")

Notes on key tweaking:

  1. Two spending paths: The tweaked key gives two spending methods:

    • Key Path: sign directly with the tweaked private key (cooperative).

    • Script Path: reveal the internal pubkey and prove script execution (fallback).

  2. Binding: the tweak binds the output key to a specific script commitment.

  3. Deterministic verification: anyone can verify the tweaked key commits to the stated conditions.

  4. Indistinguishability: the tweaked public key is indistinguishable from any other Schnorr pubkey.

5.4 Why the Appearance Is Uniform#

Schnorr signatures plus key tweaking are what make a simple payment and a complex contract look the same:

Simple Payment:
├── Internal Key: a regular private key
├── Script Commitment: empty (no conditions)
├── Tweaked Key: Internal key + H(key || empty)
└── Spending: 64-byte Schnorr signature

Complex Contract:
├── Internal Key: the same regular private key
├── Script Commitment: Merkle root of 100 conditions
├── Tweaked Key: Internal key + H(key || merkle_root)
└── Spending: 64-byte Schnorr signature (if cooperative)

To an outside observer, both produce an identical 64-byte signature.

5.5 A Simple Taproot Transaction#

A basic Taproot-to-Taproot transaction:

# Example 2: Simple Taproot transaction (btcaaron)
# Reference: examples/ch05_simple_taproot.py

from btcaaron import Key, TapTree

sender = Key.from_wif("cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT")
program = TapTree(internal_key=sender).build()

tx = (program.keypath()
    .from_utxo("b0f49d2f30f80678c6053af09f0611420aacf20105598330cb3f0ccb8ac7d7f0", 0, sats=29200)
    .to("tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h", 29000)
    .sign(sender)
    .build())

print("=== TAPROOT TRANSACTION ===")
print(f"From: {program.address}")
print(f"To:   tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h")
print(f"Amount: 29,000 sats (fee: 200 sats)")
print(f"TXID: {tx.txid}")
print()
print("Witness: 64-byte Schnorr signature, indistinguishable from any Taproot payment")

Observations:

  1. Taproot address generation: get_taproot_address() applies the BIP341 tweak.

  2. Schnorr signature: sign_taproot_input() produces a 64-byte BIP340 signature. The witness item is 64 bytes, or 65 with a non-default sighash flag — SIGHASH_DEFAULT omits the flag.

  3. Minimal witness: the witness stack holds only the signature.

  4. Identical appearance: indistinguishable from any other Taproot transaction.

5.6 On-Chain Example: Testnet Taproot Transfer#

A real Taproot transaction: a3b4d0382efd189619d4f5bd598b6421e709649b87532d53aecdc76457a42cb6

Input:  ScriptPubKey OP_1 912591f3...5f697a3, Witness [7d25fbc9...da99f3]
Output: tb1p53ncq9...

The signature is exactly 64 bytes with no variable encoding (r 32B + s 32B); the witness contains only the signature, with no public key (unlike SegWit).

5.7 Key-Path Stack Execution#

The pattern OP_1 <32-bytes> selects the Taproot interpreter. A witness containing only a signature selects the key path; a witness containing a script and control block selects the script path (covered in Chapter 6).

│ (empty)              │  →  OP_1  →  │ 912591f3...5f697a3 (output_key) │
└───────────┘                         └───────────────────────────────────┘
     →  witness push  →  │ 7d25fbc9...da99f3 (schnorr_signature) │
                    │ 912591f3...5f697a3 (output_key)        │
                    └───────────────────────────────────────┘
     →  Schnorr verification  →  │ 1 (TRUE) │

The interpreter runs BIP340 verification: parse (r, s), compute the challenge e = tagged_hash("BIP0340/challenge", r P m), compute R = s·G e·P, and accept if r equals the x-coordinate of R.

5.8 Output Shape: Legacy -> SegWit -> Taproot#

Legacy P2PKH:
├── ScriptPubKey: OP_DUP OP_HASH160 <20-byte-hash> OP_EQUALVERIFY OP_CHECKSIG
├── ScriptSig: <signature> <public_key>
└── Size: ~225 bytes
   Information Revealed: Single signature spending

SegWit P2WPKH:
├── ScriptPubKey: OP_0 <20-byte-hash>
├── Witness: [signature, public_key]
└── Size: ~165 bytes
   Information Revealed: Single signature spending

Taproot P2TR (Simple):
├── ScriptPubKey: OP_1 <32-byte-output-key>
├── Witness: [schnorr_signature]
└── Size: ~135 bytes
   Information Revealed: Nothing about internal complexity

Taproot P2TR (Complex Contract):
├── ScriptPubKey: OP_1 <32-byte-output-key>
├── Witness: [schnorr_signature]
└── Size: ~135 bytes
   Information Revealed: Nothing about internal complexity

The simple-Taproot row and the complex-Taproot row are byte-for-byte identical at the output level.

# Runnable: Parse 64-byte Schnorr signature into r/s (stdlib)
sig_hex = "7d25fbc9b98ee0eb09ed38c2afc19127465b33d6120f4db8d4fd46e532e30450d7d2a1f1dd7f03e8488c434d10f4051741921d695a44fb774897020f41da99f3"
sig = bytes.fromhex(sig_hex)
r, s = sig[:32], sig[32:]
print(f"r ({len(r)}B): {r.hex()[:16]}...{r.hex()[-8:]}")
print(f"s ({len(s)}B): {s.hex()[:16]}...{s.hex()[-8:]}")

5.9 SegWit -> Taproot: Code Differences#

# SegWit P2WPKH: address = pk.get_segwit_address(); witness = [sig, pubkey]
# Taproot P2TR:  address = pubkey.get_taproot_address([]); witness = [sig]

Two API changes are load-bearing: signing uses sign_taproot_input() (Schnorr, BIP340) instead of sign_segwit_input() (ECDSA), and the witness contains only the signature — the public key is already in the scriptPubKey as the output key.

5.10 Cooperative vs Script Path: Cost Asymmetry#

Cooperative key-path spends produce a 64-byte witness regardless of how many parties are behind the output key. Script-path spends require revealing the leaf script and a control block (33 bytes for the internal pubkey + leaf depth × 32 bytes for the Merkle proof), so witness size scales with tree depth and revealed script length. The fee difference makes cooperation the cheaper path whenever it is available.

Cooperative Spending (Key Path):
├── Parties: Alice, Bob, Charlie (all agree)
├── Witness: [64-byte signature]
├── Size: ~135 bytes
├── Privacy: looks like single-sig
└── Fee: lowest

Non-Cooperative Spending (Script Path):
├── Parties: Alice, Bob, Charlie (dispute)
├── Witness: [script_data, revealed_script, control_block]
├── Size: ~200-500 bytes
├── Privacy: reveals one condition
└── Fee: higher

Chapter Summary#

Taproot replaces ECDSA with BIP340 Schnorr (64 bytes, fixed length) and applies the BIP341 tweak P' = P + t·G to the output key. The tweak commits to either nothing (key-path-only) or a Merkle root over a script tree. On-chain, both cases produce the same shape — OP_1 <32 bytes> — and a key-path spend has the same 64-byte witness regardless of internal complexity.

Because the on-chain shape and witness are identical, an outside observer cannot distinguish:

  • a simple single-signature payment

  • a complex multi-party contract

  • a Lightning Network channel

  • a treasury transaction

A cooperative key-path spend is also the cheapest option: one 64-byte signature, a single verification, and unused conditions never touch the chain. Technical efficiency and economic incentive point the same way.

The next chapter shows how arbitrary spending conditions are organized into the script tree’s Merkle structure, committed at output creation, and revealed only when actually used.