如何用 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 設定。
本指南協助你完成的任務
- 接收 USDT / USDC 收款通知:先檢查所選鏈的 Push 支援,再於你的 HTTPS 端點接收。
- 驗證轉帳候選:在套用你的鏈上核驗與確認政策之前,檢查其鏈、代幣合約、收款地址與整數金額。
- 回填缺失的轉帳日誌:使用有界
eth_getLogs查詢與已儲存的游標。
選擇 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(已處理並提交的最高區塊):
- 在每個輪詢週期,設定
fromBlock = last_polled_block + 1。 - 透過
eth_blockNumber查詢目前鏈頭,並依你的確認深度計算安全目標高度safe_head。 - 如果
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) 去重
收款監聽器必須強制嚴格的冪等性:
- 單一交易中的多筆轉帳:單一交易可以包含多個發往同一充值地址的
Transfer事件(例如拆分交換的代幣路由器或多筆出款合約)。重要:單憑transactionHash無法唯一識別一筆收款。 - 重疊的輪詢與重試:當輪詢服務重新啟動、從暫時性網路錯誤中復原,或回溯數個區塊以處理淺層重組時,同一區塊範圍的日誌會被查詢多次。
- 日誌索引唯一性:
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計費規則與相關指南
- 關於請求計量、CU 權重與錯誤碼計費判定的詳細資訊,請參閱計費規則:錯誤與不計費請求。
- 關於
eth_getLogs區塊範圍限制與分段邏輯的深入指引,請參閱 eth_getLogs 區塊範圍限制與分段查詢。 - 關於即時 RPC 節點查詢與已索引歷史轉帳 API 的差異,請參閱鏈頭與已索引歷史:何時使用 eth_getLogs 與轉帳。
下一步
最後更新: