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

> Source: https://docs.blockvectra.com/ko/guides/stablecoin-payments/

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

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

개발자 및 AI 에이전트: [API key](https://console.blockvectra.com/login/?next=%2Fkeys%2F)와 자체 HTTPS 수신 서버를 준비하여 시작하세요. 애플리케이션에서 토큰 컨트랙트와 금액을 필터링하세요. [Webhook 설정 복사하기](#구독-생성-및-수신-주소-모니터링).

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

* 선택한 체인의 Push 지원 여부를 확인한 후 HTTPS 엔드포인트에서 [USDT / USDC 결제 알림 수신](#webhook으로-결제-수신하기).
* 온체인 검증 및 컨펌 정책을 적용하기 전에 체인, 토큰 컨트랙트, 수신 주소 및 정수 금액을 확인하여 [전송 후보 검증](#서명-검증-중복-제거-및-결제-유효성-검사).
* 범위가 제한된 `eth_getLogs` 쿼리와 저장된 커서를 사용하여 [누락된 전송 로그 백필](#커서-폴링-및-블록-범위-제한).

## Webhook, WebSocket 또는 폴링 선택하기

| 방식                                               | 용도                                  | 복구 경로                                                                             |
| ------------------------------------------------ | ----------------------------------- | --------------------------------------------------------------------------------- |
| [Webhook](https://docs.blockvectra.com/en/guides/webhook-push/)              | 토큰 입금을 포함하여 HTTPS 수신 서버로 전송되는 주소 활동 | 서명 검증, 이벤트 ID 중복 제거 및 `subscription.gap` / `chain.reorg` 처리, 보존된 일치 항목 재생(replay) |
| [WebSocket](https://docs.blockvectra.com/en/guides/websocket-subscriptions/) | 지속적인 연결을 통해 필터링된 `logs` 수신          | 재연결, 재구독 및 누락된 블록 백필                                                              |
| HTTP 폴링                                          | 정기적인 모니터링 또는 자체 커서를 통한 과거 로그 백필     | 범위가 제한된 `eth_getLogs` 쿼리 수행 및 진행 상태 지속 저장                                         |

WebSocket을 선택하기 전에 퍼블릭 `GET /v1/chains` 응답에서 `ws` 및 `subscriptions`를 확인하세요. Push 지원은 별도로 확인해야 합니다. API key를 사용하여 `GET /v1/push/chains`를 호출하세요. WebSocket이 지원되지 않는 체인이라도 해당 목록에 포함되어 있다면 주소 Webhook을 사용할 수 있습니다. 이전 블록을 스캔해야 하거나 지속적인 연결 없이 실행해야 하는 경우 폴링을 사용하세요.

## Webhook으로 결제 수신하기

### 구독 생성 및 수신 주소 모니터링

[API key를 발급받고](https://blockvectra.com/en/get-api-key/) 포트 443에 HTTPS 수신 서버를 배포하세요. 인증된 Push 체인 목록에서 `CHAIN`을 선택하고, `RECIPIENT`를 입금 주소로, `RECEIVER_URL`을 수신 서버 URL로 설정합니다. 이 셸 예제에는 `jq`가 필요하며, `{}`는 체인의 기본 컨펌 수를 사용합니다. 다른 컨펌 수를 선택하기 전에 `min_confirmations`, `default_confirmations`, `max_confirmations`를 확인하세요. [Push OpenAPI](https://docs.blockvectra.com/openapi/push.yaml)에 이러한 요청이 정의되어 있습니다.

```bash
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`을 기록하세요. 새 주소는 해당 블록부터 매칭되므로, 그 이전의 결제 구간에 대해서는 계속 폴링을 수행해야 합니다.

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

[원시 본문 서명 검증 함수](https://docs.blockvectra.com/en/guides/webhook-push/#verify-signatures)를 `verify-push.js`로 저장하세요. 아래의 수신 서버는 Node.js에서 Web API `Request`를 받아 JSON 파싱 전에 원래 바이트를 읽습니다. 구독 ID 문자열과 저장된 secret을 매핑하는 `secrets` `Map`을 구성하세요. 신뢰할 수 있는 `expected` 구성을 `{ chain, token, recipient, amountUnits }`로 설정합니다. 여기서 `token`은 해당 체인에서 검증된 스테이블코인 컨트랙트이고, `amountUnits`는 최소 단위 기준의 예상 양의 정수 금액입니다. 금액은 부동 소수점 숫자나 토큰 심볼이 아닌 반드시 `BigInt`로 비교하세요.

```js
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)](https://docs.blockvectra.com/en/guides/webhook-push/#delivery-retries-and-replay)은 현재 `replayable_from_block` 경계 내에서 `chain` 및 `from_block`을 허용합니다. 이는 보존된 일치 항목만 다시 전송하며, 주소나 체인이 추가되기 전의 기간이나 구독이 오프라인이었던 기간은 스캔하지 않습니다. 이러한 간격과 만료된 누락 구간을 처리하기 위해 폴링 커서를 유지하세요. 요청 실패 및 잘못된 재생 범위는 [오류 레퍼런스](https://docs.blockvectra.com/en/errors/)에서 다루며, 전송, 이력 및 주소-일 단위 요금은 [과금 규칙](https://docs.blockvectra.com/en/guides/billing-rules/#webhook-push-billing)에 설명되어 있습니다.

나머지 섹션에서는 모니터링 및 복구를 위한 ERC-20 로그 필터링과 커서 기반 폴링을 구현합니다.

## Transfer 이벤트 및 필터 파라미터

표준 ERC-20 토큰 컨트랙트는 전송이 발생할 때마다 다음 이벤트를 발생시킵니다:

```solidity
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`로 호출을 거부합니다:

```json
{
  "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)`입니다.

관계형 데이터베이스 스키마에서 입금 기록 테이블에 복합 고유 인덱스를 선언하세요:

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

입금을 처리하기 전에 기존 `(transactionHash, logIndex)` 항목과 대조하여 각 온체인 전송이 정확히 한 번만 입금 처리되도록 보장하세요.

## 전체 코드 예제

아래 예제는 `/v1/chains`에서 네트워크 기능을 가져오고, 안전한 블록 범위를 계산하며, 범위 제한을 준수하여 스테이블코인 `Transfer` 로그를 폴링하고 이벤트를 중복 제거하는 과정을 보여줍니다.

**TypeScript**

```ts
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
```


  **Python**

```python
from decimal import Decimal
import os
import requests

api_key = os.environ.get("BLOCKVECTRA_API_KEY")
if not api_key:
    raise RuntimeError("BLOCKVECTRA_API_KEY environment variable is not set")

CHAIN = "bsc_mainnet"
RPC_URL = "https://api.blockvectra.com/v1/bsc_mainnet"
CHAINS_URL = "https://api.blockvectra.com/v1/chains"

# Target stablecoin contract address (BSC USDT used in this example)
TOKEN_CONTRACT = "0x55d398326f99059fF775485246999027B3197955"
TOKEN_DECIMALS = 18

# Monitored deposit address
RECIPIENT_ADDRESS = "0xdded13D555B6DA811103cC1794D3d4330F69632C"

# Transfer(address,address,uint256) signature hash
TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"

# Left-pad 20-byte address to 32 bytes (64 hex characters)
padded_recipient = f"0x{RECIPIENT_ADDRESS.lower()[2:].rjust(64, '0')}"

# Confirmation depth to guard against chain reorgs
CONFIRMATION_DEPTH = 15

# 1. Fetch chain capabilities from public metadata endpoint (unauthenticated, unbilled)
chains_res = requests.get(CHAINS_URL, timeout=10)
chains_res.raise_for_status()
chain_list = chains_res.json().get("chains", [])

chain_config = next((c for c in chain_list if c["chain"] == CHAIN), None)
if not chain_config:
    raise RuntimeError(f"Chain {CHAIN} not found in /v1/chains")

max_logs_range = chain_config.get("max_logs_block_range", 1000)
ws_supported = chain_config.get("ws", False)
print(f"Chain: {CHAIN} | WebSocket supported: {ws_supported} | Max logs range: {max_logs_range}")

def rpc_request(method: str, params: list):
    res = requests.post(
        RPC_URL,
        headers={
            "Content-Type": "application/json",
            "x-api-key": api_key,
        },
        json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
        timeout=15,
    )
    res.raise_for_status()
    payload = res.json()
    if "error" in payload:
        err = payload["error"]
        raise RuntimeError(f"JSON-RPC error {err.get('code')}: {err.get('message')}")
    return payload["result"]

# 2. Query latest block number and calculate safe head
current_head_hex = rpc_request("eth_blockNumber", [])
current_head = int(current_head_hex, 16)
safe_head = max(0, current_head - CONFIRMATION_DEPTH)

# For demonstration, start cursor 10 blocks before safe_head
cursor = max(0, safe_head - 10)
print(f"Current head: {current_head} | Safe head: {safe_head} | Polling cursor: {cursor}")

# In-memory deduplication set using (transactionHash, logIndex)
processed_logs = set()

while cursor <= safe_head:
    chunk_end = min(cursor + max_logs_range - 1, safe_head)

    logs = rpc_request(
        "eth_getLogs",
        [
            {
                "address": TOKEN_CONTRACT,
                "fromBlock": hex(cursor),
                "toBlock": hex(chunk_end),
                "topics": [
                    TRANSFER_TOPIC0,
                    None,  # match any sender
                    padded_recipient,  # match monitored recipient
                ],
            }
        ],
    )

    for log in logs:
        tx_hash = log["transactionHash"]
        log_index = int(log["logIndex"], 16)
        dedup_key = (tx_hash, log_index)

        if dedup_key in processed_logs:
            continue
        processed_logs.add(dedup_key)

        raw_amount = int(log["data"], 16)
        token_amount = Decimal(raw_amount) / (Decimal(10) ** TOKEN_DECIMALS)
        block_number = int(log["blockNumber"], 16)

        print(
            f"[Payment Received] Amount: {token_amount} | "
            f"Tx: {tx_hash} | Log: {log_index} | Block: {block_number}"
        )

    cursor = chunk_end + 1

# Run with: python example.py
```


## 과금 규칙 및 관련 가이드

* 요청 계량, CU 가중치 및 오류 코드별 과금 여부에 대한 자세한 내용은 [과금 규칙: 오류 및 비과금 요청](https://docs.blockvectra.com/en/guides/billing-rules/)을 참조하세요.
* `eth_getLogs` 블록 범위 제한 및 청크 분할 로직에 대한 심층 안내는 [eth\_getLogs 블록 범위 제한 및 청크 쿼리](https://docs.blockvectra.com/en/guides/getlogs-block-range/)를 참조하세요.
* 실시간 RPC 노드 쿼리와 인덱싱된 과거 전송 API 간의 차이점은 [체인 헤드 vs 인덱싱된 이력: eth\_getLogs와 Transfers 비교](https://docs.blockvectra.com/en/guides/logs-vs-transfers/)를 참조하세요.

## 다음 단계

* [데이터셋 디렉터리 살펴보기](https://blockvectra.com/en/data/): BlockVectra가 인덱싱하는 모든 데이터셋을 확인하세요.
* [무료 플랜 및 요금 확인](https://blockvectra.com/en/pricing/#free): 계정에 포함된 혜택을 확인하세요.
* [콘솔에 로그인](https://console.blockvectra.com/login/?next=%2Fkeys%2F): API key를 생성하세요.
