Chapter 8: Four-Leaf Taproot Script Tree#


From Two Leaves to Four#

Chapter 7 built a two-leaf tree: one TapBranch over two leaves, and a control block carrying a single sibling hash. This chapter goes to four leaves, and that means a tree with two levels — leaves pair into branches, and the branches pair into the root. The control block grows to match: it now carries two sibling hashes (97 bytes), a Merkle path that climbs two levels instead of one.

Four leaves is also enough room to put genuinely different conditions side by side. The address we build has four script paths plus the key path — five ways to spend, one address:

  1. Hashlock — anyone with the preimage “helloworld” can spend (the Chapter 6/7 hash lock).

  2. 2-of-2 multisig — Alice and Bob together, written with Tapscript’s OP_CHECKSIGADD.

  3. CSV timelock — Bob can spend, but only after 2 blocks have passed.

  4. Simple signature — Bob can spend immediately.

  5. Key path — Alice spends directly with her tweaked key; looks like an ordinary payment.

What Four Leaves Are For#

These four conditions map onto real patterns — a recovery path plus a timelock plus a cooperative close is the skeleton of a wallet-recovery scheme or a Lightning channel — but the point here is mechanical: how four leaves are committed, and how each path is revealed and verified.

A few properties carry over from the earlier chapters:

  • Selective disclosure — only the executed script is revealed on chain; the other three stay hidden.

  • Fee efficiency — the cost grows with the log of the number of leaves, not the count.

  • Multiple paths, one commitment — five spending conditions sit behind a single address.

One Shared Address#

Every spend below comes out of the same address:

  • Address: tb1pjfdm902y2adr08qnn4tahxjvp6x5selgmvzx63yfqk2hdey02yvqjcr29q

  • Five different spending methods, one address.

The Tree#

Its tree is balanced — two leaves under each of two branches:

                 Merkle Root
                /            \
        Branch0              Branch1
        /      \             /      \
   Script0   Script1    Script2   Script3
  (Hashlock) (Multisig)  (CSV)    (Sig)

Each leaf’s witness is the same shape we’ve used since Chapter 6 — data, then script, then control block — with the data part differing by what the script checks:

Leaf

Unlocks with

Witness [0..]

Script 0 Hashlock

the preimage

[preimage]

Script 1 Multisig

both signatures

[bob_sig, alice_sig]

Script 2 CSV

a signature, after 2 blocks

[bob_sig] + tx sequence set

Script 3 Simple sig

a signature

[bob_sig]

Key path

Alice’s tweaked key

[alice_sig]

Building the Tree#

The setup is keys, then four scripts, then the tree. (The notebook’s runnable code uses btcaaron; the snippets below show the equivalent bitcoinutils logic.)

Keys#

alice_priv = PrivateKey("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
bob_priv = PrivateKey.from_wif("cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG")
alice_pub = alice_priv.get_public_key()
bob_pub = bob_priv.get_public_key()

The four scripts#

Two are familiar from earlier chapters; two are new:

# Script 0: Hashlock (requires import hashlib)
hash0 = hashlib.sha256(b"helloworld").hexdigest()
script0 = Script(['OP_SHA256', hash0, 'OP_EQUALVERIFY', 'OP_TRUE'])

# Script 1: 2-of-2 Multisig
script1 = Script(["OP_0", alice_pub.to_x_only_hex(), "OP_CHECKSIGADD",
                  bob_pub.to_x_only_hex(), "OP_CHECKSIGADD", "OP_2", "OP_EQUAL"])

# Script 2: CSV timelock
seq = Sequence(TYPE_RELATIVE_TIMELOCK, 2)
script2 = Script([seq.for_script(), "OP_CHECKSEQUENCEVERIFY", "OP_DROP",
                  bob_pub.to_x_only_hex(), "OP_CHECKSIG"])

# Script 3: Simple signature
script3 = Script([bob_pub.to_x_only_hex(), "OP_CHECKSIG"])

Creating the address#

The tree is written as nested pairs — two leaves per branch — and that nesting is what gives a two-level Merkle structure:

tree = [[script0, script1], [script2, script3]]
taproot_address = alice_pub.get_taproot_address(tree)
# Four-leaf Taproot script tree (btcaaron)
# Reference: examples/ch08_four_leaf_tree.py

from btcaaron import Key, TapTree

alice = Key.from_wif("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
bob   = Key.from_wif("cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG")

# Four-leaf: hashlock | 2of2 multisig | CSV timelock | bob checksig
program = (TapTree(internal_key=alice)
    .hashlock("helloworld", label="hash")
    .multisig(2, [alice, bob], label="2of2")
    .timelock(blocks=2, then=bob, label="csv")
    .checksig(bob, label="bob")
).build()

print("=== FOUR-LEAF TAPROOT TREE ===")
print(f"Address: {program.address}")
print(f"Leaves: {program.leaves}")
print(program.visualize())

# Five spending path examples
# 1. Hashlock
tx_h = program.spend("hash").from_utxo("245563c5aa4c6d32fc34eed2f182b5ed76892d13370f067dc56f34616b66c468", 0, sats=1200).to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 666).unlock(preimage="helloworld").build()
print(f"Hashlock TXID: {tx_h.txid}")
# 2. 2of2
tx_m = program.spend("2of2").from_utxo("1ed5a3e97a6d3bc0493acc2aac15011cd99000b52e932724766c3d277d76daac", 0, sats=1400).to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 668).sign(alice, bob).build()
print(f"2of2 Multisig TXID: {tx_m.txid}")
# 3. Key Path (Alice)
tx_k = program.keypath().from_utxo("42a9796a91cf971093b35685db9cb1a164fb5402aa7e2541ea7693acc1923059", 0, sats=2000).to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 888).sign(alice).build()
print(f"Key Path TXID: {tx_k.txid}")

