监听 USDT / USDC 收款
使用 eth_getLogs 轮询监听 ERC-20 Transfer 事件实现 USDT 与 USDC 收款入账,包含游标轮询、区间上限、去重与重组确认深度实践。
监听链上稳定币(如 USDT 与 USDC)转账入账是加密货币收款、充值系统与自动化结算的核心环节。EVM 兼容链上的标准 ERC-20 代币转账均通过智能合约抛出 Transfer 事件。
本指南介绍如何通过标准 JSON-RPC 方法 eth_getLogs 构建稳定的稳定币收款监听服务,涵盖事件过滤参数构造、游标分段轮询、区块重组防范与日志去重机制。
监听方式:WebSocket 与 HTTP 轮询
在实现监听服务前,需要确认目标网络是否支持实时 WebSocket 订阅。
各链对 WebSocket 与订阅类型的支持情况以公开接口 GET /v1/chains 返回的数据为准(该接口免鉴权、不计费):
- 检查目标链配置中的
ws字段(布尔值)以及subscriptions列表(例如是否包含"logs")。 - 若目标链的
ws字段为false,或方法策略将eth_subscribe列入methods.deny,则通过 HTTP 或不支持该能力的端点调用eth_subscribe会返回 JSON-RPC 错误-32601(method not available: eth_subscribe)。 - 请在程序启动时通过
/v1/chains动态获取,不要在代码中静态硬编码哪条链不支持 WebSocket。 - 当网络不支持 WebSocket 订阅,或在定时任务、无状态工作流(如 Serverless / Worker)等长连接不适用的场景下,使用 HTTP 定期分段轮询
eth_getLogs是可靠的通用方案。
Transfer 事件与过滤参数
ERC-20 标准代币合约在发生转账时会抛出如下事件:
event Transfer(address indexed from, address indexed to, uint256 value);调用 eth_getLogs 时,通过传入代币合约地址与 topics 数组进行精准过滤:
| 参数 | 取值 | 说明 |
|---|---|---|
address | 代币合约地址(或地址数组) | 目标稳定币合约地址。可传入单个地址(如 BSC USDT 合约 0x55d398326f99059fF775485246999027B3197955、Base USDC 合约 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913),也可传入数组同时匹配多个代币合约 |
topics[0] | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef | keccak256("Transfer(address,address,uint256)") 的事件签名哈希 |
topics[1] | null | 付款方地址(from)。监听充值时通常允许任意外部账户付款,传 null 表示通配任意发送方 |
topics[2] | 填充为 32 字节的收款地址 | 接收方地址(to)。在 EVM 事件日志规范中,indexed 的地址类型占用 32 字节(64 位十六进制字符),必须将 20 字节的目标地址在左侧补齐 24 个零(48 个字符的十六进制 0) |
fromBlock | 起始区块(十六进制) | 查询闭区间的起始高度 |
toBlock | 结束区块(十六进制) | 查询闭区间的结束高度 |
未被索引的 value(转账金额)存放在日志对象的 data 字段中,为 32 字节十六进制编码的 uint256。解析时需除以代币对应的精度位数(例如 BSC USDT 为 18 位小数,Base / 以太坊 USDC 为 6 位小数)。
游标轮询与单次区间上限
监听服务通常以固定的时间间隔(例如 3~5 秒)拉取新区块的日志。
游标推进机制
客户端在数据库或本地持久化存储中维护一个游标 last_polled_block(已处理完毕的最高区块号):
- 每次轮询时,将起始高度设为
fromBlock = last_polled_block + 1。 - 通过
eth_blockNumber获取链头当前高度,并结合确认深度计算目标高度safe_head。 - 若
fromBlock <= safe_head,则分段查询日志;处理成功后将游标推进至该次查询的结束高度。
单次区块跨度限制
单次 eth_getLogs 请求的区块跨度按照 toBlock − fromBlock + 1 计算,不能超过目标链在 GET /v1/chains 中公布的 max_logs_block_range(通常为 1000 块)。
若请求的区块跨度超过该上限,服务端会直接拒绝请求并返回错误:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "eth_getLogs block range too large: max 1000 blocks",
"data": {
"reason": "logs_range_too_large",
"docs_url": "https://docs.blockvectra.com/en/errors/#logs_range_too_large",
"retryable": false
}
}
}因超出跨度被服务端拒绝的请求不计费(billed: false)。在程序实现中,应先从 GET /v1/chains 读取 max_logs_block_range,在追块或处理大跨度区间时将区间拆分为不超过上限的小段:chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head)。
处理区块重组与确认深度
公链节点在链头附近可能出现短暂分叉与区块重组(Reorganization)。若直接查询 latest 并在第一时间入账,当该区块因重组被废弃时,可能发生入账资金落空的问题。
为保证记账安全,需采取以下防范措施:
确认深度(Confirmation Depth)
不要直接将查询区间推进到未决的 latest,而是在链头高度之后保留足够的安全确认深度:
safe_head = current_head - CONFIRMATION_DEPTH
- 不同的网络与业务风控要求对应的深度建议不同:例如在 BSC 或 Base 上,通常建议保留 15 个确认区块;在以太坊主网上,通常保留 12~32 个区块。
- 对于刚进入链头的转账,可以在系统中先标记为“待确认(pending)”;待其所在的区块深度达到安全阈值后,再标记为“已确认(confirmed)”并允许用户使用充值资金。
检查 removed 标记
标准以太坊 JSON-RPC 在发生区块重组导致日志失效时,会在返回的日志对象中将 removed 字段置为 true。业务处理逻辑中必须检查该字段:
- 若
log.removed === true,说明该笔转账已随区块回滚而失效,绝不能入账。 - 若此前已针对该交易记录了待确认状态,应立即将其状态置为撤销或失败。
按 (transactionHash, logIndex) 去重
构建充值监听时,必须具备严格的幂等去重能力:
- 同交易多转账:单一交易哈希可能包含多笔向同一收款地址的转账(例如路由合约的拆单兑换、聚合支付或批量分发)。因此不能仅用
transactionHash作为去重键。 - 重叠轮询与重试:当服务发生重启、异常重试,或为了防范浅层重组而在回退数个区块后重新轮询时,同一区块的日志会被多次拉取。
- logIndex 的唯一性:
logIndex是该事件在整笔交易或该区块内的日志索引号。标准 EVM 中,同一笔转账日志在链上的天然唯一标识为复合键(transactionHash, logIndex)。
在业务数据库中,应为充值入账记录表建立唯一联合索引:
CREATE UNIQUE INDEX idx_transfers_tx_log ON deposit_records (transaction_hash, log_index);在写入入账流水前,依据 (transactionHash, logIndex) 判定该事件是否已处理,确保同一笔链上转账不会被重复入账。
完整代码示例
以下示例演示连接目标链、读取 /v1/chains 配置、按安全深度和单次区间上限分段拉取稳定币 Transfer 日志、过滤 removed 并进行去重处理的完整流程。
import { createPublicClient, http, pad, type Hex } from "viem";
const apiKey = process.env.BLOCKVECTRA_API_KEY;
if (!apiKey) {
throw new Error("请设置环境变量 BLOCKVECTRA_API_KEY");
}
const CHAIN = "bsc_mainnet";
const RPC_URL = "https://api.blockvectra.com/v1/bsc_mainnet";
const CHAINS_URL = "https://api.blockvectra.com/v1/chains";
// 目标稳定币合约地址(此处以 BSC USDT 为例)
const TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955" as const;
const TOKEN_DECIMALS = 18;
// 监听的专属收款地址
const RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C" as const;
// Transfer(address,address,uint256) 签名哈希
const TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" as const;
// 将 20 字节地址左补零至 32 字节
const paddedRecipient = pad(RECIPIENT_ADDRESS.toLowerCase() as Hex, { size: 32 });
// 防范区块重组的安全确认深度(区块数)
const CONFIRMATION_DEPTH = 15n;
// 1. 从公开元数据接口读取目标链的能力配置(免鉴权、不计费)
const chainsRes = await fetch(CHAINS_URL);
const { chains } = (await chainsRes.json()) as {
chains: Array<{
chain: string;
ws: boolean;
subscriptions: string[];
max_logs_block_range: number;
}>;
};
const chainConfig = chains.find((c) => c.chain === CHAIN);
if (!chainConfig) {
throw new Error(`未在 /v1/chains 中找到链 ${CHAIN}`);
}
const maxLogsRange = BigInt(chainConfig.max_logs_block_range || 1000);
console.log(`链: ${CHAIN} | WebSocket 支持: ${chainConfig.ws} | 单次日志跨度上限: ${maxLogsRange}`);
// 2. 初始化 viem 客户端
const client = createPublicClient({
transport: http(RPC_URL, {
fetchOptions: {
headers: { "x-api-key": apiKey },
},
}),
});
// 本地去重集合,以 (transactionHash, logIndex) 为复合主键
const processedLogs = new Set<string>();
// 3. 计算查询区间:从当前链头扣除确认深度得到 safeHead
const currentHead = await client.getBlockNumber();
const safeHead = currentHead - CONFIRMATION_DEPTH;
// 演示:从 safeHead 前 10 个区块作为游标起点
let cursor = safeHead > 10n ? safeHead - 10n : 0n;
console.log(`当前链头: ${currentHead} | 安全链头: ${safeHead} | 轮询起始游标: ${cursor}`);
while (cursor <= safeHead) {
const chunkEnd = cursor + maxLogsRange - 1n < safeHead ? cursor + maxLogsRange - 1n : safeHead;
const logs = await client.getLogs({
address: TOKEN_CONTRACT,
fromBlock: cursor,
toBlock: chunkEnd,
topics: [
TRANSFER_TOPIC0,
null, // 匹配任意发送方
paddedRecipient, // 匹配目标收款地址
],
});
for (const log of logs) {
// 忽略因重组被撤回的日志
if (log.removed) continue;
const dedupKey = `${log.transactionHash}-${log.logIndex}`;
if (processedLogs.has(dedupKey)) {
continue;
}
processedLogs.add(dedupKey);
// 解析 uint256 转账金额
const rawAmount = BigInt(log.data);
const divisor = 10n ** BigInt(TOKEN_DECIMALS);
const integerPart = rawAmount / divisor;
const fractionalPart = rawAmount % divisor;
console.log(
`[收款到账] 金额: ${integerPart}.${fractionalPart} | ` +
`交易: ${log.transactionHash} | 索引: ${log.logIndex} | 区块: ${log.blockNumber}`
);
}
cursor = chunkEnd + 1n;
}
// 运行方式:npx tsx example.mts计费规则与相关参考
- 关于调用计费、CU 消耗规则与各类错误码计费判定的说明,请参阅哪些请求不计费:错误码与计费规则。
- 关于
eth_getLogs单次区块跨度限制的详细机制与分段逻辑,请参阅 eth_getLogs 区块跨度上限与分段查询。 - 关于实时节点查询与 Data API 已索引历史转账的数据差异,请参阅节点近况与已索引历史:什么时候用 eth_getLogs,什么时候用转账接口。
下一步
最后更新: