第3章:P2SH 脚本工程#

参考: code/chapter03/ 最后更新: 2026-06-16


Pay-to-Script-Hash(P2SH)是比特币脚本第一次变得实用的地方:任何脚本,无论多复杂,都能锁在一个 20 字节哈希后面,只在花费时才揭示。本章用 P2SH 搭起后面全书反复出现的两个模式——多签(multisig)和时间锁(time lock)——并把两者都在栈上走一遍。它处在第 2 章的单签 P2PKH 和 Taproot 的脚本树之间。

# 本章环境:bitcoinutils(一次性加载,后续代码块复用)
from bitcoinutils.setup import setup
from bitcoinutils.keys import PrivateKey, P2pkhAddress, P2shAddress
from bitcoinutils.script import Script
from bitcoinutils.transactions import Transaction, TxInput, TxOutput, Sequence
from bitcoinutils.utils import to_satoshis
from bitcoinutils.constants import TYPE_RELATIVE_TIMELOCK
setup('testnet')
'testnet'

3.1 P2SH 架构:哈希后面的脚本#

P2SH 让任何脚本都能用一个紧凑的 20 字节哈希来代表,把脚本的复杂度移出 UTXO 集,推迟到花费时。

两阶段验证模型#

P2SH 分两个清晰的阶段:

阶段 1:哈希验证

OP_HASH160 <script_hash> OP_EQUAL

阶段 2:脚本执行

<revealed_script> → Execute as Bitcoin Script

P2SH 地址生成过程#

P2SH 沿用第 1 章讲过的 Hash160 → Base58Check 流程,只是哈希的是脚本而不是公钥:

Script Serialization → hex_encoded_script
Hash160(script)     → 20_bytes_script_hash
Version + Base58Check → 3...address (mainnet)

所有 P2SH 地址在主网以 “3” 开头、测试网以 “2” 开头,一眼就和 P2PKH 地址区分开。

ScriptSig 构造模式#

P2SH 的解锁脚本(ScriptSig)遵循一个固定模式:

<script_data> <serialized_redeem_script>

其中 <script_data> 是满足赎回脚本(redeem script)条件所需的值,<serialized_redeem_script> 是原始脚本——它的哈希要和锁定脚本里的哈希匹配。

3.2 2-of-3 多签#

多签输出需要不止一把密钥才能花。2-of-3——三把里任意两把——是共享托管的常见形态:没有任何一个人能独自动用资金,也不会因为一把密钥丢失就把资金锁死。

设定:三把密钥#

三方各持一把密钥,任意两方就能授权一次花费:

  • AliceBobCarol —— 三把密钥,需要两把。

赎回脚本用 OP_CHECKMULTISIG 把这条规则编码进去。

# 示例 1: 创建多重签名 P2SH
# 参考: code/chapter03/01_create_multisig_p2sh.py

alice_pk = '02898711e6bf63f5cbe1b38c05e89d6c391c59e9f8f695da44bf3d20ca674c8519'
bob_pk = '0284b5951609b76619a1ce7f48977b4312ebe226987166ef044bfb374ceef63af5'
carol_pk = '0317aa89b43f46a0c0cdbd9a302f2508337ba6a06d123854481b52de9c20996011'
redeem_script = Script(['OP_2', alice_pk, bob_pk, carol_pk, 'OP_3', 'OP_CHECKMULTISIG'])
p2sh_addr = P2shAddress.from_script(redeem_script)
print(f"Redeem Script: {redeem_script.to_hex()[:32]}...{redeem_script.to_hex()[-8:]}")
print(f"P2SH Address: {p2sh_addr.to_string()}")
Redeem Script: 522102898711e6bf63f5cbe1b38c05e8...601153ae
P2SH Address: 2NDSSi5n5knjFqNMQMxyCezn6i18UQEh8Nj

关键函数#

  • Script([...]):从操作码和数据的列表创建 Script 对象,库会自动把 'OP_2' 这类操作码编码成字节(0x52)。

  • P2shAddress.from_script(script):序列化脚本 → Hash160(script) → 加版本字节 → Base58Check。

  • 序列化:这个赎回脚本序列化成 522102898711...601153ae(OP_2 + 3×公钥 + OP_3 + OP_CHECKMULTISIG)。

# 示例 2: 花费多重签名 P2SH
# 参考: code/chapter03/02_spend_multisig_p2sh.py

alice_sk = PrivateKey('cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT')
bob_sk = PrivateKey('cSNdLFDf3wjx1rswNL2jKykbVkC6o56o5nYZi4FUkWKjFn2Q5DSG')
redeem_script = Script(['OP_2', alice_pk, bob_pk, carol_pk, 'OP_3', 'OP_CHECKMULTISIG'])
txin = TxInput('4b869865bc4a156d7e0ba14590b5c8971e57b8198af64d88872558ca88a8ba5f', 0)
txout = TxOutput(to_satoshis(0.00000888), P2pkhAddress('myYHJtG3cyoRseuTwvViGHgP2efAvZkYa4').to_script_pub_key())
tx = Transaction([txin], [txout])
alice_sig = alice_sk.sign_input(tx, 0, redeem_script)
bob_sig = bob_sk.sign_input(tx, 0, redeem_script)
txin.script_sig = Script(['OP_0', alice_sig, bob_sig, redeem_script.to_hex()])
signed_tx = tx.serialize()
print(f"Transaction size: {tx.get_size()} bytes")
Transaction size: 337 bytes

