# Blockchain scanner **Repository Path**: web/blockchain-scanner ## Basic Information - **Project Name**: Blockchain scanner - **Description**: No description available - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-08 - **Last Updated**: 2026-09-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 区块链扫描器(blockchain-scanner Node) 多链多合约 ERC20 / ERC721 / ERC1155 事件监听工具,支持 **SQLite 持久化**、**推送失败自动重试**、**交易确认机制**、**进程重启断点续扫**。 ## 功能特性 - **多链支持**:BSC 主网/测试网、Sepolia(以太坊测试网) - **多合约扫描**:每个网络可配置任意数量的合约,**每个合约独立进度** - **多代币标准**:ERC20 Transfer、ERC721 Transfer、ERC1155 TransferSingle/TransferBatch,也支持自定义事件 ABI - **合约级独立配置**:每个合约可单独设置 **起始块**、**交易确认数**、**事件类型** - **交易确认机制**:全局默认 + 合约级覆盖,防止孤儿块导致误推送 - **增量扫块**:基于区块高度增量扫描,单次扫描范围可配置(`scan_block_range`) - **节点探测工具**:独立脚本实时探测节点可用性,生成可用节点列表 - **节点容错**:每条链内置多个公共 RPC 节点,故障自动切换重建,临时拉黑低质节点 - **SQLite 持久化**:扫描进度、事件记录、推送状态全部存本地,**重启断点续扫** - **推送重试**:推送失败自动重试最多 N 次(可配),超过则放弃 - **数据上限**:自动清理超量旧数据,数据库体积可控 - **服务端推送**:扫描到事件后 POST JSON 到配置的服务端接口 - **零外部依赖**:SQLite 单文件 ## 项目结构 ``` blockchain-scanner/ ├── app.js # 入口文件 ├── config.json # 运行时配置 ├── package.json # 依赖(仅 web3 + axios + better-sqlite3) ├── README.md ├── data/ │ ├── scanner.db # SQLite 数据库文件(自动创建) │ └── nodes.json # 探测工具生成的可用节点列表(自动生成) └── src/ ├── scanner.js # 扫描器核心 ├── nodes.js # RPC 节点探测工具(独立脚本) └── db.js # SQLite 封装 ``` ## 环境要求 - Node.js >= 14 ## 快速开始 ```bash # 1. 安装依赖 npm install # 2. (推荐)探测当前可用节点,生成 data/nodes.json node src/nodes.js # 探测全部链 node src/nodes.js bsc # 只探测 bsc node src/nodes.js bsc mainNet # 只探测 bsc mainNet # 3. 启动扫描器(自动优先加载 data/nodes.json,没有则用内置默认节点) node app.js # 或使用 forever 守护 npm start ``` 首次启动时 `data/scanner.db` 会自动创建,无需手动建库。 ## 配置说明 ### 完整配置示例 ```json { "server_url": "http://your-domain.com/api/scan", "sign_key": "your_secret_key", "scan_interval": 3, "scan_block_range": 50, "confirmations": 0, "db_path": "data/scanner.db", "max_rows": 10000, "retry_max": 3, "networks": [ { "name": "bsc_main", "chain": "bsc", "network": "mainNet", "contracts": [ {"address":"0x55d398******7955","type":"erc721"} {"address":"0x9A15D5****9f16","type":"erc721","confirmations": 6} ] }, { "name": "sepolia_test", "chain": "sepolia", "network": "testNet", "contracts": [ {"address":"0xF6bf3B****d97","type":"erc721","start_block": 11623300}, {"address":"0x...","type":"custom","events":[{"name":"Buy","abi":[{"anonymous":false,"inputs":[{"indexed":true,"name":"buyer","type":"address"}],"name":"Buy","type":"event"}]}]} ] } ] } ``` ### 字段说明 | 字段 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | `server_url` | string | 否 | - | 扫描结果提交地址,留空则仅打印日志 | | `sign_key` | string | 否 | - | 推送签名密钥,留空则不签名(服务端也不验签) | | `scan_interval` | number | 否 | 3 | 扫描间隔(秒) | | `scan_block_range` | number | 否 | 50 | 单次扫描最大区块范围,防节点超限 | | `confirmations` | number | 否 | 0 | **全局默认**交易确认数(0=扫到就推),合约级可覆盖 | | `db_path` | string | 否 | `data/scanner.db` | SQLite 数据库路径 | | `max_rows` | number | 否 | 10000 | `scan_events` 最大保留条数,超量自动删最早的 | | `retry_max` | number | 否 | 3 | 推送失败最大重试次数 | | `networks` | array | 是 | - | 待扫描网络列表,至少 1 个 | #### networks 数组项 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 网络唯一标识 | | `chain` | string | 是 | `bsc` 或 `sepolia` | | `network` | string | 是 | `mainNet` 或 `testNet` | | `contracts` | array | 是 | 合约列表,支持字符串或对象 | #### contracts 数组项(三种格式) | 格式 | 示例 | 说明 | |------|------|------| | **最简(ERC20)** | `"0x55d398..."` | 字符串 → 默认 ERC20,事件 Transfer,确认数继承全局 | | **对象** | `{"address":"0x...","type":"erc721"}` | 指定类型:`erc20` / `erc721` / `erc1155` | | **完整对象** | `{"address":"0x...","type":"erc721","confirmations":6,"start_block":1000}` | 可覆盖确认数、设置起始块 | **合约级可配置字段:** | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `address` | string | 必填 | 合约地址 | | `type` | string | `erc20` | `erc20` / `erc721` / `erc1155` / `custom` | | `confirmations` | number | 继承全局 | 该合约的交易确认数(0=扫到就推) | | `start_block` | number | 当前块高度 | 首次扫描的起始块(只生效一次,之后从 DB 断点续扫) | | `events` | array | 按 type 默认 | 自定义事件列表(`type:"custom"` 时必填) | ### type: "custom" —— 自定义事件合约 当合约不发标准 ERC20/ERC721/ERC1155 事件时(例如 Merkle Claim、Referral 注册等自定义合约),使用 `type: "custom"` 配合 `events` 数组配置完整的事件 ABI。 **完整示例**(ReferralRegistry 注册合约): ```json { "address": "0x0eaB41******1704F", "type": "custom", "start_block": 11623314, "events": [{ "name": "ReferrerBound", "abi": [{ "anonymous": false, "inputs": [ {"indexed": true, "name": "user", "type": "address"}, {"indexed": true, "name": "referrer", "type": "address"} ], "name": "ReferrerBound", "type": "event" }] }] } ``` **多个自定义事件**(数组里可以写多个): ```json { "address": "0xYourContract", "type": "custom", "events": [ { "name": "Buy", "abi": [{"anonymous":false,"inputs":[{"indexed":true,"name":"buyer","type":"address"},{"indexed":false,"name":"amount","type":"uint256"}],"name":"Buy","type":"event"}] }, { "name": "Sell", "abi": [{"anonymous":false,"inputs":[{"indexed":true,"name":"seller","type":"address"},{"indexed":false,"name":"amount","type":"uint256"}],"name":"Sell","type":"event"}] } ] } ``` **events 数组项字段说明**: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `name` | string | 是 | 事件名,与 ABI 中一致 | | `abi` | array | 是 | 事件的完整 ABI 定义(`anonymous` + `inputs` + `name` + `type:"event"`) | **字段自动映射**(scanContract 的 extract 函数会自动匹配): | 推送字段 | 自动匹配的参数名 | |----------|-----------------| | `from` | `from` / `owner` / `caller` / `sender` / `user` / `account` | | `to` | `to` / `receiver` / `recipient` / `referrer` / `target` | | `value_raw` | `value` / `amount` / `tokenId` / `root` | | `value` | 默认显示事件名(`display`) | 如果参数名不在上表中(例如 `newOwner` / `oldOwner`),`from` 和 `to` 会取空字符串。 **如何获取事件 ABI**: 1. 查看合约 Solidity 源码中的 `event` 定义 2. 或在 [Etherscan](https://sepolia.etherscan.io) / [BscScan](https://bscscan.com) 的合约页面复制 ABI JSON 3. 或用 `w.eth.abi.encodeEventSignature(...)` 验证事件签名是否匹配 ## SQLite 数据表 ### scan_progress(扫描进度) 每个合约独立存储 `from_block`,进程重启后从 DB 恢复断点续扫。 | 字段 | 类型 | 说明 | |------|------|------| | `id` | INTEGER | 主键 | | `net_name` | TEXT | 网络名称 | | `chain` | TEXT | 链类型 | | `network` | TEXT | 网络类型 | | `contract` | TEXT | 合约地址 | | `from_block` | INTEGER | 下一个要扫描的区块高度 | **唯一约束**:`(net_name, contract)` ### scan_events(扫描事件) 每条事件独立存储 **确认数**,入库时根据合约配置自动设置 `event_status`。 | 字段 | 类型 | 说明 | |------|------|------| | `id` | INTEGER | 主键 | | `created_at` | INTEGER | 入库时间戳 | | `net_name` | TEXT | 来源网络 | | `chain` | TEXT | 链类型 | | `contract` | TEXT | 触发合约 | | `event_name` | TEXT | 事件名(Transfer / TransferSingle 等) | | `tx_hash` | TEXT | 交易哈希 | | `block_num` | INTEGER | 区块高度 | | `from_addr` | TEXT | 转出地址 / 发起方 | | `to_addr` | TEXT | 转入地址 / 接收方 | | `value` | TEXT | 人类可读金额(ERC20=带精度,ERC721=tokenId:xxx,ERC1155=id:xxx amount:xxx) | | `value_raw` | TEXT | 原始数据 | | `block_ts` | INTEGER | 区块时间戳 | | `server_url` | TEXT | 推送目标地址 | | `push_status` | INTEGER | **0**=待推送, **1**=成功, **2**=失败 | | `retry_count` | INTEGER | 已重试次数 | | `scan_count` | INTEGER | 被扫描到的次数(重复扫到时 ++) | | `confirmations` | INTEGER | 当前已确认数(动态更新:currentBlock - blockNum) | | `required_confs` | INTEGER | 该事件要求的确认数(入库时由合约配置决定) | | `event_status` | INTEGER | **0**=等待确认, **1**=已确认可推送, **2**=已推送 | **唯一约束**:`(tx_hash, event_name)` ## 推送流程 ``` 扫描到事件 │ ▼ 入库 (upsertEvent) ├─ required_confs = 0 → event_status = 1(已确认可推送) └─ required_confs > 0 → event_status = 0(等待确认) │ ▼ tick() 主循环 ├─ updateConfirmations() │ └─ 对 event_status=0 的事件: │ confirmations = currentBlock - blockNum │ if confirmations >= required_confs → event_status = 1 │ ▼ pushAll() ├─ getConfirmedPending() ──▶ 推 event_status=1 && push_status=0 的 │ 成功 → push_status=1, event_status=2 │ 失败 → push_status=2, retry_count++ │ └─ getPendingRetry() ──▶ 推 push_status=2 且 retry_count < retry_max 超过 retry_max 的放弃不再重试 ``` ### 确认数配置策略 | 场景 | 建议 confirmations | 说明 | |------|-------------------|------| | 测试网 | 0 | 区块高度不稳定,扫到就推 | | BSC 主网(普通 NFT) | 3~6 | BSC 出块快,3 块基本安全 | | BSC 主网(高价值) | 12~24 | 更稳妥 | | 以太坊主网 | 12~32 | 经典安全值,约 2~5 分钟 | ## 扫描结果格式 ``` POST {server_url} Content-Type: application/json ``` ```json { "network": "bsc_main", "chain": "bsc", "contract": "0x55d398******7955", "eventName": "Transfer", "txHash": "0x3f2ad8b9f0******", "blockNumber": 32456789, "from": "0x4031c6******4517", "to": "0x8A6605******697F", "value": "100.00", "value_raw": "100000000000000000000", "timestamp": 1725800000, "time": "2026-09-08 12:00:00", "sign": "b0dc3ff3df1dfad49b23e8608f620f86" } ``` ### 签名机制(sign_key) 当配置了 `sign_key` 时,推送 payload 会自动附加 `sign` 字段,服务端据此验签防止伪造请求。 **签名规则**(Node.js 与 PHP 两端逻辑一致): 1. 取 payload 所有字段(除 `sign` 本身) 2. 按 key 的字母升序排列 3. 拼接成 `key1=val1&key2=val2&...` 格式 4. 末尾追加 `&key=` 5. 对拼接字符串做 MD5,输出小写 hex **PHP 验签示例**(服务端 `test/index.php`): ```php const SIGN_KEY = 'your_secret_key'; function makeSign($data, $signKey) { $keys = array_keys($data); sort($keys); $parts = []; foreach ($keys as $k) $parts[] = $k . '=' . $data[$k]; return md5(implode('&', $parts) . '&key=' . $signKey); } function verifySign(&$data, $signKey) { if (empty($data['sign'])) return false; $sign = $data['sign']; unset($data['sign']); return $sign === makeSign($data, $signKey); } // 调用 if (isset($data['sign']) && !verifySign($data, SIGN_KEY)) { echo json_encode(['code' => 401, 'msg' => 'Sign Invalid']); exit; } ``` **兼容性**:`sign_key` 留空则 Node 侧不传 `sign` 字段,PHP 侧 `isset($data['sign'])` 为 false 时跳过验签,完全向后兼容。 ## 节点探测工具 ### 工作原理 参考 [chainlist.org](https://chainlist.org) 的探测逻辑: | 特性 | 说明 | |------|------| | **原生 fetch** | 零依赖,Node 内置 `http/https` | | **chainId 精确校验** | 调 `eth_chainId` 确认返回值匹配,不只是连通性测试 | | **指数退避重试** | 5 次重试,600ms → 1200ms → 2400ms → 4800ms → 9600ms,处理临时故障/限流 | | **并发池限流** | 6 并发,避免被节点拒绝 | | **防误删** | 本次结果比上次缩水超 50% 时保留上次结果 | | **延迟排序** | 可用节点按响应时间升序,最快的推荐给扫描器优先用 | ### 用法 ```bash # 探测所有链的所有网络 node src/nodes.js # 只探测 bsc(含 mainNet + testNet) node src/nodes.js bsc # 精确到某个网络 node src/nodes.js bsc mainNet node src/nodes.js sepolia testNet ``` ### 实时输出示例 ``` ═════ bsc.mainNet BSC 主网 (chainId=56) ═════ 候选 24 个,开始实时探测... [###..................] 3/24 ✓ 316ms https://binance.nodereal.io [######...............] 6/24 ✓ 399ms https://bsc-dataseed.bnbchain.org [##########...........] 10/24 ? -----ms https://bsc-dataseed1.binance.org [####################..] 22/24 ✗ -----ms https://bsc-rpc.publicnode.com ``` 每完成一个节点立刻输出一条,不用等全部结束。 ### 探测结果汇总 ``` ─── bsc.mainNet 汇总 ─── 总 24 可用 5 死 14 不确定 5 ★ 最快: https://binance.nodereal.io (316ms) ★ 次快: https://bsc-dataseed.bnbchain.org (399ms) 最终节点 (5): 1. https://binance.nodereal.io 2. https://bsc-dataseed.bnbchain.org 3. https://bsc-dataseed3.ninicoin.io 4. https://bsc-dataseed2.ninicoin.io 5. https://bsc-dataseed4.ninicoin.io ``` ### 生成的 data/nodes.json 结构与 `scanner.js` 的 `NODES` 常量完全一致,可直接替换: ```json { "bsc": { "mainNet": [ "https://binance.nodereal.io", "https://bsc-dataseed.bnbchain.org", ... ], "testNet": [ ... ] }, "sepolia": { "mainNet": [...], "testNet": [...] } } ``` ### scanner.js 如何加载 启动时自动检测 `data/nodes.json`: - **存在** → 加载并打印 `[NODES] 已加载 data/nodes.json` - **不存在** → 使用内置默认节点,打印 `[NODES] data/nodes.json 不存在,使用内置默认节点` ### 支持的链 | chain | network | chainId | |-------|---------|---------| | `bsc` | `mainNet` | 56 | | `bsc` | `testNet` | 97 | | `sepolia` | `mainNet` | 11155111 | | `sepolia` | `testNet` | 11155111 | 如需添加自有节点或新链,编辑 `src/nodes.js` 顶部的 `NODES` 和 `CHAINS` 常量即可。 ## 运行日志示例 ``` [NODES] 已加载 data/nodes.json [DB] SQLite 初始化完成: data/scanner.db ================================== 区块链扫描器 (SQLite 持久化版) 服务端地址: http://127.0.0.1:8080/api/scan 扫描间隔: 3 秒 单次最大扫描: 50 区块 最大重试次数: 3 最大存储条数: 10000 确认数要求: 全局 0,合约级独立配置 已加载合约进度: 2 个 待扫描网络: 2 个 - bsc_main (bsc.mainNet) 合约: 1 - sepolia_test (sepolia.testNet) 合约: 1 ================================== 连接节点: https://bsc-dataseed3.binance.org/ [bsc_main] erc721 0x9A15D5****9f16 Transfer 扫描异常: ... 重建节点: https://binance.nodereal.io [bsc_main] Transfer 12345678 0x4031c6******4517 → 0x8A6605******697F tokenId:123 [推送成功] bsc_main 0x3f2ad8b9f0****** [sepolia_test] erc721 0xF6bf3B****d97 [Transfer] 初始块: 11660102 [确认] 检查 5 条,新确认 2 条 [统计] 总:12345 成功:12300 失败:35 待推送:10 ``` ## 工作原理 ``` 进程启动 │ ▼ db.init() ─── 建表 / 打开 SQLite │ ▼ db.loadAllProgress() ─── 加载所有合约 fromBlock │ ▼ tick() 循环 ├─ scanNetwork() × N 个网络 │ └─ scanContract() × M 个合约(每个独立 confirmations / startBlock) │ ├─ getBlockNumber() 获取当前高度 │ ├─ 合并所有事件 ABI → 一个 Contract 实例 │ ├─ getPastEvents(fromBlock, fromBlock+scanBlockRange-1) │ ├─ 去重批量 getBlock() 拿时间戳 │ ├─ db.upsertEvent() 入库 │ │ required_confs=0 → event_status=1 │ │ required_confs>0 → event_status=0 │ └─ db.updateProgress() 写回进度 │ ├─ updateConfirmations() │ └─ 对 event_status=0 的事件逐条更新 confirmations,够块数置 status=1 │ ├─ pushAll() │ ├─ getConfirmedPending() → 推 event_status=1 && push_status=0 │ └─ getPendingRetry() → 推 push_status=2 且 retry