首页
/ Bitcoin Core TestGen:地址与私钥测试向量的数据驱动生成指南

Bitcoin Core TestGen:地址与私钥测试向量的数据驱动生成指南

2026-09-06 13:59:43作者:卓艾滢Kingsley

本文围绕 contrib/testgen/README.md 所描述的 TestGen 工具集展开,讲解如何用它为 Bitcoin Core 的数据驱动(data-driven)测试生成 Base58Check 与 Bech32/Bech32m 地址、WIF 格式私钥的合法/非法测试向量,并深入 生成脚本 的实现细节与 C++ 消费端 的对接方式。读完本文,你能独立完成测试向量的重新生成、理解向量 JSON 的元数据语义,并掌握非法向量"受控损坏"的设计思路。

一、TestGen 在仓库中的定位

contrib/testgen/README.md 对 TestGen 的定义只有一句话,但信息量很足:

Utilities to generate test vectors for the data-driven Bitcoin tests. (用于为数据驱动型 Bitcoin 测试生成测试向量的工具集)

这句话包含两个关键信息:

  1. TestGen 是"向量的生产者",不是测试本身。真正做断言的是 C++ 单元测试 src/test/key_io_tests.cpp,它按索引遍历 JSON 文件中的向量逐条校验;TestGen 负责把"什么样的字符串算合法地址、什么样的字符串算必须被拒绝"这件事编码成可复现的数据。
  2. 向量以数据文件形式落地。当前仓库中,key_io 相关的两份向量文件是 src/test/data/key_io_valid.json(70 条)与 src/test/data/key_io_invalid.json(70 条),恰好对应 README 中命令示例里 70 这个参数。

二、快速上手:重新生成向量

README 给出的两条命令(在 contrib/testgen/ 目录下执行,或直接在脚本所在目录运行):

./gen_key_io_test_vectors.py valid 70 > ../../src/test/data/key_io_valid.json
./gen_key_io_test_vectors.py invalid 70 > ../../src/test/data/key_io_invalid.json

参数含义(来自 脚本入口 的解析逻辑):

位置 取值 说明
第 1 个参数 valid / invalid,省略时默认 valid 选择生成器:gen_valid_vectorsgen_invalid_vectors
第 2 个参数 整数,如 70,省略时为 0 通过 itertools.islice 截断的向量条数;0 表示不截断(合法向量是无限生成器,实际必须给个数)
标准输出 JSON json.dump(data, sys.stdout, sort_keys=True, indent=4) 输出,因此用重定向写入目标文件

确定性是这套工具最重要的工程属性:入口显式执行 random.seed(42)L252),所以同一版本脚本 + 同一参数在任意机器上产出的字节序列完全一致。这也意味着脚本或模板发生任何改动后,重新生成的 JSON 都会与仓库中已检入的文件产生 diff——这正是 Bitcoin Core 工作流中"scripted-diff"(README 开头所指的 "To use inside a scripted-diff")的用法:把向量重生成作为代码变更的一部分提交,让评审者能直接看到新向量。

三、向量 JSON 的数据结构

3.1 合法向量

每条合法向量是一个三元素数组:[编码字符串, 载荷十六进制, 元数据对象]。从 key_io_valid.json 中摘录一个真实样本:

[
    "1FsSia9rv4NeEwvJ2GvXrX7LyxYspbN2mo",
    "76a914a31c06bd463e3923bc1aadbde48b16976c08071788ac",
    {
        "chain": "main",
        "isPrivkey": false
    }
]
  • 第 0 个元素:Base58Check 地址(此处是主网 P2PKH 地址);
  • 第 1 个元素:C++ 侧期望解析出的完整脚本十六进制——76a914…88ac 正是 OP_DUP OP_HASH160 <20字节> OP_EQUALVERIFY OP_CHECKSIG 的序列化,这与脚本里 pubkey_prefix = (OP_DUP, OP_HASH160, 20)pubkey_suffix = (OP_EQUALVERIFY, OP_CHECKSIG) 的定义一一对应(L32-L35);
  • 第 2 个元素:元数据,键集合由 metadata_keys 固定为 ['isPrivkey', 'chain', 'isCompressed', 'tryCaseFlip'],生成时只保留非 None 的键(L156)。

