你的钱包显示余额 100。
在以太坊上,这个 100 确确实实存在某个地方——一个字段,写着 100。
在比特币上,没有任何地方写着 100。你的钱包是把散落在链上的几枚"硬币"找出来、加了一遍,才告诉你这个数的。
同一句"我有 100 块",两条链的含义完全不同,而这个差别会一路影响到交易长什么样、隐私有多好、能不能并行验证。
一、两种记账方式
账户模型(银行、以太坊)
数据库里存:Alice → 100
转 30 给 Bob:Alice -= 30,Bob += 30
UTXO 模型(现金、比特币)
⭐ 没有"余额"这个字段。
只有一堆"硬币",每一枚有面额和归属:
硬币 #a3f2 面额 50 归 Alice
硬币 #7b91 面额 30 归 Alice
硬币 #c405 面额 20 归 Alice
Alice 的余额 = 属于她的硬币加起来 = 100(这是算出来的,不是存着的)
UTXO = Unspent Transaction Output,未花费的交易输出。 它就是那枚"硬币"。
打个比方
两种模型的差别,就是记账本和钱包的差别。
账户模型是记账本:上面写着"你有 100 块"。这是一个数字,改它就是把数字擦掉重写。
UTXO 模型是钱包:里面装着一张 50、一张 30、一张 20。“你有 100 块"不是写在哪儿的,是你把里面的钞票加起来的结果。
⚠️ 由此直接跟出一条让很多人别扭的性质:花钱要"找零”。 你要付 60,手上没有正好 60 的钞票,只能掏出一张 50 加一张 30,然后收回 20 的找零。链上的交易长得和这一模一样——而不是"从余额里扣掉 60"。
⭐ 记住这个画面,后面几乎所有 UTXO 的特性都能自己推出来:为什么交易大小不固定、为什么隐私性更好一点、为什么并行验证更容易、以及为什么"我的余额"需要钱包去扫描计算。
二、交易是什么
一笔交易,就是销毁若干个旧 UTXO,创造若干个新 UTXO。
Alice 要转 60 给 Bob,她手上有 50 和 30 两枚:
输入 输出
┌──────────┐ ┌──────────────┐
│ #a3f2 50│───┐ ┌──▶│ 60 → Bob │
└──────────┘ ├────┤ └──────────────┘
┌──────────┐ │ │ ┌──────────────┐
│ #7b91 30│───┘ └──▶│ 19.9 → Alice │ ⭐ 找零
└──────────┘ └──────────────┘
输入合计 80,输出合计 79.9
差额 0.1 就是手续费——它是隐式的,没有专门的字段
三个要点:
① ⭐ UTXO 必须整体花费,不能只花一半
⟹ 所以必须有"找零"输出,把余下的还给自己
② 手续费 = 输入总额 − 输出总额,没有专门字段
忘记写找零输出 = 把全部差额当手续费送给矿工。
历史上真的发生过:2016 年有人一笔交易付了 291 BTC 手续费。
③ 被花掉的 UTXO 立刻从集合中删除
⟹ 想再花一次,就会发现它不在集合里 ⟹ 这就是双花检测
coinbase 交易
每个区块的第一笔交易是特殊的:它没有输入,凭空创造新币。
coinbase 输出金额 ≤ 区块奖励 + 本块所有交易的手续费之和
⚠️ 节点会严格检查这个上限——这是"不能凭空增发"的执行点。
三、UTXO 长什么样
type OutPoint struct { // 指向某个 UTXO 的引用
TxID [32]byte // 创造它的那笔交易
Index uint32 // 是那笔交易的第几个输出
}
type TxOut struct {
Value int64 // 金额,单位聪(1 BTC = 10⁸ 聪)
ScriptPubKey []byte // ⭐ 锁定脚本:什么条件才能花掉它
}
type TxIn struct {
PrevOut OutPoint
ScriptSig []byte // 解锁脚本:证明你满足条件
Sequence uint32 // 用于时间锁和 RBF
}
⭐ 注意 ScriptPubKey 的措辞:它不是"这枚币属于某地址",而是"满足这段脚本就能花掉它"。 这个抽象比"归属"强大得多——它可以表达多签、时间锁、哈希锁等任意条件。
四、比特币脚本:一台栈式虚拟机
设计
基于栈:所有操作从栈上取参数,结果压回栈
⚠️ 没有循环,没有跳转 ⟹ 非图灵完备
⭐ “非图灵完备"是刻意的设计,不是能力不足。 好处有二:
① 执行时间有上界 ⟹ ⭐ 不需要 Gas 机制(对比第 22 讲)
② 行为可静态分析 ⟹ 更容易保证安全
代价是表达能力有限——写不了 DeFi。以太坊选择了相反的方向,代价就是必须发明 Gas。
验证过程
把 ScriptSig 和 ScriptPubKey 依次执行,
⭐ 最后栈顶为真则花费有效。
P2PKH 完整推演
最经典的脚本,“支付到公钥哈希”:
ScriptPubKey (锁): OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
ScriptSig (钥匙): <sig> <pubKey>
一步步执行,右边是栈的状态(栈顶在右):
初始 栈: [ ]
执行 ScriptSig:
push <sig> 栈: [ sig ]
push <pubKey> 栈: [ sig, pubKey ]
执行 ScriptPubKey:
OP_DUP 复制栈顶 栈: [ sig, pubKey, pubKey ]
OP_HASH160 对栈顶做 RIPEMD160(SHA256) 栈: [ sig, pubKey, hash(pubKey) ]
push <pubKeyHash> 栈: [ sig, pubKey, hash(pubKey), pubKeyHash ]
OP_EQUALVERIFY 弹出两个比较,不等则立即失败
栈: [ sig, pubKey ]
OP_CHECKSIG 弹出公钥和签名,验证签名 栈: [ true ]
⟹ 栈顶为真,花费有效 ✅
⭐ 这段脚本同时检查了两件事:
① 你给的公钥,哈希后确实等于收款时锁定的那个哈希 → 你是收款人
② 你用对应私钥签了这笔交易 → 你本人授权了这次花费
注意 OP_CHECKSIG 签的不是整笔交易,而是交易的一个特定"简化版本”(由 SIGHASH 标志决定包含哪些字段)。第 7 讲第七节讲过:没被签的字段可以被改。这正是可延展性的来源之一。
常用操作码
| 操作码 | 作用 |
|---|---|
OP_DUP / OP_DROP / OP_SWAP |
栈操作 |
OP_HASH160 / OP_SHA256 |
哈希 |
OP_EQUAL / OP_EQUALVERIFY |
比较(后者失败即终止) |
OP_CHECKSIG / OP_CHECKMULTISIG |
验签 |
OP_CHECKLOCKTIMEVERIFY (CLTV) |
绝对时间锁:某高度/时间之前不可花 |
OP_CHECKSEQUENCEVERIFY (CSV) |
相对时间锁:被创建后 N 个块内不可花 |
OP_RETURN |
标记输出不可花,用于往链上写数据 |
OP_CHECKMULTISIG 的 off-by-one
多签脚本:
ScriptPubKey: 2 <pubKey1> <pubKey2> <pubKey3> 3 OP_CHECKMULTISIG
ScriptSig: OP_0 <sig1> <sig2>
↑
⚠️ 这个 OP_0 是个纯粹的 bug 补丁
原始实现里 OP_CHECKMULTISIG 多弹出了一个栈元素。这个 bug 被发现时比特币已经在运行,修复它需要硬分叉——于是它被永久保留下来,所有多签脚本都必须多塞一个无用的 OP_0。
这是共识系统的一个普遍教训:一旦上线,bug 就变成了规范。 第 21 讲会看到 EVM 里同样性质的历史包袱。
时间锁与闪电网络
CLTV / CSV 让"条件支付"成为可能:
这笔钱:
要么 Alice 和 Bob 都签名,立刻可花
要么 只有 Alice 签名,但必须等 1000 个区块之后
⭐ 这就是支付通道的核心:
双方合作时随时结算,一方失联时另一方等待期过后可单方面取回。
闪电网络完全建立在这类脚本之上。而它对交易 ID 稳定性的依赖,正是第 7 讲说可延展性必须被修复的原因。
五、P2SH:把复杂性转移给花费者
一个 3-of-5 多签脚本很长。如果直接放进 ScriptPubKey:
⚠️ 付款人要为这段长脚本付手续费——可他只是想给钱而已
付款人还得知道收款人的完整多签配置
**P2SH(支付到脚本哈希)**把它倒过来:
锁定时: OP_HASH160 <脚本的哈希> OP_EQUAL ← 只有 23 字节
花费时: 提供完整脚本 + 满足它的数据
⟹ ⭐ 复杂性和费用都由花费者承担,付款人只需知道一个地址
⭐ 这个"承诺 + 稍后揭示"的模式在区块链里反复出现:Merkle 根、状态根、Rollup 的状态承诺,都是同一个思路——链上只存哈希,需要时才提供原像。
Taproot 进一步优化了它:合作路径下链上只看到一个普通单签,脚本的存在完全隐藏。
六、UTXO 模型的取舍
⭐ 这一节的每一条取舍,都能从"钱包 vs 记账本"直接读出来:钱包便于并行清点、不容易泄露你的总额,但每次付款都要挑钞票、要找零,账也更难一眼看清。
三个真实优点
① 并行验证
每笔交易显式声明了它读写的全部状态(就是那几个 UTXO)
⟹ 不相交的交易可以并行验证,无需协调
⚠️ 对比账户模型:合约调用在执行前不知道会碰哪些状态
② 隐私性更好
每次收款用新地址是自然做法,没有"账户"这个天然的关联点
但找零仍会泄露关联(第 33 讲)
③ 无状态验证更友好
验证一笔交易只需要它引用的那几个 UTXO,
⟹ 可以要求交易自带 UTXO 的存在性证明
三个真实缺点
① 写不了合约
"全局状态"在 UTXO 模型里没有自然的表达。
⭐ 一个 DeFi 池子的余额是全局共享的,但 UTXO 每次只能被一个人花掉
⟹ 并发用户会互相冲突
② 钱包复杂
要选择花哪几枚 UTXO(币选择算法),要处理找零,
选择策略还会泄露隐私
③ UTXO 集合膨胀
全节点必须把整个 UTXO 集合常驻内存或快速存储(否则验证极慢)
以近年的规模计,条目数在一亿量级,序列化后数 GB。
"粉尘攻击":给别人发大量极小额 UTXO,
既污染对方钱包,又永久增加全网的存储负担。
⭐ 注意第 ③ 条和第 3 讲的"状态增长"是同一个问题的两种表现:账户模型膨胀的是账户和存储槽,UTXO 模型膨胀的是 UTXO 集合。两者都只增不减。
七、Go 实现
package utxo
import (
"errors"
"fmt"
)
type OutPoint struct {
TxID [32]byte
Index uint32
}
type TxOut struct {
Value int64
ScriptPubKey []byte
}
type TxIn struct {
PrevOut OutPoint
ScriptSig []byte
}
type Tx struct {
In []TxIn
Out []TxOut
}
// Set 是全节点必须维护的 UTXO 集合。
// ⚠️ 它必须常驻快速存储——每笔交易的每个输入都要查一次。
type Set map[OutPoint]TxOut
// Validate 验证一笔普通交易(非 coinbase),返回手续费。
func (s Set) Validate(tx *Tx, verify func(sig, pubKey []byte, prev TxOut) bool) (int64, error) {
if len(tx.In) == 0 || len(tx.Out) == 0 {
return 0, errors.New("utxo: 交易必须有输入和输出")
}
// 同一笔交易内部也可能重复引用同一个 UTXO,必须单独检查
seen := make(map[OutPoint]bool, len(tx.In))
var inSum int64
for _, in := range tx.In {
if seen[in.PrevOut] {
return 0, errors.New("utxo: 交易内部双花")
}
seen[in.PrevOut] = true
// 双花检测就是这一句:不在集合里 = 不存在或已被花掉
prev, ok := s[in.PrevOut]
if !ok {
return 0, fmt.Errorf("utxo: 引用了不存在或已花费的输出 %x:%d",
in.PrevOut.TxID[:4], in.PrevOut.Index)
}
if !verify(in.ScriptSig, prev.ScriptPubKey, prev) {
return 0, errors.New("utxo: 解锁脚本验证失败")
}
inSum += prev.Value
}
var outSum int64
for _, out := range tx.Out {
if out.Value < 0 {
return 0, errors.New("utxo: 输出金额为负") // 漏掉这一条就能凭空造币
}
outSum += out.Value
}
if inSum < outSum {
return 0, errors.New("utxo: 输出大于输入")
}
return inSum - outSum, nil // 手续费是隐式的差额
}
// Apply 把一笔已验证的交易作用到集合上。
func (s Set) Apply(txid [32]byte, tx *Tx) {
for _, in := range tx.In {
delete(s, in.PrevOut) // 花掉的立即删除
}
for i, out := range tx.Out {
s[OutPoint{TxID: txid, Index: uint32(i)}] = out
}
}
⭐ 注意 Validate 里那句 if !ok。整个双花防御就是这一行:UTXO 一旦被花,就从集合里删了;再引用它,就查不到。
八、栈式脚本解释器(节选)
// Eval 执行 ScriptSig + ScriptPubKey,栈顶为真则通过。
// ⚠️ 简化版:只实现 P2PKH 需要的几个操作码。
func Eval(scriptSig, scriptPubKey []byte, checkSig func(sig, pub []byte) bool) bool {
var stack [][]byte
run := func(script []byte) bool {
for i := 0; i < len(script); {
op := script[i]
switch {
case op >= 1 && op <= 75: // 直接压入 op 个字节
if i+1+int(op) > len(script) {
return false
}
stack = append(stack, script[i+1:i+1+int(op)])
i += 1 + int(op)
case op == OpDup:
if len(stack) < 1 {
return false
}
stack = append(stack, stack[len(stack)-1])
i++
case op == OpHash160:
if len(stack) < 1 {
return false
}
top := stack[len(stack)-1]
stack[len(stack)-1] = hash160(top)
i++
case op == OpEqualVerify:
if len(stack) < 2 {
return false
}
a, b := stack[len(stack)-1], stack[len(stack)-2]
stack = stack[:len(stack)-2]
if !bytesEqual(a, b) {
return false // VERIFY 类操作码失败即整体终止
}
i++
case op == OpCheckSig:
if len(stack) < 2 {
return false
}
pub, sig := stack[len(stack)-1], stack[len(stack)-2]
stack = stack[:len(stack)-2]
stack = append(stack, boolBytes(checkSig(sig, pub)))
i++
default:
return false // 未实现的操作码
}
}
return true
}
if !run(scriptSig) || !run(scriptPubKey) {
return false
}
return len(stack) > 0 && isTrue(stack[len(stack)-1])
}
九、本讲小结
- ⭐ UTXO 模型里没有"余额"字段,只有一堆带面额和锁定条件的"硬币"。余额是算出来的。
- 交易 = 销毁旧 UTXO + 创造新 UTXO。 UTXO 必须整体花费,所以必须有找零输出。
- ⚠️ 手续费是隐式的差额(输入 − 输出),没有专门字段。忘写找零 = 把差额全部送给矿工,历史上真的发生过。
- 双花检测就是"查集合里还在不在"——花掉即删除,再引用就查不到。
ScriptPubKey表达的是"满足什么条件能花",而不是"属于谁"。 这个抽象能表达多签、时间锁、哈希锁等任意条件。- 脚本非图灵完备是刻意设计:执行时间有上界 ⟹ 不需要 Gas;行为可静态分析 ⟹ 更安全。代价是写不了 DeFi。
- P2PKH 同时检查两件事:你给的公钥哈希对得上锁定的哈希,且你用对应私钥签了名。
OP_CHECKMULTISIG的多余OP_0是一个永远修不掉的 bug。共识系统一旦上线,bug 就变成了规范。- P2SH 把复杂性和费用转移给花费者,付款人只需知道一个地址。这个"链上存哈希、用时给原像"的模式在区块链里反复出现。
- UTXO 的三个优点:可并行验证(交易显式声明读写集)、隐私更好、对无状态验证友好。
- 三个缺点:写不了共享状态的合约、钱包管理复杂、UTXO 集合只增不减且必须常驻内存(粉尘攻击)。
思考题
- Alice 有面额 3、5、8 的三枚 UTXO,要转 10 给 Bob 并留 0.1 做手续费。写出至少两种可行的输入输出组合,并说明各自的优劣。
- 为什么手续费不设成一个显式字段?这样设计有什么好处和风险?
- 完整推演一次 P2PKH 脚本执行,写出每一步之后的栈状态。
- 如果
OP_EQUALVERIFY换成OP_EQUAL(不立即终止),这段 P2PKH 脚本还安全吗?攻击者能做什么? OP_CHECKMULTISIG的 off-by-one 为什么不能被修复?如果强行修复会发生什么?- 设计一个脚本,表达"Alice 和 Bob 共同签名可立即花费;或者 Alice 单独签名但需等待 144 个区块"。
- 说明为什么 UTXO 交易可以并行验证,而以太坊的合约调用不能。给出一个具体的冲突例子。
- 为什么 UTXO 模型很难实现一个自动做市商(AMM)?“多个用户同时与同一个池子交易"在 UTXO 下会发生什么?
- 粉尘攻击给受害者和全网分别造成什么成本?有什么防御手段?
- 上面 Go 代码里
if out.Value < 0这一行如果漏掉,攻击者能构造出什么交易?