# 监听 USDT / USDC 收款

> 原文地址: https://docs.blockvectra.com/zh/guides/stablecoin-payments/

监听链上稳定币（如 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 标准代币合约在发生转账时会抛出如下事件：

```solidity
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`（已处理完毕的最高区块号）：

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 块）。

若请求的区块跨度超过该上限，服务端会直接拒绝请求并返回错误：

```json
{
  "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)`。

在业务数据库中，应为充值入账记录表建立唯一联合索引：

```sql
CREATE UNIQUE INDEX idx_transfers_tx_log ON deposit_records (transaction_hash, log_index);
```

在写入入账流水前，依据 `(transactionHash, logIndex)` 判定该事件是否已处理，确保同一笔链上转账不会被重复入账。

## 完整代码示例

以下示例演示连接目标链、读取 `/v1/chains` 配置、按安全深度和单次区间上限分段拉取稳定币 Transfer 日志、过滤 `removed` 并进行去重处理的完整流程。

**TypeScript**

```ts
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
```


  **Python**

```python
import os
import requests

api_key = os.environ.get("BLOCKVECTRA_API_KEY")
if not api_key:
    raise RuntimeError("请设置环境变量 BLOCKVECTRA_API_KEY")

CHAIN = "bsc_mainnet"
RPC_URL = "https://api.blockvectra.com/v1/bsc_mainnet"
CHAINS_URL = "https://api.blockvectra.com/v1/chains"

# 目标稳定币合约地址（此处以 BSC USDT 为例）
TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955"
TOKEN_DECIMALS = 18

# 监听的专属收款地址
RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C"

# Transfer(address,address,uint256) 签名哈希
TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"

# 将 20 字节地址左补零至 32 字节（64 位十六进制字符）
padded_recipient = f"0x{RECIPIENT_ADDRESS.lower()[2:].rjust(64, '0')}"

# 防范区块重组的安全确认深度（区块数）
CONFIRMATION_DEPTH = 15

# 1. 从公开元数据接口读取目标链的能力配置（免鉴权、不计费）
chains_res = requests.get(CHAINS_URL, timeout=10)
chains_res.raise_for_status()
chain_list = chains_res.json().get("chains", [])

chain_config = next((c for c in chain_list if c["chain"] == CHAIN), None)
if not chain_config:
    raise RuntimeError(f"未在 /v1/chains 中找到链 {CHAIN}")

max_logs_range = chain_config.get("max_logs_block_range", 1000)
ws_supported = chain_config.get("ws", False)
print(f"链: {CHAIN} | WebSocket 支持: {ws_supported} | 单次日志跨度上限: {max_logs_range}")

def rpc_request(method: str, params: list):
    res = requests.post(
        RPC_URL,
        headers={
            "Content-Type": "application/json",
            "x-api-key": api_key,
        },
        json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
        timeout=15,
    )
    res.raise_for_status()
    payload = res.json()
    if "error" in payload:
        err = payload["error"]
        raise RuntimeError(f"JSON-RPC 错误 {err.get('code')}: {err.get('message')}")
    return payload["result"]

# 2. 查询最新区块高度并计算安全高度
current_head_hex = rpc_request("eth_blockNumber", [])
current_head = int(current_head_hex, 16)
safe_head = max(0, current_head - CONFIRMATION_DEPTH)

# 演示：从 safe_head 前 10 个区块作为游标起点
cursor = max(0, safe_head - 10)
print(f"当前链头: {current_head} | 安全链头: {safe_head} | 轮询起始游标: {cursor}")

# 本地去重集合，以 (transactionHash, logIndex) 为复合主键
processed_logs = set()

while cursor <= safe_head:
    chunk_end = min(cursor + max_logs_range - 1, safe_head)

    logs = rpc_request(
        "eth_getLogs",
        [
            {
                "address": TOKEN_CONTRACT,
                "fromBlock": hex(cursor),
                "toBlock": hex(chunk_end),
                "topics": [
                    TRANSFER_TOPIC0,
                    None,  # 匹配任意发送方
                    padded_recipient,  # 匹配目标收款地址
                ],
            }
        ],
    )

    for log in logs:
        # 忽略因重组被撤回的日志
        if log.get("removed", False):
            continue

        tx_hash = log["transactionHash"]
        log_index = int(log["logIndex"], 16)
        dedup_key = (tx_hash, log_index)

        if dedup_key in processed_logs:
            continue
        processed_logs.add(dedup_key)

        raw_amount = int(log["data"], 16)
        token_amount = raw_amount / (10 ** TOKEN_DECIMALS)
        block_number = int(log["blockNumber"], 16)

        print(
            f"[收款到账] 金额: {token_amount} | "
            f"交易: {tx_hash} | 索引: {log_index} | 区块: {block_number}"
        )

    cursor = chunk_end + 1

# 运行方式：python example.py
```


## 计费规则与相关参考

* 关于调用计费、CU 消耗规则与各类错误码计费判定的说明，请参阅[哪些请求不计费：错误码与计费规则](https://docs.blockvectra.com/zh/guides/billing-rules/)。
* 关于 `eth_getLogs` 单次区块跨度限制的详细机制与分段逻辑，请参阅 [eth\_getLogs 区块跨度上限与分段查询](https://docs.blockvectra.com/zh/guides/getlogs-block-range/)。
* 关于实时节点查询与 Data API 已索引历史转账的数据差异，请参阅[节点近况与已索引历史：什么时候用 eth\_getLogs，什么时候用转账接口](https://docs.blockvectra.com/zh/guides/logs-vs-transfers/)。

## 下一步

* [浏览数据集目录](https://blockvectra.com/zh/data/)，查看 BlockVectra 索引的全部数据集。
* [查看免费额度与定价](https://blockvectra.com/zh/pricing/#free)，确认账户可用的方案。
* [登录控制台](https://console.blockvectra.com/zh/login/?next=%2Fzh%2Fkeys%2F)创建 API key。