Spending Each Path#

The five spends differ only in what they put in the witness and which leaf index the control block points at. We’ll walk all five; the patterns repeat. (See examples/ch08_* or the btcaaron cell above for runnable code.)

1. Hashlock (Script 0)#

The Chapter 6/7 hash lock, now at index 0 of a four-leaf tree — so its control block carries a two-level proof, but the call looks identical. Witness: [preimage, script, control_block]. TXID: 1ba4835f...

cb = ControlBlock(alice_pub, tree, 0, is_odd=taproot_address.is_odd())
tx.witnesses.append(TxWitnessInput(["helloworld".encode().hex(), script0.to_hex(), cb.to_hex()]))

2. Multisig (Script 1)#

Two signatures, both produced as script-path signatures over the same leaf. Witness order is the subtle part: Bob’s signature first (stack bottom, consumed second), Alice second. Both signers use script_path=True, tapleaf_script=script1, tweak=False. TXID: 1951a3be...

cb = ControlBlock(alice_pub, tree, 1, is_odd=taproot_address.is_odd())
# When signing: script_path=True, tapleaf_script=script1
tx.witnesses.append(TxWitnessInput([sig_bob, sig_alice, script1.to_hex(), cb.to_hex()]))

3. CSV timelock (Script 2)#

The timelock path has one requirement the others don’t: the transaction has to set a matching sequence, or OP_CHECKSEQUENCEVERIFY rejects it. The script carries seq.for_script() (the rule); the input carries seq.for_input_sequence() (the evidence). Both come from the same Sequence object. Witness: [sig_bob, script, cb]. TXID: 98361ab2...

txin = TxInput(commit_txid, vout, sequence=seq.for_input_sequence())
cb = ControlBlock(alice_pub, tree, 2, is_odd=taproot_address.is_odd())
tx.witnesses.append(TxWitnessInput([sig_bob, script2.to_hex(), cb.to_hex()]))

4. Simple signature (Script 3)#

The plainest leaf — Bob signs, no extra conditions. Same as Chapter 7’s Bob script, now at index 3. Witness: [sig_bob, script, cb]. TXID: 1af46d4c...

cb = ControlBlock(alice_pub, tree, 3, is_odd=taproot_address.is_odd())
tx.witnesses.append(TxWitnessInput([sig_bob, script3.to_hex(), cb.to_hex()]))

5. Key path#

The one that reveals nothing — Alice’s key-path spend, identical in spirit to Chapter 6’s. It still needs the whole tree to rebuild the tweak, but nothing about the tree reaches the chain. The key path passes tapleaf_scripts=tree (plural, the whole tree) with script_path=False; every script path passes tapleaf_script=script_n (singular, one leaf) with script_path=True. Witness is just [sig_alice]. TXID: 1e518aa5...

sig_alice = alice_priv.sign_taproot_input(..., script_path=False, tapleaf_scripts=tree)
tx.witnesses.append(TxWitnessInput([sig_alice]))

How OP_CHECKSIGADD Runs#

The multisig leaf is the one new piece of script execution in this chapter, so let’s walk its stack. Tapscript replaced the old OP_CHECKMULTISIG with OP_CHECKSIGADD, which keeps a running count of valid signatures.

Multisig script structure#

