Chapter 6: Building Real Taproot Contracts#
Why the Script Path Matters#
Chapter 5 built a Taproot output that committed to nothing — a key-path-only address, where the only way to spend was Alice’s tweaked key. This chapter adds the other half of Taproot: a script path. We give one address two independent ways to spend, and confirm that on-chain it still looks like an ordinary payment until the moment someone spends it.
The contract is deliberately small — a single script that releases funds to anyone who knows a secret word — so the mechanics stay in view. Everything here carries over to the multi-leaf trees of Chapters 7-8; this is the one-leaf version of the same machinery.
The Scenario: A Conditional Payment#
Alice wants an address that can be spent two ways:
Conditional path: anyone who knows the secret word “helloworld” can claim the funds.
Owner path: Alice can reclaim the funds at any time with her private key.
Privacy: while unspent, the address is indistinguishable from any ordinary Taproot payment.
This two-path shape turns up constantly. A few cases that reduce to exactly the same structure:
Use case |
How the two paths map |
|---|---|
Digital goods sales |
Buyer unlocks with a key after payment; seller keeps a refund path |
Bounty tasks |
Whoever solves the puzzle claims the reward; the publisher can reclaim an unclaimed bounty |
Conditional escrow |
Funds release when a condition is met; otherwise the owner reclaims them |
Educational incentives |
A student claims a reward on a correct answer; the teacher keeps a management path |
Two Spending Paths#
Alice’s Taproot address carries two ways to spend, and they cost — and reveal — very different things.
Key path. Alice signs with her tweaked private key. One 64-byte Schnorr signature, nothing about the script revealed. This is the cheap, private path from Chapter 5.
Script path. The hash-lock script OP_SHA256 <hash> OP_EQUALVERIFY OP_TRUE. Anyone who can produce the preimage “helloworld” can spend it. Taking this path reveals the script you used — but nothing about the key path, and nothing about any other branch the tree might have held.
That asymmetry is the whole design: the key path is the quiet default; the script path is there for when you actually need the condition, and it only ever exposes the one branch you take.
The Commit-Reveal Pattern#
Almost everything we do with Taproot follows one shape, worth naming before any code: commit, then reveal.
Commit. You fold one or more spending conditions into a script tree, commit that tree into a single Taproot address, and fund it. From the outside the address is just 32 bytes — no one can tell which conditions it carries, or even whether it carries any.
Reveal. When you spend, you pick one path. The key path reveals nothing. The script path reveals exactly the one leaf you used — and leaves every other branch hidden for good.
What makes the pattern pay off: at commit time, contracts of wildly different complexity all look identical on-chain. At reveal time, you pay — in bytes and in privacy — only for the single branch you actually take.
Single-Leaf Hash Lock: From Commit to Reveal#
We’ll build the smallest possible tree — one leaf — so nothing distracts from the commit-reveal flow:
Hash-lock script: checks the SHA256 of the secret word “helloworld”.
Single-leaf tree: the simplest script tree there is, one leaf.
Two paths: key path (Alice’s direct control) plus script path (the conditional spend).
Tagged Hash (BIP340): BIP340 runs everything through a tagged hash — a SHA256 with a purpose label folded in. The label is what keeps a leaf hash from ever colliding with a tweak, even on identical input. TapLeaf and TapTweak are just two different labels feeding the same machine.
Phase 1: Commit#
The script serializes to bytes: a8=OP_SHA256, 20=PUSH32, 936a185c...07af=SHA256(“helloworld”), 88=EQUALVERIFY, 51=TRUE. That serialized script becomes a TapLeaf hash, which — for a single leaf — is the whole Merkle root. The Merkle root then tweaks the internal key into the output key.
Commit key code:
hash_hex = hashlib.sha256(b"helloworld").hexdigest()
tr_script = Script(['OP_SHA256', hash_hex, 'OP_EQUALVERIFY', 'OP_TRUE'])
tree = [[tr_script]]
taproot_address = alice_pub.get_taproot_address(tree)
Address: tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h
Its ScriptPubKey is just OP_1 <32-byte-output-key> — the same shape as every other Taproot address on chain. Nothing about it tells an observer whether it’s a plain single-sig or a conditional contract.
Key Path#
Witness: 64-byte Schnorr signature. Even on the key path the signer still passes tapleaf_scripts, because the output key was tweaked by the Merkle root at commit time — so to sign for the output key, Alice has to reconstruct the same tweak. script_path=False hides that bookkeeping.
Script Path Spending#
Witness order: [preimage, script, control_block]. The single-leaf control block is 33 bytes (version+parity byte, then the internal pubkey, no Merkle path). Read the parity off the address with is_odd() — don’t guess it.
cb = ControlBlock(alice_pub, tree, 0, is_odd=taproot_address.is_odd())
tx.witnesses.append(TxWitnessInput(["helloworld".encode().hex(), tr_script.to_hex(), cb.to_hex()]))
Transaction 68f7c8f0… Witness Stack#
[0] 68656c6c6f776f726c64 (preimage)
[1] a820936a185c...8851 (script)
[2] c150be5fc4...bb4d3 (control_block)
# Single-leaf Taproot contract implementation (btcaaron)
# Reference: examples/ch06_single_leaf_contract.py
from btcaaron import Key, TapTree
alice = Key.from_wif("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
# Commit phase: Build single-leaf script tree (hashlock + Key Path)
program = (TapTree(internal_key=alice)
.hashlock("helloworld", label="hash")
).build()
print("=== SINGLE-LEAF TAPROOT CONTRACT ===")
print(f"Address: {program.address}")
print(f"Leaves: {program.leaves}")
print(program.visualize())
print(f"Expected address: tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h")
# Key Path spending (Alice reclaims directly)
tx_key = (program.keypath()
.from_utxo("4fd83128fb2df7cd25d96fdb6ed9bea26de755f212e37c3aa017641d3d2d2c6d", 0, sats=3900)
.to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 3700)
.sign(alice)
.build())
print(f"\nKey Path TXID: {tx_key.txid}")
# Script Path spending (anyone who knows preimage can spend)
tx_hash = (program.spend("hash")
.from_utxo("9e193d8c5b4ff4ad7cb13d196c2ecc210d9b0ec144bb919ac4314c1240629886", 0, sats=5000)
.to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 4000)
.unlock(preimage="helloworld")
.build())
print(f"Script Path TXID: {tx_hash.txid}")
# Runnable: Parse single-leaf control block (33 bytes, tx 68f7c8f0... Script Path)
cb_hex = "c150be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3"
cb = bytes.fromhex(cb_hex)
print(f"Control block length: {len(cb)} bytes")
print(f"Internal pubkey: {cb[1:33].hex()[:16]}...")
When Script-Path Spending Fails: A Checklist#
Script-path spends fail in a small number of predictable ways.
Witness order. It must be [preimage, script, control_block] — data, then code, then proof. Don’t reverse it.
Script consistency. The script you reveal must be byte-for-byte the script you committed; build both with the same function so the bytes match.
Control block. Internal pubkey correct, script index matching the leaf (0 for a single leaf), and the parity flag read from the address with is_odd() rather than guessed.
Input encoding. The preimage has to be UTF-8 bytes, then hex: "helloworld" → 68656c6c6f776f726c64.
Stack Execution: Walking the Hash Lock#
Here’s the script running, one opcode at a time.
Script: OP_SHA256 OP_PUSHBYTES_32 936a185c...07af OP_EQUALVERIFY OP_PUSHNUM_1
0. Start: the witness loads the preimage onto the stack#
│ 68656c6c6f776f726c64 │
│ (preimage_hex: "helloworld") │
└──────────────────────────────────────────────────┘
1. OP_SHA256: pops the preimage, pushes its SHA256#
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(SHA256(“helloworld”) = 936a185c…07af)
2. PUSH 32 bytes: the script pushes its baked-in expected hash#
│ 936a185c...07af (expected_hash) │
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(Stack now holds two identical hashes)
3. OP_EQUALVERIFY: pops the top two, compares; equal, so execution continues and both are consumed#
│ (empty_stack) │
└───────────────┘
4. OP_TRUE: pushes 1, leaving a non-zero top of stack, which is what marks the script as satisfied#
│ 01 (true_value) │
└─────────────────┘
Key Path vs Script Path#
The two paths, side by side on the numbers we just produced:
Aspect |
Key Path |
Script Path |
|---|---|---|
Witness |
1 element (64-byte signature) |
3 elements (input + script + control block) |
Transaction size |
~153 bytes |
~234 bytes |
Privacy |
Complete — nothing about the script revealed |
Partial — only the executed leaf is revealed |
Verification |
One Schnorr check |
Control-block check, then script execution |
Fee |
Lowest |
Higher (~50% more here) |
The script path costs more bytes and gives up some privacy — but only for the one branch you use. Every other branch you might have committed stays hidden. That selective reveal is what lets one Taproot address back digital goods sales, bounties, escrow, and multi-party contracts, and still look like a plain payment until the moment it’s spent.
How This Differs from P2SH#
The contrast with P2SH is the sharpest way to see what the script path actually buys.
In P2SH, spending reveals the entire redeem script — every branch, including the ones you didn’t take. An observer learns the whole contract the first time it’s used.
Taproot’s script path reveals only the leaf you executed. Unused branches are never published; they exist only as hashes folded into the Merkle root, and the chain never sees them. And until the address is spent at all, it’s indistinguishable from an ordinary single-sig payment.
So the difference is concrete, not a slogan: P2SH exposes the contract, Taproot exposes one path through it. For contracts with multiple conditions — most real ones — that is a large reduction in what leaks on chain.
Chapter Summary#
We built Alice’s hash-lock contract end to end and saw the commit-reveal pattern in full.
Commit and reveal. At commit time, a conditional contract folds into an ordinary-looking Taproot address that locks the funds. At reveal time, Alice picks a path — key path or script path — and exposes only what that path requires.
What the implementation came down to#
Single-leaf tree — with one leaf, the TapLeaf hash is the Merkle root; no further Merkle math needed.
Control block — proves a script is committed by restoring the address from the internal key and the script’s leaf hash.
Stack execution — the hash lock spends by matching
OP_SHA256of the preimage against the committed hash.
Things that bite if you get them wrong#
Tagged hash — the tag is what separates a TapLeaf hash from a TapTweak; same machine, different label.
Witness order —
[input, script, control block], every time.Commit/reveal consistency — build the script with the same function in both phases so the bytes match exactly.
Next. Chapter 7 moves from one leaf to two: a dual-leaf script tree, where the Merkle root is computed from more than one branch and you start choosing which branch to reveal. That’s where the tree in “script tree” earns its name.