教程区块链区块链技术ch044.2 交易、UTXO 模型与脚本系统

本页目录

比特币账本上不存在"账户余额"这样的概念。本节深入 UTXO 这一基本状态单元,拆解交易结构、脚本系统的栈式执行,以及从 P2PK 到 Taproot 的脚本演进史。


4.2.1 账户余额并不存在——UTXO 才是基本单元

很多初学者会问:"我的比特币钱包里到底存了什么?"答案是:你的钱包并没有存储一个叫做"余额"的数字。你看到的"余额",实际上只是你的钱包软件扫描整条区块链后,把所有"属于你公钥但尚未被花费的输出"加总的结果。

比特币的基本状态单元叫做 UTXO(Unspent Transaction Output,未花费交易输出)。它有三个明确的生命阶段:

  1. 创建(Created):一笔交易在某次出块时将其作为输出(Output)产出;
  2. 可花费(Unspent):属于你的密钥对锁定、尚未被引用;
  3. 销毁(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。交易本身是无状态的——验证者仅需检查:

Inputs ValueOutputs Value\sum \text{Inputs Value} \geq \sum \text{Outputs Value}

差额即为矿工手续费(Fee):

Fee=Inputs ValueOutputs Value\text{Fee} = \sum \text{Inputs Value} - \sum \text{Outputs Value}

4.2.3 脚本系统:基于栈的图灵不完备语言

比特币的脚本系统是一套图灵不完备的、基于栈的语言,刻意为之——否则攻击者可能通过无限循环耗尽全节点的计算资源。

一笔交易的验证需要将解锁脚本(ScriptSig)与对应的锁定脚本(ScriptPubKey)拼接后提交给脚本引擎执行。对于最经典的 P2PKH:

  • 锁定脚本(在 UTXO 上附着的"锁"):

OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG

  • 解锁脚本(花费时提供的"钥匙"):

拼接后的执行过程是一个标准的逆波兰栈操作序列:

步骤栈顶(Top)向下说明
1. 压入 sig签名入栈
2. 压入 pubKey, sig公钥入栈
3. OP_DUP 复制pubKey, pubKey, sig复制栈顶公钥
4. OP_HASH160hash160(pubKey), pubKey, sig对公钥做 RIPEMD160(SHA256)
5. 压入 pubKeyHash, hash160(pubKey), pubKey, sig目标哈希入栈
6. OP_EQUALVERIFYpubKey, sig比较两哈希,不一致则脚本失败
7. OP_CHECKSIGtrue / false 验证

4.2.4 TypeScript 从零实现:极简栈式脚本虚拟机

下面用 TypeScript 从零实现一个支撑 P2PKH 验证核心逻辑的栈式脚本引擎。为聚焦教学,OP_CHECKSIG 用简化签名校验替代真实 ECDSA(第 2 章已完整实现过 ECDSA),其余操作码全部真实执行:

typescript
/**
 * 极简比特币脚本虚拟机(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) ? "❌(不应通过)" : "✅ 正确拒绝"
);

运行结果:

text
P2PKH 验证结果: ✅ 通过
错误哈希验证结果: ✅ 正确拒绝

4.2.5 脚本类型的演进:P2PK → P2PKH → P2SH → SegWit → Taproot

比特币的地址格式变迁本身就是一部"协议工程史":

类型地址前缀核心特征升级目的
P2PK无标准地址直接暴露完整公钥早期最简实现
P2PKH1...收款地址仅含公钥哈希缩短地址、提升隐私
P2SH3...锁定条件先哈希化支持多重签名、HTLC 等复杂合约
P2WPKH/P2WSHbc1q... (Bech32)将签名/脚本隔离到 Witness 字段修复交易可锻性、降低费用
P2TR (Taproot)bc1p... (Bech32m)Schnorr 签名 + Merkle 化脚本树隐私无区别、签名聚合、扩展性

隔离见证(SegWit)的技术本质是:将 scriptSig 数据从"交易主体"中抽离,存放到 Witness 独立字段中。由于交易 ID(txid)不再包含签名数据,它同时修复了交易可锻性(Transaction Malleability)问题——即攻击者可以修改签名但保持交易语义不变,原 txid 就会改变,导致依赖 txid 的链下合约(如闪电网络)失效。

Taproot(P2TR)则通过引入 Schnorr 签名,将"公钥路径直接花费"和"脚本路径条件花费"在链上表现得完全一致:

Q=P+H(P    merkleRoot)GQ = P + H(P \;||\; \text{merkleRoot}) \cdot G

其中 P=dGP = dG 为内部公钥。这意味着使用最普通的公钥签名路径支付,与使用复杂脚本条件的支付,对外看起来没有任何区别——极大地提升了复杂合约的隐私性。

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 编码与双重哈希校验:

typescript
/**
 * 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 开头)");

运行结果:

text
P2PKH 地址(主网): 1GejCghxCErkGS3hDZ1g1SM95h6F6XGfL3
地址前缀: 1 (主网以 1 开头)

本节要点

  • UTXO 是比特币账本的"原子单元";用户的"余额"是本地钱包对属于自身地址的 UTXO 的求和。
  • 一个 Input 通过 <交易Hash, vout> 二元组唯一引用一个具体 UTXO;总输入 ≥ 总输出,差额就是矿工手续费,多输入多输出天然实现找零。
  • 锁定脚本定义"花这笔钱的条件",解锁脚本满足该条件;比特币有意选择图灵不完备,为整个网络提供可确定性的验证成本上限。
  • 脚本的演进方向:隐私增强 + 费用优化 + 向前兼容的软分叉升级;Taproot 让复杂合约在链上看起来和简单付款一样。
  • Base58Check 和 Bech32 地址编码分别服务于兼容性与现代效率需求。

评论

0

评论加载中…

发表评论

0/2000