第2章:Bitcoin Script 基础——堆栈操作与 P2PKH#
参考:
code/chapter02/最后更新: 2026-06-16
第 1 章落在一个判断上:地址不过是锁定脚本(locking script)的一个替身。这一章讲的就是那个脚本。不过在脚本跑起来之前,得先弄清它到底锁住的是什么——所以我们从 UTXO 模型讲起,再引入 Bitcoin Script,把一笔真实的 P2PKH 花费在栈上一个操作码(opcode)一个操作码地走完。Taproot 后面做的一切,都建立在这套相同的执行模型上。
# 本章环境:bitcoinutils(一次性加载,后续代码块复用)
from bitcoinutils.setup import setup
from bitcoinutils.utils import to_satoshis
from bitcoinutils.transactions import Transaction, TxInput, TxOutput
from bitcoinutils.keys import P2wpkhAddress, P2pkhAddress, PrivateKey
from bitcoinutils.script import Script
2.1 UTXO 模型:数字现金,不是数字银行#
在看脚本之前,先把 Bitcoin 怎么持有价值说准。它不记账户余额,用的是 UTXO(Unspent Transaction Output,未花费交易输出) 模型——这套模型的行为更像实物现金,而不像银行账户。
现金 vs. 银行:一个心智模型#
银行账户是一个会上下浮动的数字。现金是一把离散的钞票,你花钱的方式是递出整张钞票、再找回零钱。Bitcoin 走的是第二种。
传统银行(账户模型):
账户显示一个余额:$500
花掉 $350 就直接从余额里扣
结果:账户余额变成 $150
不需要处理”找零”
Bitcoin UTXO 模型(现金模型):
你没有一个”$500 余额”
你持有的是一张张具体的”钞票”:一张 $200 加三张 $100
要花 $350,你必须拿出价值 $400 的钞票($200 + $100 + $100)
你会收到 $50 作为一张新”钞票”找零
结果:你现在有一张 $100 和一张 $50
这种现金式行为不是界面层的小花样——它就是 Bitcoin 设计与安全模型的根基。
UTXO 模型实战#
走一笔 Alice 付给 Bob 的款。
初始状态:
Alice 拥有一个 10 BTC 的 UTXO
Bob 没有任何 bitcoin
Alice 给 Bob 转 7 BTC:
交易输入:Alice 的 10 BTC UTXO(必须被整个消耗)
交易输出:
7 BTC 给 Bob(新 UTXO)
3 BTC 找零回 Alice(新 UTXO)
结果:原来的 10 BTC UTXO 被销毁,两个新 UTXO 被创建
每个 UTXO 用”创建它的交易 + 它在该交易输出列表中的位置”来命名——transaction_id:output_index:
Bob 的 UTXO:
TX123:0(7 BTC)Alice 的找零:
TX123:1(3 BTC)
UTXO 的关键性质#
有几条性质直接从现金模型推出来,值得单列,因为全书后面都靠它们:
完整消耗:UTXO 必须整个花掉——不存在部分花费。
原子创建:一笔交易要么完全成功(所有输入被消耗、所有输出被创建),要么完全失败。
找零处理:输入与输出金额之间的差额会成为交易手续费,除非显式作为找零返回。
并行处理:因为每个 UTXO 只能被花一次,多笔交易可以并行验证,不需要复杂的状态管理。
2.2 Bitcoin Script 与 P2PKH 基础#
Bitcoin Script:可编程的花费条件#
一个 UTXO 承载的不只是金额,还有一段锁定脚本(locking script,ScriptPubKey),写明它在什么条件下才能被花。要花掉它,就得提供一段**解锁脚本(unlocking script,ScriptSig)**来满足这些条件。两者被放在一起检验,通过了,网络才把这次花费当作有效。
脚本结构#
Unlocking Script (ScriptSig) + Locking Script (ScriptPubKey) -> Valid/Invalid
锁定脚本(ScriptPubKey):
附在每个 UTXO 输出上
定义花费条件
例:”只有能为公钥 X 提供有效签名的人才能花”
解锁脚本(ScriptSig):
在花费 UTXO 时提供
包含满足锁定脚本所需的数据
例:”这是我的签名和公钥”
验证时,节点把两段脚本拼起来,当作一个程序执行,只有最终结果为 TRUE 才接受这次花费。
基于栈的执行#
Bitcoin Script 运行在一个栈(stack)上,和 Forth、PostScript 这类语言是同一套模型。每个操作都作用在一个后进先出(LIFO)的栈上:数据被压入栈,操作码把参数弹出、再把结果压回去。一个简短的算术例子就能展示整套机制。
初始栈:空
│ (empty) │
└───────────────────────────────────────┘
PUSH 3
│ 3 │
└───────────────────────────────────────┘
PUSH 5
│ 5 │
│ 3 │
└───────────────────────────────────────┘
ADD 操作
│ 8 │
└───────────────────────────────────────┘
ADD 这一步就是整套模式的缩影:弹出栈顶两个数(先 5,后 3),相加,把结果(8)压回去。没有别的东西在发生。正是这种可预测性,让这套模型能承载复杂的花费条件,而不至于变成安全隐患。
P2PKH:基础脚本#
Pay-to-Public-Key-Hash(P2PKH)是最基础的脚本类型,也是在 Taproot 把事情复杂化之前、学习栈模型最合适的地方。
P2PKH 锁定脚本
OP_DUP OP_HASH160 <pubkey_hash> OP_EQUALVERIFY OP_CHECKSIG
用一句话说:这个 UTXO 可以被任何人花费,只要他能拿出一把哈希值等于 pubkey_hash 的公钥,外加一个来自对应私钥的有效签名。
P2PKH 解锁脚本
<signature> <public_key>
花费者提供两样东西:一个证明自己掌握私钥的数字签名,以及公钥本身——脚本会把这把公钥哈希后,与已承诺的哈希做比对。
真实案例:Satoshi 转给 Hal Finney#
Bitcoin 史上第一笔付款——Satoshi Nakamoto 转 10 BTC 给 Hal Finney——是天然的例子。
交易 ID:f4184fc5...831e9e16
交易结构:
输入:Satoshi 的 coinbase UTXO(挖矿得到的 50 BTC)
输出:
10 BTC 给 Hal Finney
40 BTC 找零回 Satoshi
有一点要说明:那笔 2009 年的交易用的是 P2PK(Pay-to-Public-Key),把公钥直接嵌进锁定脚本,并不是 P2PKH。P2PKH 紧随其后出现并成为常规做法,因为对公钥做哈希既在链上更省空间,又能让公钥在花费前一直隐藏。下面的走查仍以 Hal 作为花费者,但用 P2PKH 脚本,这样栈追踪对应的就是 Bitcoin 最终定型的那种形态。
逐步执行 P2PKH —— Hal Finney 案例#
设想 Hal 之后去花一个被 P2PKH 锁定的 10 BTC,把脚本从头到尾走一遍。
锁定脚本(来自 UTXO):
OP_DUP OP_HASH160 OP_PUSHBYTES_20 340cfcffe029e6935f4e4e5839a2ff5f29c7a571 OP_EQUALVERIFY OP_CHECKSIG
解锁脚本(由 Hal 提供):
OP_PUSHBYTES_71 30440220576497b7e6f9b553c0aba0d8929432550e092db9c130aae37b84b545e7f4a36c022066cb982ed80608372c139d7bb9af335423d5280350fe3e06bd510e695480914f01
OP_PUSHBYTES_33 02898711e6bf63f5cbe1b38c05e89d6c391c59e9f8f695da44bf3d20ca674c8519
解锁脚本先运行,压入它的两个项;接着锁定脚本的操作码把它们消耗掉。下面每一步展示的,都是所标注操作执行完之后的栈。
把签名压栈:
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
把公钥压栈:
│ 02898711...8519 (public_key) │
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
OP_DUP:复制栈顶项(公钥):
│ 02898711...8519 (public_key) │
│ 02898711...8519 (public_key) │
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
OP_HASH160:对栈顶项做哈希:
│ 340cfcff...7a571 (hash160_result) │
│ 02898711...8519 (public_key) │
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
压入期望哈希:来自锁定脚本:
│ 340cfcff...7a571 (expected_hash) │
│ 340cfcff...7a571 (computed_hash) │
│ 02898711...8519 (public_key) │
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
OP_EQUALVERIFY:比较栈顶两项,相等则都移除:
│ 02898711...8519 (public_key) │
│ 30440220...914f01 (signature) │
└───────────────────────────────────────┘
(哈希不匹配则脚本失败)
OP_CHECKSIG:用公钥和交易验证签名:
│ 1 (TRUE) │
└───────────────────────────────────────┘
最终检查:脚本成功,因为栈上唯一剩下的项是非零值。
P2PKH 的安全性质#
这套设计落出四条性质,每一条后面都有用:
公钥哈希挡在公钥本身前面,所以公钥在首次花费前一直隐藏——这层原像抗性(pre-image resistance)也给”将来 ECDSA 被攻破”留了一点余量。OP_CHECKSIG 用密码学把花费和私钥绑死:只有持有那把私钥的人才能造出通得过的签名。因为签名对交易的细节做了承诺,它同时充当完整性(integrity)校验——签名后再改动交易,签名就验不过了。又因为每个签名只绑定一笔特定交易,它无法被抽出来在别处重放(replay)。
2.3 实操:构建一笔 P2PKH 交易#
在测试网上做一笔 Legacy 到 SegWit 的真实交易#
把前面这些拼到一起看的最清楚的办法,是真造一笔交易。下面用一个 Legacy P2PKH 输入在测试网上支付一个 SegWit 输出;之后我们再把广播结果拆开,在栈上追踪它的脚本。
# 示例 1: 构建 P2PKH 交易
# 参考: code/chapter02/01_build_p2pkh_transaction.py
setup('testnet')
private_key = PrivateKey('cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT')
public_key = private_key.get_public_key()
from_address = P2pkhAddress('myYHJtG3cyoRseuTwvViGHgP2efAvZkYa4')
to_address = P2wpkhAddress('tb1qckeg66a6jx3xjw5mrpmte5ujjv3cjrajtvm9r4')
txin = TxInput('34b90a15d0a9ec9ff3d7bed2536533c73278a9559391cb8c9778b7e7141806f7', 1)
txout = TxOutput(to_satoshis(0.00029400), to_address.to_script_pub_key())
tx = Transaction([txin], [txout])
p2pkh_script = from_address.to_script_pub_key()
signature = private_key.sign_input(tx, 0, p2pkh_script)
txin.script_sig = Script([signature, public_key.to_hex()])
signed_tx = tx.serialize()
print(f"Transaction size: {tx.get_size()} bytes")
Transaction size: 188 bytes
关键函数#
TxInput(txid, vout) | TxOutput(amount, script_pubkey) | Transaction([txin], [txout])
sign_input(tx, idx, script) | Script([sig, pk]) → script_sig
to_script_pub_key() 从地址推出锁定脚本,to_satoshis() 把 BTC 换算成 satoshi(1 BTC = 100,000,000 satoshi)。
真实数据分析与栈执行#
运行上面的代码会产出一笔被广播到测试网的真实交易。我们可以把它的字节重新拆开,确认脚本所做的,正是追踪所预测的。
TXID: bf41b47481a9d1c99af0b62bb36bc864182312f39a3e1e06c8f6304ba8e58355
ScriptSig:473044...8519(签名 + 公钥)
ScriptPubKey:76a914c5b28d6b...890fb288ac(OP_DUP OP_HASH160 hash OP_EQUALVERIFY OP_CHECKSIG)
P2PKH 栈执行简要#
│ sig │ → │ pk, sig │ → OP_DUP → │ pk, pk, sig │ → OP_HASH160 → 哈希匹配 → OP_CHECKSIG → │ 1 (TRUE) │
从结果里有几点值得读出来:手续费是 206 satoshi(29,606 - 29,400)——输入减输出,正如 UTXO 模型所说;签名证明了对私钥的掌握,却从没把私钥放上链。
这些会带进后面的章节#
P2PKH 是 Bitcoin 可编程货币最小的一个完整例子:基于栈的执行、一个哈希承诺、一次签名检查。后面的一切都在复用这三块,只改动夹在它们之间的东西。
P2SH(Pay-to-Script-Hash)(第 3 章):
哈希的是整个脚本而非一把公钥,于是花费条件可以任意复杂,地址却依旧很短
把脚本复杂度从链上挪到花费者一侧
是日后包裹 SegWit 与多签方案的基础
P2WPKH(Pay-to-Witness-Public-Key-Hash)(第 4 章):
保留 P2PKH 的逻辑,但把签名挪进一个独立的见证(witness)
把签名数据与交易数据分离
修掉可锻性(malleability),并为 Lightning Network 铺路
P2TR(Pay-to-Taproot)(第 5 章起):
把同一套栈模型带进 Schnorr 签名和 Merkle 承诺的脚本树
让复杂的花费条件在链上看起来就是一次普通支付
操作码会变得更丰富,但这一页上的执行模型不变
理解 P2PKH 的栈执行模型至关重要,因为 Taproot 用的就是同一套基础方法,只是换上更复杂的密码学原语和脚本结构。下一章我们转向 P2SH。
本章小结#
这一章立起了后面每一章都默认成立的两件事。UTXO 模型把价值持有为一个个离散的输出、每个都整笔消耗,正是这一点让交易得以并行验证、不存在一个共享余额需要去锁。而 Bitcoin Script 给每个输出附上一段锁定脚本,由一段解锁脚本去满足,两者在同一个 LIFO 栈上一起检验。
栈上的 P2PKH ——
OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG:复制并哈希公钥,确认它与承诺的哈希匹配,再验证签名。从构造到上链 —— 用
bitcoinutils,我们构造、签名并广播了一笔真实的测试网 P2PKH 花费,再把它的字节按同样的七步追溯回去。
下一章。 第 3 章转向 P2SH:它把整段脚本藏在一个哈希后面,只在花费时才揭示——这是迈向 Taproot 所建立的脚本树的第一步。