Chapter 7: Taproot Dual-Leaf Script Tree

Chapter 7: Taproot Dual-Leaf Script Tree#


From One Leaf to Two#

Chapter 6 built a Taproot address with a single leaf — one hash-lock script, alongside Alice’s key path. With one leaf, the TapLeaf hash was the Merkle root, and the control block carried nothing but the internal key. This chapter adds a second leaf, and that one change pulls in the rest of the script-tree machinery: a real Merkle root computed from two branches, and a control block that has to carry a sibling hash to prove its leaf belongs.

The contract we build gives one address three independent ways to spend:

  • Script path 1 — a hash lock: anyone who knows “helloworld” can spend.

  • Script path 2 — Bob’s script: only Bob’s private key can spend.

  • Key path — Alice, the internal-key holder, can spend directly (the quiet, private default).

As in Chapter 6, none of this is visible from outside. Until someone spends, the address is indistinguishable from a plain payment; spending reveals only the one path taken.

The Merkle Structure of a Two-Leaf Tree#

With one leaf there was nothing to combine. With two, you build an actual Merkle tree:

        Merkle Root
       /           \
  TapLeaf A    TapLeaf B
(Hash Script) (Bob Script)

Three steps, and the second is the genuinely new one:

  1. TapLeaf hash — each script hashes to its own leaf, exactly as in Chapter 6.

  2. TapBranch hash — the two leaf hashes are sorted lexicographically, then hashed together into the parent. The sort is what makes the root deterministic: whichever order you list the scripts, the smaller hash always goes first, so everyone computes the same root.

  3. Control block — to spend one leaf, you have to prove it sits under that root. The proof is the other leaf’s hash, carried in the control block, so a verifier can recompute the branch and land on the root.

Let’s see that structure through real on-chain data.

We’ll work backwards from two real testnet spends of the same dual-leaf address.

Transaction 1: Hash-script path#

  • Transaction ID: b61857a05852482c9d5ffbb8159fc2ba1efa3dd16fe4595f121fc35878a2e430

  • Taproot address: tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z

  • Spending method: Script path (using the preimage “helloworld”)

Transaction 2: Bob-script path#

  • Transaction ID: 185024daff64cea4c82f129aa9a8e97b4622899961452d1d144604e65a70cfe0

  • Taproot address: tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z

  • Spending method: Script path (Bob’s private key signature)

The two spends share the same address — which is the whole point. Both come out of one dual-leaf tree; each just reveals a different leaf. (They spend two different fundings of that address, since a UTXO can only be spent once.)

Commit Phase: Building the Two-Leaf Tree#

The tree is flat: [hash_script, bob_script] — two leaves at the same level. Order fixes the index: hash_script is index 0, bob_script is index 1. That index is what you pass to the control block when spending each leaf, so it has to match. (See the btcaaron cell below for runnable code.)

Script construction#

# Requires: import hashlib, and bitcoinutils Script, PrivateKey, get_taproot_address
preimage_hash = hashlib.sha256(b"helloworld").hexdigest()
hash_script = Script(['OP_SHA256', preimage_hash, 'OP_EQUALVERIFY', 'OP_TRUE'])
bob_script = Script([bob_pub.to_x_only_hex(), 'OP_CHECKSIG'])

Address generation#

all_leafs = [hash_script, bob_script]
taproot_address = alice_pub.get_taproot_address(all_leafs)

The library takes that list, computes both TapLeaf hashes, sorts and combines them into the Merkle root, and tweaks Alice’s key into the output key.

# Dual-leaf Taproot script tree (btcaaron)
# Reference: examples/ch07_dual_leaf_tree.py

from btcaaron import Key, TapTree

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

# Dual-leaf tree: [hashlock] | [bob checksig]
program = (TapTree(internal_key=alice)
    .hashlock("helloworld", label="hash")
    .checksig(bob, label="bob")
).build()

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

# Hash lock path spending
tx_hash = (program.spend("hash")
    .from_utxo("f02c055369812944390ca6a232190ec0db83e4b1b623c452a269408bf8282d66", 0, sats=1234)
    .to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 1034)
    .unlock(preimage="helloworld")
    .build())
print(f"\nHashlock TXID: {tx_hash.txid}")

# Bob signature path spending
tx_bob = (program.spend("bob")
    .from_utxo("8caddfad76a5b3a8595a522e24305dc20580ca868ef733493e308ada084a050c", 1, sats=1111)
    .to("tb1pshzcvake3a3d76jmue3jz4hyh35yvk0gjj752pd53ys9txy5c3aswe5cn7", 900)
    .sign(bob)
    .build())
print(f"Bob signature TXID: {tx_bob.txid}")

Reveal Phase: Spending Each Script Path#

1. Hash-script path (index 0)#

Witness: [preimage_hex, script, control_block]. TXID: b61857a0...

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

This is the Chapter 6 hash-lock spend, with one change that matters: the control block is built from all_leafs (both scripts) at index 0. The library needs the whole tree to know what the sibling is.

2. Bob-script path (index 1)#

