Cách giám sát thanh toán USDT / USDC bằng Webhook và RPC

Xây dựng bộ nhận thanh toán và con trỏ thăm dò. Xác minh hợp đồng token, người nhận và số tiền nguyên, loại trùng sự kiện và đối chiếu các khối bị thiếu hoặc thay thế.

Để giám sát thanh toán stablecoin hoặc phát hiện tiền nạp sàn, hãy theo dõi các chuyển khoản ERC-20 USDT / USDC đến trên các chuỗi EVM bằng Webhook, log WebSocket hoặc thăm dò HTTP. Nhà phát triển và AI Agent dùng cùng các API; chọn chuỗi, hợp đồng token, người nhận và độ sâu xác nhận trước khi xử lý thanh toán. Chọn quy trình nạp tiền, thông báo người bán hoặc chi trả trong giải pháp giám sát chuyển USDT / USDC.

  • Bước đầu tiên: Tạo đăng ký và theo dõi người nhận, bắt đầu với API key và bộ nhận HTTPS của bạn.
  • Hoàn thành khi: Chuyển khoản khớp vượt qua kiểm tra chữ ký, chuỗi, token, người nhận và số tiền nguyên, được lưu một lần thành khoản thanh toán cần xác minh và bộ nhận trả HTTP 204; xác minh trên chuỗi theo chính sách xác nhận của bạn trước khi ghi có.

Quy trình thanh toán stablecoin.

Giám sát chuyển tiền cơ bản đã khả dụng. Việc lọc số tiền và token được thực hiện tại bộ nhận của bạn. Điều kiện phía máy chủ, nhiều giai đoạn xác nhận và cảnh báo IM sẽ sớm ra mắt.

Dành cho nhà phát triển và AI Agent: bắt đầu với API key và bộ nhận HTTPS của bạn; lọc hợp đồng token và số tiền trong ứng dụng. Sao chép cấu hình Webhook.

Các tác vụ hướng dẫn này giúp bạn hoàn thành

Chọn Webhook, WebSocket hoặc thăm dò

Phương thứcDùng choKhôi phục
WebhookHoạt động địa chỉ gửi tới bộ nhận HTTPS, bao gồm chuyển token đếnXác minh chữ ký, loại trùng ID sự kiện và xử lý subscription.gap / chain.reorg; phát lại sự kiện khớp còn lưu
WebSocketlogs đã lọc qua kết nối duy trì liên tụcKết nối lại, đăng ký lại và truy xuất bổ sung các khối bị bỏ lỡ
Thăm dò HTTPGiám sát theo lịch hoặc truy xuất bổ sung log lịch sử bằng con trỏ của bạnTruy vấn các khoảng eth_getLogs có giới hạn và lưu tiến độ bền vững

Đọc ws và subscriptions trong phản hồi công khai GET /v1/chains trước khi chọn WebSocket. Hỗ trợ Push cần kiểm tra riêng: đọc GET /v1/push/chains bằng API key. Chuỗi không có WebSocket có thể dùng Webhook địa chỉ nếu có trong danh sách đó. Dùng thăm dò khi cần quét các khối trước đó hoặc chạy mà không duy trì kết nối liên tục.

Nhận thanh toán bằng Webhook

Tạo đăng ký và theo dõi người nhận

Lấy API key và triển khai bộ nhận HTTPS trên port 443. Chọn CHAIN từ danh sách chuỗi Push có xác thực, đặt RECIPIENT thành địa chỉ nạp tiền và RECEIVER_URL thành URL bộ nhận. Ví dụ shell này cần jq; {} dùng số xác nhận mặc định của chuỗi. Kiểm tra min_confirmations, default_confirmations và max_confirmations trước khi chọn số khác. Push OpenAPI định nghĩa các yêu cầu này.

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"

Khi tạo, phản hồi trả id và secret. Lưu secret an toàn cho bộ nhận; subscription.json chứa thông tin xác thực. Thăm dò đăng ký cho đến khi applied_version >= change_version từ address-change.json, rồi ghi lại chains[CHAIN].applied_from_block. Địa chỉ mới bắt đầu khớp từ khối đó, vì vậy tiếp tục thăm dò cho mọi khoảng thanh toán trước đó.

Xác minh, loại trùng và kiểm tra thanh toán

Lưu hàm chữ ký body gốc thành verify-push.js. Bộ nhận bên dưới chấp nhận Web API Request trong Node.js và đọc byte gốc trước khi phân tích JSON. Tạo secrets dưới dạng Map ánh xạ chuỗi ID đăng ký tới secret đã lưu. Đặt cấu hình expected đáng tin cậy thành { chain, token, recipient, amountUnits }: token là hợp đồng stablecoin đã xác minh trên chuỗi đó và amountUnits là số tiền nguyên dương dự kiến theo đơn vị nhỏ nhất. So sánh số tiền bằng BigInt, tuyệt đối không dùng số dấu phẩy động hoặc ký hiệu token.

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 });
}

