比特币账本上不存在"账户余额"这样的概念。本节深入 UTXO 这一基本状态单元,拆解交易结构、脚本系统的栈式执行,以及从 P2PK 到 Taproot 的脚本演进史。
4.2.1 账户余额并不存在——UTXO 才是基本单元
很多初学者会问:"我的比特币钱包里到底存了什么?"答案是:你的钱包并没有存储一个叫做"余额"的数字。你看到的"余额",实际上只是你的钱包软件扫描整条区块链后,把所有"属于你公钥但尚未被花费的输出"加总的结果。
比特币的基本状态单元叫做 UTXO(Unspent Transaction Output,未花费交易输出)。它有三个明确的生命阶段:
- 创建(Created):一笔交易在某次出块时将其作为输出(Output)产出;
- 可花费(Unspent):属于你的密钥对锁定、尚未被引用;
- 销毁(Spent):在新的交易输入(Input)中被引用并签名验证后,该 UTXO 从全局 UTXO 集合中移除,同时生成新的 UTXO。
stateDiagram-v2
[*] --> Created: 某笔交易 Output 产生
Created --> Unspent: 确认上链
Unspent --> Spent: 被新交易 Input 引用并签名消耗
Spent --> [*]: 从 UtxoSet 中移除
这种模型与日常生活中的现金更为相似:你钱包中有 1 张 100 元和 2 张 20 元——它们不是用一个数字"140 元"来代表,而是由三张具体面值的钞票(UTXO)所组成。当你需要支付 130 元时,你必须花费 100+20 这两张"钞票",并生成一张 10 元的"找零"UTXO 回到你的地址。
4.2.2 一笔标准交易的输入输出结构
一笔标准的 P2PKH(Pay-to-PubKeyHash,付给公钥哈希)交易由以下部分构成:
| 字段 | 说明 |
|---|---|
| 版本号(4字节) | 当前为 0x02000000(版本 2) |
| 输入计数(VarInt) | 包含几个输入 |
| Inputs[] | 每个输入包含:引用的前序交易 Hash、输出索引 vout、解锁脚本(scriptSig)长度与内容、序列号 |
| 输出计数(VarInt) | 包含几个输出 |
| Outputs[] | 每个输出包含:金额(Satoshi,8 字节小端)、锁定脚本(scriptPubKey)长度与内容 |
| 锁定时间(4字节) | 0 或区块高度/Unix 时间 |
flowchart TD
subgraph Tx[比特币交易]
dir[Version 4B] --> IC[InCount VarInt]
dir --> OC[OutCount VarInt]
dir --> LT[Locktime 4B]
end
IC --> I1[输入1: prevHash + vout + scriptSig + seq]
IC --> I2[输入2: prevHash + vout + scriptSig + seq]
OC --> O1[输出1: 50,000,000 Sat + P2PKH]
OC --> O2[输出2: 1,500,000 Sat + P2PKH 找零]
UTXO 引用机制:
一个输入通过 <交易Hash, vout> 二元组唯一引用一个具体的 UTXO。交易本身是无状态的——验证者仅需检查:
差额即为矿工手续费(Fee):
4.2.3 脚本系统:基于栈的图灵不完备语言
比特币的脚本系统是一套图灵不完备的、基于栈的语言,刻意为之——否则攻击者可能通过无限循环耗尽全节点的计算资源。
一笔交易的验证需要将解锁脚本(ScriptSig)与对应的锁定脚本(ScriptPubKey)拼接后提交给脚本引擎执行。对于最经典的 P2PKH:
- 锁定脚本(在 UTXO 上附着的"锁"):
OP_DUP OP_HASH160
- 解锁脚本(花费时提供的"钥匙"):
拼接后的执行过程是一个标准的逆波兰栈操作序列:
| 步骤 | 栈顶(Top)向下 | 说明 |
|---|---|---|
1. 压入 | sig | 签名入栈 |
2. 压入 | pubKey, sig | 公钥入栈 |
3. OP_DUP 复制 | pubKey, pubKey, sig | 复制栈顶公钥 |
4. OP_HASH160 | hash160(pubKey), pubKey, sig | 对公钥做 RIPEMD160(SHA256) |
5. 压入 | pubKeyHash, hash160(pubKey), pubKey, sig | 目标哈希入栈 |
6. OP_EQUALVERIFY | pubKey, sig | 比较两哈希,不一致则脚本失败 |
7. OP_CHECKSIG | true / false | 用 验证 |
4.2.4 TypeScript 从零实现:极简栈式脚本虚拟机
下面用 TypeScript 从零实现一个支撑 P2PKH 验证核心逻辑的栈式脚本引擎。为聚焦教学,OP_CHECKSIG 用简化签名校验替代真实 ECDSA(第 2 章已完整实现过 ECDSA),其余操作码全部真实执行:
/**
* 极简比特币脚本虚拟机(P2PKH 核心流程)
* 纯 TypeScript 从零实现,无外部依赖
*/
// 操作码定义(与比特币一致)
enum Op {
PUSH1 = 0x01,
PUSH20 = 0x14, // 压入 20 字节数据(公钥哈希长度)
PUSH33 = 0x21, // 压入 33 字节数据(压缩公钥长度)
DUP = 0x76,
HASH160 = 0xa9,
EQUALVERIFY = 0x88,
CHECKSIG = 0xac,
}
/** 简化双 SHA-256(教学实现,非加密安全) */
function sha256Loop(data: Uint8Array): Uint8Array {
// 占位:真实实现参见 02.02-sha256 从零实现
// 此处返回确定性伪哈希以便演示栈逻辑
const out = new Uint8Array(32);
let acc = 0x9e3779b9;
for (let i = 0; i < data.length; i++) {
acc = (acc ^ data[i]) * 0x85ebca6b;
acc ^= acc >>> 13; // 仅演示用
}
for (let i = 0; i < 32; i++) {
acc = (acc * 0x5bd1e995 + i) >>> 0;
out[i] = acc & 0xff;
}
return out;
}
/** RIPEMD160(SHA256(x)) —— 比特币 HASH160 */
function hash160(data: Uint8Array): Uint8Array {
const inner = sha256Loop(data);
return sha256Loop(inner).slice(0, 20);
}
/** 栈式脚本执行器 */
export function runScript(
scriptSig: Uint8Array[], // 解锁脚本压入的数据
scriptPubKey: Uint8Array, // 锁定脚本字节
pubKeyHashExpected: Uint8Array
): boolean {
const stack: Uint8Array[] = [];
// 1. 执行解锁脚本:压入 sig 与 pubKey
for (const item of scriptSig) stack.push(item);
// 2. 执行锁定脚本(简化:按操作码序列遍历)
const pubKey = stack[stack.length - 1]; // 栈顶公钥
const sig = stack[stack.length - 2]; // 次栈顶签名
// OP_DUP: 复制栈顶
stack.push(pubKey);
// OP_HASH160: 栈顶公钥 → 哈希
const hashed = hash160(pubKey);
stack.push(hashed);
// 压入期望的公钥哈希
stack.push(pubKeyHashExpected);
// OP_EQUALVERIFY: 比较两个哈希
const h1 = stack.pop()!;
const h2 = stack.pop()!;
if (!bytesEqual(h1, h2)) return false;
// OP_CHECKSIG: 验证签名(教学简化:仅检查签名长度合法)
if (sig.length < 8) return false;
return true;
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
// ---- 演示 ----
// 模拟:公钥 → 哈希 → 用配对公钥花费成功
const fakePubKey = new Uint8Array(33).fill(2); // 压缩公钥占位
const correctHash = hash160(fakePubKey);
const fakeSig = new Uint8Array(70).fill(7); // DER 签名占位
const unlock = [fakeSig, fakePubKey]; // scriptSig
const ok = runScript(unlock, new Uint8Array(), correctHash);
console.log("P2PKH 验证结果:", ok ? "✅ 通过" : "❌ 失败");
// 错误的公钥哈希 → 验证失败
const wrongHash = new Uint8Array(20).fill(99);
console.log(
"错误哈希验证结果:",
runScript(unlock, new Uint8Array(), wrongHash) ? "❌(不应通过)" : "✅ 正确拒绝"
);运行结果:
P2PKH 验证结果: ✅ 通过
错误哈希验证结果: ✅ 正确拒绝4.2.5 脚本类型的演进:P2PK → P2PKH → P2SH → SegWit → Taproot
比特币的地址格式变迁本身就是一部"协议工程史":
| 类型 | 地址前缀 | 核心特征 | 升级目的 |
|---|---|---|---|
| P2PK | 无标准地址 | 直接暴露完整公钥 | 早期最简实现 |
| P2PKH | 1... | 收款地址仅含公钥哈希 | 缩短地址、提升隐私 |
| P2SH | 3... | 锁定条件先哈希化 | 支持多重签名、HTLC 等复杂合约 |
| P2WPKH/P2WSH | bc1q... (Bech32) | 将签名/脚本隔离到 Witness 字段 | 修复交易可锻性、降低费用 |
| P2TR (Taproot) | bc1p... (Bech32m) | Schnorr 签名 + Merkle 化脚本树 | 隐私无区别、签名聚合、扩展性 |
隔离见证(SegWit)的技术本质是:将 scriptSig 数据从"交易主体"中抽离,存放到 Witness 独立字段中。由于交易 ID(txid)不再包含签名数据,它同时修复了交易可锻性(Transaction Malleability)问题——即攻击者可以修改签名但保持交易语义不变,原 txid 就会改变,导致依赖 txid 的链下合约(如闪电网络)失效。
Taproot(P2TR)则通过引入 Schnorr 签名,将"公钥路径直接花费"和"脚本路径条件花费"在链上表现得完全一致:
其中 为内部公钥。这意味着使用最普通的公钥签名路径支付,与使用复杂脚本条件的支付,对外看起来没有任何区别——极大地提升了复杂合约的隐私性。
flowchart LR
subgraph 脚本演进
A[P2PK] --> B[P2PKH]
B --> C[P2SH]
C --> D[SegWit<br/>P2WPKH]
D --> E[Taproot<br/>P2TR]
end
subgraph 能力
A2[直接公钥]
B2[公钥哈希+短地址]
C2[多签/HTLC]
D2[签名隔离+修复可锻性]
E2[Schnorr聚合+脚本隐私]
end
A --- A2
B --- B2
C --- C2
D --- D2
E --- E2
4.2.6 TypeScript 从零实现:Base58Check 地址编码
从公钥哈希生成 P2PKH 地址,完整实现 Base58 编码与双重哈希校验:
/**
* Base58Check 地址编码(P2PKH)
* 从零实现,无外部依赖
*/
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz";
/** 字节 → Base58 字符串(保留前导零) */
function base58Encode(bytes: Uint8Array): string {
// 统计前导零字节
let zeros = 0;
for (const b of bytes) {
if (b === 0) zeros++;
else break;
}
// 字节 → 大整数
let num = 0n;
for (const b of bytes) num = (num << 8n) | BigInt(b);
let encoded = "";
while (num > 0n) {
const rem = num % 58n;
num /= 58n;
encoded = ALPHABET[Number(rem)] + encoded;
}
return "1".repeat(zeros) + encoded;
}
/** 双重 SHA-256 取前 4 字节作为校验和 */
function checksum4(data: Uint8Array): Uint8Array {
// 教学占位:真实实现见 02.02
let acc = 0x811c9dc5;
for (const b of data) {
acc ^= b;
acc = (acc * 0x01000193) >>> 0;
}
const out = new Uint8Array(4);
for (let i = 0; i < 4; i++) {
acc = (acc * 0x5bd1e995) >>> 0;
out[i] = acc & 0xff;
}
return out;
}
/** Base58Check 编码:version + payload + 前4字节双SHA校验 */
export function base58CheckEncode(version: number, payload: Uint8Array): string {
const data = new Uint8Array(1 + payload.length + 4);
data[0] = version;
data.set(payload, 1);
const checksum = checksum4(data.subarray(0, 1 + payload.length));
data.set(checksum, 1 + payload.length);
return base58Encode(data);
}
// ---- 演示:主网 P2PKH(version 0x00)----
// 占位公钥哈希(真实情境由 HASH160(pubkey) 得到)
const pubKeyHashDemo = new Uint8Array(20).fill(0xAB);
const address = base58CheckEncode(0x00, pubKeyHashDemo);
console.log("P2PKH 地址(主网):", address);
console.log("地址前缀:", address[0], "(主网以 1 开头)");运行结果:
P2PKH 地址(主网): 1GejCghxCErkGS3hDZ1g1SM95h6F6XGfL3
地址前缀: 1 (主网以 1 开头)本节要点
- UTXO 是比特币账本的"原子单元";用户的"余额"是本地钱包对属于自身地址的 UTXO 的求和。
- 一个 Input 通过
<交易Hash, vout>二元组唯一引用一个具体 UTXO;总输入 ≥ 总输出,差额就是矿工手续费,多输入多输出天然实现找零。 - 锁定脚本定义"花这笔钱的条件",解锁脚本满足该条件;比特币有意选择图灵不完备,为整个网络提供可确定性的验证成本上限。
- 脚本的演进方向:隐私增强 + 费用优化 + 向前兼容的软分叉升级;Taproot 让复杂合约在链上看起来和简单付款一样。
- Base58Check 和 Bech32 地址编码分别服务于兼容性与现代效率需求。
评论
0评论加载中…