元数据语义(在 C++ 端 key_io_tests.cpp 消费):

  • isPrivkey:区分 WIF 私钥与公钥地址,决定走 DecodeSecret 还是 DecodeDestination 断言分支;
  • chain:取值 main / testnet4 / signet / regtest,C++ 端用 ChainTypeFromString 解析后调用 SelectParams 切换链参数再校验(私钥与地址的前缀字节是链相关的);
  • isCompressed:仅私钥向量携带,断言 privkey.IsCompressed() 与之相符;
  • tryCaseFlip:bech32 向量携带 true,要求 C++ 端把字符串大小写翻转后再次解码并断言其有效性(见 L62-L75)。

当前检入的 70 条合法向量在四条链上的分布为:main 18 条、testnet4 18 条、signet 18 条、regtest 16 条。

3.2 非法向量

非法向量更简单:["编码字符串"] 单元素数组即可(测试代码注释说明允许额外元素用于注释,L132)。key_io_invalid.json 开头是两条手工边缘用例空串 "" 和单字符 "x"(对应 gen_invalid_vectors 中的两个 yield),其后是随机生成的损坏样本,例如 1GAdfviErV2Ew95FPtZyikz2qGP3gyCB6Hyu94sedAkPpA523m3fQwps9YKUZkKgQckGPKhRsFR(长度超长的 Base58 串)。

四、合法向量的生成原理

4.1 Base58Check 模板表

templates 是一张 16 行模板表,每行形如 (前缀字节, 载荷长度, 后缀字节, 元数据, 期望脚本前缀, 期望脚本后缀)。前缀字节直接来自脚本顶部的版本常量(L20-L29):

版本字节 类型 出现链
0 主网 P2PKH 公钥地址 main
5 主网 P2SH 脚本地址 main
111 测试网/Signet/Regtest P2PKH test、signet、regtest
196 测试网/Signet/Regtest P2SH test、signet、regtest
128 主网 WIF 私钥(无压缩标记后缀) main
239 测试系 WIF 私钥 test、signet、regtest

注意主网私钥有两行模板:一行后缀为空(非压缩,元数据 isCompressed: False),一行后缀为 (1,)(压缩私钥末尾多一个 0x01 压缩标记,元数据 isCompressed: True)——这解释了 JSON 中主网私钥为何存在 64 字符(32 字节)与 65 字符(33 字节)两种长度。

4.2 Bech32 模板表

bech32_templates 按 HRP(人类可读前缀)×见证版本×程序长度组合出 15 行:

  • bc(主网):v0 P2WPKH(20 字节,BECH32)、v0 P2WSH(32 字节,BECH32)、v1 P2TR(32 字节,BECH32M)、v2 + 2 字节自定义程序(BECH32M);
  • tb(测试系):覆盖 v0/v1 及 v3 + 16 字节等组合;
  • bcrt(Regtest):覆盖 v0/v1 及 v16 + 40 字节这种极端长度组合。

v0/v1 分别用 BECH32 与 BECH32M 编码这一分界,正是 BIP-350 对 Taproot 引入的编码升级;生成时通过 bech32_encode(encoding, hrp, [witver] + convertbits(witprog, 8, 5)) 完成 8→5 bit 分组(gen_valid_bech32_vector)。

4.3 自校验闭环

gen_valid_vectors 是一个无限生成器:轮询所有模板产出向量后,先用脚本内置的 is_valid() 断言其确实合法(Base58 路径按"前缀/载荷长度/后缀"逐模板匹配,Bech32 路径调用 decode_segwit_address 试解 bc/tb/bcrt 三个 HRP)。这个"生成即验证"的闭环保证写进 JSON 的"合法向量"不会因模板改动而静默失效。

五、非法向量的"受控损坏"设计

非法向量生成器 gen_invalid_base58_vectorgen_invalid_bech32_vector 不是纯随机乱串,而是按概率对合法结构做单维度定点破坏,配合 bech32_ng_templates 中显式声明的结构性违例,确保覆盖面:

破坏维度 Base58 侧实现 概率/条件
前缀字节随机化 corrupt_prefix = randbool(0.2) 20%
载荷长度随机化 按指数分布取 max(int(random.expovariate(0.5)), 50) 字节 20%
后缀(脚本 opcode 序列)随机化 corrupt_suffix = randbool(0.2) 20%
行内字符损坏 10% 概率在串尾追加或中部替换一个 base58 字符 random.randint(0,10) < 1
校验和天然损坏 上述任一破坏都会使 base58_to_byte 的校验和断言失败 结构性

