如何用 Webhook 與 RPC 監控 USDT / USDC 收款

建立收款接收端與輪詢游標。核驗代幣合約、收款地址與整數金額,去重事件並對帳缺失或被替換的區塊。

若要進行穩定幣收款監控或交易所充值偵測,請使用 Webhook、WebSocket 日誌或 HTTP 輪詢,監控 EVM 鏈上傳入的 ERC-20 USDT / USDC 轉帳。開發者與 AI Agent 使用同一套 API;處理收款前先選定鏈、代幣合約、收款地址與確認深度。在 USDT / USDC 轉帳監控方案 中選擇充值、商戶通知或出款工作流。

  • 第一步:建立訂閱並關注收款地址,先備妥 API key 與你的 HTTPS 接收端。
  • 完成條件:一筆符合的轉帳通過簽章、鏈、代幣、收款地址與整數金額檢查,以收款候選只儲存一次,且接收端回傳 HTTP 204;入帳前仍須依你的確認政策完成鏈上核驗。

穩定幣收款工作流。

基礎轉帳監控已可用。金額與代幣篩選在你的接收端執行。伺服器端條件、多階段確認與 IM 提醒即將推出。

給開發者與 AI Agent:先備妥 API key 與自有 HTTPS 接收端;在應用程式中篩選代幣合約與金額。複製 Webhook 設定。

本指南協助你完成的任務

選擇 Webhook、WebSocket 或輪詢

方式適用於復原
Webhook送達你的 HTTPS 接收端的地址活動,包含傳入的代幣轉帳驗證簽章、去重事件 ID,並處理 subscription.gap / chain.reorg;重放保留的符合事件
WebSocket透過持久連線的篩選後 logs重新連線、重新訂閱並回填遺漏的區塊
HTTP 輪詢使用自有游標的排程監控或歷史日誌回填查詢有界的 eth_getLogs 範圍並持久化進度

選擇 WebSocket 前,請先讀取公開 GET /v1/chains 回應中的 ws 與 subscriptions。Push 支援是另一項檢查:使用你的 API key 讀取 GET /v1/push/chains。沒有 WebSocket 的鏈,只要列在該清單中,仍可使用地址 Webhook。當你需要掃描較早的區塊,或在沒有持久連線的情況下執行時,請使用輪詢。

使用 Webhook 接收收款

建立訂閱並關注收款地址

取得 API key 並在連接埠 443 部署 HTTPS 接收端。從認證的 Push 鏈清單中選擇 CHAIN,將 RECIPIENT 設為你的充值地址、RECEIVER_URL 設為你的接收端 URL。此 shell 範例需要 jq;{} 會使用該鏈的預設確認數。選擇不同確認數之前,請先檢查 min_confirmations、default_confirmations 與 max_confirmations。Push OpenAPI 定義了這些請求。

set -eu
umask 077
: "${BLOCKVECTRA_API_KEY:?Set your API key}"
: "${CHAIN:?Select a chain from the Push chain list}"
: "${RECIPIENT:?Set the watched EVM recipient address}"
: "${RECEIVER_URL:?Set your HTTPS receiver URL}"
PUSH_URL='https://api.blockvectra.com/v1/push'

curl --fail-with-body -sS "$PUSH_URL/chains" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" > push-chains.json
jq -e --arg chain "$CHAIN" 'any(.chains[]; .chain == $chain)' push-chains.json
jq -n --arg url "$RECEIVER_URL" --arg chain "$CHAIN" \
  '{url: $url, chains: {($chain): {}}}' > create.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @create.json > subscription.json

SUBSCRIPTION_ID=$(jq -er '.id' subscription.json)
jq -n --arg recipient "$RECIPIENT" '{addresses: [$recipient]}' > addresses.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID/addresses/add" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' -d @addresses.json > address-change.json
curl --fail-with-body -sS "$PUSH_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