Triển khai store.transaction bằng lưu trữ bền vững. Trong một giao dịch, insertEventOnce chèn sự kiện theo khóa duy nhất (subscription_id, event.id) và trả false nếu trùng; commit cùng với recordPaymentCandidate hoặc enqueueRecovery. Hoàn tác toàn bộ ghi khi thất bại để lần thử lại có thể xử lý sự kiện. Tác vụ khôi phục cũng phải có tính lũy đẳng. Chỉ trả 2xx trong vòng 10 giây sau khi commit; áp dụng giới hạn body 1 MiB trên máy chủ HTTP.

Ví dụ này kiểm tra một số tiền thanh toán dự kiến. Với nhiều đơn hàng, tra cấu hình thanh toán đáng tin cậy theo chuỗi, token và người nhận, rồi đối chiếu thanh toán thiếu hoặc dư theo quy tắc của bạn. Khoản thanh toán cần xác minh vẫn phải được xác minh trên chuỗi và đáp ứng chính sách xác nhận trước khi ghi có. Giữa các đăng ký và thăm dò, đối chiếu cùng một chuyển khoản theo chuỗi, hash giao dịch và chỉ số log để hai đường gửi không ghi có hai lần; giữ hash khối để theo dõi khối bị thay thế.

Khôi phục các khối bị thiếu hoặc thay thế

Với subscription.gap, đưa tác vụ quét từ from_block đến to_block vào hàng đợi bằng đường thăm dò bên dưới hoặc các bộ dữ liệu Data API khả dụng. chain.reorg là thông báo miễn phí rằng các khối đã gửi bị thay thế, không phải khoảng trống gửi sự kiện. Đánh dấu hoặc loại bỏ sự kiện cũ trong khoảng đó theo ref; đối chiếu bản ghi thanh toán theo ref và tx_hash với chuỗi chuẩn trước khi xử lý sự kiện chuẩn được tự động gửi lại với ID mới. Loại trùng các sự kiện đó theo id. Thông báo tái tổ chức không đẩy tiến độ đã hoàn thành; ghi complete_through_block theo từng chuỗi, tuyệt đối không suy ra hoàn thành từ số khối lớn nhất của sự kiện.

Phát lại chấp nhận chain và from_block trong giới hạn replayable_from_block hiện tại. Nó chỉ gửi lại sự kiện khớp còn lưu; không quét khoảng trước khi địa chỉ hoặc chuỗi được thêm, hay lúc đăng ký ngoại tuyến. Giữ con trỏ thăm dò để bao phủ các khoảng đó và khoảng trống đã hết thời gian lưu. Lỗi yêu cầu và khoảng phát lại không hợp lệ được mô tả trong tham chiếu lỗi; phí gửi sự kiện, lịch sử và địa chỉ-ngày được giải thích trong quy tắc thanh toán.

Các phần còn lại triển khai lọc log ERC-20 và thăm dò bằng con trỏ để giám sát và khôi phục.

Sự kiện Transfer và tham số lọc

Hợp đồng token ERC-20 tiêu chuẩn phát sự kiện sau cho mỗi lần chuyển tiền:

event Transfer(address indexed from, address indexed to, uint256 value);

Khi gọi eth_getLogs, truyền địa chỉ hợp đồng token và mảng topics để lọc log khớp:

Tham sốGiá trịMô tả
addressĐịa chỉ hợp đồng token (hoặc mảng địa chỉ)Địa chỉ hợp đồng stablecoin mục tiêu. Có thể chỉ định một địa chỉ (ví dụ BSC USDT 0x55d398326f99059fF775485246999027B3197955, Base USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913), hoặc mảng địa chỉ để giám sát đồng thời nhiều token
topics[0]0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efHash chữ ký sự kiện: keccak256("Transfer(address,address,uint256)")
topics[1]nullĐịa chỉ người gửi (from). Vì giám sát tiền nạp chấp nhận tiền từ bất kỳ ví người dùng nào, truyền null để khớp mọi người gửi
topics[2]Địa chỉ người nhận đệm số 0 thành 32 byteĐịa chỉ đích (to). Theo đặc tả log EVM, tham số địa chỉ indexed chiếm 32 byte (64 ký tự hex). Đệm bên trái địa chỉ người nhận 20 byte bằng 12 byte 0 (24 ký tự 0 thập lục phân) để tạo topic 32 byte.
fromBlockKhối bắt đầu (thập lục phân)Đầu khoảng khối truy vấn (bao gồm)
toBlockKhối kết thúc (thập lục phân)Cuối khoảng khối truy vấn (bao gồm)

value không được đánh chỉ mục (số tiền chuyển) được mã hóa trong trường data của đối tượng log dưới dạng uint256 thập lục phân 32 byte. Chia số tiền thô này cho 10^decimals để được số lượng token dễ đọc (ví dụ 18 chữ số thập phân cho BSC USDT; 6 chữ số cho Base và Ethereum USDC).