Witness: [sig, script, control_block]. Key: script_path=True, tapleaf_script=bob_script (singular), tweak=False. TXID: 185024da...

cb = ControlBlock(alice_pub, all_leafs, 1, is_odd=taproot_address.is_odd())
sig = bob_priv.sign_taproot_input(..., script_path=True, tapleaf_script=bob_script, tweak=False)
tx.witnesses.append(TxWitnessInput([sig, bob_script.to_hex(), cb.to_hex()]))

Two differences from the hash path, both following from the fact that Bob’s leaf is a signature check rather than a hash check. The control block is at index 1, so its sibling is the hash script’s hash. And the spend signs: tapleaf_script=bob_script is singular because you sign against the one leaf you’re executing, and tweak=False because a script-path signature is checked by OP_CHECKSIG against Bob’s plain key inside the script — the key is not tweaked. This is the opposite of the key path, where the whole point was signing with the tweaked key.

Control Blocks, Read From the Chain#

Each control block carries the other leaf’s hash — that’s the Merkle proof. Let’s read both straight off the chain.

Hash-script path control block#

Data extracted from transaction b61857a0…:

Control Block: c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d32faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df
Structure breakdown:
├─ c0: Leaf version (0xc0)
├─ 50be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3: Alice internal pubkey
└─ 2faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df: Bob script's TapLeaf hash ← the sibling

Bob-script path control block#

Data extracted from transaction 185024da…:

Control Block: c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659e
Structure breakdown:
├─ c0: Leaf version (0xc0)
├─ 50be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3: Alice internal pubkey (same!)
└─ fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659e: Hash script's TapLeaf hash ← the sibling

Two things to read off these directly. The internal pubkey is identical in both — same Alice, same tree. And the trailing 32 bytes are swapped: each leaf carries its sibling’s hash. The hash path carries Bob’s leaf hash; Bob’s path carries the hash leaf’s hash. That swap is the Merkle proof — give a verifier the one leaf plus its sibling’s hash, and they can rebuild the branch and the root.

Dual-leaf control block structure (65 bytes)#

Byte 0: version+parity; 1–32: internal pubkey; 33–64: sibling TapLeaf hash.

cb = bytes.fromhex(control_block_hex)
internal_pubkey = cb[1:33].hex()
sibling = cb[33:65].hex()
# Runnable: Parse dual-leaf control block (65 bytes, tx b61857a0... hash path)
cb_hex = "c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d32faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df"
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 (Bob TapLeaf): {cb[33:65].hex()[:16]}...")

Now let’s walk the hash script path’s full execution, from transaction b61857a0.... This is the same hash lock from Chapter 6, so the stack walk is the same.

Witness data structure#

Witness Stack:
[0] 68656c6c6f776f726c64 (preimage_hex)
[1] a820936a185c...8851 (script_hex)
[2] c050be5fc4...cf9df (control_block)

Script bytecode#

Hash script: a820936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af8851

Bytecode breakdown:
a8 = OP_SHA256
20 = OP_PUSHBYTES_32
936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af = SHA256("helloworld")
88 = OP_EQUALVERIFY
51 = OP_PUSHNUM_1 (OP_TRUE)

Stack execution — hash script path#

Execution script: OP_SHA256 OP_PUSHBYTES_32 936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af OP_EQUALVERIFY OP_PUSHNUM_1

0. Start: the preimage loads onto the stack#

│ 68656c6c6f776f726c64 (preimage_hex) │
└──────────────────────────────────────┘

(Preimage “helloworld” in hex, already on the stack)

1. OP_SHA256: pops the preimage, pushes its SHA256#

│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘

(SHA256(“helloworld”) = 936a185c…07af)

2. OP_PUSHBYTES_32: pushes the script’s expected hash#

│ 936a185c...07af (expected_hash) │
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘

(Stack top now holds two identical hash values)

3. OP_EQUALVERIFY: pops both, compares; equal, so execution continues#

│ (empty_stack) │
└───────────────┘

(expected_hash == computed_hash, both elements removed)

4. OP_PUSHNUM_1: pushes 1, a non-zero top of stack, marking the script satisfied#

│ 01 (true_value) │
└─────────────────┘

Now Bob’s script path, from transaction 185024da.... This leaf is new — a P2PK check instead of a hash lock.

Witness data structure#

Witness Stack:
[0] 26a0eadc...f9f1c5c (bob_signature)
[1] 2084b59516...63af5ac (script_hex)
[2] c050be5fc4...0f659e (control_block)

Script bytecode#

Bob script: 2084b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5ac

Bytecode breakdown:
20 = OP_PUSHBYTES_32
84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5 = Bob's x-only pubkey
ac = OP_CHECKSIG

The script is two steps: push Bob’s key, then check a signature against it.

Stack execution — Bob script path#

Execution script: OP_PUSHBYTES_32 84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5 OP_CHECKSIG

0. Start: Bob’s signature loads onto the stack#

│ 26a0eadc...f9f1c5c (bob_signature) │
└────────────────────────────────────┘

(Bob’s 64-byte Schnorr signature, from the witness, already on the stack)