多重签名栈执行(简要)#

TXID: e68bef534c7536300c3ae5ccd0f79e031cab29d262380a37269151e8ba0fd4e0

阶段 1(哈希验证):ScriptSig → OP_0 + sig1 + sig2 + redeem_script → OP_HASH160 + 预期哈希 → OP_EQUAL

阶段 2(赎回脚本):OP_2 + 3 公钥 + OP_3 + OP_CHECKMULTISIG → 验证 2-of-3 签名 → TRUE

P2SH 两阶段:先验证脚本的哈希,再重置栈、运行揭示出的赎回脚本 → 1 (true)。

3.3 用 CSV 做时间锁#

CheckSequenceVerify(CSV)强制一个相对时间锁:花费被推迟若干个区块,从 UTXO 创建那一刻起算。下面是一个真实的 testnet 实现。

一个 3 区块时间锁#

交易 ID34f5bf0cf328d77059b5674e71442ded8cdcfc723d0136733e0dbf180861906f

这笔交易把 CSV 时间锁和 P2PKH 签名检查合进同一段赎回脚本——继承和托管条件用的就是这个形态。

# 示例 3: 创建 CSV 时间锁脚本
# 参考: code/chapter03/03_create_csv_script.py

sk_csv = PrivateKey('cRxebG1hY6vVgS9CSLNaEbEJaXkpZvc6nFeqqGT7v6gcW7MbzKNT')
pk_csv = sk_csv.get_public_key()
seq = Sequence(TYPE_RELATIVE_TIMELOCK, 3)
redeem_csv = Script([seq.for_script(), 'OP_CHECKSEQUENCEVERIFY', 'OP_DROP',
    'OP_DUP', 'OP_HASH160', pk_csv.get_address().to_hash160(), 'OP_EQUALVERIFY', 'OP_CHECKSIG'])
p2sh_csv = P2shAddress.from_script(redeem_csv)
print(f"P2SH Address: {p2sh_csv.to_string()}")
print(f"Time Lock: 3 blocks")
P2SH Address: 2N4Lnv72RRSJzz21PTnsBeECZHuMmVyS3iG
Time Lock: 3 blocks

关键函数#

  • Sequence(TYPE_RELATIVE_TIMELOCK, blocks):创建一个基于区块的相对延迟 sequence 对象。

  • seq.for_script():返回供脚本操作码使用的 sequence 值(把延迟值压入栈)。

  • seq.for_input_sequence():返回供交易输入 sequence 字段使用的值,CSV 会拿它来校验。

# 示例 4: 花费 CSV 时间锁脚本
# 参考: code/chapter03/04_spend_csv_script.py

txin_csv = TxInput('34f5bf0cf328d77059b5674e71442ded8cdcfc723d0136733e0dbf180861906f', 0, sequence=seq.for_input_sequence())
txout_csv = TxOutput(to_satoshis(0.00001), P2pkhAddress('myYHJtG3cyoRseuTwvViGHgP2efAvZkYa4').to_script_pub_key())
tx_csv = Transaction([txin_csv], [txout_csv])
sig_csv = sk_csv.sign_input(tx_csv, 0, redeem_csv)
txin_csv.script_sig = Script([sig_csv, pk_csv.to_hex(), redeem_csv.to_hex()])
print(f"Transaction size: {tx_csv.get_size()} bytes")
Transaction size: 220 bytes

CSV 栈执行(简要)#

赎回脚本:OP_3 OP_CHECKSEQUENCEVERIFY OP_DROP + P2PKH

流程:PUSH 3 → CSV 验证 → OP_DROP → OP_DUP → OP_HASH160 → OP_EQUALVERIFY → OP_CHECKSIG → 1 (true)

用在哪里:继承、托管的 fallback 路径、Lightning 的结算延迟。

3.4 P2SH vs Taproot#

P2SH 把比特币脚本从单签授权扩展到多方和基于时间的条件,同时保持紧凑的地址格式。但它有一个局限:花费时整段赎回脚本都会被揭示——每一条分支,无论它有没有被走到。Taproot 针对的正是这一点——复杂脚本承诺进一棵树里,只揭示实际执行的那条路径。

章节总结#

P2SH 把一段脚本锁在它的哈希后面,只在花费时揭示。我们搭起了后面全书反复出现的两个模式:

  • 多签 —— OP_CHECKMULTISIG 配一个 2-of-3 赎回脚本,外加补它差一位 bug 的 OP_0

  • 时间锁 —— OP_CHECKSEQUENCEVERIFY 做一个相对延迟,配上一次 P2PKH 检查。

两者我们都在栈上走了一遍,包括 P2SH 的两阶段执行:先验证脚本的哈希,再重置栈、运行揭示出的脚本。

有一个局限对后文最关键:P2SH 在花费时揭示整段赎回脚本,每条分支都在内。Taproot 针对的正是这一点——只揭示你用的那条分支——而这是全书后面所要搭向的目标。

下一章。 第 4 章转到 SegWit:把见证移出交易主体,修掉可锻性,并为 Taproot 基于见证的花费路径做铺垫。