比特币脚本:一种故意不做完的编程语言(师生对话实录)
Web3 区块链系列 · 阶段 1 · 比特币 · 第 8/57 篇
上一篇:《挖矿、难度调整与最长链:51% 攻击推演》 · 下一篇:《比特币的取舍与现状:为什么需要以太坊》
学习大纲:《Web3 区块链学习总纲》
写在前面
第 6 篇拆真实交易时我记下一个悬念:输出上标着 v0_p2wpkh、(p2sh) 这样的「地址类型」——说好的「锁定给地址」,锁到底长什么样?而且老师说比特币其实也有「智能合约」,只是故意设计成残缺的——残缺在哪、为什么故意?
继续对话老办法:AI 当老师我当学生,每课一个概念,有问题就打断。本篇动手量很大:亲手写一个 20 行的栈式虚拟机,把一笔 P2PKH 支付从解锁推演到验证,再拼一个 2-of-3 多签。
课程路线图:
① 锁与钥匙:locking / unlocking → ② 栈式虚拟机 → ③ P2PKH 逐操作码推演 → ④ 两种攻击各死在哪 → ⑤ 多签与 P2SH → ⑥ 图灵不完备是故意的 → ⑦ SegWit 与 Taproot
环境:WSL2 Ubuntu-22.04 + Python 3.10(ecdsa + pycryptodome)。真实脚本数据回看第 1 篇(创世块)与第 6 篇(区块 964022 的交易)。参考:Bitcoin Script 文档、developer.bitcoin.org — Scripts。
第 1 课:锁与钥匙——输出上锁,输入开锁
🧑🏫 老师:
先回到第 6 篇的那句话:「输出 = 金额 + 锁定脚本」。锁定脚本是回答「这张零钱凭什么能被花」的一段小程序。看一眼真实数据——创世块的那笔 coinbase 输出(第 1 篇拉过),类型是 P2PK(Pay to Public Key,最古老的形式):
锁定脚本(创世块真实数据,asm 形式):
OP_PUSHBYTES_65 04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f
OP_CHECKSIG翻译成人话:「想花这 50 BTC?用这把公钥对应的私钥,签一个名。」(那串 130 个 hex 字符就是中本聪刻在创世块里的公钥本体。)
于是一笔「花费」的完整拼图是:
旧输出的锁定脚本(lock): 「凭这个公钥的签名可花」 ← 铸币时写死
新交易的输入解锁脚本(unlock): <签名> <公钥> ← 花钱时提交
│
验证 = 把两段拼在一起执行: <签名> <公钥> + OP_CHECKSIG
结果为 TRUE → 这笔花费合法锁在输出上、钥匙在输入里——这就是比特币脚本的全部框架。所谓「地址」,不过是锁定脚本的另一种编码(第 3 课兑现);所谓「智能合约」,不过是换一种锁法(时间锁、多签、哈希锁……)。
一句话收口:输出带锁(什么条件能花),输入带钥匙(满足条件的材料);验证 = 两段脚本拼起来跑一遍,结果只有 TRUE/FALSE。
第 2 课:栈式虚拟机——20 行跑起来的脚本引擎
🧑🎓 学生: 「跑一遍」——用什么机器跑?也是 CPU 吗?
🧑🏫 老师:
不是,是比特币自带的脚本虚拟机,而且极简:没有变量、没有循环、只有一个栈。规则朴素到 20 行 Python 就能模拟(本篇的实验引擎):
def run_script(script):
stack, trace = [], []
for op in script:
if op == "OP_DUP": stack.append(stack[-1]) # 复制栈顶
elif op == "OP_HASH160": stack.append(hash160(stack.pop())) # 栈顶哈希后放回
elif op == "OP_EQUALVERIFY": # 弹两个,必须相等
b, a = stack.pop(), stack.pop()
if a != b: return trace, False
elif op == "OP_CHECKSIG": # 弹公钥和签名,验签
p = stack.pop(); s = stack.pop()
stack.append(verify(p, s))
else: stack.append(op) # 其它一律是「数据推送」
trace.append(...)
return trace, stack[-1] == True操作就三类:数据入栈(签名、公钥、哈希值都是数据)、栈内变换(DUP 复制、HASH160 哈希)、判定(EQUALVERIFY 相等否则失败、CHECKSIG 验签后压入 true/false)。脚本跑完,栈顶是 true 就通过——整个「智能合约引擎」就这么大。
真实比特币虚拟机(在每台全节点里)比这多几十个操作码,但没有的东西是关键:没有循环、没有任意跳转、执行步数严格受脚本字节数限制——为什么这么抠,第 6 课讲。
一句话收口:脚本 VM = 数据 + 一个栈 + 少量栈操作;跑完栈顶为 true 即通过——引擎小到能用 20 行 Python 复刻。
第 3 课:P2PKH——最常见的锁,逐操作码推演
🧑🏫 老师:
现代(legacy 时代)最常见的形式是 P2PKH(Pay to Public Key Hash)——锁的不是公钥本体,而是公钥的哈希(0.3 篇插问 1 的伏笔:更短、更隐私)。完整脚本:
锁定脚本: OP_DUP OP_HASH160 <公钥哈希> OP_EQUALVERIFY OP_CHECKSIG
解锁脚本: <签名> <公钥>两段拼接后交给我们的栈机,逐行看栈的变化(本机实跑 trace):
===== 正常花费:unlocking(sig, pub) + locking =====
0. <推送 64 字节> | 栈深 1 ← 签名入栈
1. <推送 65 字节> | 栈深 2 ← 公钥入栈
2. OP_DUP | 栈深 3 ← 复制公钥(原版留着给 CHECKSIG 用)
3. OP_HASH160 | 栈深 3 ← 副本被哈希成 20 字节
4. <推送 20 字节> | 栈深 4 ← 锁定的公钥哈希入栈
5. OP_EQUALVERIFY | 栈深 2 ← 两个哈希相等 → 弹掉,继续;不等 → 立即失败
6. OP_CHECKSIG | 栈深 1 ← 用剩下的公钥验签名 → 栈顶 = true
结果: 通过 ✔配一张数据流图,一眼看懂设计意图:
(sig) (pub) ← 解锁提供的
│ OP_DUP
(sig) (pub) (pub) ← 复制一份去「对暗号」
│ OP_HASH160
(sig) (pub) (hash160(pub))
(锁定值) ← 锁里的哈希
│ OP_EQUALVERIFY ← hash160(pub) == 锁定值 ?
(sig) (pub)
│ OP_CHECKSIG ← verify(pub, sig)?
(true)一道锁、两道验证:EQUALVERIFY 先确认「你带来的公钥,确实是铸币时指定的那把」(哈希对暗号);CHECKSIG 再确认「签名确实是这把公钥对应的私钥做的」。公钥平时藏在哈希后面(第 3 篇的隐私伏笔),花钱那一刻才亮出来。
一句话收口:P2PKH = 「对哈希」+「验签名」两道关;OP_DUP 那一步的巧思:副本去对暗号,原版留着验签。
插问 1:攻击者能从失败信息里套出什么吗?
🧑🎓 学生: 我的栈机里 EQUALVERIFY 失败和 CHECKSIG 失败是两条不同的路——真实网络里,攻击者能不能靠「错误信息」分辨自己哪一步错了,从而逐步逼近?
🧑🏫 老师:
设计者早就想到了:真实协议不告诉你死在哪一步。脚本验证只有两种结局——通过,或「脚本失败」,没有错误码、没有异常消息、没有行号。你的交易要么被打包,要么整笔被拒,失败原因一概不透露。
这不是偷懒,是三层考虑:
- 不泄露信息:告诉攻击者「哈希对了但签名错了」等于免费情报——公钥猜对了一半;
- 防探测放大:详细的错误路径会让「构造恶意脚本、观察节点反应」成为攻击界面;
- 实现简单 = 一致性容易:所有节点的验证实现只需对「true/false」达成一致,不用对错误分类达成一致——共识协议最怕的就是「不同实现报不同的错」。
工程上的一般规律在这提前出场:共识层的输出越贫瘠,全网越容易达成一致。以太坊的 EVM 也继承了这一点(revert 不给原因字符串——后来用 revert reason 做了折中,2.3 篇见)。
一句话收口:脚本验证只有通过/失败两种输出,错误细节一概不给——少说话是共识层的美德,也是不给攻击者递情报。
第 4 课:两种攻击,各死在哪一关
🧑🎓 学生: 该攻击测试了。攻击者能构造什么样的解锁脚本?
🧑🏫 老师:
两类典型攻击,栈机跑给你看:
===== 攻击一:用【别人的公钥】+ 自己不知道哪来的签名 =====
解锁脚本: <某串签名> <攻击者的公钥>
死因:OP_HASH160 → OP_EQUALVERIFY —— 攻击者公钥的哈希 ≠ 锁定值
→ 失败 ✘(连签名验签那步都没走到)
===== 攻击二:用【真公钥】+ 伪造的签名 =====
解锁脚本: <瞎编的 192 字节> <受害者的真公钥>
死因:EQUALVERIFY 通过(公钥是真的),OP_CHECKSIG 验签失败
→ 失败 ✘(伪造签名 = 离散对数问题,0.3 篇)(本机实跑两组的结果:「别人的公钥:失败 ✘」「伪造签名:失败 ✘」。)
注意两道关的分工恰好对应密码学的两块地基:HASH160 那关挡「冒充收款人」(你没有那把公钥),CHECKSIG 那关挡「伪造授权」(就算公钥是公开的,私钥在人家手里)。还剩第三类「攻击」——重放旧交易(把别人历史上某笔花费的原样再发)——它死于 UTXO 层:那张零钱已被花掉,未花费集合里没有它(第 6 篇插问 2)。脚本、UTXO、签名三层叠起来,花费的合法性才算验完。
一句话收口:冒充公钥死于哈希关,伪造签名死于验签关,重放死于 UTXO 关——三道墙各挡一类攻击。
第 5 课:多签与 P2SH——换一种锁法
🧑🎓 学生: 「智能合约就是换锁法」——除了「一个人的签名」,还能锁成什么样?
🧑🏫 老师:
最经典的是多签(m-of-n):n 把公钥,任意 m 个签名即可花费。2-of-3 的脚本:
锁定脚本: 2 <pk1> <pk2> <pk3> 3 OP_CHECKMULTISIG
解锁脚本: OP_0 <签名A> <签名B>栈机加上 CHECKMULTISIG 再跑(本机实跑,3 把钥匙、2-of-3):
===== 2-of-3 多签 =====
1 号 + 3 号签名 → 通过 ✔(2 个签名各配一个公钥)
只有 1 号签名 → 失败 ✘(栈下溢:脚本要求 2 个签名,unlocking 只给了 1 个)用途立刻能想到:公司金库(财务 3 人管钱、任意 2 人可动)、托管(买家、卖家、仲裁各一把)。但原始多签有个工程问题:锁定脚本又长又复杂——收款方给你付款时,得把这坨 2 <pk1><pk2><pk3> 3 OP_CHECKMULTISIG 的哈希当作「地址」用,体验糟糕。
P2SH(Pay to Script Hash)的解法优雅:把「复杂的锁」先哈希——
铸币时锁定: OP_HASH160 <赎回脚本的哈希> OP_EQUAL ← 只锁一个 20 字节哈希
花费时解锁: <签名们…> <完整的赎回脚本> ← 这时才亮出真正的锁好处两头赚:铸币方只需处理一个哈希(跟普通地址一样短);复杂逻辑推迟到花费时才暴露。这也是「隔离见证 SegWit」思想的垫脚石——把「锁怎么写」和「钥匙怎么交」进一步拆开(插问 3)。
一句话收口:多签 = m-of-n 的锁;P2SH = 把复杂锁哈希了先收着,花钱时才亮——锁可以很复杂,地址永远很短。
插问 2:解锁脚本开头的 OP_0 是什么?那个「历史 bug」?
🧑🎓 学生: 我注意到解锁脚本最前面垫了个 OP_0(推个空值),而且栈机里还要专门把它弹掉——它到底干嘛的?
🧑🏫 老师:
这是一个活化石级的历史 bug,教学价值极高。当年 OP_CHECKMULTISIG 的参考实现里,循环弹出签名前多弹了一个栈元素——按理说该修掉,但发布后已经有交易按「带 bug 的行为」写进了链里。修掉它 = 那些历史交易验证失败 = 硬分叉(账本撕裂)。
中本聪们的选择:不修,让所有后来者垫一个 OP_0「喂」给那个多余的弹出——用一个装傻的兼容,保住整条链的历史一致性。从此每笔多签交易都背着这个无意义的空操作,十四年没变。
背后的原则值得记一辈子:共识协议里,bug 也是协议。一旦发布,「应该怎样」就不重要了,「一直怎样」才是全部——改一个字节的语义都可能撕裂账本。以太坊的 EIP 处理、客户端的升级策略,全都活在这个约束下(1.4 篇讲治理代价时它还会回来)。
一句话收口:CHECKMULTISIG 多弹一次栈是 bug,但改它就是硬分叉——于是 OP_0 垫底成了永远的仪式:共识协议里,bug 也是协议。
第 6 课:图灵不完备——安全靠「少做事」
🧑🎓 学生: 终于到那个问题了:为什么说比特币脚本「故意不做完」?没有循环、没有跳转,这算哪门子语言?
🧑🏫 老师:
算一门把「能算坏什么」先删干净的语言。逐项对比通用语言缺了什么、换来什么:
| 缺失的东西 | 换来的东西 |
|---|---|
| 循环 / 任意跳转 | 执行时间有严格上界——脚本最长 10000 字节,每步耗栈操作,跑多久出生时就知道了 |
| 任意内存读写 | 只有栈——没有状态可污染,跑一万遍结果一样 |
| 浮点 / 大数运算溢出面 | 整数运算受限——攻击面最小化 |
| 访问外部世界(网络/磁盘/时间之外的随机) | 确定性——所有节点跑出同一结果,共识才有基础 |
核心账目是一条:每台全节点要免费跑全世界所有交易里的所有脚本。如果脚本能写 while(true),一笔交易的验证时间就没有上界——全节点被一个死循环拖死,这叫停机问题型 DoS。「图灵不完备」就是用一行循环都不给的代价,把这个攻击类别整个从物理上删掉。
白皮书时代的判断至今被验证:比特币脚本跑了十七年,没有出过「脚本死循环打挂网络」这种事故——不是防住了,是压根不可能发生。代价也如实写:表达力天花板极低——做不了复杂的链上逻辑(AMM、借贷、拍卖……),这正是第 1.4 篇「为什么需要以太坊」的起点:以太坊选择图灵完备 + Gas 计费(你付钱,跑多久你买单)来换表达力——用经济约束替代物理约束。两条路线没有对错,只有取舍。
一句话收口:图灵不完备 = 把「跑多久」从运行时问题变成出生时的常数——用表达力换免疫;以太坊用 Gas 反向把表达力买回来。
插问 3:那第 6 篇的 v0_p2wpkh 呢?SegWit 和 Taproot 又是什么?
🧑🎓 学生: 第 6 篇那笔交易的输出类型是 v0_p2wpkh,跟 P2PKH 什么关系?还听说 Taproot,了解级扫一眼?
🧑🏫 老师:
兑现支票。SegWit(隔离见证,2017)干的事:把「钥匙」(解锁脚本=签名数据,学名见证 witness)从交易里搬出去另放:
legacy 交易: [输入(引用+解锁脚本) | 输出] ← 钥匙挤在车厢里
SegWit 交易: [输入(引用) | 输出] + 见证区(签名们) ← 钥匙挂到车厢外好处一箭三雕:变相扩容(见证数据按 1/4 计重,1MB 块装下约 4 倍交易);交易 ID 不再含签名——修好了「改签名就能改 txid」的延展性 bug(闪电网络的前提);多重签名便宜很多。v0_p2wpkh 就是「SegWit v0 版的 P2PKH」——锁的逻辑一模一样(第 3 课),只是钥匙换了地方放。开头 bc1 的地址就是它。
Taproot(2021)再进一步,了解级记三点:换 Schnorr 签名(签名 64 字节、天然支持多方聚合成一个——多签在链上看起来跟普通转账一样);MAAST 让复杂脚本(时间锁、多路径)只暴露实际走的那条分支——隐私和体积双赢;v1_p2tr 地址(bc1p 开头)。第 6 篇标本里没碰到它,主网 Taproot 交易占比近年持续爬升。
一条演进主线串起来:P2PK → P2PKH(锁哈希)→ P2SH(锁复杂脚本的哈希)→ SegWit(钥匙外挂)→ Taproot(钥匙聚合、锁只亮一条缝)——十七年五次换锁法,每次都在回答同一个问题:怎么让「花钱的证据」更小、更隐私、更不可篡改。
一句话收口:SegWit = 钥匙外挂(v0_p2wpkh 兑现);Taproot = 钥匙聚合 + 锁只亮一条缝;换锁五次,主线只有一个:证据更小更私密。
小结
- 锁与钥匙:输出带锁定脚本、输入带解锁脚本,拼起来跑一遍,TRUE 才算数。
- 栈机:数据 + 一个栈 + 少量操作,20 行 Python 可复刻;跑完栈顶即结论。
- P2PKH 推演(实跑):OP_DUP 的巧思——副本对哈希暗号、原版留给验签;两道关分工明确。
- 攻击测试(实跑):冒充公钥死于 HASH160 关、伪造签名死于 CHECKSIG 关、重放死于 UTXO 关。
- 多签与 P2SH(实跑):2-of-3 通过/凑不齐栈下溢;P2SH 把复杂锁哈希了先收着。
- OP_0 垫底:CHECKMULTISIG 的历史 bug 不能修——共识协议里 bug 也是协议。
- 图灵不完备:无循环 = 执行时间出生时定死,DoS 类别物理消失;以太坊用 Gas 反向买表达力。
- 失败无细节:脚本只有通过/失败——共识层少说话,不给攻击者递情报。
- 换锁五次:P2PK → P2PKH → P2SH → SegWit(v0_p2wpkh)→ Taproot,证据越来越小越来越私密。
验收清单(做完再进下一篇):
思考题:时间锁 OP_CHECKLOCKTIMEVERIFY(「这个输出 3 天后才能花」)用栈机怎么实现?提示:它不消耗签名,只要求栈顶数字 ≥ 交易声明的锁定时间——想想它挡住了谁(给自己留的「后悔药」、给对手方的「提款窗口」)。
下一篇:《比特币的取舍与现状:为什么需要以太坊》——阶段 1 收官:把三篇的零件拼成一张「比特币刻意不做的事」清单。
本篇实验脚本(可照抄)
# script_vm.py —— 20 行栈机:P2PKH 推演 + 攻击测试
import ecdsa, hashlib
from Crypto.Hash import keccak, RIPEMD160
def keccak256(b):
k = keccak.new(digest_bits=256); k.update(b); return k.digest()
def hash160(b):
return RIPEMD160.new(hashlib.sha256(b).digest()).digest()
sk = ecdsa.SigningKey.from_string(b"1"*32, curve=ecdsa.SECP256k1)
pub = b"\x04" + sk.get_verifying_key().to_string()
sig = sk.sign_digest(keccak256(b"tx"),
sigencode=ecdsa.util.sigencode_string_canonize)
def run_script(script):
stack, trace = [], []
for op in script:
if op == "OP_DUP": stack.append(stack[-1])
elif op == "OP_HASH160": stack.append(hash160(stack.pop()))
elif op == "OP_EQUALVERIFY":
b, a = stack.pop(), stack.pop()
if a != b: return trace, False
elif op == "OP_CHECKSIG":
p = stack.pop(); s = stack.pop()
stack.append(p == pub and s == sig)
else: stack.append(op)
trace.append(op if isinstance(op, str) else f"<push {len(op)}B>")
return trace, stack[-1] is True
locking = ["OP_DUP", "OP_HASH160", hash160(pub), "OP_EQUALVERIFY", "OP_CHECKSIG"]
print("正常花费:", run_script([sig, pub] + locking)[1]) # True
print("别人公钥:", run_script([sig, b"\x04"+b"9"*64] + locking)[1]) # False
print("伪造签名:", run_script([b"bad"*10, pub] + locking)[1]) # False(多签版与 2-of-3 推演见正文第 5 课;同样建议分段 -c 执行。)
参考资料
- Bitcoin Wiki — Script(操作码总表)
- developer.bitcoin.org — Transactions: Scripts(P2PKH 标准模板)
- BIP-16(P2SH)、BIP-141(SegWit)、BIP-340/341(Schnorr/Taproot)
- OP_CHECKMULTISIG 的 off-by-one:Bitcoin Core #2992 等历史讨论(插问 2)
- 本机:WSL2 Ubuntu-22.04 + Python 3.10(ecdsa + pycryptodome)