# Monitoring USDT / USDC Payments

> Original page: https://docs.blockvectra.com/en/guides/stablecoin-payments/

Monitoring incoming on-chain stablecoin payments (such as USDT and USDC) is a core requirement for crypto payment checkouts, deposit accounting, and automated reconciliation systems. On EVM-compatible blockchains, standard ERC-20 token transfers emit `Transfer` events from their smart contracts.

This guide explains how to build a reliable stablecoin payment monitor using the standard JSON-RPC method `eth_getLogs`, covering topic filter construction, cursor-based chunked polling, chain reorganization mitigation, and event deduplication.

## Delivery Mechanism: WebSocket vs HTTP Polling

Before implementing event monitoring, determine whether your target network supports live WebSocket subscriptions.

WebSocket and subscription capabilities are published dynamically per chain via `GET /v1/chains` (unauthenticated and unbilled):

* Inspect the target chain configuration for the `ws` flag (boolean) and the `subscriptions` array (such as whether `"logs"` is included).
* If a chain sets `ws: false` or lists `eth_subscribe` under `methods.deny`, calling `eth_subscribe` over HTTP or an unsupported endpoint returns JSON-RPC error `-32601` (`method not available: eth_subscribe`).
* Inspect `/v1/chains` at runtime rather than hardcoding which chains support or do not support WebSocket.
* When a network does not support WebSocket subscriptions, or in serverless functions, background workers, and cron jobs where long-lived persistent connections are impractical, cursor-based HTTP polling with `eth_getLogs` provides a reliable, standard solution.

## Transfer Event and Filter Parameters

Standard ERC-20 token contracts emit the following event on every transfer:

```solidity
event Transfer(address indexed from, address indexed to, uint256 value);
```

When calling `eth_getLogs`, pass the token contract address and the `topics` array to filter matching logs:

| Parameter   | Value                                                                | Description                                                                                                                                                                                                                                                 |
| ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `address`   | Token contract address (or array of addresses)                       | Target stablecoin contract address. You can specify a single address (e.g. BSC USDT `0x55d398326f99059fF775485246999027B3197955`, Base USDC `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`), or an array of addresses to monitor multiple tokens concurrently |
| `topics[0]` | `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef` | Event signature hash: `keccak256("Transfer(address,address,uint256)")`                                                                                                                                                                                      |
| `topics[1]` | `null`                                                               | Sender address (`from`). Because deposit monitoring accepts funds from any user wallet, pass `null` to match any sender                                                                                                                                     |
| `topics[2]` | 32-byte zero-padded recipient address                                | Destination address (`to`). Under EVM log specifications, `indexed` address parameters occupy 32 bytes (64 hex characters). Left-pad the 20-byte recipient address with 24 leading zeros (48 hex zero characters)                                           |
| `fromBlock` | Starting block (hexadecimal)                                         | Beginning of the query block range (inclusive)                                                                                                                                                                                                              |
| `toBlock`   | Ending block (hexadecimal)                                           | End of the query block range (inclusive)                                                                                                                                                                                                                    |

The unindexed `value` (transfer amount) is encoded in the log object's `data` field as a 32-byte hexadecimal `uint256`. Divide this raw amount by 10^decimals to get the human-readable token amount (e.g. 18 decimals for BSC USDT; 6 decimals for Base and Ethereum USDC).

## Cursor Polling and Block Range Limits

A polling service queries new blocks at regular intervals (such as every 3 to 5 seconds).

### Cursor Advancement

Maintain a persistent cursor `last_polled_block` (the highest block processed and committed) in your database:

1. For each polling cycle, set `fromBlock = last_polled_block + 1`.
2. Query the current chain head via `eth_blockNumber`, and calculate the safe target height `safe_head` based on your confirmation depth.
3. If `fromBlock <= safe_head`, query logs in chunks up to `safe_head`. After successfully processing each chunk, advance the cursor.

### Block Range Limit

The block span of a single `eth_getLogs` call is calculated as `toBlock − fromBlock + 1`. It must not exceed the `max_logs_block_range` published for that chain in `GET /v1/chains` (typically 1,000 blocks).

If a request exceeds this range, the service rejects the call with error code `-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
    }
  }
}
```

Requests rejected by the service for exceeding the block range are **not billed** (`billed: false`). In your application logic, read `max_logs_block_range` from `GET /v1/chains` and clamp each poll slice: `chunk_end = min(fromBlock + max_logs_block_range - 1, safe_head)`.

## Handling Block Reorganizations and Confirmation Depth

Near the tip of the blockchain, temporary block reorganizations (reorgs) can occur. Crediting payments at `latest` without confirmation depth risks crediting transactions on an orphaned branch that is subsequently discarded.

