比特币白皮书精读与 UTXO 模型(师生对话实录)
Web3 区块链系列 · 阶段 1 · 比特币 · 第 6/57 篇
上一篇:《手写 100 行迷你区块链》 · 下一篇:《挖矿、难度调整与最长链:51% 攻击推演》
学习大纲:《Web3 区块链学习总纲》
写在前面
昨天刚写完 120 行迷你链,今天翻开 17 年前的比特币白皮书,惊喜地发现每一节都认得——时间戳服务器、PoW、默克尔树、最长链,全是我昨天装过的零件。但读到交易那部分时卡住了:我的迷你链里「alice 转给 bob 5 块」就是签个名的事,白皮书里的比特币转账却是「输入若干、输出若干」——像在描述一次零钱的点付。
继续对话老办法:AI 当老师我当学生,每课一个概念,有问题就打断。本篇把白皮书精读和 UTXO(未花费交易输出)模型一次打通,全部用主网真实交易做标本(blockstream.info 公共 API 实查)。
课程路线图:
① 白皮书的骨架 → ② UTXO:钱是零钱不是余额 → ③ 拆一笔真实交易 → ④ 手续费的账本铁律 → ⑤ 找零是自付 → ⑥ coinbase:钱从哪来 → ⑦ UTXO vs 账户模型
环境:WSL2 Ubuntu-22.04 + Python 3.10;标本来自主网区块 964022(2026-08 实查)。文档:白皮书原文、开发者文档。
第 1 课:白皮书的骨架——九页纸里只发明了一件事
🧑🏫 老师:
精读之前先俯瞰。中本聪 2008 年这篇论文的题目就很克制:Bitcoin: A Peer-to-Peer Electronic Cash System——点对点电子现金。通篇要解决的只有第 1 篇讲过的那个问题:去掉金融机构这个中间人,双花怎么防。九节的骨架,每一节都能对应你已经学过的零件:
| 白皮书的节 | 干什么 | 你在哪里学过 |
|---|---|---|
| 介绍 / 交易 Transactions | 定义「数字签名链」形式的电子货币 | 0.3 篇签名 |
| 时间戳服务器 Timestamp Server | 把交易打成块、哈希串成链、公开发布 | 0.2 篇哈希指针 |
| 工作量证明 Proof-of-Work | 用哈希搜索实现「一 CPU 一票」,最长链胜出 | 0.5 篇挖矿 |
| 网络 Network | 节点广播、独立验证、最长链收敛 | 0.5 篇分叉 |
| 激励 Incentive | 出块奖励 + 手续费,让诚实有利可图 | 本篇第 6 课 |
| 回收磁盘空间 / SPV | 默克尔树 + 区块头验证 | 0.2 篇 SPV |
| 组合与价值 / 隐私 | 多输入多输出;公钥匿名 | 本篇第 2、7 课 |
真正新发明的只有一件事:用 PoW 把「最长链」变成全网的共同历史——其余零件(哈希、签名、默克尔树)全是现成的密码学积木(0.5 篇差距清单同款结论)。这个「新发明」下一篇专门压测(51% 攻击),本篇先把它的账本模型读透。
一句话收口:白皮书九节 = 密码学积木 + 一个新发明(PoW 最长链);精读的重点不是仰望,是认出每个你已经装过的零件。
第 2 课:UTXO——钱不是余额,是零钱
🧑🎓 学生: 我的迷你链里 alice: 10, bob: 5 记余额。比特币不是这样记的?
🧑🏫 老师:
完全不是。比特币的世界里没有「alice 的余额」这个东西——链上不存任何人的余额。存的是一堆未花费的交易输出:
账户模型(你的迷你链、以太坊) UTXO 模型(比特币)
───────────────────────── ─────────────────────────
状态:alice → 10 状态:一组「未花费输出」的集合
bob → 5 UTXO#1: 7 BTC 锁定给 A 的公钥哈希
转账:alice -= 5 UTXO#2: 3 BTC 锁定给 B 的公钥哈希
bob += 5 「A 有 7 个 BTC」= A 能解锁 UTXO#1「你的钱」= 链上所有「锁定条件你能满足」的未花费输出。钱包界面显示的「余额 0.1059 BTC」,是钱包扫了一遍链、把属于你的 UTXO 加总出来的显示值——链上只有零钱,没有余额。
转账因此不再是「改数字」,而是「花掉旧零钱、生成新零钱」。白皮书的原话框架:一枚电子货币就是一条数字签名链——每次转账,付款人对接收者的公钥加上前一笔交易的哈希签名,签在币的末尾。推演 A→B→C:
交易 1(B 收钱): 交易 2(B 把钱转给 C):
输入:A 花掉自己的 UTXO#0(10 BTC) 输入:B 花掉刚收到的 UTXO#1(10 BTC)
输出:UTXO#1 = 10 BTC 锁给 B 输出:UTXO#2 = 6 BTC 锁给 C
输出:UTXO#3 = 3.9 BTC 锁给 B 自己(找零!)
(0.1 BTC 差额 = 手续费,下一课)
此刻账上活着:UTXO#2(C 的 6)+ UTXO#3(B 的 3.9)
UTXO#0、#1 已被花掉,从「未花费集合」里销账 —— 钱在被引用的瞬间转移注意交易 2 里 B 把 10 个 BTC 全部花掉——UTXO 是不可分割的整张零钱,就像一张 100 元钞票买 30 块的东西:整张递出去、找回 70。这就是「找零是自付」的来源(第 5 课细看)。
一句话收口:UTXO 模型里链上没有余额、只有零钱集合;「你的钱」= 你能解锁的未花费输出;转账 = 花旧零钱 + 造新零钱。
第 3 课:拆一笔主网真实交易
🧑🏫 老师:
纸上推演完毕,上真标本。从主网区块 964022 里取一笔普通交易(1 进 2 出),拆开:
===== 一笔真实交易 =====
txid: 9f8a49a354ffde0674ea19ffad3947c27b440297f05f70f1d4dd0ce34d25809a
大小 222 字节,手续费 28200 聪
--- 输入 vin:花掉的旧 UTXO ---
[0] 6e2ff96bf6f86196…:1 0.10598200 BTC 来自 bc1qy5vtjv230rqa22z6…
--- 输出 vout:新生成的 UTXO ---
[0] 0.08070588 BTC → bc1q8eudvyjpca3zghrx… (v0_p2wpkh)
[1] 0.02499412 BTC → bc1q58n8dv6l92fsqgy0… (v0_p2wpkh)
--- 账本核对 ---
输入合计: 0.10598200 BTC
输出合计: 0.10570000 BTC
手续费 : 0.00028200 BTC = 输入 - 输出对照第 2 课的推演图,一个字都不差:
- vin 只有一项:
6e2ff96b…:1——「交易 6e2ff96b… 的第 1 号输出」。输入不是「从谁的账户扣钱」,而是一个精确到输出编号的引用:花掉的是那一张具体的零钱; - vout 两项:两张新零钱,各自锁定到地址(
v0_p2wpkh是地址类型,1.3 篇讲它对应的脚本); - 输入合计 0.10598200,输出合计 0.10570000——差额 0.00028200 BTC 没有进任何输出,它就是手续费。
交易结构总表(真实交易还有几个字段一并交代):
| 字段 | 含义 | 本标本的值 |
|---|---|---|
txid | 整笔交易的哈希(它的「身份证」,别人的输入会引用它) | 9f8a49a3… |
vin[] | 输入列表:引用 (prev txid, 输出编号) + 解锁脚本/见证 | 1 项 |
vout[] | 输出列表:金额 + 锁定脚本(发给谁) | 2 项 |
locktime | 最早可上链的高度/时间(0 = 立即) | 0 |
size / fee | 字节数 / 手续费(聪) | 222 / 28200 |
一句话收口:真实交易的输入是「(旧 txid, 输出编号)」的引用、输出是带锁定条件的新零钱; vin 和 vout 就是 UTXO 的死亡与新生。
插问 1:输出为什么必须小于输入?多花行不行?手续费凭什么归矿工?
🧑🎓 学生: 「输入 - 输出 = 手续费」这条铁律是规则还是巧合?我要是构造一笔输出大于输入的交易呢?手续费为什么「不在任何输出里」,它去哪了?
🧑🏫 老师:
三个问题一根线。
输出 > 输入的交易,根本不存在——不是被罚款,是全节点直接拒收。验证规则明摆着:sum(vin) ≥ sum(vout),违反即整笔无效(你的迷你链第 3 课五道关卡的同族检查)。UTXO 模型下这尤其硬:输入引用的零钱面值就是上限,没有「透支」这个概念。
手续费的去向:它没有进任何输出,意味着谁也拿不走——除了挖出这个块的矿工。规则是:coinbase 交易(下一课拆)可以凭空造出 区块奖励 + 该块全部手续费。所以手续费不是「付给谁」的转账,而是留在桌上被记账人顺手收走的账——矿工打包你的交易的动力正在于此(1.2 篇内存池排序时它还会回来:同样的位置,手续费高的交易先上车)。
计价方式也顺带说透:手续费 ≈ 费率 × 交易字节数——本标本 222 字节、28200 聪,约 127 聪/字节。它跟金额无关、跟体积有关:你的交易越大(输入输出越多、脚本越长),占的区块空间越多,付得越多。买的是空间,不是转账额度。
一句话收口:sum(输入) ≥ sum(输出) 是硬验证规则;差额=手续费随块被矿工收走,按字节数计价——买的是区块空间。
第 4 课:找零——自付的第二个输出
🧑🎓 学生: 那笔交易的两个输出:0.08070588 和 0.02499412——哪个是付款、哪个是找零?还是都是付款?
🧑🏫 老师:
链上没有字段标注哪个是付款哪个是找零——两个输出在数据结构上完全平等。我们只能用启发式猜:
- 旧启发式:找零会回到输入的地址——但本标本两个输出地址都不同于输入地址,没匹配上;
- 现实:现代钱包出于隐私,找零一律用新地址(地址复用会暴露关联,第 7 课),所以「回原地址」这条几乎失效了;
- 形状启发式:1 进 2 出、金额一个零一个整,典型就是「付款 + 找零」。哪个是找零还能看零头(0.02499412 这种不规则尾数更像找零,钱包付款爱取整)——但这都是猜,链上不承诺。
真正确定的只有机制:UTXO 不可分割 → 花零钱必整张 → 多余的必须造一张新零钱找给自己。没写找零输出的话,差额就全成了手续费——真发生过「忘记写找零、把几十 BTC 全给了矿工」的历史事故(2013 年有笔交易手续费 200 BTC,就是输入几乎原面值地付了出去)。
一句话收口:找零是机制必然(零钱不可分割)、归属不可知(输出平等);现代钱包用新找零地址换隐私,链读不出哪个是「收款的那个」。
插问 2:UTXO 凭什么天然防双花?
🧑🎓 学生: 第 1 篇说账本防双花。UTXO 模型下「同一笔钱花两次」具体怎么被拦?
🧑🏫 老师:
因为「钱」有身份证。每个 UTXO 的唯一坐标是 (所在交易的 txid, 输出编号)——比如本标本输入引用的 6e2ff96b…:1。防双花的验证就一条:
全节点为每个 UTXO 维护一个「未花费集合」(UTXO set):
交易到达 → 查每个输入引用的坐标在不在集合里?
├─ 在 → 允许;验证通过后,把引用的坐标从集合里【删除】
└─ 不在 → 拒收整笔(“double spend”或引用不存在)
第二笔想花同一个坐标 → 集合里已经没了 → 拒收花掉的瞬间,那张零钱就从集合里销账——同一张零钱不存在「第二次被花」的状态。这就是「天然防双花」的准确含义:不需要风控、不需要账户锁,集合的增删本身就是账。
两个衍生后果,都是这个机制的直通车:
- 内存池的兄弟检查:两笔冲突交易同时在网,先被矿工打包的那笔生效,另一笔作废——1.2 篇的双花攻击推演就在这个窗口上做文章;
- 并行友好:不同 UTXO 互不干扰,验证可以随便并行(对比账户模型要串行维护每个账户的顺序,第 7 课对比表)。
一句话收口:每个 UTXO 有唯一坐标;全节点的未花费集合里「花掉即删」——第二笔引用同一坐标时集合里已空,双花在验证层死掉。
第 5 课:coinbase——钱从哪里来
🧑🏫 老师:
还剩最后一个谜:普通交易的钱都是「花别人的旧零钱」,那最早的那张零钱哪来的?看每个块的第 0 号交易(coinbase):
===== coinbase 交易对比 =====
第 0 笔 is_coinbase: True
coinbase txid: 3d3d7c45f0e15e6b752f49d2 …
vout 数量: 6
[0] 0.00000546 BTC (p2sh) ← 546 聪:dust 标记输出(矿池记号)
[1] 3.15153625 BTC (p2sh) ← 真身:区块奖励 + 本块全部手续费
[2-5] 0.00000000 BTC (op_return) ← 4 条不可花费的「留言」(矿池广告位)
总输出: 3.15154171 BTC拆开读:
- 没有输入(
is_coinbase: True,vin 里的「引用」是摆设)——它是全链唯一被允许凭空造钱的交易:3.15153625 = 区块奖励 3.125 + 本块全部手续费 ≈ 0.0265。这 3.15 BTC 从这个块开始成为新的 UTXO,等矿池来花; - 区块奖励的时间表是协议写死的:创世起 50 BTC,每 21 万块(约四年)减半——50 → 25 → 12.5 → 6.25 → 3.125(2024-04 起)→ 1.5625(预计 2028-04)。「4 年减半」不是事件,是每 210000 块自动切换的常数;1.4 篇算距下次减半还剩多少块;
- OP_RETURN 输出金额 0、从出生就不可花费——它是往链上写 40 字节任意数据的正规通道(矿池在这里写「挖自 F2Pool」的广告)。这也回答了一个新人常问的「能不能往比特币链上刻字」——能,就这里。
激励闭环到此完整:矿工出块 → coinbase 凭空领取「奖励 + 全块手续费」→ 这笔新 UTXO 进入流通。白皮书「激励」一节那句「第一个区块里的 coinbase 交易」说的就是它——诚实记账的工资条。
一句话收口:coinbase 是全链唯一无输入的交易:奖励 3.125 + 手续费凭空铸成新 UTXO;每 21 万块奖励减半,OP_RETURN 是它的免费留言板。
第 6 课:白皮书的隐与显——UTXO 模型与账户模型的全面对比
🧑🎓 学生: 读到这里我反而困惑了:UTXO 这么绕——看余额要扫链、转账要拼零钱——为什么白皮书要这么设计?
🧑🏫 老师:
因为它的目标极端明确:验证最小化。逐项对比账户模型(右边是你熟悉的、以太坊用的):
| 维度 | UTXO(比特币) | 账户(以太坊/迷你链) |
|---|---|---|
| 状态大小 | 只存未花费输出集合;花掉即删 | 存所有账户的现值;只增不减 |
| 验证一笔交易 | 查集合 + 验签名,纯函数、可无限并行 | 读写账户状态,同账户交易需串行定序 |
| 双花 | 集合增删天然防 | 靠 nonce 计数器防(2.2 篇) |
| 隐私 | 每张零钱一个地址,链上无「账户」聚合 | 地址即公开账本行,余额一目了然 |
| 「余额」 | 钱包扫链算出来的显示值 | 链上直接存的状态 |
| 可编程性 | 脚本只能描述「怎么解锁这张零钱」(1.3 篇:故意不做完) | 任意状态读写——智能合约的地基 |
白皮书的时代命题是「电子现金」:现金的关键属性就是每张钞票独立存在、验证时只看这张钞票。UTXO 把这个属性保留到了数字世界——代价是「余额」变成派生值、任何涉及持续状态的逻辑(计数器、合约)都无处安放。
以太坊看到了硬币的另一面:想在链上跑程序,程序需要可读写的状态——于是把账本换成「全局状态机」(世界状态 = 所有账户的当前值),双花改用 nonce 防。这是阶段 2 的开场白,也是 1.4 篇「为什么需要以太坊」的机制伏笔。两代账本的取舍一句话:
UTXO 为「现金验证」优化,账户为「状态计算」优化——比特币做成了一张不可篡改的支票簿,以太坊做成了一台状态机。
一句话收口:UTXO 状态只减不增、验证可并行、隐私好——为现金而生;账户状态可写、串行定序、可编程——为合约而生。
插问 3:那「比特币余额」到底存在哪?钱包显示的数字是链上的吗?
🧑🎓 学生: 我钱包里显示 0.05 BTC——这个数字既然不在链上,是从哪来的?换个钱包 App 还能显示一样的数吗?
🧑🏫 老师:
问到了 UTXO 的用户侧真相:余额是钱包算出来的缓存。
钱包干的事:拿你的地址(或按 0.4 篇的助记词派生一串地址)→ 扫描链上所有交易 → 找出「锁定给你且未被花费」的输出 → 加总。这个扫描可以自己做(全节点钱包),也可以委托第三方服务查(轻钱包)。所以:
- 换钱包 App、导入同一助记词,余额一样——因为算的是同一份链上事实,谁算都一样(0.4 篇「账本即资产」的又一次现身);
- 「可用余额」和「未确认」会分开显示——刚被你花掉还没上链的那张零钱,钱包要标记为「等确认」;
- 零钱太多也是负担:UTXO 多的交易体积大、手续费高(按字节计价!),钱包有「合并零钱」的运维操作——这在账户模型里根本不存在。
顺带呼应第 2 课那笔真实交易:输入金额 0.10598200——尾数那么零碎,正是「扫了一圈找到一张差不多大的零钱」的痕迹。
一句话收口:余额 = 钱包扫链加总的派生值,链上只有零钱集合;换钱包不变的是事实,变的是算账的工具。
小结
带着迷你链读完白皮书的账本部分:
- 白皮书骨架:密码学积木(全学过)+ 一个新发明(PoW 最长链)。
- UTXO:链上没有余额、只有未花费零钱集合;「你的钱」= 你能解锁的输出。
- 真实交易:vin 是「(txid, 输出编号)」的引用,vout 是带锁定的新零钱(主网 964022 实拆)。
- 铁律:sum(输入) ≥ sum(输出) 硬验证;差额 = 手续费,按字节计价、随块归矿工。
- 找零:零钱不可分割 → 必须自付找零;输出之间无标注,哪个是付款链上读不出。
- 防双花:UTXO 有唯一坐标,「花掉即删」,第二笔引用时集合已空。
- coinbase:唯一无输入的交易,凭空铸「3.125 奖励 + 手续费」;21 万块减半;OP_RETURN 是留言板。
- 模型对比:UTXO 为现金验证优化(状态小、并行、隐私),账户为状态计算优化(可编程)——以太坊换赛道的机制起点。
验收清单(做完再进下一篇):
思考题:一笔交易有 3 个输入、2 个输出——它「销毁」几个 UTXO、「创造」几个?手续费怎么算?(3 销毁 2 创造;手续费 = 三个输入面值和 − 两个输出面值和。)
下一篇:《挖矿、难度调整与最长链:51% 攻击推演》——给你的迷你链装上难度调整,再推演一次完整的双花攻击时序。
本篇实验脚本(可照抄)
# utxo.py —— 拆一笔主网真实交易
import json, urllib.request
def fetch(url):
return json.load(urllib.request.urlopen(
urllib.request.Request(url, headers={"User-Agent": "w3-lab"}), timeout=20))
block_hash = fetch("https://blockstream.info/api/blocks")[0]["id"] # 最新块
txs = fetch(f"https://blockstream.info/api/block/{block_hash}/txs")
tx = next(t for t in txs if not t["vin"][0].get("is_coinbase")) # 第一笔普通交易
print("txid:", tx["txid"])
for i, vin in enumerate(tx["vin"]):
print(f"输入[{i}]: {vin['txid'][:16]}…:{vin['vout']} "
f"{vin['prevout']['value']/1e8:.8f} BTC")
for i, vout in enumerate(tx["vout"]):
print(f"输出[{i}]: {vout['value']/1e8:.8f} BTC → {vout.get('scriptpubkey_address')}")
fee = sum(v["prevout"]["value"] for v in tx["vin"]) - sum(v["value"] for v in tx["vout"])
print(f"手续费: {fee/1e8:.8f} BTC(= 输入 − 输出)")
cb = txs[0] # coinbase
print("coinbase 总产出:", sum(v["value"] for v in cb["vout"])/1e8,
"BTC(区块奖励 + 全块手续费)")参考资料
- 比特币白皮书(中译)——第 2、3、6 节对应本篇第 1、2、5 课
- Bitcoin Developer Guide — Transactions(vin/vout/locktime 字段口径)
- blockstream.info API(本篇标本来源)
- 减半时间表以协议规则(每 210000 块)为准,1.4 篇按实时块高实算剩余
- 本机:WSL2 Ubuntu-22.04;标本 = 主网区块 964022(2026-08 实查)