1. 项目缘起:为什么折腾一个链上地址扫描工具
大概两个月前,我在做链上数据分析的时候,发现了一件非常头疼的事:遇到一个不认识的地址,想快速搞清楚它到底活跃在哪条链、跟哪些地址打交道多、交易习惯是什么,常规手段要么打开商业浏览器一个一个点,要么写一次性脚本去节点同步数据,前者费手,后者费时间。来回折腾几次后,我决定自己写一个轻量级的扫描工具,代号就叫BSCAN,全称是Blockchain Scan,目标是做一个能通过地址反查交易记录、归纳交互行为、生成基础画像的命令行工具。
这个系列的第一篇,我先把项目的地基讲清楚:为什么做、技术选型怎么定的、目录结构怎么规划、核心扫描流程怎么一步步实现,以及在开发初期踩过的一些坑。适合正在入门链上数据分析、想自己搭一套本地地址扫描方案的开发者参考,也适合对区块链数据底层结构好奇的产品和技术朋友围观。
先说结论:BSCAN不是一个想要替代商业浏览器的重工程,它的定位更接近一个个人工具箱——你给它一个地址,它从本地节点同步数据,把该地址的交易记录、交互对手方、代币流动情况整理成结构化结果,输出到SQLite数据库里供后续分析。整个项目用Python实现,代码总量控制在两千行内,单机就能跑。
为什么不做成Web服务?因为在项目初期,核心矛盾是“快速验证扫描逻辑”而不是“对外提供服务”。Web化意味着要处理接口鉴权、并发请求、前端展示,这些都会挤占核心开发时间。先把CLI版本跑通,后面有需要再加API层,这个节奏我试下来比较舒服。
还有一个考虑是数据自主权。依赖第三方API虽然省事,但请求配额、响应延迟、字段口径都不受控,遇到深度扫描动不动要拉几万条记录时,体验会很差。本地同步节点数据虽然前期搭建成本高一些,但一旦跑起来,查询和分析都随你折腾,没有配额烦恼。
2. 整体架构与技术选型,为什么用这套组合
2.1 语言为什么选Python而不是Node或者Go
选语言这件事,我前后纠结了两天。Go在处理并发和二进制分发上有天然优势,Node在异步IO上也不差,但最终我还是选了Python,理由非常朴素:项目里数据处理和格式化的活占了大头,Python的交互式开发体验和数据生态最适合这种快速迭代的场景。
链上地址扫描的核心工作其实是“清洗数据”:从原始区块数据里把转账记录抠出来,把事件日志解码成可读字段,再聚合统计。这些操作在Python里写起来非常顺手,尤其是配合类型注解和pydantic做数据模型验证,能省掉大量手工检查的麻烦。另外,后续如果要做统计分析,pandas、numpy直接就能接上,不用跨语言传递数据。
有人可能担心Python性能不够用。我的实测结论是:在处理单地址扫描的场景下,瓶颈几乎都在节点同步和磁盘IO上,Python本身的解析开销占比不高。只有当扫描地址量达到上万个、需要全量重建索引时才有必要考虑性能优化,那时候再引入并行或多进程也不迟。
2.2 数据获取:节点RPC直连,不折腾第三方聚合API
数据获取是BSCAN最底层的模块。我一开始想过用现成的区块浏览器API,这样可以跳过同步节点的过程。但实际调研后发现,几个主流方案都有绕不开的问题:有的限流严格,单IP每秒只能请求几次,深度扫描一个热门地址可能要跑几小时;有的字段格式不统一,同一个合约调用在不同浏览器上返回的解析结果差异很大,增加了兼容成本。
最终我选择直接对接链上节点的JSON-RPC接口。项目初期我接了一个本地同步节点,用官方客户端跑在测试网上,区块数据实时同步到本地磁盘。之所以用本地节点而不是公共RPC服务,是因为扫描过程需要反复回溯历史区块——公共节点通常只允许查最近几百个区块的高度范围,这满足不了深度回溯的需求。
本地节点一次同步完成后,后续所有扫描都走本地RPC,速度非常稳定。代码层面用httpx库发POST请求调用eth_getBlockByNumber、eth_getTransactionReceipt等标准接口,接口地址配置在环境变量里,方便切换主网和测试网。
2.3 存储设计:SQLite起步,别急着上重型数据库
很多朋友一上来就规划PostgreSQL加Redis,我觉得对于这种个人项目属于过度设计。BSCAN的第一版存储直接用SQLite,理由有三:一是单文件部署方便,整个数据库几十兆,拷贝到哪都能用;二是事务能力够用,扫描时批量写入、一致性要求不高;三是后续如果要迁移到PostgreSQL,SQL语句基本不用改,SQLAlchemy的ORM层已经把差异屏蔽了。
当然SQLite也有它的边界。如果后续地址扫描量达到百万级,并发读写增多,SQLite的单写多读限制就会成为瓶颈。我的规划是:当单表记录超过五百万条时,再考虑迁移到PostgreSQL,同时把历史归档和热数据分离。这个切换动作在设计表结构时就预留了接口,字段类型尽量不用SQLite私有特性,确保迁移顺畅。
数据库表结构我设计了四张核心表:addresses存地址的基础标注信息,transactions存交易哈希、区块高度和时间戳,interactions存地址间的转账关系,token_transfers存代币转移事件。每张表都建了必要的索引,特别是地址和区块高度的组合索引,查询速度直接影响扫描效率。
2.4 项目目录结构与模块划分
目录结构我用一个比较常规的分层方式,核心目标是让新人拿到代码后能快速定位每个功能的位置:
bscan/ ├── cli.py # 命令行入口,参数解析 ├── config.py # 全局配置,读环境变量 ├── models.py # 数据模型定义 ├── db.py # 数据库连接与初始化 ├── scanner/ │ ├── __init__.py │ ├── block.py # 区块同步与高度管理 │ ├── transaction.py # 交易解析与转账关系提取 │ └── token.py # 代币事件解析 ├── analyzer/ │ ├── __init__.py │ ├── profile.py # 地址画像生成 │ └── report.py # 分析结果格式化输出 └── utils/ ├── __init__.py └── rpc.py # RPC请求封装与重试逻辑这样拆分的思路是:scanner负责从链上取数和基础解析,analyzer负责在已经入库的数据上做统计,utils提供底层网络能力。上下层之间的数据交互全部通过models.py里定义的数据模型完成,避免彼此内部字段的耦合。
CLI部分用argparse做参数入口,支持三个子命令:init用于初始化数据库,scan用于执行地址扫描,report用于生成分析报告。整个过程不依赖任何Web框架,跑起来非常清爽。
3. 核心功能实操:从零搭出第一个可用的扫描流程
3.1 环境准备与关键依赖
开发环境我用Python 3.11,操作系统是常规的Linux服务器,配置了4核8G内存和500G磁盘,其中大约一半用于节点数据。依赖库非常克制,一共四个:
pip install httpx web3 sqlalchemy python-dotenv这里说明一下选型原因:httpx负责RPC请求,支持异步和连接池重试;web3库其实是可选的,用它主要是方便做事件日志解码,如果用原生RPC解析会比较繁琐;sqlalchemy作为ORM层管理数据库操作;python-dotenv管理环境变量。
我不建议在这个阶段引入celery、redis这类重量级组件。地址扫描的耗时主要花在网络请求和数据写入上,用一个简单的任务队列——甚至直接同步for循环——就足够了。前期多做减法,后面才有空间做加法。
3.2 连接本地节点,搞定区块高度同步
区块高度同步是扫描的地基。首先要确认本地节点已经完成了初始同步,这个状态可以通过RPC调用web3_clientVersion接口来判断,返回版本信息说明节点服务正常。然后再调用eth_blockNumber拿到当前最新高度。
我封装了一个BlockManager类,核心逻辑是:启动时读取数据库里记录的最新已处理区块高度,如果数据库为空,则从配置指定的起始高度开始;之后周期性轮询节点最新高度,发现新高后按顺序逐块处理。这里有个重要的点:节点同步是异步的,本地磁盘的区块数据可能落后于网络实际状态,所以扫描绝对高度要以本地节点返回的最新高度为准,不要额外依赖外部API。
import httpx import asyncio from time import sleep class BlockManager: def __init__(self, rpc_url, db_session): self.rpc_url = rpc_url self.db = db_session self.current_height = self._load_processed_height() def _rpc_call(self, method, params): payload = { "jsonrpc": "2.0", "method": method, "params": params, "id": 1 } resp = httpx.post(self.rpc_url, json=payload, timeout=10) resp.raise_for_status() return resp.json()["result"] def latest_height(self) -> int: return int(self._rpc_call("eth_blockNumber", []), 16) def sync_once(self, batch_size=100) -> int: target = self.latest_height() processed = 0 while self.current_height < target: end = min(self.current_height + batch_size, target) for height in range(self.current_height + 1, end + 1): self._process_block(height) self.current_height = end processed += end - self.current_height sleep(0.2) return processed def _load_processed_height(self) -> int: # 从数据库读取,逻辑略 return 0这里batch_size我默认设成100,单位是区块高度。需要注意,不同链的单块交易量差异很大——有的链平均每块只有几十笔交易,有的热门链高峰期单块能到几百笔。如果发现同步速度过慢,可以动态调低batch_size,让每一轮的RPC请求数和内存占用更平滑。
3.3 解析转账事件,建立地址映射关系
区块链地址扫描的核心不是拿到交易哈希列表,而是从交易里解析出“谁给谁转了多少钱”。这一步需要拆开区块数据,看每一笔交易内部的Transfer记录。
对于主链币的转账,需从区块内的transaction对象里读取from、to和value字段。value是以Wei为单位的大整数,显示时需要除以10的18次方。对于代币转账,要解码交易receipt的logs,根据事件签名的topic定位Transfer事件,再解析其中的from、to和value。
我在transaction.py里写了两个解析函数,一个处理主链币,一个处理代币。主链币的处理比较直接,代币的逻辑需要额外做ABI解码,这里用了web3库的decode_event_log方法简化操作:
def parse_native_transfer(tx) -> NativeTransfer: return NativeTransfer( tx_hash=tx["hash"], block_height=int(tx["blockNumber"], 16), from_addr=tx["from"], to_addr=tx["to"], value=int(tx["value"], 16), timestamp=tx.get("timestamp") ) def parse_token_transfer(receipt, token_address) -> TokenTransfer: for log in receipt["logs"]: if log["address"].lower() != token_address.lower(): continue topics = log["topics"] if len(topics) < 3: continue from_addr = "0x" + topics[1][-40:] to_addr = "0x" + topics[2][-40:] value = int(log["data"], 16) return TokenTransfer( tx_hash=receipt["transactionHash"], token_address=token_address, from_addr=from_addr, to_addr=to_addr, value=value ) return None这里有一个新手容易忽略的细节:topics数组里的第一个元素是事件签名哈希,后面才是索引参数。Transfer事件的topic0固定是0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。判断一个日志是否为转账事件时,除了检查topics长度,还要确认topic0匹配,否则会把其他事件当转账解析进来。
解析出来的结构体统一落库到interactions表。每次写入前用交易哈希做去重:以tx_hash加from_addr加to_addr为唯一键,防止同一笔交易在区块回滚后被重复处理。
3.4 生成一张基础地址画像
当扫描完成一批交易后,analyzer模块会根据interactions表的数据为指定地址生成画像。画像包含五个维度:累计转账次数、累计转账金额、活跃日期跨度、交互对手方Top名单、活跃时间段的粗略分布。
画像的目的是快速回答三个问题:这个地址是不是高频交易者?它的资金主要流向哪里?它的操作习惯是否符合某个模式。这三个问题的答案能帮助我初步判断地址属性——是普通用户、交易所归集地址,还是自动化机器人。
生成画像的SQL用GROUP BY和ORDER BY组合,逻辑集中在profile.py里,下面是核心查询逻辑的示意:
def build_profile(session, address): # 统计转账次数与总额 stats = session.execute( text(""" SELECT COUNT(*) AS cnt, SUM(value) AS total_value FROM interactions WHERE from_addr = :addr OR to_addr = :addr """), {"addr": address.lower()} ).fetchone() # 统计对手方Top10 counterparties = session.execute( text(""" SELECT counterparty, SUM(cnt) AS total FROM ( SELECT to_addr AS counterparty, COUNT(*) AS cnt FROM interactions WHERE from_addr = :addr UNION ALL SELECT from_addr AS counterparty, COUNT(*) AS cnt FROM interactions WHERE to_addr = :addr ) GROUP BY counterparty ORDER BY total DESC LIMIT 10 """), {"addr": address.lower()} ).fetchall() return Profile(address=address, stats=stats, counterparties=counterparties)实测下来,一个中等活跃度的地址,扫描5000个区块的数据,从同步到生成画像大约需要两分钟。时间主要花在RPC请求上,本地节点响应速度在5毫秒以内,但几千个请求累积起来还是有可见的延迟。
3.5 参数调优与性能取舍
初版跑通后,我做了几个关键参数的调整:一是RPC请求的超时从默认的30秒改为10秒,配合自动重试避免某个请求卡住拖死整个任务;二是批量写入从单条insert改成bulk_save_objects,每次攒够200条再提交;三是同步轮询间隔从0.5秒改成0.2秒,测试网响应快,可以适当激进一点。
这几个调整下来,扫描同样规模的区块,耗时从原来的三分钟缩短到两分以内。我不是特别追求极致的性能,毕竟目标是个人工具,稳定和可读性优先级更高。后续如果要大幅提速,方向是把RPC请求改成异步并发,但异步带来的复杂度提升会让这个项目的入门成本变高,我暂时不碰。
4. 开发中踩过的坑,以及排查实录
4.1 RPC限流和数据一致性
开发中最先遇到的问题就是RPC限流。本地节点虽然没有严格的IP限制,但大流量下的响应延迟会明显变高,偶尔直接返回空结果。我最开始没有处理这种情况,结果扫描任务跑到一半就停了下来,数据库里留下了一半数据。
解决办法是给RPC请求加上超时重试和退避机制。具体实现是:接收侧如果连续三次无响应,任务主动暂停15秒再继续,避免把本地节点拖崩。同时在请求头里加了一个自定义标记字段,方便在节点日志里追踪。
数据一致性方面,初始设计里我没有考虑区块回滚的情况。某次测试网操作后,我刷新了数据库,发现同一笔交易出现在了两个地址的交互记录里,排查后确认是节点在同步时先给了一个未完全确认的区块,后续回滚后又重新广播了同一笔交易。解决方式是在扫描前做一个区块确认检查:只有当区块高度落后于最新高度超过6个确认数时才处理,同时为interactions表加唯一索引兜底。
4.2 大区块数据处理异常的修复过程
有一次扫描时,任务停在某个区块高度不动了,日志里疯狂打印超时异常。我手动用RPC调了一下那个区块的高度,发现该区块里有超过300笔交易,单块的数据量是平均水平的五倍以上。我的同步逻辑是逐笔解析入库,300笔交易串行处理,每笔交易还要查询一次receipt,累积下来耗时接近一分钟。
优化思路是加缓存:同一区块内的交易receipt一次性批量获取,避免逐笔请求。不过现实的限制是RPC接口不支持批量返回receipt,我只能在应用层做并发,用asyncio.gather并发请求多个receipt,限制最大并发数为10。修改后该区块的处理时间降到十秒以内,整个扫描过程又顺畅了。
这里我学到一个经验:链上数据的分布极不均匀,单个区块的数据量可能有数量级的波动,代码设计时不能只看平均值,要给极端情况留缓冲。
4.3 地址大小写与校验问题
地址处理也踩了一个不大不小的坑。链上地址在原始数据里是带大小写的,EIP-55格式时同一地址的大小写变体会被视觉识别为不同地址。我在做地址关联统计时,没有先做lower()处理,导致同一地址被拆成了两个实体,画像数据直接失真。
修复方案是所有地址入库前统一转小写,对外展示时再按EIP-55格式校验还原。这个改动涉及模型层和查询层的所有字段,虽然改动面大,但一次性解决后就没有再出现过类似问题。
4.4 常见问题速查表
我整理了一份高频问题的排查对照表,给遇到类似问题的朋友做参考:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| RPC请求超时 | 本地节点负载过高 | 加退避重试,降低batch_size |
| 同一交易入库两次 | 区块回滚后重复广播 | 唯一索引约束,检查确认高度 |
| 转账金额显示为超大数 | value未从最小单位换算 | 除以对应精度,主链币除1e18 |
| 地址关联统计失真 | 大小写未统一 | 统一lower后再入库 |
| 代币事件解析为空 | topic0不匹配 | 核对事件签名哈希 |
| 数据库体积膨胀过快 | 全量存了原始日志 | 只存解析后的结构化记录 |
4.5 关于RPC使用的一个额外提醒
这里要特别提醒一点:扫描过程中如果直接使用公共RPC服务,需要在代码里体面地控制请求频率,避免给对方服务造成压力。我之前测试过某些公共节点,请求频率稍高就会被临时封禁,封禁时间从几分钟到几小时不等。自建本地节点虽然前期成本高,但从长期使用体验来说更可控,也不用担心被封禁影响任务进度。
5. 写在BSCAN系列的开头:目前进展与后续方向
目前BSCAN第一版已经能完成单地址的基础扫描和画像生成,代码量在1800行左右,核心功能稳定可用。整个开发过程大概用了两周左右——前三天在做技术选型和数据结构设计,中间一周集中写完采集和解析逻辑,最后三天用来调性能和处理边界情况。
我自己在测试中比较满意的一个场景是:给一个看起来平平无奇的测试网地址,扫描它最近三万条交易记录,BSCAN能快速告诉我它的交互对手集中在哪些地址,主要活跃时间段分布在几点,是否出现了零转账的异常模式。这些信息对于判断地址是否自动化操作很有帮助。不过必须说明,我目前的分析只是针对公开链上数据的技术性观察,不涉及对任何地址背后主体的身份判断。
后续的系列规划大致是这样:第二篇重点写代币事件解析和合约调用追踪,第三篇写地址聚类和标签体系的建设,第四篇如果时间允许,会整理一个批量扫描多个地址并输出对比报告的功能。到时候也会把代码完整整理成开源形态发布,方便有同样需求的朋友直接拿去用。
BSCAN目前还需要持续打磨的短板是底层的数据清洗逻辑对不同链的兼容性——现在主要支持的是EVM系列的公链和测试网,非EVM链的数据格式差异较大,只能后续单独适配。我个人的计划是等EVM侧的功能稳定后,再考虑扩展支持其他类型的链。
这个项目从想法到落地,最大的体会是:链上数据分析的门槛比想象中低,主要工作在于定义清楚自己的分析目标,以及构建一套可靠的本地数据管道。扫描工具本身不是目的,能够持续产出有价值的链上行为洞察才是做这个项目的真正动力。下一篇再见。