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
wsflag (boolean) and thesubscriptionsarray (such as whether"logs"is included). - If a chain sets
ws: falseor listseth_subscribeundermethods.deny, callingeth_subscribeover HTTP or an unsupported endpoint returns JSON-RPC error-32601(method not available: eth_subscribe). - Inspect
/v1/chainsat 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_getLogsprovides 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:
| 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:
- For each polling cycle, set
fromBlock = last_polled_block + 1. - Query the current chain head via
eth_blockNumber, and calculate the safe target heightsafe_headbased on your confirmation depth. - If
fromBlock <= safe_head, query logs in chunks up tosafe_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 asconfirmedand 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:
- Multiple Transfers in One Transaction: A single transaction can contain multiple
Transferevents to the same deposit address (for example, token routers splitting swaps or multi-payout contracts). Important:transactionHashalone is not unique per payment. - 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.
- Log Index Uniqueness: The
logIndexidentifies 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.mtsBilling Rules and Related Guides
- For details on request metering, CU weights, and error code billing determinations, see Billing Rules: Errors and Non-Billed Requests.
- For in-depth guidance on
eth_getLogsblock range limits and chunking logic, see eth_getLogs Block Range Limits and Chunked Queries. - 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.
Next steps
- Browse the datasets directory to see every dataset BlockVectra indexes.
- See the free plan and pricing to check what your account includes.
- Log in to the console to create an API key.
Last updated:
Programmatic sign-up
Sign up and create an API key programmatically using an Ethereum wallet signature (EIP-191) without a browser for AI agents, scripts, and CI workflows.
Logs vs Transfers API
Compare the JSON-RPC eth_getLogs method with the Data API transfers endpoints: block ranges, pagination, coverage bounds, and which fits a task.