第6章:构建真实 Taproot 合约#
参考:
examples/ch06_single_leaf_contract.py
最后更新: 2026-06-16
为什么 Script Path 改变一切#
第 5 章构建的 Taproot 输出没有承诺任何东西——一个仅 key-path 的地址,唯一的花费方式就是 Alice 那把经过 tweak 的密钥。本章补上 Taproot 的另一半:脚本路径(script path)。我们让同一个地址拥有两条互相独立的花费路径,并验证:在链上,直到有人真正花掉它之前,它看起来仍然和一笔普通支付别无二致。
这个合约刻意做得很小——一段脚本,谁知道某个暗号就能取走资金——好让机制本身始终清晰可见。这里的一切都能推广到后续章节的多叶脚本树;本章是同一套机制的单叶版本。
场景:一笔有条件的支付#
Alice 想要一个能用两种方式花费的地址:
条件路径:任何知道暗号 “helloworld” 的人都能取走资金。
所有者路径:Alice 随时可以用自己的私钥取回资金。
隐私:在未花费时,这个地址和任何普通 Taproot 支付无法区分。
这种双路径结构极为常见。下面几个例子,本质上都归结为完全相同的结构:
应用场景 |
两条路径如何对应 |
|---|---|
数字商品销售 |
买家付款后用密钥解锁;卖家保留一条退款路径 |
悬赏任务 |
谁解开谜题谁领赏;发布者可取回无人认领的悬赏 |
条件托管 |
满足条件时释放资金;否则所有者取回 |
教学激励 |
学生答对即可领取奖励;教师保留一条管理路径 |
两条花费路径#
Alice 的 Taproot 地址带着两种花费方式,而它们在成本和暴露的信息上差别极大。
Key path(密钥路径)。 Alice 用她经过 tweak 的私钥签名。一个 64 字节的 Schnorr 签名,不暴露脚本的任何信息。这就是第 5 章那条便宜、私密的路径。
Script path(脚本路径)。 哈希锁脚本 OP_SHA256 <hash> OP_EQUALVERIFY OP_TRUE。任何能给出原像(preimage)”helloworld” 的人都能花费它。走这条路会暴露你用的那段脚本——但不会暴露 key path,也不会暴露这棵树里可能存在的任何其他分支。
这种不对称正是整个设计的要点:key path 是安静的默认选项;script path 是你真正需要那个条件时才用的,而且它每次只暴露你实际走的那一条分支。
Commit–Reveal 模式#
我们用 Taproot 做的几乎所有事情都遵循同一种形态,值得在写任何代码之前先给它命名:先承诺(commit),再揭示(reveal)。
Commit。 你把一条或多条花费条件折叠进一棵脚本树,把这棵树承诺进单个 Taproot 地址,然后给它打钱。从外部看,这个地址只是 32 字节——没人能看出它带着哪些条件,甚至看不出它到底带没带条件。
Reveal。 花费时,你挑一条路径。Key path 什么都不暴露。Script path 恰好只暴露你用的那一片叶子——其余每条分支都永远藏着。
这个模式的回报在于:在 commit 时,复杂程度天差地别的合约在链上看起来完全一样;在 reveal 时,你只为实际走的那一条分支付出代价——无论是字节还是隐私。
让我们通过简单的哈希锁案例,走一遍 Taproot 单叶脚本的完整流程。基于前面 Alice 的有条件支付场景,我们要构建尽可能小的树——一片叶子——好让 commit→reveal 的流程不被任何东西干扰:
哈希锁脚本:检查暗号 “helloworld” 的 SHA256。
单叶树:最简单的脚本树,只有一片叶子。
两条路径:key path(Alice 直接控制)加 script path(条件花费)。
Tagged Hash(BIP340)。先讲一个基础构件:BIP340 把一切都过一道 tagged(带标签的)哈希——一个掺入了用途标签的 SHA256。正是这个标签,让一个叶子哈希永远不会和一个 tweak、或一个签名哈希撞上,哪怕输入完全相同。每个标签划出自己的领域,于是为不同用途计算出的哈希永远不会意外对齐。
阶段 1:Commit 阶段#
脚本序列化成字节: a8=OP_SHA256,20=PUSH32,936a185c...07af=SHA256(“helloworld”),88=OP_EQUALVERIFY,51=OP_TRUE。
Commit 关键代码:
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)
地址:tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h
它的 ScriptPubKey 只是 OP_1 <32 字节输出密钥>——和链上其他每一个 Taproot 地址逐字节同形。它身上没有任何东西能告诉观察者:这是一笔普通单签,还是一份条件合约。这种无法区分,正是 commit 阶段买来的东西。
Key Path#
走 key path 时,见证里就一个 64 字节 Schnorr 签名,与任何普通 Taproot 支付无法区分,脚本从头到尾没有出现。有个细节很容易漏掉:即便走 key path,签名时仍然要传 tapleaf_scripts。原因是输出密钥在 commit 时被 Merkle 根 tweak 过,要为它签名,Alice 必须重建出同一个 tweak,这就需要那棵脚本树,哪怕她从不揭示它。script_path=False 把这些账藏了起来。
Script Path 支出#
script path 这边要装够东西,既证明某片特定的叶子确实长在已承诺的树里,又提供脚本运行所需的输入。见证顺序固定为 [preimage, script, control_block]——数据、代码、证明。单叶控制块 33 字节(version+parity + internal_pubkey,无 Merkle 路径)。
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()]))
交易 68f7c8f0… 见证栈#
[0] 68656c6c6f776f726c64 (preimage)
[1] a820936a185c...8851 (script)
[2] c150be5fc4...bb4d3 (control_block)
# 单叶 Taproot 合约实现(btcaaron)
# 参考: examples/ch06_single_leaf_contract.py
from btcaaron import Key, TapTree
alice = Key.from_wif("cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT")
# Commit 阶段:构建单叶脚本树(哈希锁 + Key Path)
program = (TapTree(internal_key=alice)
.hashlock("helloworld", label="hash")
).build()
print("=== 单叶 Taproot 合约 ===")
print(f"地址: {program.address}")
print(f"叶子: {program.leaves}")
print(program.visualize())
print(f"预期地址: tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h")
# Key Path 支出(Alice 直接收回)
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 支出(知道原像的人花费)
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}")
=== 单叶 Taproot 合约 ===
地址: tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h
叶子: ['hash']
Taproot
|
[hash]
预期地址: tb1p53ncq9ytax924ps66z6al3wfhy6a29w8h6xfu27xem06t98zkmvsakd43h
Key Path TXID: dcb366c4c08fc15f0bf78d8079305a5cd29f3ec769c9afc57baccf7b536e27b5
Script Path TXID: 6602f1948b71521c5dc3010d37d766d4b229109f6190429292cf8dce2573755d
# 可运行:解析单叶控制块(33 字节,交易 68f7c8f0... Script Path)
cb_hex = "c150be5fc44ec580c387bf45df275aaa8b27e2d7716af31f10eeed357d126bb4d3"
cb = bytes.fromhex(cb_hex)
print(f"控制块长度: {len(cb)} 字节")
print(f"Internal pubkey: {cb[1:33].hex()[:16]}...")
Script-path 花费失败时:一份排查清单#
script-path 花费失败的方式就那么几种、且可预测。一旦失败,照这张清单往下查。
1. 见证顺序。 必须是 [preimage, script, control_block]——数据、代码、证明。✅ 不要颠倒。
2. 脚本一致性。 你揭示的脚本必须和你承诺的脚本逐字节相同——一样的操作码、一样的哈希。最可靠的办法是用同一个函数构建两边。
3. Control block。 内部公钥对不对?脚本索引和叶子位置匹配吗(单叶为 0)?还有奇偶标志(is_odd),要从地址读,别猜。
4. 输入编码。 原像必须先是 UTF-8 字节,再转十六进制:"helloworld" → 68656c6c6f776f726c64。
栈执行:走一遍哈希锁#
下面是脚本一步一个操作码地跑起来。
执行脚本:OP_SHA256 OP_PUSHBYTES_32 936a185c...07af OP_EQUALVERIFY OP_PUSHNUM_1
0. 起点:witness 把原像加载到栈上#
│ 68656c6c6f776f726c64 │
│ (preimage_hex: "helloworld") │
└──────────────────────────────────────────────────┘
1. OP_SHA256:弹出原像,压入它的 SHA256#
OP_SHA256 从栈顶弹出原像数据,计算其 SHA256 哈希,然后将结果推回栈:
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(计算过程:SHA256(“helloworld”) = 936a185c…07af)
2. PUSH 32 字节:脚本压入它内置的期望哈希#
脚本将预设的预期哈希值推送到栈顶:
│ 936a185c...07af (expected_hash) │
│ 936a185c...07af (computed_hash) │
└─────────────────────────────────┘
(栈中现在有两个相同的哈希值)
3. OP_EQUALVERIFY:弹出栈顶两个元素比较#
OP_EQUALVERIFY 弹出栈顶两个元素进行比较;相等则继续执行,两个元素都被消耗,否则脚本失败:
│ (empty_stack) │
└───────────────┘
(验证成功:936a185c…07af == 936a185c…07af,两个元素都被消耗)
4. OP_TRUE:压入成功标志#
最后,OP_TRUE(OP_PUSHNUM_1)压入 1,使栈顶为非零值,这正是脚本被满足的标志:
│ 01 (true_value) │
└─────────────────┘
(脚本执行成功:栈顶是非零值)
Key path 与 Script path 对比#
两条路径并排放在一起,用我们刚刚产出的真实数字:
Aspect |
Key Path |
Script Path |
|---|---|---|
Witness Data |
1 element (64-byte signature) |
3 elements (input+script+control block) |
Transaction Size |
~153 bytes |
~234 bytes |
Privacy Level |
完全——不暴露脚本的任何信息 |
部分——只暴露被执行的那片叶子 |
Verification Complexity |
一次 Schnorr 检查 |
控制块检查,然后脚本执行 |
Fee Cost |
最低 |
更高(这里约多 50%) |
script path 多花字节、也让出一些隐私——但只针对你用的那一条分支。你可能承诺过的其余每条分支都仍然藏着。正是这种选择性揭示,让一个 Taproot 地址既能撑起数字商品销售、悬赏、托管、多方合约,又能在被花掉之前一直看起来像一笔普通支付。
这和 P2SH 有何不同#
和 P2SH 对比,是看清 script path 到底买来什么的最锐利角度。
在 P2SH 里,花费会暴露整个赎回脚本(redeem script)——每一条分支,包括你没走的那些。观察者在它第一次被使用时就学到了整份合约。
Taproot 的 script path 只暴露你执行的那片叶子。没走的分支从不公布;它们只作为哈希折叠进 Merkle 根,链上永远见不到。而且在地址被花费之前,它和一笔普通单签支付无法区分。
所以这个区别是具体的,不是口号:P2SH 暴露整份合约,Taproot 只暴露穿过它的一条路径。对于带多个条件的合约——大多数真实合约都是——这是链上泄露信息的一次大幅削减。
本章小结#
我们从头到尾构建了 Alice 的哈希锁合约,完整看到了 commit–reveal 模式。
Commit 与 reveal。 commit 时,一份条件合约折叠进一个外观普通的 Taproot 地址,把资金锁住。reveal 时,Alice 挑一条路径——key path 或 script path——只暴露这条路径所需的东西。
实现归结为这几件事:
单叶树——只有一片叶子时,TapLeaf 哈希就是 Merkle 根,不需要额外的 Merkle 计算。
Control block——通过从内部密钥和脚本叶子哈希还原出地址,来证明某段脚本确实被承诺。
栈执行——哈希锁靠把原像的
OP_SHA256和已承诺的哈希做匹配来完成花费。
做错就会咬你的地方:
Tagged hash——标签才是把 TapLeaf 哈希和 TapTweak 分开的东西;同一台机器,不同标签。
Witness 顺序——
[输入, 脚本, 控制块],每次都是。commit/reveal 一致性——两个阶段用同一个函数构建脚本,让字节精确相同。
下一章。 第 7 章从一片叶子走向两片:一棵双叶脚本树,Merkle 根要从不止一条分支算出来,你也开始选择揭示哪条分支。到那时,”脚本树”里的”树”才真正名副其实。