Як відстежувати платежі в USDT / USDC за допомогою Webhook та RPC

Створіть обробник платежів та курсор опитування. Верифікуйте контракти токенів, одержувачів і цілочисельні суми, дедуплікуйте події та узгоджуйте пропущені або замінені блоки.

Для моніторингу платежів у стейблкоїнах або виявлення депозитів на біржі відстежуйте вхідні перекази ERC-20 USDT / USDC в EVM-мережах за допомогою Webhook, логів WebSocket або HTTP-опитування. Розробники та AI-агенти використовують ті самі API; оберіть мережу, контракт токена, одержувача та глибину підтвердження перед обробкою платежів. Оберіть робочий процес для депозитів, сповіщень продавців або виплат у рішенні для моніторингу переказів USDT / USDC.

  • Перший крок: Створіть підписку та додайте одержувача до списку відстеження, почавши з API key та вашого HTTPS-обробника.
  • Критерій завершення: відповідний переказ проходить перевірку підпису, мережі, токена, одержувача та цілочисельної суми, зберігається один раз як кандидат на платіж, а обробник повертає HTTP 204; верифікуйте його ончейн відповідно до вашої політики підтверджень перед зарахуванням.

Робочі процеси платежів у стейблкоїнах.

Базовий моніторинг переказів уже доступний. Фільтрація сум і токенів виконується у вашому обробнику. Серверні умови, кілька стадій підтвердження та сповіщення в месенджери з'являться незабаром.

Для розробників та AI-агентів: почніть з API key і власного HTTPS-обробника; фільтруйте контракти токенів і суми у вашому застосунку. Скопіюйте налаштування webhook.

Завдання, які допомагає вирішити цей посібник

Вибір між Webhook, WebSocket або опитуванням

МетодДля чого використовуватиВідновлення
WebhookАктивність адреси, надіслана на ваш HTTPS-обробник, включно із вхідними переказами токенівВерифікація підписів, дедуплікація ID подій та обробка subscription.gap / chain.reorg; повторне відтворення збережених збігів
WebSocketВідфільтровані logs через постійне з'єднанняПовторне підключення, повторна підписка та бекфіл пропущених блоків
HTTP-опитуванняМоніторинг за розкладом або бекфіл історичних логів із власним курсоромЗапит обмежених діапазонів eth_getLogs та збереження прогресу

Перед вибором WebSocket перевірте поля ws та subscriptions у публічній відповіді GET /v1/chains. Підтримка Push — це окрема перевірка: виконайте GET /v1/push/chains із вашим API key. Мережа без WebSocket може використовувати Webhook для адрес, якщо вона присутня в цьому списку. Використовуйте опитування, коли потрібно сканувати раніші блоки або працювати без постійного з'єднання.

Отримання платежів за допомогою Webhook

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

Отримайте API key та розгорніть HTTPS-обробник на порту 443. Оберіть CHAIN зі списку автентифікованих Push-мереж, встановіть 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. Надійно збережіть 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 у разі дубліката; фіксуйте її разом із recordPaymentCandidate або enqueueRecovery. Відкочуйте всі операції запису в разі збою, щоб повторна спроба могла обробити подію. Завдання відновлення також мають бути ідемпотентними. Повертайте 2xx упродовж 10 секунд лише після фіксації транзакції; забезпечте ліміт розміру тіла запиту в 1 MiB на вашому 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. Він лише повторно надсилає збережені збіги; він не сканує періоди до додавання адреси чи мережі або періоди, коли підписка була офлайн. Зберігайте курсор опитування для покриття цих інтервалів та застарілих прогалин. Збої запитів і недійсні діапазони повторного відтворення розглядаються в довіднику помилок; нарахування за доставку, історію та дні адрес описані в правилах білінгу.

У наступних розділах реалізовано фільтрацію логів 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";

// Цільова адреса контракту стейблкоїна (у цьому прикладі використовується BSC USDT)
const TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955" as const;
const TOKEN_DECIMALS = 18;

// Відстежувана депозитна адреса
const RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C" as const;

// Глибина підтвердження для захисту від реорганізацій ланцюга
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(`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. Ініціалізуйте клієнт viem із заголовком x-api-key
const client = createPublicClient({
  transport: http(RPC_URL, {
    fetchOptions: {
      headers: { "x-api-key": apiKey },
    },
  }),
});

// Set для відстеження оброблених подій за складеним ключем: (transactionHash, logIndex)
const processedLogs = new Set<string>();

// 3. Розрахуйте діапазон запиту: відніміть глибину підтвердження від поточної вершини
const currentHead = await client.getBlockNumber();
const safeHead = currentHead - CONFIRMATION_DEPTH;

// Для демонстрації почніть курсор за 10 блоків до 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;
}

// Запуск: npx tsx example.mts

Наступні кроки

Востаннє оновлено:

На цій сторінці