监听 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]0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efkeccak256("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(已处理完毕的最高区块号):

  1. 每次轮询时,将起始高度设为 fromBlock = last_polled_block + 1。
  2. 通过 eth_blockNumber 获取链头当前高度,并结合确认深度计算目标高度 safe_head。
  3. 若 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) 去重

构建充值监听时,必须具备严格的幂等去重能力:

  1. 同交易多转账:单一交易哈希可能包含多笔向同一收款地址的转账(例如路由合约的拆单兑换、聚合支付或批量分发)。因此不能仅用 transactionHash 作为去重键。
  2. 重叠轮询与重试:当服务发生重启、异常重试,或为了防范浅层重组而在回退数个区块后重新轮询时,同一区块的日志会被多次拉取。
  3. 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

计费规则与相关参考

下一步

最后更新:

本页目录