前几天有个朋友让我帮他看钱包程序的问题:他手里的几个地址从节点同步之后,余额怎么都刷不出来。我登上去一看,问题不复杂——他把所有地址都按老格式去解析,新格式的地址直接被程序忽略了。这其实是很多刚接触比特币开发的人都会遇到的一个坎:地址看起来都是一串字符,但地址类型背后的脚本结构、编码规则和签名方式完全不是一回事。
这篇文章我就把比特币地址类型和签名方式从头到尾拆开讲一遍,从最基础的 P2PKH 讲到 SegWit 再到 Taproot,把每种地址长什么样、怎么生成的、签名过程有什么差异、实际开发会遇到哪些坑都交代清楚。不管你是钱包开发者、区块链学习者,还是只是好奇想弄明白“为什么比特币地址有 1 开头、3 开头和 bc1 开头”的人,这篇文章都适合你慢慢读。
1. 地址与签名的基础认知
1.1 地址到底是什么
很多科普把比特币地址比喻成“银行账号”,这个类比大方向没错,但有个关键区别:银行账号是银行帮你开的,而比特币地址本质上是一串通过公钥计算出来的哈希值,不需要任何机构发号。换句话说,任何人在任何地方,只要生成一对公私钥,就能得到自己的比特币地址。
这里要明确一点:地址本身不“存钱”。比特币网络上真正记录资产的是 UTXO(未花费交易输出),地址只是锁住 UTXO 的“锁”。当你把币转到某个地址,实际上是构造了一笔交易,这个交易的输出脚本里记录了一段“解锁条件”,只有满足条件的人才能花这笔币。而地址,就是这段脚本的一种“编码表示”。
那这就引出了签名的作用:签名就是钥匙。地址表示“币被谁锁住了”,签名表示“我证明我有资格开这把锁”。两者是一对:地址是锁,签名是开锁动作。
1.2 从私钥到地址的完整链路
我习惯用一条流水线来理解地址的生成过程:
私钥(256位随机数) → 椭圆曲线乘法 → 公钥 → 哈希计算(SHA256 + RIPEMD160) → 编码(Base58Check 或 Bech32) → 地址这里每一步都有讲究。私钥是一串 256 位的随机数,它的取值范围是 1 到 n-1(n 是一个特定的大素数)。公钥则是通过椭圆曲线乘法算出来的:私钥乘以椭圆曲线的生成元 G,得到一个曲线上的点,这个点的 x 坐标加上一个前缀(02 或 03 表示 y 的奇偶性),就构成了 33 字节的压缩公钥。整个过程是单向的:私钥算出公钥很容易,公钥反推私钥在计算上是不可行的。
再往后的哈希环节,涉及两次哈希:先做 SHA256,再做 RIPEMD160,得到 20 字节的哈希值。这一步的意义在于,公钥不会直接暴露在地址里,从而为私钥增加了一层保护(哪怕未来椭圆曲线算法有弱点,攻击者也还要先过哈希这一关)。
最后一层是编码。不同的地址格式用不同的编码方式,比如 P2PKH 用 Base58Check,SegWit 地址用 Bech32,Taproot 地址用 Bech32m。编码不只是“把字节转成字符串”这么简单,它还要加上校验和,用来防止用户抄错地址。到这一步我后面会详细展开。
1.3 椭圆曲线密码学基础
比特币采用的椭圆曲线是 secp256k1,方程是 y² = x³ + 7。说实话,写应用的时候大部分人都不会直接跟曲线方程打交道,都是调用现成的密码学库。但你至少要理解两个关键概念:私钥变公钥是“乘法”,签名是“用私钥对消息做计算并输出可验证的证明”。
以 ECDSA 签名来说,过程大致是:
- 随机选一个 nonce(临时密钥)k
- 计算 R = k × G,取 R 的 x 坐标作为 r
- 计算 s = k⁻¹ × (e + r × d) mod n,其中 e 是消息哈希,d 是私钥
- 输出签名 (r, s)
验证的时候,验证者只需要公钥 P、消息 e 和签名 (r, s),就能通过 s × G = R + e × P 这个关系来确认签名是否有效。整个签名过程不需要那个随机数 k 参与验证,但 k 一旦泄露或者重复使用,私钥就可能被别人算出来。这个话题我在后面的“常见问题”里专门讲。
2. 地址类型详解与应用场景
2.1 P2PKH:比特币最早的地址格式
P2PKH(Pay to Public Key Hash)是比特币最经典的地址类型,以数字 1 开头,比如1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa。它的锁定脚本(scriptPubKey)是:
OP_DUP OP_HASH160 <20字节公钥哈希> OP_EQUALVERIFY OP_CHECKSIG花这笔币的时候,解锁脚本需要提供公钥本身和对应的签名。验证节点会做这样的检查:先把你提供的公钥做哈希,看是否等于脚本里锁定的 20 字节哈希,等于则继续验证签名,签名有效就能花。
P2PKH 地址的生成流程是:
- 公钥做 SHA256 再 RIPEMD160,得到 20 字节的 pubkey hash
- 在最前面加上版本字节 0x00
- 对这 25 字节做两次 SHA256,取前 4 字节作为校验和
- 把“版本字节 + 哈希 + 校验和”整体做 Base58 编码
Base58 编码剔除了 0(零)、O(大写 o)、I(大写 i)、l(小写 L)这四个容易混淆的字符,同时保留了数字和大部分字母。这套设计在当年没有二维码的时代很实用,手抄不容易出错。直到今天 P2PKH 地址仍然占着很大比例的存量资金,大多数老钱包和中转地址都还是这个格式。
2.2 P2SH:脚本哈希带来的灵活性
P2SH(Pay to Script Hash)是后来为了支持复杂脚本引入的格式,地址以数字 3 开头。它的设计理念是:把任意一段完整的脚本(redeemScript)先哈希,锁定脚本里只放这个哈希。
锁定脚本极简:
OP_HASH160 <20字节脚本哈希> OP_EQUAL花币的时候,解锁方需要提供一段能匹配哈希的 redeemScript,以及满足这段脚本要求的签名数据。验证节点会先把提供的 redeemScript 做哈希,跟锁定的哈希对比,匹配之后再执行这段脚本的验证逻辑。
P2SH 最大的价值在于,不管底层脚本多复杂(多签、时间锁、哈希锁),对外都只暴露一个 3 开头的地址。2-of-3 多签地址就是一个典型场景:三个公钥哈希合成一个地址,花费时需要其中两个对应的私钥签名。这在企业钱包、托管方案和跨链原子交换里非常常见。
当初设计 P2SH 时还有一个没预料到的用途:P2SH 包装的 SegWit 地址(也就是 P2SH-P2WPKH)。因为老钱包不认识新的 SegWit 地址,区块链升级时就把 SegWit 的 program 塞进一个 P2SH 的 redeemScript 里。这样老节点也能识别,算是新老协议之间的桥梁。
2.3 SegWit 与 P2WPKH / P2WSH
SegWit(隔离见证)是比特币历史上一次非常重要的升级,它要解决的主要问题是交易延展性(transaction malleability)。简单说,在老格式交易里,签名是在交易体内部的,第三方可以改动签名的某些字节(比如把 s 值按椭圆曲线群的对称性翻转),让整个交易的 ID 发生变化,但签名仍然有效。这在闪电网络的通道设计中是个大麻烦。
SegWit 的解决方案是:把签名等见证数据从交易体中移出来,放到独立的 witness 字段中。交易 ID 只对交易体计算,不再包含见证数据。这样一来,签名怎么变动都不会影响交易 ID。
SegWit 地址正是原生 SegWit 输出(P2WPKH / P2WSH)的编码格式,以bc1q开头,使用 Bech32 编码。P2WPKH 是 SegWit 对“公钥哈希”的封装,有效负载是 20 字节;P2WSH 是 SegWit 对“脚本哈希”的封装,有效负载是 32 字节(用 SHA256 算脚本哈希)。
它的锁定脚本看起来只有两字节:
OP_0 <20字节公钥哈希>这里 OP_0 实际上是版本号 0(witness version 0)。脚本本身不验证签名,签名验证逻辑被移到了 witness 字段,由共识规则单独处理。
从用户视角看,SegWit 地址的体验提升主要在手续费更低:同样一笔交易,见证数据按折扣价计费,输出脚本又短,整体字节数比老格式少一截。而且因为交易 ID 稳定了,做链上状态通道和跨链原子交换都方便得多。
但要注意一个细节:SegWit 原生地址(bc1q)在很老的钱包里可能不被识别。如果收款方的钱包只支持 P2PKH 或 P2SH 地址,你需要用 P2SH-P2WPKH 的包装格式(3 开头)来兼容。
2.4 Taproot 与 P2TR:聚合签名与脚本树的融合
P2TR(Pay to Taproot)是 2021 年 Taproot 升级引入的地址格式,以bc1p开头,使用 Bech32m 编码。它的核心创新在于两点:Schnorr 聚合签名和MAST 脚本树。
先说 Schnorr 签名。跟 ECDSA 相比,Schnorr 有一个非常漂亮的数学性质:多个签名可以线性相加得到一个聚合签名。假设有 3 个参与方,各自持有私钥,对同一笔交易签名,最后可以合并成一个 64 字节的签名,验证者只需要验证一个签名的同时,也可以只看到聚合后的公钥。这在多签场景里直接省下了大量空间,而且多签参与方的身份也不会暴露在链上。
再说 MAST(Merkle 化抽象语法树)。Taproot 的锁定脚本只有一个 32 字节的输出公钥(tweaked key),它可以表示两条花费路径:
- key path:直接用聚合公钥和聚合签名花费
- script path:提供满足某个脚本条件的解锁数据,以及该脚本在 Merkle 树中的证明路径
这个设计让复杂的合约逻辑(比如多签、时间锁、闪电网络通道)在平时看起来跟普通单签地址一模一样。只有真正走脚本路径时,才会暴露你用了哪条脚本。
P2TR 的地址是版本 1 的 SegWit 输出,锁定脚本是:
OP_1 <32字节tweaked key>注意这里版本号变成了 1,所以编码时用的校验算法变成了 Bech32m,而不是 SegWit v0 用的 Bech32。这个区别非常容易踩坑,我后面会专门讲。
Taproot 面世之后,越来越多新钱包把默认地址切成了 P2TR,因为它能在保证隐私的前提下降低手续费。但它的脚本门槛比前面几种高不少,如果你只是做简单收付款,建议先用好 P2WPKH 再碰 Taproot。
2.5 四种地址类型对比速查
我整理了一张表格,方便你快速对照:
| 地址类型 | 前缀 | 编码方式 | 有效负载 | 版本 | 典型用途 |
|---|---|---|---|---|---|
| P2PKH | 1 | Base58Check | 20 字节公钥哈希 | 无 | 最老的单签收款 |
| P2SH | 3 | Base58Check | 20 字节脚本哈希 | 无 | 多签、复杂脚本 |
| P2WPKH | bc1q | Bech32 | 20 字节公钥哈希 | v0 | SegWit 单签收款 |
| P2WSH | bc1q | Bech32 | 32 字节脚本哈希 | v0 | SegWit 多签/复杂脚本 |
| P2SH-P2WPKH | 3 | Base58Check | 20 字节脚本哈希 | 无 | 兼容老旧钱包的 SegWit 地址 |
| P2TR | bc1p | Bech32m | 32 字节 tweaked key | v1 | 聚合签名、复杂合约 |
如果你的钱包程序要支持多种地址,建议先做一个“地址类型识别”的模块,把前缀和编码方式作为判定条件,再决定后续的签名构造流程。
3. 签名机制原理解析
3.1 ECDSA 签名:随机数、R 和 S
ECDSA 的签名过程我上面已经提过,这里把几个容易搞混的细节展开说清楚。
首先是 DER 编码。ECDSA 签名在比特币交易里的标准编码格式是 DER(Distinguished Encoding Rules)。你签出来的 (r, s) 是一对大整数,要转成 DER 格式的字节串才能放进交易。DER 格式里每个整数都要用 0x02 标签开头,后跟长度和内容,如果整数最高位是 1 前面还要补一个 0x00。我见过不少同学直接拿 (r, s) 的裸字节塞进交易,结果链上验证怎么都不通过——就是因为少了 DER 编码这道工序。
其次是低 S 值规则。因为椭圆曲线群的对称性,同一个签名可以有两个合法的 (r, s) 和 (r, n-s),两者验证都能通过。为了减少签名延展性的风险,比特币社区规定交易里的签名必须使用较低的那个 s 值(即 s ≤ n/2)。在代码里要在签名后做一次判断:
if s > n // 2: s = n - s做完低 S 处理再编码成 DER。
第三是 sighash 类型。签名时不是对整个原始交易直接做哈希,而是要先把交易序列化成特定格式,并附加一个 sighash 标志字节(比如 SIGHASH_ALL = 0x01),表示“我对所有的输入和输出都负责”。常见的还有 SIGHASH_NONE(0x02)表示“我只承诺输入,输出随便改”,SIGHASH_SINGLE(0x03)表示“我只承诺对应对下标的那个输出”。这个设计是比特币多签和合约的基础。
3.2 什么是签名延展性,为什么 SegWit 解决了它
签名延展性这个问题,很多教程一笔带过,但它实际上是推动协议演进的一条暗线。
在非 SegWit 交易里,签名字段位于交易主体的锁定脚本部分。任何广播交易的人,都可以在不改变资金流向的前提下,把签名中某个合法变体替换掉,得到一个 ID 完全不同的新交易。虽然不影响资金安全,但在依赖交易 ID 追踪状态的系统(比如闪电网络通道的建立与关闭)里,这种“合法的交易变形”会造成严重的状态混淆。
SegWit 的解决办法很干脆:把签名从交易体的计算范围内挪出去。从 SegWit 升级之后,交易 ID 不再哈希见证数据,第三方怎么篡改签名,交易 ID 都纹丝不动。原生 SegWit 交易以及后来所有地址类型,都继承了这一特性。
这里还要多说一句:签名延展性并不是“别人能偷你的币”,而是“别人能让你的交易 ID 发生变化”。这个区别一定要搞清楚——前者是安全问题,后者是可用性问题。但可用性问题在闪电网络、跨链结算这些敏感场景里同样致命。
3.3 Schnorr 签名:可聚合的数学之美
Schnorr 签名协议在 1989 年就被提出来了,但直到 BIP 340 才正式引入比特币,主要原因之一是它当时有专利限制。从数学上看,Schnorr 比 ECDSA 简洁很多:
- 签名生成:选随机数 k,计算 R = k × G,令 e = H(R || P || m),s = k + e × d
- 签名验证:计算 e = H(R || P || m),检查 s × G 是否等于 R + e × P
注意,Schnorr 的签名验证过程中,R 和 P 都参与哈希计算,而 ECDSA 里 R 和 P 不直接参与哈希。这带来一个很直观的好处:不存在非法的签名变体(除了把整个 (R, s) 换掉),Schnorr 签名没有 malleability 问题。
更漂亮的是聚合能力。假设有 2 个签名者,各自有私钥 d₁、d₂,各自生成 R₁、R₂ 和 s₁、s₂,那最终的聚合签名就是:
- 聚合公钥 P = P₁ + P₂
- 聚合 nonce R = R₁ + R₂
- 聚合 s = s₁ + s₂
验证者只需要验证一个 (R, s) 是否对应聚合公钥 P。整个多签过程跟单签几乎一样,链上数据量从 2 组签名变成 1 组,隐私和效率都大幅提升。这也是 Taproot 地址能把复杂多签做得“看起来像单签”的根本原因。
3.4 Taproot 中的两种花费路径
Taproot 输出在花费时有两种路径,理解这两条路径,是看懂现代比特币脚本的关键。
Key path spending(密钥路径花费)是最常见的方式:你只需要提供聚合签名。验证时,节点取出锁定脚本里的 32 字节输出公钥(tweaked key),用 Schnorr 验签公式验证签名即可。链上表现跟单签完全一致。
Script path spending(脚本路径花费)走的是 MAST 逻辑:解锁方要提供满足某个脚本条件的 witness 数据、脚本本身的字节码,以及从脚本的叶子哈希到根哈希的 Merkle 证明路径。验证节点会先计算脚本的哈希,用证明路径逐步往上合并,最终得到 Merkle 根;再把这个根与内部公钥一起做 tweak 计算,与锁定脚本里的输出公钥比对,一致后再执行脚本验证。
这里有个关键概念叫tweak。Taproot 地址里的公钥并不是参与方的原始聚合公钥,而是原始聚合公钥再加上一个哈希值偏移后的结果。偏移量的计算包含了 Merkle 根:P_tweak = P + H(P || MerkleRoot) × G。这个 tweak 操作确保了:如果你走 key path,别人不知道你有没有脚本路径;如果你走 script path,哈希过程也覆盖了整个脚本树,防止有人偷换脚本。
实操层面,大部分应用场景用法很简单:
- 你的钱包只有一个私钥 → 用 key path,签名算法走 BIP 340 Schnorr
- 你要做多签或复杂合约 → 需要实现脚本树的构造和 Merkle 证明逻辑
4. 实操记录:地址生成与交易签名的完整流程
4.1 环境与工具准备
我平时做地址相关实验用的是 Python,生态里密码学库比较齐全。建议你准备:
- Python 3.10+
ecdsa库(secp256k1 曲线支持完整)hashlib(内置)- 一个 Base58 编码实现(可以手写,也可以从开源代码里抄)
- Bech32 / Bech32m 编码函数(建议手写一遍,理解更透彻)
如果你只是想快速验证地址对不对,网上也有在线的地址生成和签名工具,但我不推荐在带真实私钥的环境里用在线工具。任何私钥都不应该离开你自己的设备。
4.2 生成不同类型地址的完整代码
这里给出一段可以直接跑的 Python 示例,展示从私钥到 P2PKH、P2WPKH、P2TR 地址的完整链路。为了避免依赖过多第三方库,Base58 和 Bech32 我都用函数手写主要逻辑:
import hashlib, hmac, struct # secp256k1 曲线参数(部分,演示用) P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 def sha256(b): return hashlib.sha256(b).digest() def ripemd160(b): return hashlib.new('ripemd160', b).digest() def hash160(b): return ripemd160(sha256(b)) def point_mul(k, point): # 椭圆曲线标量乘法简化实现(需要处理模逆和点加法) # 实际开发建议直接引用 ecdsa 库的 SECP256k1 pass def pubkey_to_address_p2pkh(pub_bytes): h = hash160(pub_bytes) payload = b'\x00' + h checksum = sha256(sha256(payload))[:4] return base58_encode(payload + checksum) def pubkey_to_address_p2wpkh(pub_bytes): h = hash160(pub_bytes) return bech32_encode('bc', 0, h) # witness version 0 def pubkey_to_address_p2tr(pub_bytes): # Taproot 需要先对内部公钥做 tweak,这里简化成直接把压缩公钥 x 坐标作为内部 key internal_key = pub_bytes[1:33] # 无脚本树时,Merkle root 为空,tweak = H(internal_key || 0x00) root_hash = bytes(32) tweak = int.from_bytes(sha256(internal_key + root_hash), 'big') # 实际实现还要对内部公钥做点加法,生成 tweaked key tweaked_key = ... return bech32m_encode('bc', 1, tweaked_key)这段代码我先留了两个空函数(Base58、Bech32、点运算),更适合用来理解流程而不是直接跑。实际项目里建议用成熟库,比如直接调用ecdsa生成公钥,然后再用专门的地址编码库处理 Base58 和 Bech32。把流程拆开理解一遍之后,再用库封装,上手速度会快很多。
4.3 构造并签名一笔交易:SegWit 版本的 SIGHASH
构造交易签名是整个流程里最需要耐心的地方。以 SegWit 单签(P2WPKH)为例,核心步骤是:
- 拿到输入 UTXO:需要知道来源交易 ID、输出索引、金额和锁定脚本
- 构造交易输出:给收款方地址生成对应的 scriptPubKey
- 构造 sighash preimage:按 BIP 143 的规则,把所有输入输出、金额、脚本等信息序列化
- 对 preimage 做双重 SHA256
- 用私钥签出 DER 格式的 ECDSA 签名,加上 SIGHASH_ALL 标志
- 把签名和公钥放进 witness 字段
注意 SegWit 签名哈希的构造规则跟老格式不一样:输入 scriptCode 用的是隔离见证的版本,并且要放入“本输入金额”这个字段。老格式的 sighash 里只有输出金额的哈希,没有单个输入的金额。这个差异是 SegWit 签名验证最容易出错的地方。
给你一个伪代码流程:
sighash_preimage = ( version(4) + input_count(1) + input.txid(32) + input.vout(4) + scriptCode_length + scriptCode + input.amount(8) + # SegWit 特有 input.sequence(4) + output_count(1) + output.value(8) + output.scriptPubKey(locktime 等末尾) ) hash_to_sign = sha256(sha256(sighash_preimage + SIGHASH_ALL))签名之后,构造 witness 字段:
witness = [ signature_der + 0x01, # 0x01 = SIGHASH_ALL compressed_pubkey ]把 witness 放进交易的 witness 字段,同时锁定脚本用OP_0 <20字节公钥哈希>,交易就能广播了。
4.4 签名验证的实操要点
验证一笔交易签名是否有效,我建议你按下面的顺序排查:
- 先看地址类型,确认签名方案(ECDSA 还是 Schnorr)
- 检查签名编码(DER 格式是否正确、S 值是否低 S)
- 检查 sighash 类型和对应的 preimage 构造规则
- 检查公钥压缩格式(33 字节以 02/03 开头,还是 65 字节未压缩)
- 最后才做实际验签
我在调试的最后一步踩过最多的坑就是 preimage 构造:漏了输入金额、脚本长度字节顺序不对、或者 SIGHASH 标志没有写入末尾,都会导致验签失败。这里没有捷径,只能每步都打印中间结果,跟参考实现(比如某个开源区块浏览器展示的 hex 交易)逐一对照。
5. 常见问题与排查技巧实录
5.1 地址前缀混用:为什么我的“1”开头地址在 SegWit 钱包里余额为 0
这是最经典的问题。很多人把一个 P2PKH 地址导入新的 SegWit 钱包,发现余额为 0,就以为是钱包坏了。其实新钱包默认只监听自己的地址派生路径,老格式地址没有被导入——地址类型不同,对应的脚本结构就不同,节点不会自动“认领”不同脚本下的资金。
- 如果你的币在 P2PKH 地址上,就必须用能处理 P2PKH 脚本的钱包
- 如果你的币在 P2TR 地址上,就必须用支持 Taproot 验证的钱包
- 把地址导入钱包时,注意钱包是否是按“地址导入”方式处理,而不是仅按 HD 路径派生
排查技巧:用区块浏览器查一下地址下面的交易和脚本类型。看到pubkeyhash就是 P2PKH,scripthash就是 P2SH,witness_v0_keyhash就是 P2WPKH,witness_v1_taproot就是 P2TR。
5.2 Bech32 与 Bech32m 的坑:bc1p 地址校验总失败
Bech32 编码在 SegWit v0 地址里用的是原始版本,而 Taproot(witness v1)必须用更新后的 Bech32m。两者的区别在于校验和对字符的处理方式。如果你用解析 SegWit 地址的库去解析 Taproot 地址,会直接报校验失败。
简单记忆法:
- 地址
bc1q开头 → v0 → Bech32 - 地址
bc1p开头 → v1 → Bech32m
如果你在写通用解析函数,一定要根据 witness version 来选编码方式,而不是写死一种。很多本地工具链更新不及时,就会在这里翻车。
另外还有一个易错点:Bech32 地址只允许小写字母。虽然规范允许全大写版本(用于特殊场景的字符集反转),但绝大多数钱包都不支持。我的建议是强制全部转小写再处理。
5.3 随机数 nonce 复用:私钥是怎么泄露的
ECDSA 签名在生成时会引入一个随机数 k,我把这个 k 称为“一次性随机数”。它的要求是:第一次签名之后马上丢弃,绝不能在另一条消息里复用。如果同一个 k 值被用了两次,攻击者可以通过两次签名的 r 值相同这个特征,直接暴力解出私钥。
这不是理论上的风险,历史上已经发生过多次真实事故。某托管服务在生成的随机数时随机源有问题,一度导致大量地址的私钥被公开推算。你如果自己实现签名,务必做以下检查:
- 使用密码学安全随机数生成器(
secrets模块而不是random) - 对 k 的随机性做足够长度的熵源(至少要 256 位)
- 如果用的是 RFC 6979 方式(根据私钥和消息哈希确定性地生成 k),可以完全避免 k 重复问题
5.4 地址派生路径与 Purpose 字段不对
HD 钱包在生成地址时会用 BIP 44 / 49 / 84 / 86 等不同的派生路径。路径里的第三个层级叫 purpose,它决定了后续地址的格式:
| Purpose | 派生路径 | 地址类型 |
|---|---|---|
| 44 | m/44'/0'/0'/0/k | P2PKH |
| 49 | m/49'/0'/0'/0/k | P2SH-P2WPKH |
| 84 | m/84'/0'/0'/0/k | P2WPKH |
| 86 | m/86'/0'/0'/0/k | P2TR |
很多程序员在写新钱包的时候,直接沿用 44 路径去生成地址,结果出来的全是 1 开头的老地址。如果你想支持新格式,给用户选地址类型的同时,也必须在背后切换派生路径。否则即使用户选择了 SegWit 地址,你生成的“历史找零地址”也可能是错误的脚本格式。
5.5 一个真实的调试案例记录
有一次我做一个交易签名比对实验,两个核心模块分别支持 P2PKH 和 P2WPKH。程序跑起来后,老格式的签名全部失败,但新格式又没问题。我逐层打印调试信息,最终定位到问题:我在计算 sighash 时,用的脚本 Code 是收款输出脚本而不是输入 UTXO 的原始锁定脚本。
这里有个很多人都会忽略的概念:签名时要用的 scriptCode,对于 P2PKH 是输入 UTXO 里的完整锁定脚本(OP_DUP OP_HASH160 ...),但对于 P2WPKH,必须换成规定格式的虚拟脚本(OP_0 <20字节公钥哈希>),而不是 UTXO 里的那个短脚本。这个规则在 BIP 143 里写得明明白白,但实现时就是容易顺手搞混。
遇到验签问题别慌,先确认你用的协议规则是哪一个,然后找一份链上已经确认的交易的原文,把它的 preimage 算出来和签名输入对比。这个过程虽然枯燥,但一旦跑通,你对交易结构的理解会上升一个台阶。
从我个人的经验来看,地址类型和签名方式这块知识,最好的学习方式不是只看文档,而是自己把四个地址的生成流程完整写一遍,再把 SegWit 和 Taproot 各签一笔本地测试交易,上测试网广播验证。代码无非是编码、哈希、点运算、序列化这几样,难的是把这些组件拼起来的时候每一步为什么这样做。等你亲手把bc1p地址签出一笔全网可见的交易,再回头看这些地址格式的差异,会觉得一切都顺理成章。之后再做钱包、做链上脚本、做自定义合约,都会轻松不少。