Webhook と RPC を使用して USDT / USDC の支払いを監視する方法

支払いレシーバーとポーリングカーソルを構築。トークンコントラクト、受取先、整数金額を検証し、イベントを重複排除し、欠落または置き換えられたブロックを照合します。

ステーブルコイン決済の監視や取引所の入金検知を行うには、Webhook、WebSocket ログ、または HTTP ポーリングを使用して、EVM チェーン上の ERC-20 USDT / USDC の受取送金を監視します。開発者と AI エージェントは同じ API を使用します。支払いを処理する前に、チェーン、トークンコントラクト、受取先、および確認深度を選択してください。USDT / USDC 送金監視ソリューション で、入金、店舗通知、または出金のワークフローを選択してください。

基本的な送金監視が利用可能です。金額とトークンのフィルタリングはレシーバーで実行されます。サーバー側の条件設定、複数段階の確認、および IM 通知は近日提供予定です。

開発者および AI エージェント向け:まずは 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 を設定します。このシェルスクリプトの例では 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 には認証情報が含まれます。address-change.json の applied_version >= change_version になるまでサブスクリプションをポーリングし、chains[CHAIN].applied_from_block を記録します。新しいアドレスはそのブロックから一致するため、それより前の支払い期間についてはポーリングを継続してください。

支払いの検証、重複排除、および妥当性確認

raw-body 署名関数を verify-push.js として保存します。以下のレシーバーは Node.js で Web API の Request を受け取り、JSON 解析の前にその元のバイト列を読み取ります。サブスクリプション ID 文字列から保存されたシークレットへの Map として secrets を構築します。信頼できる 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 は永続ストレージを使用して実装してください。1 つのトランザクション内で、insertEventOnce は一意の (subscription_id, event.id) キーの下にイベントを挿入し、重複の場合は false を返します。これを recordPaymentCandidate または enqueueRecovery と一緒にコミットします。失敗した場合はすべての書き込みをロールバックして、再試行時にイベントを処理できるようにします。リカバリジョブも冪等でなければなりません。コミット後にのみ 10 秒以内に 2xx を返します。HTTP サーバーで 1 MiB のボディ制限を適用してください。

この例では、1 つの期待される支払い金額をチェックします。複数の注文がある場合は、チェーン、トークン、および受取先によって信頼できる支払い設定を検索し、独自のルールに基づいて部分的な支払いまたは超過支払いを調整します。候補レコードであっても、残高へ反映する前にオンチェーンでの検証と確認ポリシーの適用が必要です。サブスクリプションとポーリングの両方において、2 つの配信パスが同じ送金を二重に反映しないよう、チェーン、トランザクションハッシュ、およびログインデックスによって同一の送金を照合します。置き換えられたブロックを追跡するためにブロックハッシュを保持してください。

欠落または置き換えられたブロックのリカバリ

subscription.gap の場合、以下のポーリングパスまたは利用可能な Data API データセットを使用して、from_block から to_block までのスキャンをキューに入れます。chain.reorg は、配信されたブロックが置き換えられたことを示す無料の通知であり、配信ギャップではありません。その範囲内の古いイベントを ref でマークまたは破棄します。新しい ID で自動的に再配信される正規チェーンのイベントを処理する前に、ref と tx_hash によって支払い記録を正規チェーンと照合します。これらのイベントは id で重複排除します。reorg 通知によって完了済みの進捗が進むことはありません。チェーンごとに complete_through_block を記録し、最大のイベントブロック番号から完了を推測しないでください。

リプレイは、現在の replayable_from_block 境界内の chain と from_block を受け付けます。保持されている一致イベントのみを再送信し、アドレスまたはチェーンが追加される前の期間や、サブスクリプションがオフラインだった期間はスキャンしません。それらの期間や期限切れのギャップをカバーするために、ポーリングカーソルを維持してください。リクエストの失敗と無効なリプレイ範囲はエラーリファレンスで説明されています。配信、履歴、およびアドレス日数の料金は請求ルールで説明されています。

残りのセクションでは、監視とリカバリのための ERC-20 ログフィルタリングとカーソルベースのポーリングを実装します。

送金イベントとフィルタパラメータ

標準の 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(送金額)は、ログオブジェクトの data フィールドに 32 バイトの十六進数 uint256 としてエンコードされています。この生の金額を 10^decimals で割ることで、可読性のあるトークン金額が得られます(例:BSC USDT の場合は 18 桁、Base および Ethereum 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)と確認深度の処理

ブロックチェーンの先端付近では、一時的なブロック再編成(reorg)が発生する可能性があります。確認深度を設けずに latest で支払いを反映すると、その後破棄される孤立したブランチ上のトランザクションを反映してしまうリスクがあります。

支払い処理を保護するために、以下のセーフガードを適用してください:

確認深度

latest までクエリするのではなく、安全な目標ブロック高までクエリします:

safe_head = current_head - CONFIRMATION_DEPTH

アプリケーションのリスク許容度に応じて CONFIRMATION_DEPTH を設定します。safe_head までのみクエリすることで、十分な確認数を持つブロックのみが処理されるようになります。

ポーリング中の再編成

標準の EVM JSON-RPC は、以前に発行されたイベントがチェーンの reorg によって元に戻された場合、WebSocket ログ購読ストリームでのみログオブジェクトに removed: true を設定します。eth_getLogs を使用して HTTP 経由でポーリングする場合、クエリは正規チェーンからのログを返します。reorg されたログは、その後のクエリに単に現れなくなります。safe_head 内でポーリングすることで、十分に確認されたブロックでのみ支払いが処理されます。

(transactionHash, logIndex) による重複排除

支払いリスナーは厳密な冪等性を適用する必要があります:

  1. 1 つのトランザクション内での複数の送金:単一のトランザクションに、同じ入金先アドレスへの複数の Transfer イベントが含まれる場合があります(たとえば、スワップを分割するトークンルーターや複数の支払いを実行するコントラクト)。重要: transactionHash だけでは支払いごとに一意ではありません。
  2. 重複するポーリングと再試行:ポーリングサービスが再起動した場合、一時的なネットワークエラーから回復した場合、または浅い reorg を処理するために数ブロック巻き戻した場合、同じブロック範囲のログが複数回クエリされます。
  3. ログインデックスの一意性:logIndex は、ブロック内でのイベントログの相対位置を識別します。EVM の仕様では、イベントの正規の複合一意識別子は (transactionHash, logIndex) です。

リレーショナルデータベースのスキーマでは、入金記録テーブルに複合ユニークインデックスを宣言します:

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

入金を処理する前に、既存の (transactionHash, logIndex) エントリと照合して、各オンチェーントランスファーが正確に 1 回だけ反映されることを保証します。

完全なコード例

以下の例は、/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

請求ルールと関連ガイド

次のステップ

最終更新:

このページの目次