script1 = Script(["OP_0", alice_pub.to_x_only_hex(), "OP_CHECKSIGADD",
                  bob_pub.to_x_only_hex(), "OP_CHECKSIGADD", "OP_2", "OP_EQUAL"])

Witness order#

The witness puts both signatures on the stack before the script runs, and the order matters: Bob’s signature first (stack bottom, consumed second), Alice’s second (stack top, consumed first).

tx.witnesses.append(TxWitnessInput([sig_bob, sig_alice, script1.to_hex(), cb.to_hex()]))

Stack walk#

Execution script: OP_0 [Alice_PubKey] OP_CHECKSIGADD [Bob_PubKey] OP_CHECKSIGADD OP_2 OP_EQUAL

Start: both signatures loaded, sig_alice on top#

Stack State (bottom to top):
│ sig_alice     │ ← top, consumed first
│ sig_bob       │ ← consumed second by OP_CHECKSIGADD
└─────────────--┘

1. OP_0: pushes the counter, initialized to 0#

Stack State:
│ 0           │ ← counter
│ sig_alice   │
│ sig_bob     │
└─────────────┘

2. [Alice_PubKey]: the script pushes Alice’s key#

Stack State:
│ alice_pubkey│ ← Alice's 32-byte x-only public key
│ 0           │ ← counter
│ sig_alice   │
│ sig_bob     │
└─────────────┘

3. OP_CHECKSIGADD: verify sig_alice, increment counter#

Pops the key, pops the counter, pops the signature below it; verifies sig_alice against alice_pubkey; pushes counter+1.

Stack State:
│ 1           │ ← counter is now 1
│ sig_bob     │
└─────────────┘

4. [Bob_PubKey]: the script pushes Bob’s key#

Stack State:
│ bob_pubkey  │ ← Bob's 32-byte x-only public key
│ 1           │ ← counter
│ sig_bob     │
└─────────────┘

5. OP_CHECKSIGADD: same again for Bob, consuming sig_bob#

Stack State:
│ 2           │ ← counter is now 2
└─────────────┘

6. OP_2: push the required count#

Stack State:
│ 2           │ ← required signature count
│ 2           │ ← actual verified count
└─────────────┘

7. OP_EQUAL: compare; 2 == 2, so push 1#

Final Stack State:
│ 1           │ ← script satisfied
└─────────────┘

That’s why sig_alice has to be on top of sig_bob in the witness: the first OP_CHECKSIGADD is Alice’s, and it consumes whichever signature is on top at that moment. The witness lists [sig_bob, sig_alice] — bob first, so alice ends up on top, so alice is checked first. Reverse them and both checks fail.

Why OP_CHECKSIGADD and not OP_CHECKMULTISIG#

Three concrete reasons:

  1. It checks one signature at a time and stops on failure, instead of trying combinations.

  2. The counter is explicit — no off-by-one OP_CHECKMULTISIG dummy-element quirk.

  3. It takes 32-byte x-only keys directly, where OP_CHECKMULTISIG wanted 33-byte compressed keys.

The Four-Leaf Control Block#

With two levels in the tree, each leaf’s Merkle proof is two hashes — its immediate sibling, then that pair’s sibling branch — so the control block is 97 bytes. Example transaction 1951a3be0f05df377b1789223f6da66ed39c781aaf39ace0bf98c3beb7e604a1 (Script 1 multisig).

Witness stack: Bob sig → Alice sig → multisig script → 97-byte control block.

Control block byte layout:

  • Byte 0: version (0xc0) + parity

  • Bytes 1–32: internal pubkey (Alice x-only)

  • Bytes 33–64: sibling 1 (Script 0’s TapLeaf hash)

  • Bytes 65–96: sibling 2 (Branch 1’s TapBranch hash)

Climbing the two levels back to the address: Script1_TapLeaf + sibling1 → Branch0; Branch0 + sibling2 → Root; TapTweak(internal ‖ root) → output pubkey. Every TapBranch sorts its two inputs lexicographically, which is what makes the root reproducible by anyone.

Which two hashes each leaf needs#

paths = {
    0: "[Script1_TapLeaf, Branch1_TapBranch]",  # Hashlock
    1: "[Script0_TapLeaf, Branch1_TapBranch]",  # Multisig
    2: "[Script3_TapLeaf, Branch0_TapBranch]",  # CSV
    3: "[Script2_TapLeaf, Branch0_TapBranch]"   # Simple Sig
}

Byte parsing#

cb_bytes = bytes.fromhex(cb_hex)
internal_pubkey = cb_bytes[1:33].hex()
sibling_1 = cb_bytes[33:65].hex()
sibling_2 = cb_bytes[65:97].hex()

