How to watch ERC-20 Transfer events with eth_getLogs (and where polling breaks)

By Chainitor · Published

Every ERC-20 token emits a Transfer event when tokens move. If you want to know when a wallet receives USDC, when a treasury sends tokens out, or when a token is minted, you don’t need to trace transactions. You ask a node for the logs that match a filter. The JSON-RPC method for that is eth_getLogs, and every EVM chain supports it.

This guide covers the filter, a quick look from the command line with Foundry’s cast, a polling loop in ethers v6, and the places where that loop goes wrong in production. If what you want is a wallet’s current balance rather than the stream of transfers, read how to check and monitor ERC-20 token balances instead. A balance is one eth_call, and you don’t need logs for it.

The event and its topic hash

The ERC-20 standard defines the event as:

event Transfer(address indexed _from, address indexed _to, uint256 _value);

A log stores up to four 32-byte topics plus a data blob. For a non-anonymous event, topics[0] is the keccak-256 hash of the canonical signature, which has no parameter names and no spaces:

$ cast keccak 'Transfer(address,address,uint256)'
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

Indexed parameters go into the remaining topics, left-padded to 32 bytes. Non-indexed parameters are ABI-encoded into data. So for an ERC-20 Transfer:

FieldContents
topics[0]0xddf252ad…523b3ef, the event signature
topics[1]from, zero-padded to 32 bytes
topics[2]to, zero-padded to 32 bytes
datavalue, a 32-byte unsigned integer in the token’s smallest unit

ERC-721 uses the same signature, Transfer(address,address,uint256), but it indexes the third argument (the token ID). An ERC-721 Transfer therefore has four topics and empty data. If you filter by topic alone across many contracts, you get NFT transfers as well, so check topics.length === 3 before you decode.

A mint is a Transfer whose from is the zero address. A burn is a Transfer to the zero address in most implementations, though some tokens burn without emitting anything.

A quick look with cast

cast logs takes the event signature with names and indexed keywords, then one value per indexed parameter. Pass an empty string as a wildcard. This returns USDC transfers to one address over the last 1,000 blocks on Ethereum:

RPC=https://ethereum-rpc.publicnode.com
HEAD=$(cast block-number -r $RPC)

cast logs -r $RPC \
  --from-block $((HEAD - 1000)) --to-block $HEAD \
  --address 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  'Transfer(address indexed from, address indexed to, uint256 value)' \
  '' 0x28C6c06298d514Db089934071355E5743bf21d60

Leave both arguments out to get every USDC transfer in the range. Swap them, 0x… '', to get transfers from the address. Without --address you get every ERC-20 and ERC-721 Transfer on the chain, which a public endpoint will usually refuse for anything but a handful of blocks (more on that below).

The raw JSON-RPC request underneath looks like this. null is the wildcard, and you can pass an array in any topic position to match any of several values:

{
  "jsonrpc": "2.0", "id": 1, "method": "eth_getLogs",
  "params": [{
    "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "fromBlock": "0x192c5f0", "toBlock": "0x192c9d8",
    "topics": [
      "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
      null,
      "0x00000000000000000000000028c6c06298d514db089934071355e5743bf21d60"
    ]
  }]
}

A polling loop in ethers v6

A one-off query is easy. A watcher has to run indefinitely without missing or double-counting a transfer. The loop below keeps a block cursor, the last block it has fully processed. It stays a few blocks behind the chain head, splits large ranges, and gives each log a stable identity so it can be deduplicated:

import { ethers } from 'ethers'; // ethers v6

const RPC_URL = process.env.RPC_URL ?? 'https://ethereum-rpc.publicnode.com';
const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC on Ethereum
const WATCHED = '0x28C6c06298d514Db089934071355E5743bf21d60';
const CONFIRMATIONS = 3;   // blocks to stay behind head (tune per chain)
const MAX_RANGE = 500;     // blocks per eth_getLogs call; shrinks on "range too large"

const provider = new ethers.JsonRpcProvider(RPC_URL);
const TRANSFER = ethers.id('Transfer(address,address,uint256)');
const iface = new ethers.Interface([
  'event Transfer(address indexed from, address indexed to, uint256 value)',
]);

// [event signature, from (any), to (= WATCHED)]
const topics = [TRANSFER, null, ethers.zeroPadValue(WATCHED, 32)];

async function getLogsChunked(fromBlock, toBlock, range = MAX_RANGE) {
  const out = [];
  for (let start = fromBlock; start <= toBlock; start += range) {
    const end = Math.min(start + range - 1, toBlock);
    try {
      out.push(...(await provider.getLogs({ address: TOKEN, topics, fromBlock: start, toBlock: end })));
    } catch (err) {
      const msg = String(err?.error?.message ?? err?.message ?? err);
      // Rate limited: wait and retry the same window. Splitting it would only send more requests.
      if (/rate limit|request limit|too many requests|429/i.test(msg)) {
        await new Promise((r) => setTimeout(r, 2000));
        out.push(...(await getLogsChunked(start, end, range)));
        continue;
      }
      // Range or result-size refusal: every provider words this differently, so split the window.
      if (!/range|exceeds limit|too many results|10000|query returned more/i.test(msg) || range <= 1) throw err;
      out.push(...(await getLogsChunked(start, end, Math.ceil(range / 2))));
    }
  }
  return out;
}

