第7章:Taproot 双叶脚本树#
参考:
book/Chapter 07.md代码示例:code/chapter07/最后更新: 2026-06-16
从一片叶子到两片#
第 6 章构建的 Taproot 地址只有一片叶子——一段哈希锁脚本,外加 Alice 的 key path。只有一片叶子时,TapLeaf 哈希就是 Merkle 根,control block 里除了内部密钥什么都不带。本章加上第二片叶子,而仅仅这一处改动,就把脚本树其余的机制都拽了进来:一个由两条分支算出来的真正 Merkle 根,以及一个必须携带兄弟哈希(sibling hash)来证明自己叶子归属的 control block。
我们要构建的合约,给同一个地址三条互相独立的花费路径:
脚本路径 1——哈希锁:任何知道 “helloworld” 的人都能花。
脚本路径 2——Bob 的脚本:只有 Bob 的私钥能花。
Key path——Alice,作为内部密钥持有者,可以直接花(安静、私密的默认选项)。
和第 6 章一样,这些从外面都看不见。直到有人花费之前,这个地址和一笔普通支付无法区分;花费时也只揭示走过的那一条路径。
双叶树的 Merkle 结构#
一片叶子时没有什么可合并的。两片叶子时,你就要搭一棵真正的 Merkle 树:
Merkle Root
/ \
TapLeaf A TapLeaf B
(Hash Script) (Bob Script)
三步,其中第二步是真正新出现的:
TapLeaf 哈希——每段脚本各自哈希成自己的叶子,和第 6 章完全一样。
TapBranch 哈希——两个叶子哈希按字典序排序,再一起哈希成父节点。正是这个排序让根具有确定性:无论你以什么顺序列出脚本,较小的哈希总是排在前面,于是所有人都算出同一个根。
Control block——要花掉某一片叶子,你得证明它确实位于那个根之下。证明就是另一片叶子的哈希,装在 control block 里,好让验证方重新算出这条分支、落回根上。
本章余下的内容,就是从真实链上数据看这套结构。
两笔链上交易#
我们从同一个双叶地址的两笔真实 testnet 花费倒推着看。
交易 1:哈希脚本路径#
交易 ID:
b61857a05852482c9d5ffbb8159fc2ba1efa3dd16fe4595f121fc35878a2e430Taproot 地址:
tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z用原像 “helloworld” 花费。
交易 2:Bob 脚本路径#
交易 ID:
185024daff64cea4c82f129aa9a8e97b4622899961452d1d144604e65a70cfe0Taproot 地址:
tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z用 Bob 的签名花费。
两笔花费共用同一个地址——这正是要点。它们都出自同一棵双叶树,只是各自揭示了不同的叶子。(它们花的是这个地址的两笔不同充值,因为一个 UTXO 只能花一次。)
Commit 阶段:构建双叶树#
有两点要留意,因为它们决定了后面的一切:
这棵树是扁平的:
[hash_script, bob_script]——两片叶子在同一层。顺序定下索引:
hash_script是索引 0,bob_script是索引 1。花费每片叶子时,你要把这个索引传给 control block,所以它必须对得上。(完整可运行代码见下方 btcaaron cell。)
脚本构建#
# 需 import hashlib,及 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'])
地址生成#
all_leafs = [hash_script, bob_script]
taproot_address = alice_pub.get_taproot_address(all_leafs)
库拿到这个列表,算出两个 TapLeaf 哈希,排序并合并成 Merkle 根,再把 Alice 的密钥 tweak 成输出密钥。如果两笔不同的脚本路径花费都能重建出同一个地址,就说明这棵树每次都是按同样方式构建的。
# 双叶 Taproot 脚本树(btcaaron)
# 参考: examples/ch07_dual_leaf_tree.py
from btcaaron import Key, TapTree
alice = Key.from_wif("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
bob = Key.from_wif("cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG")
# 双叶树: [hashlock] | [bob checksig]
program = (TapTree(internal_key=alice)
.hashlock("helloworld", label="hash")
.checksig(bob, label="bob")
).build()
print("=== 双叶 Taproot 树 ===")
print(f"地址: {program.address}")
print(f"叶子: {program.leaves}")
print(program.visualize())
# 哈希锁路径支出
tx_hash = (program.spend("hash")
.from_utxo("f02c055369812944390ca6a232190ec0db83e4b1b623c452a269408bf8282d66", 0, sats=1234)
.to("tb1p060z97qusuxe7w6h8z0l9kam5kn76jur22ecel75wjlmnkpxtnls6vdgne", 1034)
.unlock(preimage="helloworld")
.build())
print(f"\n哈希锁 TXID: {tx_hash.txid}")
# Bob 签名路径支出
tx_bob = (program.spend("bob")
.from_utxo("8caddfad76a5b3a8595a522e24305dc20580ca868ef733493e308ada084a050c", 1, sats=1111)
.to("tb1pshzcvake3a3d76jmue3jz4hyh35yvk0gjj752pd53ys9txy5c3aswe5cn7", 900)
.sign(bob)
.build())
print(f"Bob 签名 TXID: {tx_bob.txid}")
=== 双叶 Taproot 树 ===
地址: tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z
叶子: ['hash', 'bob']
Merkle Root
/ \
[hash] [bob]
哈希锁 TXID: 51fbb47cd777798afe1a0d0d55b5f128e6396013a6c3099eb5ad10f3624c7dbf
Bob 签名 TXID: a7dd4e294374bdece6eb6412c58974fe1907b90d064ff792a749303cf54c6d2c
Reveal 阶段:分别花费两条脚本路径#
1. 哈希脚本路径(索引 0)#
见证:[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()]))
这就是第 6 章的哈希锁花费,只有一处关键不同:control block 是从 all_leafs(两段脚本)在索引 0 处构建的。库需要整棵树才能知道兄弟是谁——索引 0 意味着”这是哈希脚本;另一片叶子是它的兄弟,把那片的哈希作为证明带上”。
2. Bob 脚本路径(索引 1)#
见证:[sig, script, control_block]。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)
和哈希路径有两处不同,都源自一个事实:Bob 的叶子是一次签名检查,而不是哈希检查。
control block 在索引 1——这是第二片叶子,所以它的兄弟是哈希脚本的哈希。
这次花费要签名,而哈希路径不签。签名参数值得逐个细读:
script_path=True——为某片叶子签名,而不是为 key path。tapleaf_script=bob_script——单数,因为你是针对正在执行的那一片叶子签名(对比第 6 章 key path 的tapleaf_scripts,复数,那时需要整棵树来重建 tweak)。tweak=False——脚本路径的签名是被脚本里的OP_CHECKSIG拿 Bob 的原始密钥去验的,所以这把密钥不做 tweak。这和 key path 正好相反——key path 的全部要点就是用 tweak 后的密钥签名。
从链上读 control block#
每个 control block 都携带另一片叶子的哈希——这就是 Merkle 证明。我们直接从两笔链上花费里把它读出来。
哈希脚本路径控制块#
从交易 b61857a0… 提取的数据:
Control Block: c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d32faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df
Structure breakdown:
├─ c0: Leaf version (0xc0)
├─ 50be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3: Alice internal pubkey
└─ 2faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df: Bob Script's TapLeaf hash ← 兄弟
Bob 脚本路径控制块#
从交易 185024da… 提取的数据:
Control Block: c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659e
Structure breakdown:
├─ c0: Leaf version (0xc0)
├─ 50be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3: Alice internal pubkey (same!)
└─ fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659e: Hash Script's TapLeaf hash ← 兄弟
直接从这两段能读出两件事:
两个里的内部公钥完全相同——同一个 Alice,同一棵树。
末尾的 32 字节互换了:每片叶子都带着它兄弟的哈希。哈希路径带的是 Bob 叶子的哈希;Bob 路径带的是哈希叶子的哈希。这个互换就是 Merkle 证明——给验证方一片叶子加它兄弟的哈希,它就能重建分支和根。
双叶控制块结构(65 字节)#
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()
# 可运行:解析双叶控制块(65 字节,交易 b61857a0... 哈希路径)
cb_hex = "c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d32faaa677cb6ad6a74bf7025e4cd03d2a82c7fb8e3c277916d7751078105cf9df"
cb = bytes.fromhex(cb_hex)
print(f"控制块长度: {len(cb)} 字节")
print(f"Internal pubkey: {cb[1:33].hex()[:16]}...")
print(f"Sibling (Bob TapLeaf): {cb[33:65].hex()[:16]}...")
控制块长度: 65 字节
Internal pubkey: 50be5fc44ec580c3...
Sibling (Bob TapLeaf): 2faaa677cb6ad6a7...
脚本路径 1:哈希脚本#
来自交易 b61857a0... 的实际数据。
见证栈#
Witness Stack:
[0] 68656c6c6f776f726c64 (preimage_hex)
[1] a820936a185c...8851 (script_hex)
[2] c050be5fc4...cf9df (control_block)
脚本字节码解析#
哈希脚本:a820936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af8851
Bytecode breakdown:
a8 = OP_SHA256
20 = OP_PUSHBYTES_32
936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af = SHA256("helloworld")
88 = OP_EQUALVERIFY
51 = OP_PUSHNUM_1 (OP_TRUE)
这就是第 6 章那把哈希锁,所以栈的走法也一样。
栈执行动画——哈希脚本路径#
执行脚本:OP_SHA256 OP_PUSHBYTES_32 936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af OP_EQUALVERIFY OP_PUSHNUM_1
0. 起点:原像加载到栈上#
│ 68656c6c6f776f726c64 (preimage_hex) │
└──────────────────────────────────────┘
(原像 “helloworld” 的十六进制表示已在栈上)
1. OP_SHA256:弹出原像,压入它的 SHA256#
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(SHA256(“helloworld”) = 936a185c…07af)
2. OP_PUSHBYTES_32:压入脚本内置的期望哈希#
│ 936a185c...07af (expected_hash) │
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(栈顶现在有两个相同的哈希值)
3. OP_EQUALVERIFY:弹出两个比较;相等,于是继续执行#
│ (empty_stack) │
└───────────────┘
(验证成功:expected_hash == computed_hash,两个元素都被移除)
4. OP_PUSHNUM_1:压入 1,栈顶非零,标志脚本被满足#
│ 01 (true_value) │
└─────────────────┘
(脚本执行成功:栈顶是非零值)
脚本路径 2:Bob 脚本#
来自交易 185024da... 的实际数据。这片叶子是新的——P2PK 检查,而不是哈希锁。
见证栈#
Witness Stack:
[0] 26a0eadc...f9f1c5c (bob_signature)
[1] 2084b59516...63af5ac (script_hex)
[2] c050be5fc4...0f659e (control_block)
脚本字节码解析#
Bob 脚本:2084b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5ac
Bytecode breakdown:
20 = OP_PUSHBYTES_32
84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5 = Bob's x-only pubkey
ac = OP_CHECKSIG
脚本就两步:压入 Bob 的密钥,然后对它做一次签名检查。
栈执行动画——Bob 脚本路径#
执行脚本:OP_PUSHBYTES_32 84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5 OP_CHECKSIG
0. 起点:Bob 的签名加载到栈上(它来自 witness,不是脚本)#
│ 26a0eadc...f9f1c5c (bob_signature) │
└────────────────────────────────────┘
(Bob 的 64 字节 Schnorr 签名已在栈上)
1. OP_PUSHBYTES_32:脚本把 Bob 的 x-only 公钥压到顶上#
│ 84b59516...eef63af5 (bob_pubkey) │
│ 26a0eadc...f9f1c5c (bob_signature) │
└────────────────────────────────────┘
(Bob 的 32 字节 x-only 公钥被推送到栈顶)
2. OP_CHECKSIG:弹出密钥和签名,按 BIP340 Schnorr 对着这笔交易核验,成立就压入 1#
│ 01 (signature_valid) │
└──────────────────────┘
(签名验证成功:Bob 的私钥对应此公钥,签名对交易数据有效)
验证过程详情:
从栈弹出公钥:
84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5从栈弹出签名:
26a0eadca0bba3d1bb6f82b8e1f76e2d84038c97a92fa95cc0b9f6a6a59bac5f...使用 BIP340 Schnorr 签名验证算法验证签名有效性
验证成功,推送 1 表示 TRUE
两片叶子以相同方式收尾——栈顶一个 1——但走到那里的方式不同:哈希叶子证明知道一个秘密,Bob 叶子证明持有一把密钥。一个地址,两个解锁条件,而你用的那个才会被揭示。
和单叶相比,变了什么#
把单叶和双叶并排看,差别恰好就在一处——Merkle 根怎么形成。
单叶脚本树#
单叶——根就是那片叶子:
Merkle Root = TapLeaf Hash
= Tagged_Hash("TapLeaf", 0xc0 + len(script) + script)
control block 只带内部密钥;没有兄弟,所以没有 Merkle 路径。
双叶脚本树#
双叶——根是覆盖两片叶子的一条分支:
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)
字典序排序让根与列出顺序无关,而 control block 现在要带一个兄弟哈希。
控制块大小对比#
这个兄弟哈希是 control block 唯一变大的来源,而且变大得很有规律——每多一层树深,就多一个哈希:
Script Tree Type |
Control Block Size |
Structure |
|---|---|---|
Single-leaf |
33 bytes |
[version+parity] + [internal_pubkey] |
Dual-leaf |
65 bytes |
[version+parity] + [internal_pubkey] + [sibling_hash] |
Four-leaf |
97 bytes |
[version+parity] + [internal_pubkey] + [sibling_hash] + [parent_sibling_hash] |
每多一层深度,证明就多 32 字节——一条 Merkle 路径,随叶子数量的对数增长,而不是随叶子的个数增长。
构建双叶 Taproot 的若干模式#
上面两笔花费可以归纳成一小组可复用的零件。
1. Commit——构建树(索引顺序很重要)#
leafs = [hash_script, bob_script] # 索引顺序固定
taproot_address = alice_key.get_taproot_address(leafs)
2. Reveal——一个模板花任意叶子#
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. 要盯防的错误#
脚本索引和实际揭示的脚本对不上:❌ ControlBlock(..., 1, ...) 搭配 leafs[0] 会构出错误的 Merkle 证明,验证失败。✅ 让 control block 和 witness 都由同一个变量驱动。
调试 sibling:脚本路径花费过不了 Merkle 检查时,第一个要查的就是这里——把 control block 末尾的 32 字节抠出来,确认它确实是你预期的兄弟:cb = bytes.fromhex(cb_hex); actual = cb[33:65],与预期 TapLeaf 哈希对比。
三条路径的成本与隐私#
一个地址有三种花法,下面是各自的成本和暴露的东西(取自链上花费)。
Spending Method |
Transaction Size |
Witness Data |
Computational Complexity |
Privacy Level |
Relative Fee Cost |
|---|---|---|---|---|---|
Key Path |
~110 bytes |
64-byte signature |
1 signature verification |
Complete privacy |
Baseline (1.0x) |
Hash Script |
~180 bytes |
preimage+script+cb |
Hash calculation+Merkle verification |
Exposes Hash Lock |
Medium (1.6x) |
Bob Script |
~185 bytes |
signature+script+cb |
Signature verification+Merkle verification |
Exposes P2PK structure |
Medium (1.7x) |
从这些数字能引出三点:
只要 key path 可用,它永远是最好的花法——最小、最便宜、什么都不暴露——无论它背后那棵树多复杂。
脚本路径的溢价不大——多出的成本就是一段脚本加一个 control block,远比把等价条件写成经典 multisig 赎回脚本要少。
你只为走过的那条路径付费。 没用到的叶子从不上链;它们作为哈希一直折叠在 Merkle 根里。
本章小结#
加上第二片叶子,把单叶时的那条捷径变成了真东西:一棵用 TapBranch 在两片字典序排序的叶子之上构建的 Merkle 树,以及携带兄弟哈希作为 Merkle 证明的 control block。我们直接从链上读出了这份证明的两半——每片叶子的 control block 装着另一片叶子的哈希——并确认任一条路径都能重建出同一个地址。
回报还是和第 6 章一样的选择性揭示,只是现在覆盖了不止一个条件:一个地址,把一把哈希锁、一次密钥检查、加上 Alice 的 key path 全都承诺进去,而只有实际用到的那一条路径才会被展示。
下一章。 第 8 章把树扩到四片叶子——Merkle 路径变长,control block 要带不止一个兄弟哈希(就是上面表里 97 字节、两个兄弟哈希那一档),让一个地址同时撑起好几个花费条件。