What the parse shows#

  1. Control block structure: internal pubkey 50be5fc4..., sibling 1 fe78d852... (Script 0), sibling 2 da551975... (Branch 1).

  2. Sibling 1 is Script 0’s TapLeaf hash — the very same value we computed for the hash lock back in Chapter 7. Same script, same leaf hash, across chapters.

  3. The proof is hierarchical: level 0 is the multisig leaf itself, level 1 is Branch0 = TapBranch(Script0, Script1), level 2 is Root = TapBranch(Branch0, Branch1).

  4. Lexicographic order: every TapBranch sorts its two inputs before hashing, so the control block proves the script belongs to the original Taproot commitment.

# Runnable: Parse 97-byte control block of tx 1951a3be... (stdlib only)
cb_hex = "c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659eda55197526f26fa309563b7a3551ca945c046e5b7ada957e59160d4d27f299e3"
cb = bytes.fromhex(cb_hex)
print(f"Control block length: {len(cb)} bytes")
print(f"Internal pubkey: {cb[1:33].hex()[:16]}...")
print(f"Sibling 1:       {cb[33:65].hex()[:16]}...")
print(f"Sibling 2:       {cb[65:97].hex()[:16]}...")

Three Things That Bite#

The four-leaf spends fail in a few predictable ways. In order of how often they catch people.

1. Witness order, for multisig#

Bob’s signature goes first in the list so Alice’s ends up on top of the stack — the reverse fails both checks (see the stack walk above):

# wrong
witness = [sig_alice, sig_bob, script, control_block]
# right — bob first, alice ends up on top
witness = [sig_bob, sig_alice, script, control_block]

2. Sequence, for CSV#

A CSV script only passes if the input’s sequence says enough blocks have elapsed. Forget it and OP_CHECKSEQUENCEVERIFY rejects the spend:

# wrong — default sequence, CSV fails
txin = TxInput(txid, vout)
# right — sequence matches the script's timelock
txin = TxInput(txid, vout, sequence=seq.for_input_sequence())

3. Key path vs script path signing#

The two take different parameters; mixing them up is the most common single mistake:

# key path:    whole tree (to tweak the key), script_path=False
sig = priv.sign_taproot_input(..., script_path=False, tapleaf_scripts=tree)
# script path: one leaf, script_path=True
sig = priv.sign_taproot_input(..., script_path=True, tapleaf_script=script)

Chapter Summary#

Four leaves turned the single TapBranch of Chapter 7 into a two-level tree, and the control block grew to match — 97 bytes carrying a two-hash Merkle path. We put four genuinely different conditions under one address (a hash lock, a 2-of-2 multisig, a CSV timelock, and a plain signature), spent each one on testnet, and verified the multisig’s control block by climbing both branch levels back to the same funding address every path shares.

Key takeaways#

  1. Four-leaf two-level proof: the control block expands from 65 bytes (two-leaf) to 97 bytes, with two sibling hashes forming a two-level Merkle path. A taller tree means a longer path — each level adds one 32-byte sibling, and the cost grows with the log of the number of leaves, not the count.

  2. OP_CHECKSIGADD multisig: Tapscript’s counter-style multisig — a running count of valid signatures, fed by a witness whose signature order has to match the order the script checks them.

  3. CSV timelock sequence handling: the input’s sequence must match the script’s OP_CHECKSEQUENCEVERIFY — the script states the rule, the transaction supplies the evidence.

  4. Five paths, one address: five real testnet transactions verify the tree; the key-path spend is indistinguishable from an ordinary single-sig on chain.

Limitations#

  • This chapter’s tree is balanced. In practice, place high-probability scripts in shallow layers to shrink the Merkle proof and the fee.

  • The hash lock script still ends with OP_TRUE (see the Chapter 6 security note); production should bind signature verification.

  • Elliptic-curve operations in the verification code are library-internal; the underlying implementation isn’t shown.

What Chapters 5–8 covered#

This chapter completes the foundational part of the book. Since Chapter 5, the four chapters have built up Taproot one mechanism at a time: the key tweak (Ch 5) commits the Merkle root, the commit–reveal pattern (Ch 6) is how every script path works, and the Merkle proof grows by one sibling hash per level (Ch 7 → Ch 8). The second half of the book moves from how Taproot works to how it is used — Ordinals, RGB, Lightning channels, and Silent Payments.

Next#

Chapter 9 starts with a single leaf, like Chapter 6, but uses it for something different: storing data in a Taproot output.