建立後會回傳 id 與 secret。請為接收端安全儲存該密鑰;subscription.json 含有憑證。輪詢訂閱,直到 applied_version >= change_version(來自 address-change.json),然後記錄 chains[CHAIN].applied_from_block。新地址從該區塊開始比對,因此任何較早的收款區間仍須持續輪詢。

驗證、去重並核驗收款

將原始主體簽章函式儲存為 verify-push.js。以下接收端在 Node.js 中接受 Web API Request,並在 JSON 解析前讀取其原始位元組。將 secrets 建為訂閱 ID 字串到已儲存密鑰的 Map。將受信任的 expected 設定為 { chain, token, recipient, amountUnits }:token 是該鏈上已核驗的穩定幣合約,amountUnits 是以其最小單位表示的預期正整數金額。使用 BigInt 比較金額,絕不要用浮點數或代幣符號。

import { verifyPush } from './verify-push.js';

export function selectPayment(data, event, expected) {
  if (data.chain !== expected.chain || event.type !== 'token.transfer' ||
      event.standard !== 'erc20') return null;
  const address = value => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value);
  if (![event.token, event.to, expected.token, expected.recipient].every(address)) return null;
  if (event.token.toLowerCase() !== expected.token.toLowerCase() ||
      event.to.toLowerCase() !== expected.recipient.toLowerCase()) return null;
  const integer = value => typeof value === 'string' && /^[1-9][0-9]{0,77}$/.test(value);
  if (!integer(event.amount) || !integer(expected.amountUnits)) return null;
  const amount = BigInt(event.amount);
  if (amount >= (1n << 256n) || amount !== BigInt(expected.amountUnits)) return null;
  if (typeof event.id !== 'string' || typeof event.ref !== 'string' ||
      !/^0x[0-9a-f]{64}$/.test(event.tx_hash) ||
      !/^0x[0-9a-f]{64}$/.test(event.block_hash) ||
      !Number.isSafeInteger(event.log_index) || event.log_index < 0 ||
      !Number.isSafeInteger(event.block_number) || event.block_number < 0) return null;
  return {
    eventId: event.id, ref: event.ref, chain: data.chain,
    token: event.token, recipient: event.to, amountUnits: event.amount,
    txHash: event.tx_hash, logIndex: event.log_index,
    blockHash: event.block_hash, blockNumber: event.block_number,
  };
}

export async function receivePayments(request, expected, secrets, store) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const headers = Object.fromEntries(request.headers);
  if (!verifyPush(rawBody, headers, secrets)) return new Response(null, { status: 401 });
  let message;
  try { message = JSON.parse(rawBody.toString('utf8')); }
  catch { return new Response(null, { status: 400 }); }
  const data = message?.data;
  if (message?.type !== 'push.events' ||
      !Number.isSafeInteger(data?.subscription_id) || data.subscription_id <= 0 ||
      String(data.subscription_id) !== headers['bv-subscription-id'] ||
      data.chain !== expected.chain || !Array.isArray(data.events)) {
    return new Response(null, { status: 400 });
  }
  try {
    await store.transaction(async tx => {
      for (const event of data.events) {
        if (!event || typeof event.id !== 'string') continue;
        const recovery = event.type === 'subscription.gap' || event.type === 'chain.reorg';
        const payment = selectPayment(data, event, expected);
        if (!recovery && !payment) continue;
        if (!await tx.insertEventOnce(data.subscription_id, event)) continue;
        if (recovery) await tx.enqueueRecovery(data.chain, event);
        else await tx.recordPaymentCandidate(payment);
      }
    });
  } catch {
    return new Response(null, { status: 503 });
  }
  return new Response(null, { status: 204 });
}

以持久化儲存實作 store.transaction。在單一交易中,insertEventOnce 會以唯一的 (subscription_id, event.id) 鍵插入事件,遇到重複時回傳 false;將它與 recordPaymentCandidate 或 enqueueRecovery 一起提交。失敗時回復所有寫入,讓重試能處理該事件。復原工作也必須具冪等性。只有在提交後才於 10 秒內回傳 2xx;請在你的 HTTP 伺服器強制 1 MiB 主體限制。

