第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

  1. 交易输入:Alice 的 10 BTC UTXO(必须被整个消耗)

  2. 交易输出

    • 7 BTC 给 Bob(新 UTXO)

    • 3 BTC 找零回 Alice(新 UTXO)

  3. 结果:原来的 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——是天然的例子。

交易 IDf4184fc5...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

解锁脚本先运行,压入它的两个项;接着锁定脚本的操作码把它们消耗掉。下面每一步展示的,都是所标注操作执行完之后的栈。

  1. 把签名压栈


│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. 把公钥压栈


│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_DUP:复制栈顶项(公钥):


│ 02898711...8519 (public_key)          │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_HASH160:对栈顶项做哈希:


│ 340cfcff...7a571 (hash160_result)     │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. 压入期望哈希:来自锁定脚本:


│ 340cfcff...7a571 (expected_hash)      │
│ 340cfcff...7a571 (computed_hash)      │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_EQUALVERIFY:比较栈顶两项,相等则都移除:


│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
(哈希不匹配则脚本失败)
  1. OP_CHECKSIG:用公钥和交易验证签名:


│ 1 (TRUE)                              │
└───────────────────────────────────────┘
  1. 最终检查:脚本成功,因为栈上唯一剩下的项是非零值。

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

ScriptSig473044...8519(签名 + 公钥) ScriptPubKey76a914c5b28d6b...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 所建立的脚本树的第一步。