第8章:四叶 Taproot 脚本树#
参考:
book/Chapter 08.md
代码示例:code/chapter08/
最后更新: 2026-06-16
从两片叶子到四片#
第 7 章构建了一棵双叶树:一个 TapBranch 覆盖两片叶子,control block 带一个兄弟哈希。本章走到四片叶子,这意味着一棵两层的树——叶子两两配成分支,分支再两两配成根。control block 也随之变大:它现在带两个兄弟哈希(97 字节),一条向上爬两层、而不是一层的 Merkle 路径。
四片叶子也腾出了足够空间,把真正不同的条件并排放在一起。我们要构建的地址有四条脚本路径,加上 key path——五种花法,一个地址:
哈希锁——任何拿到原像 “helloworld” 的人都能花(第 6/7 章那把哈希锁)。
2-of-2 多签——Alice 和 Bob 一起,用 Tapscript 的
OP_CHECKSIGADD写。CSV 时间锁——Bob 能花,但要等 2 个区块之后。
简单签名——Bob 立刻就能花。
Key path——Alice 用她 tweak 后的密钥直接花;看起来就是一笔普通支付。
这些都对应着真实模式——一条恢复路径加一个时间锁加一条合作关闭,就是钱包恢复方案或闪电通道的骨架——但这里的重点是机制:四片叶子怎么承诺,每条路径怎么揭示和验证。
这些路径对应的真实模式#
四片叶子腾出空间,把真正不同的花费条件放在一个地址下。这些条件对应着现实里的常见模式:
钱包恢复:一条恢复路径加一个时间锁加一条合作关闭。
闪电通道:不同参与者集合下的多条合作关闭路径。
原子交换:带回退条件的哈希时间锁合约。
继承规划:基于时间、带多受益人选项的访问控制。
机制层面,四叶树给出几样东西:
选择性揭示:只暴露被执行的那片叶子,其余叶子保持隐藏。
费用:比等价的传统多条件脚本更小。
多条路径:一个承诺之内并排放多个花费条件。
这棵树,以及一个共享地址#
下面每一笔花费都出自同一个地址:
地址:
tb1pjfdm902y2adr08qnn4tahxjvp6x5selgmvzx63yfqk2hdey02yvqjcr29q五种不同花法共用这一个地址。
它的树是平衡的——两个分支下各两片叶子:
Merkle Root
/ \
Branch0 Branch1
/ \ / \
Script0 Script1 Script2 Script3
(Hashlock) (Multisig) (CSV) (Sig)
每片叶子的 witness 还是第 6 章以来那个形状——数据、脚本、control block——只是数据部分因脚本检查的东西不同而不同:
叶子 |
用什么解锁 |
Witness |
|---|---|---|
Script 0 哈希锁 |
原像 |
|
Script 1 多签 |
两个签名 |
|
Script 2 CSV |
一个签名,且过了 2 块 |
|
Script 3 简单签名 |
一个签名 |
|
Key path |
Alice 的 tweak 密钥 |
|
四个脚本路径的要点:
Script 0(SHA256 哈希锁):任何拿到原像 “helloworld” 的人都能花,是原子交换里的哈希锁模式。
Script 1(2-of-2 多签):Alice 和 Bob 一起,用 Tapscript 的
OP_CHECKSIGADD而不是旧的OP_CHECKMULTISIG。Script 2(CSV 时间锁):Bob 能花,但要等 2 个区块之后;交易输入必须设好匹配的 sequence。
Script 3(简单签名):Bob 立刻就能花,是最朴素的一片叶子。
Key path:Alice 用 tweak 后的密钥直接花,看起来就是一笔普通支付。
构建这棵树#
用 bitcoinutils 构建:先准备密钥,然后四段脚本,然后树。
准备密钥#
alice_priv = PrivateKey("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
bob_priv = PrivateKey.from_wif("cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG")
alice_pub = alice_priv.get_public_key()
bob_pub = bob_priv.get_public_key()
四段脚本#
两段是前面章节的老面孔,两段是新的:
# Script 0: Hashlock(需 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 时间锁
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: 简单签名
script3 = Script([bob_pub.to_x_only_hex(), "OP_CHECKSIG"])
创建 Taproot 地址#
树写成嵌套的成对结构——每个分支两片叶子——正是这种嵌套给出了两层 Merkle 结构:
tree = [[script0, script1], [script2, script3]]
taproot_address = alice_pub.get_taproot_address(tree)
# 四叶 Taproot 脚本树(btcaaron)
# 参考: examples/ch08_four_leaf_tree.py
from btcaaron import Key, TapTree
alice = Key.from_wif("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
bob = Key.from_wif("cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG")
# 四叶: 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("=== 四叶 Taproot 树 ===")
print(f"地址: {program.address}")
print(f"叶子: {program.leaves}")
print(program.visualize())
# 五种支出路径示例
# 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}")
=== 四叶 Taproot 树 ===
地址: tb1pjfdm902y2adr08qnn4tahxjvp6x5selgmvzx63yfqk2hdey02yvqjcr29q
叶子: ['hash', '2of2', 'csv', 'bob']
Merkle Root
/ \
Branch0 Branch1
/ \ / \
[hash] [2of2] [csv] [bob]
Hashlock TXID: 1ba4835fca1c94e7eb0016ce37c6de2545d07d84a97436f8db999f33a6fd6845
2of2 Multisig TXID: 1951a3be0f05df377b1789223f6da66ed39c781aaf39ace0bf98c3beb7e604a1
Key Path TXID: 1e518aa540bc770df549ec9836d89783ca19fc79b84e7407a882cbe9e95600da
分别花费每条路径#
五笔花费的差别只在于:往 witness 里放什么,以及 control block 指向哪个叶子索引。下面是 bitcoinutils 的关键逻辑(完整可运行代码见 examples/ch08_* 或上方 btcaaron cell)。
1. 哈希锁(Script 0)#
第 6/7 章那把哈希锁,现在位于四叶树的索引 0——control block 带两层证明,但调用看起来一模一样。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. 多签(Script 1)#
两个签名,都作为针对同一片叶子的脚本路径签名产生(script_path=True、tapleaf_script=script1、tweak=False)。witness 顺序是微妙的地方:Bob 签名在前(栈底,后消费),Alice 在后落到栈顶(先消费)。TXID: 1951a3be...
cb = ControlBlock(alice_pub, tree, 1, is_odd=taproot_address.is_odd())
# 签名时:script_path=True, tapleaf_script=script1
tx.witnesses.append(TxWitnessInput([sig_bob, sig_alice, script1.to_hex(), cb.to_hex()]))
3. CSV 时间锁(Script 2)#
时间锁路径有一个别的路径没有的要求:交易本身必须设一个匹配的 sequence,否则 OP_CHECKSEQUENCEVERIFY 会拒绝它。脚本带 seq.for_script()(时间锁条件),输入带 seq.for_input_sequence()(”它已满足”的声明),两者来自同一个 Sequence 对象。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. 简单签名(Script 3)#
最朴素的叶子——Bob 签名,没有额外条件。和第 7 章的 Bob 脚本一样,现在位于索引 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#
什么都不暴露的那一条——Alice 的 key-path 花费,精神上和第 6 章一致。它仍然需要整棵 tree 来重建 tweak,但树的任何信息都不会上链。key path 传 tapleaf_scripts=tree(复数,整棵树,用来算 tweak)配 script_path=False;witness 仅 [sig_alice]。TXID: 1e518aa5...
sig_alice = alice_priv.sign_taproot_input(..., script_path=False, tapleaf_scripts=tree)
tx.witnesses.append(TxWitnessInput([sig_alice]))
OP_CHECKSIGADD 怎么跑#
多签叶子是本章唯一新出现的脚本执行,我们走一遍它的栈。Tapscript 用 OP_CHECKSIGADD 取代了旧的 OP_CHECKMULTISIG,它维护一个有效签名的累加计数。
多签脚本结构#
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 在脚本运行前把两个签名都放上栈,而顺序很重要:Bob 签名在前(栈底,被第二个 OP_CHECKSIGADD 消费),Alice 在后落到栈顶(被第一个 OP_CHECKSIGADD 消费)。
tx.witnesses.append(TxWitnessInput([sig_bob, sig_alice, script1.to_hex(), cb.to_hex()]))
栈走法#
脚本:OP_0 [Alice_PubKey] OP_CHECKSIGADD [Bob_PubKey] OP_CHECKSIGADD OP_2 OP_EQUAL。
起点——两个签名都已加载,sig_alice 在顶上:
│ sig_alice │ ← 顶,先被消费
│ sig_bob │ ← 后被消费
└─────────────┘
1. OP_0:压入计数器,初始化为 0#
│ 0 │ ← 计数器
│ sig_alice │
│ sig_bob │
└─────────────┘
2. [Alice_PubKey]:脚本压入 Alice 的密钥#
│ alice_pubkey│
│ 0 │
│ sig_alice │
│ sig_bob │
└─────────────┘
3. OP_CHECKSIGADD:验 Alice,计数器加 1#
弹出密钥、弹出计数器、弹出它下面的签名;用 alice_pubkey 验 sig_alice;压入计数器+1:
│ 1 │ ← 计数器现在是 1
│ sig_bob │
└─────────────┘
4. [Bob_PubKey]:脚本压入 Bob 的密钥#
│ bob_pubkey │
│ 1 │
│ sig_bob │
└─────────────┘
5. OP_CHECKSIGADD:验 Bob,计数器加 1#
对 Bob 同样来一遍,消费 sig_bob:
│ 2 │ ← 计数器现在是 2
└─────────────┘
6. OP_2:压入要求的数量#
│ 2 │ ← 要求的签名数
│ 2 │ ← 实际验过的计数
└─────────────┘
7. OP_EQUAL:比较两值#
2 == 2,于是压入 1,脚本被满足:
│ 1 │ ← 脚本满足
└─────────────┘
这就是为什么 witness 里 sig_alice 必须在 sig_bob 上面:第一个 OP_CHECKSIGADD 是 Alice 的,它消费当下栈顶的那个签名。witness 列的是 [sig_bob, sig_alice]——bob 在前,于是 alice 落到顶上,于是 alice 先被检查。反过来,两个检查都会失败。
为什么用 OP_CHECKSIGADD 而不是 OP_CHECKMULTISIG#
三个具体原因:
它一次检查一个签名、失败即停,而不是去试各种组合。
计数器是显式的——没有
OP_CHECKMULTISIG那个差一位的多余元素怪癖。它直接吃 32 字节的 x-only 密钥,而
OP_CHECKMULTISIG要 33 字节压缩密钥。
四叶 control block#
树有两层,每片叶子的 Merkle 证明就是两个哈希——它的直接兄弟,再加上那一对的兄弟分支——所以 control block 是 97 字节。以交易 1951a3be0f05df377b1789223f6da66ed39c781aaf39ace0bf98c3beb7e604a1(Script 1 多签)为例。
33 字节:版本+奇偶 (1) + 内部公钥 (32)
+32 字节:兄弟叶子哈希 (第 1 层)
+32 字节:兄弟分支哈希 (第 2 层)
= 97 字节
见证栈结构:Bob 签名 → Alice 签名 → 多签脚本 → 97 字节控制块。
字节布局:
Byte 0:version (0xc0) + parity
Bytes 1–32:internal pubkey(Alice x-only)
Bytes 33–64:sibling 1(Script 0 的 TapLeaf 哈希)
Bytes 65–96:sibling 2(Branch 1 的 TapBranch 哈希)
每片叶子需要哪两个哈希,取决于它的位置:
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
}
字节解析#
把 97 字节的 control block 拆成四部分:
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()
爬两层回到地址#
有了脚本和它的两个兄弟哈希,验证还是第 7 章那个思路——重算根、看它能否重建地址——只是现在要走两步 TapBranch,而不是一步:
兄弟 1 是
fe78d852...——恰好是 Script 0 的 TapLeaf 哈希,也正是第 7 章为那把哈希锁算出的同一个值。同一段脚本,同一个叶子哈希,跨章一致。证明是分层的:第 0 层是多签叶子本身,第 1 层是
Branch0 = TapBranch(Script0, Script1),第 2 层是Root = TapBranch(Branch0, Branch1)。每一步 TapBranch 都把两个输入按字典序排,这正是任何人都能复算出根的原因。最后一步是
TapTweak(internal_pubkey ‖ root)得到输出公钥,能重建出五笔花费共出的那个地址tb1pjfdm...jcr29q,control block 就此证明这片叶子归属原始 Taproot 承诺。
# 可运行:解析交易 1951a3be... 的 97 字节控制块(仅用标准库)
cb_hex = "c050be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3fe78d8523ce9603014b28739a51ef826f791aa17511e617af6dc96a8f10f659eda55197526f26fa309563b7a3551ca945c046e5b7ada957e59160d4d27f299e3"
cb = bytes.fromhex(cb_hex)
print(f"控制块长度: {len(cb)} 字节")
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]}...")
三个会咬人的地方#
四叶花费会以几种可预测的方式失败。按踩坑频率排序。
1. 多签的 witness 顺序#
Bob 的签名在列表里排第一,好让 Alice 的落到栈顶——反过来两个检查都失败(见上面的栈走法):
# 错
witness = [sig_alice, sig_bob, script, control_block]
# 对
witness = [sig_bob, sig_alice, script, control_block]
2. CSV 的 sequence#
CSV 脚本只有在输入的 sequence 表明已过足够区块时才通过。忘了它,OP_CHECKSEQUENCEVERIFY 就拒绝这笔花费:
# 错 —— 默认 sequence,CSV 失败
txin = TxInput(txid, vout)
# 对 —— sequence 匹配脚本的时间锁
txin = TxInput(txid, vout, sequence=seq.for_input_sequence())
3. key path 与 script path 的签名#
两者参数不同,混用是最常见的单一错误:
# key path: 整棵树(用来 tweak 密钥),script_path=False
sig = priv.sign_taproot_input(..., script_path=False, tapleaf_scripts=tree)
# script path: 一片叶子,script_path=True
sig = priv.sign_taproot_input(..., script_path=True, tapleaf_script=script)
本章小结#
四片叶子把第 7 章的单个 TapBranch 变成了一棵两层的树,control block 也随之变大——97 字节,带一条两个哈希的 Merkle 路径。我们把四个真正不同的条件放在了一个地址下(一把哈希锁、一个 2-of-2 多签、一个 CSV 时间锁、一个普通签名),在 testnet 上各花了一遍,并通过把多签的 control block 沿两层分支爬回每条路径共享的同一个地址,验证了它。
本章核心收获#
OP_CHECKSIGADD是 Tapscript 做多签的方式——一个有效签名的累加计数,喂给它的 witness 里签名顺序必须匹配脚本检查它们的顺序。树越高,Merkle 路径越长。 每多一层深度,control block 就多一个 32 字节的兄弟;成本随叶子数量的对数增长,而不是随个数增长。
五种花法共享同一地址。 五笔真实 testnet 交易验证了这棵树;key-path 花费在链上与普通单签名交易不可区分。
局限性说明#
本章的四叶树用均衡结构(两个分支各两片叶子)。实际应用中,应把高概率使用的脚本放在树的浅层,以减小 Merkle 证明、降低手续费。
哈希锁脚本仍用
OP_TRUE结尾(见第 6 章安全提示),生产环境应绑定签名验证。验证代码里的椭圆曲线点运算由库内部封装,未展示底层实现。
下一步#
第 9 章和第 6 章一样从单叶起步,但用途不同:把数据存进一个 Taproot 输出,而不是写花费条件。后面四章把这些机制放进真实系统——Ordinals 与 BRC-20、RGB 与 Tapret、闪电通道、静默支付。