此範例只檢查一個預期收款金額。若有多筆訂單,請依鏈、代幣與收款地址查詢受信任的收款設定,並依你自己的規則對帳部分或超額付款。候選項目在入帳前仍需鏈上核驗與你的確認政策。跨訂閱與輪詢時,請依鏈、交易雜湊與日誌索引對帳同一筆轉帳,以免兩條送達路徑重複入帳;保留區塊雜湊以追蹤被替換的區塊。

復原缺失或被替換的區塊

對於 subscription.gap,請使用下方的輪詢路徑或可用的 Data API 資料集,排程掃描 from_block 到 to_block。chain.reorg 是免費通知,表示已送達的區塊被替換,而不是送達缺口。依 ref 標記或丟棄該範圍內的舊事件;在處理以新 ID 自動重新送達的規範鏈事件之前,先依 ref 與 tx_hash 將收款記錄與規範鏈對帳。按 id 去重這些事件。重組通知不會推進已完成進度;請按鏈記錄 complete_through_block,絕不要從最大的事件區塊號推斷完成。

重放 接受目前 replayable_from_block 邊界內的 chain 與 from_block。它只重新送出保留的符合事件;不會掃描地址或鏈加入之前的期間,或訂閱離線期間。請保留輪詢游標以涵蓋那些區間與已過期的缺口。請求失敗與無效的重放範圍見錯誤參考;送達、歷史與地址日費用見計費規則。

其餘章節將實作 ERC-20 日誌篩選與以游標為基礎的輪詢,用於監控與復原。

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 位元組的收款地址左側填充 12 個零位元組(24 個十六進位零字元),以形成 32 位元組的 topic。
fromBlock起始區塊(十六進位)查詢區塊範圍的開始(含)
toBlock結束區塊(十六進位)查詢區塊範圍的結束(含)

未索引的 value(轉帳金額)會以 32 位元組十六進位 uint256 編碼在日誌物件的 data 欄位中。將此原始金額除以 10^decimals,即可得到人類可讀的代幣金額(例如 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,以分批方式查詢日誌直到 safe_head。成功處理每個批次後,推進游標。

區塊範圍限制

單次 eth_getLogs 呼叫的區塊跨度計算為 toBlock − fromBlock + 1。它不得超過 GET /v1/chains 中為該鏈發布的 max_logs_block_range。

如果請求超過此範圍,服務會以錯誤碼 -32602 拒絕該呼叫:

{
  "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
    }
  }
}

超過區塊範圍的請求會回傳 JSON-RPC 錯誤 -32602(不計費)。在你的應用程式邏輯中,從 GET /v1/chains 讀取 max_logs_block_range,並對每個輪詢切片進行箝制:chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head)。

處理區塊重組與確認深度

在區塊鏈頂端附近,可能發生暫時性的區塊重組(reorg)。在沒有確認深度的情況下於 latest 入帳收款,會有將交易入帳到隨後被丟棄的孤支上的風險。

請套用以下防護措施來保護收款處理:

確認深度

不要查詢到 latest,而是查詢到安全目標區塊高度:

safe_head = current_head - CONFIRMATION_DEPTH

依你的應用程式風險承受度設定 CONFIRMATION_DEPTH。只查詢到 safe_head 可確保只處理具有足夠確認數的區塊。

輪詢期間的重組

標準 EVM JSON-RPC 只在 WebSocket 日誌訂閱串流中,當先前發出的事件因鏈重組而被回復時,才會在日誌物件上設定 removed: true。透過 HTTP 使用 eth_getLogs 輪詢時,查詢會回傳規範鏈的日誌;被重組的日誌只會不再出現在後續查詢中。在 safe_head 內輪詢可確保收款只在足夠確認的區塊上處理。

依 (transactionHash, logIndex) 去重

