Как отслеживать платежи USDT / USDC с помощью Webhooks и RPC

Создайте приемник платежей и курсор опроса. Проверяйте контракты токенов, получателей и целочисленные суммы, дедуплицируйте события и согласовывайте пропущенные или замененные блоки.

Для мониторинга платежей в стейблкоинах или обнаружения депозитов на бирже отслеживайте входящие переводы ERC-20 USDT / USDC в EVM-сетях с помощью Webhooks, логов WebSocket или HTTP-опроса. Разработчики и 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 и сохранение прогресса

Проверьте ws и subscriptions в публичном ответе GET /v1/chains перед выбором WebSocket. Поддержка Push проверяется отдельно: выполните GET /v1/push/chains с вашим API key. Сеть без WebSocket может использовать Webhooks для адресов, если она указана в этом списке. Используйте опрос, когда необходимо просканировать более ранние блоки или работать без постоянного соединения.

Прием платежей с помощью Webhooks

Создание подписки и отслеживание получателя

Получите API key и разверните HTTPS-приемник на порту 443. Выберите CHAIN из авторизованного списка сетей Push, установите RECIPIENT в адрес вашего депозита, а RECEIVER_URL — в URL вашего приемника. Для этого примера оболочки требуется 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. Приведенный ниже приемник принимает Web API Request в Node.js и считывает его исходные байты до парсинга JSON. Сформируйте secrets как Map строк ID подписок к сохраненным секретам. Задайте доверенную конфигурацию 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 при дубликате; зафиксируйте (commit) его вместе с recordPaymentCandidate или enqueueRecovery. Откатывайте все записи при сбое, чтобы повторная попытка могла обработать событие. Задачи восстановления также должны быть идемпотентными. Возвращайте ответ 2xx в течение 10 секунд только после фиксации; настройте лимит размера тела запроса в 1 МиБ на вашем HTTP-сервере.

Этот пример проверяет одну ожидаемую сумму платежа. Для нескольких заказов находите доверенную конфигурацию платежа по сети, токену и получателю и сопоставляйте частичные или избыточные платежи по собственным правилам. Кандидат на платеж все еще требует ончейн-проверки и соблюдения вашей политики подтверждений перед зачислением. При совместном использовании подписок и опроса сопоставляйте один и тот же перевод по сети, хешу транзакции и индексу лога, чтобы два пути доставки не зачислили его дважды; сохраняйте хеш блока для отслеживания замененных блоков.

Восстановление пропущенных или замененных блоков

Для subscription.gap поставьте в очередь сканирование от from_block до to_block, используя путь опроса ниже или доступные наборы данных Data API. chain.reorg — это бесплатное уведомление о том, что доставленные блоки были заменены, а не о пропуске доставки. Пометьте или отбросьте старые события в этом диапазоне по ref; сверяйте записи платежей по ref и tx_hash с канонической цепочкой перед обработкой автоматически повторно доставленных канонических событий с новыми ID. Дедуплицируйте эти события по id. Уведомление о реорганизации не продвигает завершенный прогресс; фиксируйте complete_through_block для каждой сети, никогда не выводя завершение из наибольшего номера блока событий.

Повторная отправка (Replay) принимает chain и from_block в пределах текущей границы replayable_from_block. Она лишь повторно отправляет сохраненные совпадения; она не сканирует периоды до добавления адреса или сети, а также периоды, когда подписка была отключена. Поддерживайте курсор опроса для покрытия этих интервалов и устаревших пропусков. Ошибки запросов и недопустимые диапазоны replay описаны в справочнике ошибок; плата за доставку, историю и адресо-дни объясняется в правилах биллинга.

В остальных разделах рассматривается реализация фильтрации логов 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-байтового топика.
fromBlockНачальный блок (шестнадцатеричный)Начало диапазона блоков запроса (включительно)
toBlockКонечный блок (шестнадцатеричный)Конец диапазона блоков запроса (включительно)

Неиндексированное значение value (сумма перевода) закодировано в поле data объекта лога как 32-байтовое шестнадцатеричное число uint256. Разделите эту исходную сумму на 10^decimals, чтобы получить понятное человеку количество токенов (например, 18 знаков после запятой для BSC USDT; 6 знаков для Base и Ethereum USDC).

Опрос по курсору и ограничения диапазона блоков

Сервис опроса запрашивает новые блоки через регулярные интервалы (например, каждые 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. Он не должен превышать max_logs_block_range, опубликованный для этой сети в GET /v1/chains.

Если запрос превышает этот диапазон, сервис отклоняет вызов с кодом ошибки -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 (не тарифицируются). В логике вашего приложения считайте max_logs_block_range из GET /v1/chains и ограничивайте каждый интервал опроса: chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head).

Обработка реорганизаций блоков и глубина подтверждений

Вблизи вершины блокчейна могут происходить временные реорганизации блоков (reorgs). Зачисление платежей на блоке latest без необходимой глубины подтверждений создает риск зачисления транзакций в изолированной ветке, которая впоследствии будет отброшена.

Применяйте следующие защитные меры для обеспечения безопасности обработки платежей:

Глубина подтверждений

Вместо запроса вплоть до latest запрашивайте данные до безопасной целевой высоты блока:

safe_head = current_head - CONFIRMATION_DEPTH

Задайте CONFIRMATION_DEPTH в соответствии с допустимым уровнем риска вашего приложения. Запрос только до safe_head гарантирует обработку только тех блоков, которые получили достаточное количество подтверждений.

Реорганизации во время опроса

Стандартный EVM JSON-RPC устанавливает removed: true в объектах логов только в потоках подписок на логи WebSocket, когда ранее сгенерированное событие откатывается из-за реорганизации сети. При опросе по 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

Следующие шаги

Последнее обновление:

На этой странице