
简介一款面向比特币开发者与链上数据分析者的 Python 工具用于从 Bitcoin Core 数据目录快速导出指定区块高度的 UTXO 快照。工具通过命令行参数接收 bitcoind 路径、数据目录与目标高度支持 reindex 和 verbose 模式覆盖主网与测试网场景代码包含 chainstate 状态解析、脚本/公钥处理、b128 变长编码等模块既可直接运行也便于二次开发。压缩包共 13 个文件以 8 个 Python 脚本为核心涉及命令行入口、UTXO 解析、比特币脚本处理等职责另有使用说明文档、依赖清单、CSV 辅助数据和 License 文件整体仅 344KB轻量紧凑。示例给出 2018 年不同高度如 511346、506265的导出命令可帮助用户快速重现历史时点的 UTXO 集合用于链上数据研究、钱包同步验证或教学实验等场景。目前已有 181 人学习是一份小巧实用、值得参考的比特币工具类资源。1. UTXO 快照不是数据目录备份utxo-dump 读的是链上状态做过系统快照比如在 vbox 里给虚拟机打快照的人第一次接触 UTXO 快照时很容易觉得它就是「把数据目录备份一份」。直到我对着用 utxo-dump 生成的快照文件做校验才发现这个直觉错得离谱节点数据目录里的 chainstate 表面上是一堆 LevelDB 文件实际上里面混着 WAL 日志、未刷盘的内存写缓存每条记录还被一层 XOR 混淆过直接拷贝出来的东西既不一致、也没法拿去做分析。utxo-dump 就是一个专门解决这个问题的实用程序。它直接以只读方式打开节点的 chainstate 数据库把链上所有未花费交易输出UTXO完整读出来整理成统一的文本或二进制格式落盘。落盘结果可以直接交给 Python 做余额分布统计、脚本类型分析和链上状态审计不需要把节点重新拉起来。适合手里有一份同步好的 Bitcoin 系节点数据、想把 UTXO 集变成可分析数据集的开发者。下面从 chainstate 的存储结构讲起落到编译、运行、解析和校验最后给一份踩坑清单。2. 快照的底层逻辑chainstate 里究竟存了什么2.1 链上状态的最小单位是「一笔未花费输出」Bitcoin Core 的 chainstate 数据库不是按「地址 → 余额」组织的而是按「币」组织的。一条 UTXO 记录的键是txid vout值是四个字段字段含义nValue这笔输出的面值单位是聪satoshiscriptPubKey锁定脚本决定了它属于哪类地址nHeight这笔输出所在的区块高度fCoinBase是否为 coinbase 输出影响花费成熟期等逻辑键的构造是 32 字节 txid 加一个 varint 编码的 vout 索引键名前缀是 0x63字符c表示这是一条 coin 记录。数据库里还有B、H、F、R等元数据键分别对应区块索引、高度映射、快照标志位和混淆密钥。其中R键最关键节点为了防止磁盘数据被直接扒走所有键值在读写前都会和一个 32 字节的随机密钥做 XOR。utxo-dump 要做的第一件事就是读出这把密钥把后续每一条记录还原成明文。所以「把 chainstate 拷贝出来」这件事想简单了必然翻车拷贝出来的值全是混淆过的用strings看就是乱码节点运行中时 LevelDB 的 WAL 和内存表里还压着没落盘的数据拷贝出来的文件本身处于不一致状态。这就是为什么快照必须用工具按格式读而不是用文件拷贝。2.2 两种快照格式给机器看的和给人看的utxo-dump 一般支持两种输出。文本格式--format text每行一条 UTXO五个字段用冒号分隔高度:txid:vout:金额(聪):scriptPubKey(hex)典型的一行长这样800000:02944b5c8f5f16728c4e1f37781d70b60c9a0d8a7e1c3e2b9f0a1b2c3d4e5f60:0:546154:76a9148f6d4c8e9a3b5c2d1e0f4a6b7c8d9e0f1a2b3c4d88ac二进制格式--format raw和节点 RPCdumptxoutset的输出兼容文件头长这样字段字节数说明network magic4主网f9beb4d9测试网各不相同version4当前快照格式版本取 1height4快照对应的区块高度block hash32该高度的区块哈希coins count8UTXO 总数每条记录依次是txid32 字节→ voutvarint→ height*2 coinbase 标志varint→ 金额8 字节小端→ 脚本长度加内容varint 加定长字节。注意这里的 varint 不是普通大端数字而是 CompactSize 编码后面避坑章节会专门讲它的坑。2.3 为什么「停节点拷贝」依然不等于快照有人会问那我先停节点再整个目录复制行不行文件系统层面是一致的但还有三个现实问题。第一chainstate 是 LevelDB光复制目录而不带上数据库版本信息换一台机器、换一个节点版本未必打得开。第二拷贝出来的每个值仍然是 XOR 混淆过的分析脚本没法直接用。第三拷贝没有给你一个区块高度锚点你根本不知道这份数据对应链上哪个位置后续做审计没法对齐。常见做法是二选一用节点自带 RPCdumptxoutset或者用 utxo-dump 这类工具直读。前者输出二进制快照适合拿去loadtxoutset做 assumeutxo 加速同步后者输出文本格式适合做链上数据分析和审计。如果你的目标是后者utxo-dump 更顺手它不要求节点在线输出也更好对接 Python 生态。3. 用 utxo-dump 生成一份快照编译、参数与输出解读3.1 环境准备先确认 chainstate 是「干净」的动手之前先确认两件事。第一手上有同步完成的节点数据主网默认在~/.bitcoin/chainstate测试网在~/.bitcoin/testnet3/chainstate或testnet4/chainstate第二节点处于停止状态或者你复制了一份 chainstate 作为输入。utxo-dump 是直接以只读方式打开 LevelDB 的节点运行中虽然也能读但读到的是遍历那一瞬间的数据库状态高度和余额之间未必对得上。我把节点数据单独放一个目录快照输入输出分开避免把 chainstate 和快照文件混在一起。资源包里带了构建脚本依赖主要是g、make和 LevelDB 开发头文件Debian/Ubuntu 上常见做法是用apt install libleveldb-dev装依赖。3.2 编译与运行五个参数一次讲透cd utxo-dump make -j4 ./utxo-dump \ --chain ~/.bitcoin/chainstate \ --output /tmp/utxo_snapshot.txt \ --height 0 \ --format text \ --progress 1000000make -j4是并行编译核数多可以调大。运行命令里五个参数的作用参数取值示例作用--chain指向 chainstate 目录输入路径必须存在且有读权限--output/tmp/utxo_snapshot.txt输出文件不传则打到 stdout--height0表示当前最佳高度指定快照对应到哪个高度--formattext/rawtext 人类可读raw 兼容 dumptxoutset--progress1000000每处理 100 万条打印一行进度--height 0是我最常用的写法直接取区块索引里记录的最佳高度。如果你要的是某个历史高度的状态就得确保节点已经用-stopatheight之类的方式停在那个高度否则 chainstate 里根本没有历史状态可选这个后面避坑章节会展开。跑完之后重点核对三样东西日志里打印的 UTXO 总数、文件的实际行数、以及最后一行的区块高度。行数和总数对不上说明输出被中断过这种文件不要用。3.3 输出解读一行记录里的五个字段用tail看输出文件以刚才那行为例800000:02944b5c8f5f16728c4e1f37781d70b60c9a0d8a7e1c3e2b9f0a1b2c3d4e5f60:0:546154:76a9148f6d4c8e9a3b5c2d1e0f4a6b7c8d9e0f1a2b3c4d88ac从左到右800000是这笔输出所在的区块高度02944b...是 64 位十六进制的交易哈希0是 vout 索引546154是金额单位是聪换算成 BTC 要除以 10^8最后一段76a914...88ac是锁定脚本的十六进制这里的76 a9 14开头和88 ac结尾是标准 P2PKH 锁定脚本的特征。字段之间用冒号分隔行尾是\n解析时按行切分最稳妥。提示文本格式为了可读性牺牲了体积一个主网全量快照可能有数 GB。追求加载速度或磁盘占用用--format raw更合适。4. Python 解析快照生成器、哈希校验与脚本类型统计4.1 最小解析器把快照变成可迭代对象拿到文本快照后最常见的需求是「按条件过滤 统计」。我不建议一次性read()把几 GB 的文件全部装进内存那样很快会 OOM。正确做法是写成生成器逐行处理内存占用恒定def parse_snapshot(path: str): 逐行解析 utxo-dump 文本快照产出 (height, txid, vout, amount, script) with open(path, r, encodingutf-8) as f: for line in f: line line.rstrip(\n) if not line: continue height, txid, vout, amount, script line.split(:) yield int(height), txid, int(vout), int(amount), bytes.fromhex(script)逻辑说明每行拆成五个字段bytes.fromhex把脚本还原成字节串后续判断脚本类型直接取首字节。这里的性能细节是只做rstrip(\n)而不是strip()因为冒号分隔的字段本身不会有多余空白strip()会复制字符串行数上亿时这个开销不可忽略。4.2 校验哈希快照有没有被写坏一算便知文本快照的完整性校验一般是对规范化后的文件内容做两次 SHA256再把摘要反序输出和生成方提供的哈希比对。规范化指的是行尾统一\n、字段顺序固定、金额用整数聪任何一处不一致算出来的哈希都不同。我一般用分段读取避免把文件整体载入内存import hashlib def snapshot_hash(path: str) - str: 对快照内容做双重 SHA256返回反序 hex first hashlib.sha256() with open(path, rb) as f: for chunk in iter(lambda: f.read(1024 * 1024), b): first.update(chunk) second hashlib.sha256(first.digest()).digest() return second[::-1].hex()逻辑说明双层哈希先对整个文件内容做一次 SHA256再对摘要做一次是比特币系工具最常用的哈希手法[::-1]反序是因为节点显示哈希时习惯用小端序。比对时两边必须用同一个规范化规则我的习惯是「谁生成快照谁就把规范写进旁边的 META 文件」不要假设对方默认和你一样。4.3 统计脚本脚本类型和余额分布一次跑出来有了生成器统计就很简单了。下面这段按脚本前缀分类输出 P2PKH / P2SH / P2WPKH / P2TR 的数量和金额合计from collections import Counter def analyze(path: str): counts Counter() amounts Counter() total 0 for height, txid, vout, amount, script in parse_snapshot(path): total 1 if script.startswith(b\x76\xa9): # 76 a9 14 20字节 88 ac kind P2PKH elif script.startswith(b\xa9): # a9 14 20字节 87 kind P2SH elif script[:2] b\x00\x14: # 00 14 20字节 kind P2WPKH elif script[:2] b\x51\x20: # 51 20 32字节 kind P2TR else: kind fother({script[0]:02x}) counts[kind] 1 amounts[kind] amount for kind in counts: sats amounts[kind] print(f{kind:12} 数量{counts[kind]:12} 金额{sats / 1e8:14.8f} BTC) print(f总计 {total} 条 UTXO)参数说明script[:2]取前两个字节判断类型P2WPKH 是00 14开头隔离见证版本 0 20 字节哈希P2TR 是51 20开头版本 1 32 字节哈希。注意script.startswith(b\x76\xa9)比只判断script[0] 0x76更稳可以过滤掉极少数以76 a9开头的自定义脚本。5. 避坑清单快照 UTXO 的五个常见翻车现场5.1 节点还在跑dump 出来的余额和高度对不上现象utxo-dump 跑完日志显示处理到高度 800123但用第 4 章的脚本统计总余额和区块浏览器给出的该高度数据差了几十上百个币。原因节点运行中chainstate 每时每刻都在被写。utxo-dump 以只读方式打开 LevelDB读到的是遍历那一瞬间的数据库状态先读的键和后读的键可能分属不同的数据库写入版本行数对得上、余额对不上。解决先停节点再 dump或者等节点完全退出后用cp -r复制一份 chainstate把--chain指向副本。想完全不停节点就用 RPCdumptxoutset它拿的是节点内部锁一致性有保证。5.2 解析出来的脚本全是乱码忘了 XOR 混淆现象脚本字段不是76a914...这种正常样子而是毫无规律的字节或者所有金额都大得离谱。原因chainstate LevelDB 里的值全部经过 XOR 混淆密钥存在R键下。utxo-dump 会自动处理这一步但如果输入路径指错了比如指到没挂载完整的旧目录或者你拿自己写的脚本直接去读 LevelDB就会拿到混淆前的原始字节。解决确认--chain指向的目录和节点版本匹配。如果自己实现 LevelDB 读取逻辑第一步永远是读R键拿到 32 字节密钥然后对每个键值做 XOR。在怀疑「节点版本太新、格式改了」之前先验证混淆这一步。5.3 varint 解析错位脚本类型统计全歪现象第 4 章的统计脚本跑出来P2SH 占比异常高或者出现大量other(00)。原因快照里凡是长度、数量字段用的都是 CompactSize varint 编码。规则是值小于 0xfd 用 1 字节0xfd 开头后面跟 2 字节0xfe 开头跟 4 字节0xff 开头跟 8 字节。如果固定按 1 字节读长度遇到超过 252 字节的长脚本就会错位后面的字段全部读歪。解决自己解析二进制快照时写一个read_compact_size函数按上面四条分支读取。验证方法很直接先解出第一条记录的 scriptPubKey和一个已知地址或交易对一下或者用文本格式在同一高度交叉验证。5.4 哈希对不上不是文件坏了是两边规范不一致现象生成方提供的校验哈希和本地snapshot_hash()算出来的永远不一致。原因最常见的三个差异是行尾\nvs\r\n、金额单位聪 vs BTC、脚本 hex 大小写。任何一处不一致SHA256 结果就完全不同而且这种错误没有任何提示只能靠排除法定位。解决约定统一规范文本快照一律\n结尾、金额用整数聪、hex 用小写并把规范写进快照旁的 META 文件。校验前先做一次行尾和大小写的归一化再算哈希。5.5 磁盘写满留残卷第二次跑总在同一个地方挂现象dump 到一半磁盘满了进程退出清掉空间再跑新文件还是很快报错或者解析时文件尾部全是半截数据。原因工具直接把内容往目标文件写中途挂了就留一个残缺文件。第二次运行还在写同一路径时残留状态会干扰新任务即使换了路径解析脚本也没法分辨文件是否完整照样拿残缺数据往下算。解决工具支持临时文件的话强烈建议--output指到.tmp后缀路径跑完用mv原子改名。跑完第一步永远是比对「头部声明的 UTXO 总数」和「实际行数」对不上直接删了重来不要抱着侥幸心理继续分析。6. 进阶把快照变成可查询数据集的流式处理技巧6.1 流式过滤不要为了查一个条件加载整个快照def filter_by_script_prefix(path: str, prefix: bytes): 按脚本前缀过滤例如只留 P2TR: prefixb\x51\x20 for height, txid, vout, amount, script in parse_snapshot(path): if script.startswith(prefix): yield height, txid, vout, amount, script逻辑说明生成器链式处理内存占用恒定为单条记录大小。我一般在外层再套一个进度回调每处理 500 万条打一行日志长任务跑起来心里有数。parse_snapshot是生成器filter_by_script_prefix也是生成器两层都不会把数据一次性加载进内存。6.2 大批量分析先导入 SQLite 再做聚合如果要做多条件聚合按高度段 脚本类型 金额区间组合查询Python 里的Counter就不够用了。我的习惯是把快照导入 SQLite用 SQL 做聚合导入同样走流式import sqlite3 def import_to_sqlite(path: str, db_path: str): con sqlite3.connect(db_path) con.execute(CREATE TABLE IF NOT EXISTS utxo (height INTEGER, txid TEXT, vout INTEGER, amount INTEGER, script BLOB)) con.execute(BEGIN) batch [] for height, txid, vout, amount, script in parse_snapshot(path): batch.append((height, txid, vout, amount, sqlite3.Binary(script))) if len(batch) 100_000: con.executemany(INSERT INTO utxo VALUES (?, ?, ?, ?, ?), batch) batch.clear() if batch: con.executemany(INSERT INTO utxo VALUES (?, ?, ?, ?, ?), batch) con.commit() con.execute(CREATE INDEX idx_utxo_height ON utxo(height)) con.commit() con.close()参数说明BEGIN显式开启事务10 万条一批提交比逐条自动提交快两个数量级最后按height建索引后续「查某个高度段的余额分布」就是索引扫描。脚本存 BLOB 而不是 hex 文本查询时在 SQL 里用substr(script, 1, 2)判断类型性能比 Python 侧过滤高得多。这份资源里通常带编译好的工具、示例快照和一个能直接跑的 Python 解析脚本下载后按第 3 章的流程对着跑一遍十分钟内应该能出第一份统计结果。从那以后我每次跑 utxo-dump 都强制走一遍五步流程停节点 → 确认高度 → dump 到临时文件 → 核验总数和哈希 → rename。中间任何一步失败就从头来绝不抱着「数据差不多能用」的心态往下走——UTXO 快照这种一次几 GB 的东西错了不是重跑一遍那么简单而是你后面所有分析结论全部作废。希望这套流程和上面的解析脚本能帮到你。本文还有配套的精品资源点击获取