收款監聽器必須強制嚴格的冪等性:

  1. 單一交易中的多筆轉帳:單一交易可以包含多個發往同一充值地址的 Transfer 事件(例如拆分交換的代幣路由器或多筆出款合約)。重要:單憑 transactionHash 無法唯一識別一筆收款。
  2. 重疊的輪詢與重試:當輪詢服務重新啟動、從暫時性網路錯誤中復原,或回溯數個區塊以處理淺層重組時,同一區塊範圍的日誌會被查詢多次。
  3. 日誌索引唯一性:logIndex 標識事件日誌在區塊內的相對位置。依 EVM 規格,事件的規範複合唯一識別碼是 (transactionHash, logIndex)。

在關聯式資料庫結構定義中,請在充值記錄資料表上宣告複合唯一索引:

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

處理充值前,請比對現有的 (transactionHash, logIndex) 項目,以確保每筆鏈上轉帳只入帳一次。

完整程式碼範例

以下範例示範如何從 /v1/chains 取得網路能力、計算安全區塊範圍、在符合範圍限制下輪詢穩定幣 Transfer 日誌,並去重事件。

import { createPublicClient, formatUnits, http, parseAbiItem } from "viem";

const apiKey = process.env.BLOCKVECTRA_API_KEY;
if (!apiKey) {
  throw new Error("BLOCKVECTRA_API_KEY environment variable is not set");
}

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

// Target stablecoin contract address (BSC USDT used in this example)
const TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955" as const;
const TOKEN_DECIMALS = 18;

// Monitored deposit address
const RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C" as const;

// Confirmation depth to guard against chain reorgs
const CONFIRMATION_DEPTH = 15n;

// 1. Fetch chain capabilities from public metadata endpoint (unauthenticated, unbilled)
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(`Chain ${CHAIN} not found in /v1/chains`);
}

const maxLogsRange = BigInt(chainConfig.max_logs_block_range || 1000);
console.log(`Chain: ${CHAIN} | WebSocket supported: ${chainConfig.ws} | Max logs range: ${maxLogsRange}`);

// 2. Initialize viem client with x-api-key header
const client = createPublicClient({
  transport: http(RPC_URL, {
    fetchOptions: {
      headers: { "x-api-key": apiKey },
    },
  }),
});

// Set to track processed events by composite key: (transactionHash, logIndex)
const processedLogs = new Set<string>();

// 3. Compute query range: subtract confirmation depth from current head
const currentHead = await client.getBlockNumber();
const safeHead = currentHead - CONFIRMATION_DEPTH;

// For demonstration, start cursor 10 blocks before safeHead
let cursor = safeHead > 10n ? safeHead - 10n : 0n;

console.log(`Current head: ${currentHead} | Safe head: ${safeHead} | Polling cursor: ${cursor}`);

while (cursor <= safeHead) {
  const chunkEnd = cursor + maxLogsRange - 1n < safeHead ? cursor + maxLogsRange - 1n : safeHead;

  const logs = await client.getLogs({
    address: TOKEN_CONTRACT,
    event: parseAbiItem(
      "event Transfer(address indexed from, address indexed to, uint256 value)"
    ),
    args: {
      to: RECIPIENT_ADDRESS,
    },
    fromBlock: cursor,
    toBlock: chunkEnd,
  });

  for (const log of logs) {
    const dedupKey = `${log.transactionHash}-${log.logIndex}`;
    if (processedLogs.has(dedupKey)) {
      continue;
    }
    processedLogs.add(dedupKey);

    const tokenAmount = formatUnits(log.args.value ?? 0n, TOKEN_DECIMALS);

    console.log(
      `[Payment Received] Amount: ${tokenAmount} | ` +
      `Tx: ${log.transactionHash} | Log: ${log.logIndex} | Block: ${log.blockNumber}`
    );
  }

  cursor = chunkEnd + 1n;
}

// Run with: npx tsx example.mts

計費規則與相關指南

下一步

最後更新:

本頁目錄