第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)

三步,其中第二步是真正新出现的:

  1. TapLeaf 哈希——每段脚本各自哈希成自己的叶子,和第 6 章完全一样。

  2. TapBranch 哈希——两个叶子哈希按字典序排序,再一起哈希成父节点。正是这个排序让根具有确定性:无论你以什么顺序列出脚本,较小的哈希总是排在前面,于是所有人都算出同一个根。

  3. Control block——要花掉某一片叶子,你得证明它确实位于那个根之下。证明就是另一片叶子的哈希,装在 control block 里,好让验证方重新算出这条分支、落回根上。

本章余下的内容,就是从真实链上数据看这套结构。

两笔链上交易#

我们从同一个双叶地址的两笔真实 testnet 花费倒推着看。

交易 1:哈希脚本路径#

  • 交易 IDb61857a05852482c9d5ffbb8159fc2ba1efa3dd16fe4595f121fc35878a2e430

  • Taproot 地址tb1p93c4wxsr87p88jau7vru83zpk6xl0shf5ynmutd9x0gxwau3tngq9a4w3z

  • 用原像 “helloworld” 花费。

交易 2:Bob 脚本路径#

  • 交易 ID185024daff64cea4c82f129aa9a8e97b4622899961452d1d144604e65a70cfe0

  • Taproot 地址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 的私钥对应此公钥,签名对交易数据有效)

验证过程详情

  1. 从栈弹出公钥:84b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5

  2. 从栈弹出签名:26a0eadca0bba3d1bb6f82b8e1f76e2d84038c97a92fa95cc0b9f6a6a59bac5f...

  3. 使用 BIP340 Schnorr 签名验证算法验证签名有效性

  4. 验证成功,推送 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 字节、两个兄弟哈希那一档),让一个地址同时撑起好几个花费条件。