Thăm dò bằng con trỏ và giới hạn khoảng khối

Dịch vụ thăm dò truy vấn khối mới theo chu kỳ đều đặn (chẳng hạn mỗi 3 đến 5 giây).

Tiến con trỏ

Duy trì con trỏ bền vững last_polled_block (khối cao nhất đã xử lý và commit) trong cơ sở dữ liệu:

  1. Với mỗi chu kỳ thăm dò, đặt fromBlock = last_polled_block + 1.
  2. Truy vấn đầu chuỗi hiện tại bằng eth_blockNumber và tính độ cao mục tiêu an toàn safe_head theo độ sâu xác nhận.
  3. Nếu fromBlock <= safe_head, truy vấn log theo từng phần đến safe_head. Sau khi xử lý thành công mỗi phần, tiến con trỏ.

Giới hạn khoảng khối

Độ rộng khối của một lần gọi eth_getLogs được tính bằng toBlock − fromBlock + 1. Nó không được vượt quá max_logs_block_range công bố cho chuỗi đó trong GET /v1/chains.

Nếu yêu cầu vượt khoảng này, dịch vụ từ chối lệnh gọi với mã lỗi -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
    }
  }
}

Yêu cầu vượt khoảng khối trả lỗi JSON-RPC -32602 (không tính phí). Trong logic ứng dụng, đọc max_logs_block_range từ GET /v1/chains và giới hạn mỗi phần thăm dò: chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head).

Xử lý tái tổ chức khối và độ sâu xác nhận

Gần đầu chuỗi, có thể xảy ra tái tổ chức khối tạm thời (reorg). Ghi có thanh toán tại latest mà không có độ sâu xác nhận có nguy cơ ghi có giao dịch trên nhánh khối mồ côi sau đó bị loại bỏ.

Áp dụng các biện pháp sau để bảo vệ việc xử lý thanh toán:

Độ sâu xác nhận

Thay vì truy vấn đến latest, truy vấn đến độ cao khối mục tiêu an toàn:

safe_head = current_head - CONFIRMATION_DEPTH

Đặt CONFIRMATION_DEPTH theo mức chấp nhận rủi ro của ứng dụng. Chỉ truy vấn đến safe_head đảm bảo chỉ xử lý khối có đủ xác nhận.

Tái tổ chức trong khi thăm dò

JSON-RPC EVM tiêu chuẩn chỉ đặt removed: true trên đối tượng log trong luồng đăng ký log WebSocket khi sự kiện đã phát bị đảo ngược do tái tổ chức chuỗi. Khi thăm dò qua HTTP bằng eth_getLogs, truy vấn trả log từ chuỗi chuẩn; log bị tái tổ chức đơn giản sẽ không xuất hiện trong các truy vấn tiếp theo. Thăm dò trong safe_head đảm bảo thanh toán chỉ được xử lý trên các khối đủ xác nhận.

Loại trùng theo (transactionHash, logIndex)

Bộ theo dõi thanh toán phải bảo đảm tính lũy đẳng nghiêm ngặt:

  1. Nhiều chuyển khoản trong một giao dịch: Một giao dịch có thể chứa nhiều sự kiện Transfer đến cùng địa chỉ nạp (ví dụ bộ định tuyến token chia giao dịch hoán đổi hoặc hợp đồng chi trả nhiều lần). Quan trọng: Chỉ riêng transactionHash không duy nhất cho từng khoản thanh toán.
  2. Thăm dò chồng lấn và thử lại: Khi dịch vụ thăm dò khởi động lại, khôi phục sau lỗi mạng tạm thời hoặc lùi vài khối để xử lý tái tổ chức nông, log từ cùng khoảng khối được truy vấn nhiều lần.
  3. Tính duy nhất của chỉ số log: logIndex xác định vị trí tương đối của log sự kiện trong khối. Theo đặc tả EVM, định danh duy nhất kết hợp chuẩn của sự kiện là (transactionHash, logIndex).

Trong lược đồ cơ sở dữ liệu quan hệ, khai báo chỉ mục duy nhất kết hợp trên bảng bản ghi tiền nạp:

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

Trước khi xử lý tiền nạp, đối chiếu các mục (transactionHash, logIndex) hiện có để bảo đảm mỗi chuyển khoản trên chuỗi chỉ được ghi có đúng một lần.

Ví dụ mã hoàn chỉnh

Các ví dụ bên dưới minh họa việc lấy khả năng mạng từ /v1/chains, tính khoảng khối an toàn, thăm dò log Transfer stablecoin tuân thủ giới hạn khoảng và loại trùng sự kiện.

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

Quy tắc thanh toán và hướng dẫn liên quan

Các bước tiếp theo

Cập nhật lần cuối:

Trên trang này