1. OP_PUSHBYTES_32: the script pushes Bob’s x-only pubkey on top#

│ 84b59516...eef63af5 (bob_pubkey)   │
│ 26a0eadc...f9f1c5c (bob_signature) │
└────────────────────────────────────┘

(Bob’s 32-byte x-only pubkey pushed to the stack top)

2. OP_CHECKSIG: pops the key and the signature, runs BIP340 Schnorr verification, pushes 1 if it holds#

│ 01 (signature_valid) │
└──────────────────────┘

Verification details:

  1. Pop the pubkey from the stack: 84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5

  2. Pop the signature from the stack: 26a0eadca0bba3d1bb6f82b8e1f76e2d84038c97a92fa95cc0b9f6a6a59bac5f...

  3. Run BIP340 Schnorr verification against the transaction

  4. Verification holds, push 1 for TRUE

So the two leaves end the same way — a 1 on top of the stack — but get there differently: the hash leaf proves knowledge of a secret, the Bob leaf proves possession of a key. One address, two unlock conditions, and only the one you use is ever revealed.

What Changed From the Single-Leaf Case#

Side by side, the single-leaf and dual-leaf cases differ in exactly one place — how the Merkle root is formed.

Single leaf — the root is the leaf#

Merkle Root = TapLeaf Hash
            = Tagged_Hash("TapLeaf", 0xc0 + len(script) + script)

The control block carries only the internal key; there’s no sibling, so no Merkle path.

Two leaves — the root is a branch over both#

Merkle Root = TapBranch Hash
            = Tagged_Hash("TapBranch", sorted(TapLeaf_A, TapLeaf_B))
TapLeaf_A = Tagged_Hash("TapLeaf", 0xc0 + len(script_A) + script_A)
TapLeaf_B = Tagged_Hash("TapLeaf", 0xc0 + len(script_B) + script_B)

The lexicographic sort makes the root independent of listing order, and the control block now carries one sibling hash.

Control block size comparison#

That sibling hash is the only thing that grows the control block, and it grows predictably — one hash per level of tree depth.

Script Tree Type

Control Block Size

Contents

Single-leaf

33 bytes

version+parity, internal pubkey

Dual-leaf

65 bytes

+ one sibling hash

Four-leaf

97 bytes

+ a second sibling hash (one per level)

Each extra level of depth adds 32 bytes to the proof — a Merkle path, growing with the log of the number of leaves, not the count of them.

Patterns for Building Two-Leaf Taproot#

The two spends above generalize into a small set of reusable pieces.

1. Commit — build the tree (index order matters)#

leafs = [hash_script, bob_script]  # Index 0 and 1, fixed
taproot_address = alice_key.get_taproot_address(leafs)

2. Reveal — one template for any leaf#

control_block = ControlBlock(internal_key, leafs, script_index, is_odd=taproot_addr.is_odd())
witness = TxWitnessInput([*input_data, leafs[script_index].to_hex(), control_block.to_hex()])

3. The mistake to watch for#

A control block whose index doesn’t match the script you actually reveal. The library builds the wrong Merkle proof and verification fails. ❌ ControlBlock(..., 1, ...) paired with leafs[0] fails. ✅ Drive both from one script_index variable so they can’t disagree.

Debug the sibling: cb = bytes.fromhex(cb_hex); actual = cb[33:65], then compare with the sibling TapLeaf hash you expect.

Cost and Privacy of the Three Paths#

With three ways to spend one address, here’s what each costs and reveals, read off the on-chain spends.

Spending Method

Transaction Size

Witness Data

Verification

Privacy

Relative Fee

Key Path

~110 bytes

64-byte signature

1 signature check

Reveals nothing

Baseline (1.0x)

Hash Script

~180 bytes

preimage+script+cb

Hash check + Merkle verification

Reveals the hash lock

~1.6x

Bob Script

~185 bytes

signature+script+cb

Signature check + Merkle verification

Reveals the P2PK structure

~1.7x

Three things fall out of these numbers:

  1. The key path is always the best spend when it’s available — smallest, cheapest, reveals nothing — regardless of how complex the tree behind it is.

  2. The script-path premium is modest — the extra cost is one script plus a control block, far less than spelling out the equivalent conditions as a classic multisig redeem script.

  3. You only ever pay for the path you take. The unused leaves never touch the chain; they stay folded into the Merkle root as hashes.

Chapter Summary#

Adding a second leaf turned the single-leaf shortcut into the real thing: a Merkle tree built with TapBranch over two lexicographically sorted leaves, and control blocks that carry a sibling hash as the Merkle proof. We read both halves of that proof straight off the chain — each leaf’s control block holding the other leaf’s hash — and confirmed that either path rebuilds the same address.

The payoff is the same selective reveal as Chapter 6, now over more than one condition: one address, a hash lock and a key check and Alice’s key path all committed to it, and only the single path actually used ever shown.

Next. Chapter 8 scales the tree to four leaves — the Merkle paths get longer, and the control block carries more than one sibling hash (the 97-byte, two-sibling case from the table above), as one address grows to back several spending conditions at once.