Webhook 및 RPC로 USDT / USDC 결제 모니터링하기

결제 수신 서버와 폴링 커서를 구축하세요. 토큰 컨트랙트, 수신 주소 및 정수 금액을 검증하고, 이벤트를 중복 제거하며 누락되거나 교체된 블록을 대조합니다.

스테이블코인 결제 모니터링이나 거래소 입금 감지를 위해 Webhook, WebSocket 로그 또는 HTTP 폴링을 사용하여 EVM 체인에서 발생하는 ERC-20 USDT / USDC 전송을 모니터링하세요. 개발자와 AI 에이전트는 동일한 API를 사용합니다. 결제를 처리하기 전에 체인, 토큰 컨트랙트, 수신 주소 및 컨펌 깊이를 선택하세요. USDT / USDC 전송 모니터링 솔루션에서 입금, 가맹점 알림 또는 출금 워크플로를 선택하세요.

기본 전송 모니터링을 사용할 수 있습니다. 금액 및 토큰 필터링은 수신 서버에서 직접 수행합니다. 서버 측 조건, 다단계 컨펌 및 메신저 알림은 곧 제공될 예정입니다.

개발자 및 AI 에이전트: API key와 자체 HTTPS 수신 서버를 준비하여 시작하세요. 애플리케이션에서 토큰 컨트랙트와 금액을 필터링하세요. Webhook 설정 복사하기.

이 가이드를 통해 완료할 수 있는 작업

Webhook, WebSocket 또는 폴링 선택하기

방식용도복구 경로
Webhook토큰 입금을 포함하여 HTTPS 수신 서버로 전송되는 주소 활동서명 검증, 이벤트 ID 중복 제거 및 subscription.gap / chain.reorg 처리, 보존된 일치 항목 재생(replay)
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이 반환됩니다. 수신 서버를 위해 secret을 안전하게 저장하세요. subscription.json에는 자격 증명이 포함되어 있습니다. address-change.json의 applied_version >= change_version이 될 때까지 구독을 폴링한 다음 chains[CHAIN].applied_from_block을 기록하세요. 새 주소는 해당 블록부터 매칭되므로, 그 이전의 결제 구간에 대해서는 계속 폴링을 수행해야 합니다.

서명 검증, 중복 제거 및 결제 유효성 검사

원시 본문 서명 검증 함수를 verify-push.js로 저장하세요. 아래의 수신 서버는 Node.js에서 Web API Request를 받아 JSON 파싱 전에 원래 바이트를 읽습니다. 구독 ID 문자열과 저장된 secret을 매핑하는 secrets 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는 전달된 블록이 교체되었음을 알리는 무료 통지이며, 전송 누락(gap)이 아닙니다. 해당 범위의 이전 이벤트를 ref로 표시하거나 폐기하세요. 새로운 ID로 자동 재전송된 정규 이벤트를 처리하기 전에 표준 체인과 대조하여 ref 및 tx_hash를 기준으로 결제 기록을 정산하세요. 이러한 이벤트는 id로 중복을 제거합니다. 재구성(reorg) 통지는 완료된 진행 상태를 앞으로 전진시키지 않습니다. 체인별로 complete_through_block을 기록하고, 가장 큰 이벤트 블록 번호로부터 완료 여부를 유추하지 마세요.

재생(Replay)은 현재 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바이트 0으로 채워진 수신 주소목적지 주소 (to). EVM 로그 사양에 따라 indexed 주소 파라미터는 32바이트(64자리 16진수)를 차지합니다. 20바이트 수신 주소의 왼쪽에 12개의 0 바이트(24자리 16진수 0 문자)를 채워 32바이트 토픽을 만듭니다.
fromBlock시작 블록 (16진수)조회할 블록 범위의 시작 (포함).
toBlock종료 블록 (16진수)조회할 블록 범위의 끝 (포함).

인덱싱되지 않은 value(전송 금액)는 로그 객체의 data 필드에 32바이트 16진수 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에서 결제를 확정하면 나중에 버려지는 고립(orphaned) 브랜치의 트랜잭션을 승인할 위험이 있습니다.

결제 처리를 보호하기 위해 다음 안전 장치를 적용하세요:

컨펌 깊이

latest까지 쿼리하는 대신 안전한 목표 블록 높이까지 쿼리하세요:

safe_head = current_head - CONFIRMATION_DEPTH

애플리케이션의 위험 감수 수준에 따라 CONFIRMATION_DEPTH를 설정하세요. safe_head까지만 쿼리하면 충분한 컨펌을 거친 블록만 처리되도록 보장할 수 있습니다.

폴링 중 블록 재구성

표준 EVM JSON-RPC는 체인 재구성으로 인해 이전에 발생한 이벤트가 되돌려질 때 WebSocket 로그 구독 스트림의 로그 객체에서만 removed: true를 설정합니다. 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

과금 규칙 및 관련 가이드

다음 단계

최종 수정일:

이 페이지의 내용