Apply the following safeguards to protect payment processing:

### Confirmation Depth

Instead of querying up to `latest`, query up to a safe target block height:

`safe_head = current_head - CONFIRMATION_DEPTH`

* Recommended depths vary by network and risk tolerance: on BSC or Base, 15 blocks is standard; on Ethereum mainnet, 12 to 32 blocks is common.
* When an incoming transfer is first detected near the chain head, record it in your system as `pending`. Mark the payment as `confirmed` and credit user account balances only after the transaction achieves the required confirmation depth.

### Checking the `removed` Flag

Standard EVM JSON-RPC sets `removed: true` on log objects when a previously emitted event is reverted due to a chain reorg. Your processing pipeline must check this property:

* If `log.removed === true`, discard the log and do not credit the payment.
* If a matching pending deposit was previously recorded for that log, mark it as cancelled or revoked.

## Deduplication by (transactionHash, logIndex)

Payment listeners must enforce strict idempotency:

1. **Multiple Transfers in One Transaction**: A single transaction can contain multiple `Transfer` events to the same deposit address (for example, token routers splitting swaps or multi-payout contracts). &#x2A;*Important:** `transactionHash` alone is not unique per payment.
2. **Overlapping Polling & Retries**: When polling services restart, recover from transient network errors, or rewind several blocks to handle shallow reorgs, logs from the same block range are queried multiple times.
3. **Log Index Uniqueness**: The `logIndex` identifies the relative position of the event log within the transaction or block. Under EVM specifications, the canonical composite unique identifier for an event is `(transactionHash, logIndex)`.

In relational database schemas, declare a composite unique index on your deposit records table:

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

Before processing a deposit, check against existing `(transactionHash, logIndex)` entries to guarantee that each on-chain transfer is credited exactly once.

## Complete Code Examples

The examples below illustrate fetching network capabilities from `/v1/chains`, calculating safe block ranges, polling stablecoin `Transfer` logs in compliance with range limits, checking `removed`, and deduplicating events.

**TypeScript**

```ts
import { createPublicClient, http, pad, type Hex } 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;

// Transfer(address,address,uint256) signature hash
const TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" as const;

// Pad 20-byte address to 32 bytes (64 hex characters)
const paddedRecipient = pad(RECIPIENT_ADDRESS.toLowerCase() as Hex, { size: 32 });

// 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,
    fromBlock: cursor,
    toBlock: chunkEnd,
    topics: [
      TRANSFER_TOPIC0,
      null, // match any sender
      paddedRecipient, // match monitored recipient
    ],
  });

  for (const log of logs) {
    // Skip logs invalidated by a chain reorg
    if (log.removed) continue;

    const dedupKey = `${log.transactionHash}-${log.logIndex}`;
    if (processedLogs.has(dedupKey)) {
      continue;
    }
    processedLogs.add(dedupKey);

    // Decode uint256 transfer value
    const rawAmount = BigInt(log.data);
    const divisor = 10n ** BigInt(TOKEN_DECIMALS);
    const integerPart = rawAmount / divisor;
    const fractionalPart = rawAmount % divisor;

    console.log(
      `[Payment Received] Amount: ${integerPart}.${fractionalPart} | ` +
      `Tx: ${log.transactionHash} | Log: ${log.logIndex} | Block: ${log.blockNumber}`
    );
  }

  cursor = chunkEnd + 1n;
}

// Run with: npx tsx example.mts
```


  **Python**

```python
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:
        # Discard logs reverted by a chain reorg
        if log.get("removed", False):
            continue

        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 = raw_amount / (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
```


## Billing Rules and Related Guides

* For details on request metering, CU weights, and error code billing determinations, see [Billing Rules: Errors and Non-Billed Requests](https://docs.blockvectra.com/en/guides/billing-rules/).
* For in-depth guidance on `eth_getLogs` block range limits and chunking logic, see [eth\_getLogs Block Range Limits and Chunked Queries](https://docs.blockvectra.com/en/guides/getlogs-block-range/).
* For differences between real-time RPC node queries and indexed historical transfer APIs, see [Head-of-Chain vs Indexed History: When to Use eth\_getLogs vs Transfers](https://docs.blockvectra.com/en/guides/logs-vs-transfers/).

## Next steps

* [Browse the datasets directory](https://blockvectra.com/en/data/) to see every dataset BlockVectra indexes.
* [See the free plan and pricing](https://blockvectra.com/en/pricing/#free) to check what your account includes.
* [Log in to the console](https://console.blockvectra.com/en/login/?next=%2Fen%2Fkeys%2F) to create an API key.
