第8章:四叶 Taproot 脚本树#

参考: book/Chapter 08.md
代码示例: code/chapter08/
最后更新: 2026-06-16


从两片叶子到四片#

第 7 章构建了一棵双叶树:一个 TapBranch 覆盖两片叶子,control block 带一个兄弟哈希。本章走到四片叶子,这意味着一棵两层的树——叶子两两配成分支,分支再两两配成根。control block 也随之变大:它现在带两个兄弟哈希(97 字节),一条向上爬两层、而不是一层的 Merkle 路径。

四片叶子也腾出了足够空间,把真正不同的条件并排放在一起。我们要构建的地址有四条脚本路径,加上 key path——五种花法,一个地址:

  1. 哈希锁——任何拿到原像 “helloworld” 的人都能花(第 6/7 章那把哈希锁)。

  2. 2-of-2 多签——Alice 和 Bob 一起,用 Tapscript 的 OP_CHECKSIGADD 写。

  3. CSV 时间锁——Bob 能花,但要等 2 个区块之后。

  4. 简单签名——Bob 立刻就能花。

  5. Key path——Alice 用她 tweak 后的密钥直接花;看起来就是一笔普通支付。

这些都对应着真实模式——一条恢复路径加一个时间锁加一条合作关闭,就是钱包恢复方案或闪电通道的骨架——但这里的重点是机制:四片叶子怎么承诺,每条路径怎么揭示和验证。

这些路径对应的真实模式#

四片叶子腾出空间,把真正不同的花费条件放在一个地址下。这些条件对应着现实里的常见模式:

  • 钱包恢复:一条恢复路径加一个时间锁加一条合作关闭。

  • 闪电通道:不同参与者集合下的多条合作关闭路径。

  • 原子交换:带回退条件的哈希时间锁合约。

  • 继承规划:基于时间、带多受益人选项的访问控制。

机制层面,四叶树给出几样东西:

  • 选择性揭示:只暴露被执行的那片叶子,其余叶子保持隐藏。

  • 费用:比等价的传统多条件脚本更小。

  • 多条路径:一个承诺之内并排放多个花费条件。

这棵树,以及一个共享地址#

下面每一笔花费都出自同一个地址:

  • 地址tb1pjfdm902y2adr08qnn4tahxjvp6x5selgmvzx63yfqk2hdey02yvqjcr29q

  • 五种不同花法共用这一个地址。

它的树是平衡的——两个分支下各两片叶子:

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

每片叶子的 witness 还是第 6 章以来那个形状——数据、脚本、control block——只是数据部分因脚本检查的东西不同而不同:

叶子

用什么解锁

Witness [0..]

Script 0 哈希锁

原像

[preimage]

Script 1 多签

两个签名

[bob_sig, alice_sig]

Script 2 CSV

一个签名,且过了 2 块

[bob_sig] + 交易 sequence 设好

Script 3 简单签名

一个签名

[bob_sig]

Key path

Alice 的 tweak 密钥

[alice_sig]

四个脚本路径的要点:

  1. Script 0(SHA256 哈希锁):任何拿到原像 “helloworld” 的人都能花,是原子交换里的哈希锁模式。

  2. Script 1(2-of-2 多签):Alice 和 Bob 一起,用 Tapscript 的 OP_CHECKSIGADD 而不是旧的 OP_CHECKMULTISIG

  3. Script 2(CSV 时间锁):Bob 能花,但要等 2 个区块之后;交易输入必须设好匹配的 sequence。

  4. Script 3(简单签名):Bob 立刻就能花,是最朴素的一片叶子。

  5. 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=Truetapleaf_script=script1tweak=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_pubkeysig_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、闪电通道、静默支付。