Monitoring USDT / USDC Payments

Monitor incoming USDT and USDC payments via eth_getLogs polling on ERC-20 Transfer events, including cursor management, block range limits, deduplication, and reorg depth handling.

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:

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:

ParameterValueDescription
addressToken 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]0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efEvent signature hash: keccak256("Transfer(address,address,uint256)")
topics[1]nullSender address (from). Because deposit monitoring accepts funds from any user wallet, pass null to match any sender
topics[2]32-byte zero-padded recipient addressDestination 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)
fromBlockStarting block (hexadecimal)Beginning of the query block range (inclusive)
toBlockEnding 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:

{
  "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). 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:

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.

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

Next steps

Last updated:

On this page