Bech32 侧则覆盖:no_data(空数据,10%)、大小写翻转 swapcase(10%,BIP-173 规定全大写须整体合法——翻转后校验失败)、非法 HRP(如 tcbt)、非法见证版本(1715)、违例程序长度(14116)、编码不匹配(v1 却用 BECH32,v0 却用 BECH32M)、以及模板显式指定的校验和位翻转(invalid_checksum)与非法字符替换(invalid_char,利用 CHARSET 替换实现)。

生成的每条候选都经过 is_valid(val) 过滤,只有确实无法被解码器接受的字符串才会进入 invalid JSONL246-L247)。这保证 negative 测试集里不存在"名义非法、实际合法"的脏数据。

六、C++ 端:数据驱动测试如何消费向量

6.1 三个测试用例

src/test/key_io_tests.cpp 定义了三个 Boost 用例,全部以 BasicTestingSetup 为 fixture:

  1. key_io_valid_parseL24-L82):按元数据中的 chain 调用 SelectParams,私钥走 DecodeSecret 并断言 IsValid、压缩标志、逐字节比对载荷;公钥走 DecodeDestination + GetScriptForDestination,断言脚本十六进制与向量一致;随后翻转大小写再解码,验证结果与 tryCaseFlip 相符;最后做交叉断言——私钥当地址解码必须失败、地址当私钥解码必须失败。
  2. key_io_valid_genL85-L119):方向相反的往返测试——从十六进制载荷构造 CKey/脚本,调用 EncodeSecret/EncodeDestination,断言输出字符串与向量的编码完全一致。parse 与 gen 双向合围,任何一端实现漂移都会暴露。
  3. key_io_invalidL123-L148):对每条非法字符串在 MAIN/TESTNET/SIGNET/REGTEST 四条链上分别调用 DecodeDestinationDecodeSecret,要求全部拒绝。

6.2 JSON 如何进入二进制

向量并非在运行时读文件,而是构建期嵌入:src/test/CMakeLists.txttarget_json_data_sources(test_bitcoin ... data/key_io_invalid.json data/key_io_valid.json ...) 将 JSON 编译为 C++ 字符串常量,测试代码通过 #include <test/data/key_io_valid.json.h> 拿到 json_tests::key_io_valid 再交给 read_json 解析。因此向量更新必须"重新生成 + 重新构建"才能生效,也天然保证了测试数据与可执行文件版本严格一致。

七、实操注意事项

  • 命令的工作目录:README 中的 ../../src/test/data/... 假定你在 contrib/testgen/ 下执行;若改为从仓库根目录运行,则命令应写作 python3 contrib/testgen/gen_key_io_test_vectors.py valid 70 > src/test/data/key_io_valid.json
  • 条数要匹配:当前检入文件均为 70 条;若生成其他数量,C++ 测试仍可运行(它按实际长度遍历),但会偏离仓库当前状态。
  • 脚本与检入数据的历史差异:仓库现版脚本的 templates 中测试链元数据写的是 'test',而检入的 key_io_valid.json 里出现的键值是 "testnet4";C++ 端 chaintype.cpp 同时兼容 testnettestnet4 两种写法。可以推断检入向量由较早版本脚本生成,当前脚本重跑会产生元数据键值 diff,属于预期行为而非错误。
  • 依赖关系:脚本头部通过 sys.path.append(... '../../test/functional') 复用 test_framework 中的 addresssegwit_addrscript 模块(L14-L18),因此它必须在完整仓库树内运行,单独拷贝脚本会因缺少这些模块而失败。

八、小结

TestGen 展示了数据驱动测试的完整闭环:模板表把"何为合法"形式化(前缀/长度/后缀/HRP/见证版本/编码),受控损坏把"何为非法"穷举化,固定随机种子保证可复现,target_json_data_sources 把数据编译期固化进测试二进制,parse/gen 双向用例再锁死实现与数据的一致性。当你在 src/key_io.cppsrc/consensus/amount.h 相关编码路径上做了改动,重新运行 contrib/testgen/README.md 给出的两条命令、审阅向量 diff、再跑 test_bitcoin 中的 key_io 用例,就是标准的验证流程。

登录后查看全文
热门项目推荐
相关项目推荐