export async function pollOnce(cursor) {
  const head = await provider.getBlockNumber();
  const safeHead = head - CONFIRMATIONS;
  if (safeHead <= cursor) return cursor; // nothing new that is deep enough

  for (const log of await getLogsChunked(cursor + 1, safeHead)) {
    if (log.topics.length !== 3) continue; // ERC-721 Transfer has 4 topics
    const { args } = iface.parseLog(log);
    const id = `${log.transactionHash}:${log.index}`; // idempotency key
    await handleTransfer(id, args.from, args.to, ethers.formatUnits(args.value, 6)); // your code
  }
  return safeHead; // persist this only after every log above is handled
}

Call pollOnce on a timer and store the value it returns in a database, not in memory. The 6 in formatUnits is USDC’s decimals(). Read it from the token rather than hard-coding it if you watch more than one.

Where polling breaks

The loop above works on a quiet afternoon. These are the things that break it later.

1. Block-range and result limits

eth_getLogs is expensive for a node to serve, so every provider caps it, and no two cap it the same way. Asking four public endpoints for every Transfer log in the last 20,000 blocks on 12 November 2026 returned four different refusals:

EndpointResponse
mainnet.optimism.ioeth_getLogs is limited to a 10,000 range
arb1.arbitrum.io/rpclogs matched by query exceeds limit of 10000
mainnet.base.orgrequest limit reached
ethereum-rpc.publicnode.comArchive requests require a personal token

Some limits are on the block span. Others are on the number of logs returned, so a range that works at 3 a.m. fails during a busy hour. Others are rate limits that look like range errors, or restrictions on how old a block may be. Paid providers publish their own limits, and they change. A production poller needs the split-and-retry behaviour above, plus a backoff for rate limits, and it should never assume that a request which worked yesterday will work today.

2. Reorgs

The newest blocks are not final. If the chain reorganises after you’ve read a block, a Transfer you alerted on can disappear or move to a different block. A WebSocket subscription (eth_subscribe with logs) re-sends affected logs with removed: true. A poller never sees that flag, because it already moved on.

You have three options, from cheapest to safest:

  • Stay a few blocks behind head, as CONFIRMATIONS does above. This handles the common one- or two-block reorg on most chains.
  • Use the safe or finalized block tag as the upper bound: provider.getBlock('finalized'). Nothing behind finalized should reorg, but it lags. On 12 November 2026 it was about 67 blocks behind head on Ethereum, and on the L2s it trails by minutes because it follows Ethereum finality of the batch.
  • Store blockHash with each event and re-check it for a few blocks. If a stored block’s hash no longer matches the canonical chain, roll the cursor back and reprocess.

Pick the latency you can tolerate. An accounting job should read up to finalized. An alert that a human will look at can usually accept a small reorg risk in exchange for minutes of speed.

3. Missed blocks and cursor handling

Most lost events come from cursor bugs, not from the chain:

  • Advancing the cursor before handling the logs. If the process crashes between the two steps, those blocks are skipped forever. Advance only after the work is committed, and make handling idempotent on transactionHash:logIndex so a replay is harmless.
  • Using “the last N blocks” instead of a cursor. If the poller is down for longer than N blocks, the gap is silently lost. A cursor catches up from where it stopped. Bound the catch-up per run so one long outage doesn’t produce a request the provider refuses.
  • Load-balanced endpoints. Behind a pool of nodes, eth_blockNumber and eth_getLogs can land on different nodes, and a node one block behind returns an empty result for a block it hasn’t imported yet. It doesn’t return an error. Staying behind head covers this case as well.
  • Block-denominated windows on fast chains. Block time differs by a factor of 60 across popular chains. On 5 November 2026, Base moved to 200 ms blocks, so a 1,000-block window there covers about 200 seconds, compared with roughly three hours on Ethereum. Size windows and confirmations per chain.

4. Transfer events are not balances

Rebasing tokens change balances without emitting Transfer. Fee-on-transfer tokens emit a value larger than what arrives. Some tokens emit nothing on mint. If the question is “how much does this wallet hold?”, read balanceOf, which is the subject of the ERC-20 balance guide. Watch Transfer logs when the question is “what moved, when, and to whom?”.

Doing it without the poller

Chainitor’s event monitor does this for any contract event, including ERC-20 Transfer. You give it a contract, an event signature and optional filters on the decoded parameters, for example to equals your treasury, or value greater than a threshold. It keeps a block cursor for each monitor, reads in bounded windows up to a per-chain number of blocks behind head, and catches up after an interruption instead of skipping the gap. Matching events go to email, Slack, Telegram, PagerDuty or a webhook on every plan.

Event monitors run on every plan. Starter checks every 5 minutes and Pro every minute, so expect an alert within one check interval plus the confirmation depth, not instantly. If you need a delay measured in seconds, keep a WebSocket subscription of your own and use the monitor as the safety net that catches what the subscription drops.