10 min read
|
Oct 24, 2026

ERC-20 Transfer Event: Structure, Topics and How to Listen

How the ERC-20 Transfer event works: its signature, topic hash, how to decode logs, the pitfalls of eth_getLogs, and how to get deposit alerts without running nodes.

Chapters
C
Chaingateway Team
Blockchain Experts

Abstract illustration of token transfer event structure

The ERC-20 Transfer event is the log a token contract emits every time tokens move. Its signature is Transfer(address indexed from, address indexed to, uint256 value), and it is the only reliable way for an app outside the chain to learn that a payment happened. To catch it you filter logs by the event’s topic hash, decode the sender, recipient and amount, and wait until the block is safe to trust. This guide walks through each step, the traps that cost real money, and when it makes sense to stop polling logs yourself.

TL;DR

  • The standard defines Transfer(address indexed from, address indexed to, uint256 value). It must fire on every movement of tokens, including zero-value transfers.
  • To filter logs, use the keccak256 hash of Transfer(address,address,uint256) as topic 0. Topics 1 and 2 are the padded from and to addresses; the amount sits in the data field.
  • eth_getLogs on public endpoints has limits and can return incomplete results for large block ranges without an error. Paginate, and use dedicated infrastructure for production.
  • ERC-20 has no callback to the recipient, so listening off-chain is not optional if you credit user balances.
  • If you would rather not run log indexers, a webhook service can push decoded transfer notifications to your backend.

Table of Contents

What the Transfer event actually is

A smart contract can emit events, which are signals written to the transaction receipt as logs. They are not stored in contract state, so they are cheap to write and cheap to read, but a contract cannot read them afterward. Their audience is the outside world: wallets, block explorers, indexers and your backend. Ethereum’s own tutorial on events describes them as signals that dapps and anything connected to the JSON-RPC API can listen to and act on.

Event receipt separated from contract state

For tokens, the ERC-20 specification defines the event like this:

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

The rules that matter in practice:

  • It MUST be emitted when tokens are transferred, and that includes transfers of zero value.
  • The transfer function must fire it, and so must transferFrom, the function a spender uses after an allowance is granted.
  • Minting is expected to emit it with the from address set to the zero address. Burning mirrors that, with the zero address as recipient (many implementations follow this convention).

That last point is a common source of bugs. A listener that only watches “transfers to my address” will behave fine, but one that treats every Transfer as a user-to-user payment will misread mints and burns.

The token’s balance is what counts on-chain, and the event is a report about it. If you want the background on the full interface, we cover it in our post on the ERC-20 token standard.

Anatomy of a Transfer log

When the event is emitted, the node stores a log with three parts: the emitting contract’s address, a list of topics, and a data blob. Indexing decides where each argument lands.

Log fieldContent for an ERC-20 Transfer
addressThe token contract that emitted the event (for example a stablecoin contract)
topics[0]Hash of the event signature
topics[1]Sender (from), left-padded to 32 bytes
topics[2]Recipient (to), left-padded to 32 bytes
dataThe value, a 32-byte unsigned integer

The two addresses are indexed, so they are searchable through the log filter. The value is not indexed, so it is stored in the data field, which keeps on-chain costs down and means you cannot filter by amount at the node level. If you need “transfers over 1,000 tokens”, you fetch and filter in your own code.

The topic 0 hash

To query through JSON-RPC you need the keccak256 hash of the canonical signature string Transfer(address,address,uint256). No spaces, no parameter names, no indexed keyword. The result is:

0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

The ethers.js events documentation uses this same mechanism: topic 0 is the signature hash, and topics 1 and 2 correspond to the indexed from and to addresses, each padded to 32 bytes.

Two details save debugging time:

  1. Decimals are your job. The value is a raw integer. A token with 6 decimals and a value of 2500000 is 2.5 tokens. Read decimals() from the contract (or cache it per token) before showing amounts.
  2. Same hash, different standards. ERC-721 uses the same event name and argument types, so its topic 0 is identical. The difference is that the token ID is indexed, so an NFT transfer log carries four topics and an empty data field, while a fungible transfer carries three topics. Check the topic count before decoding, or you will parse NFT moves as token payments. Our post on the ERC-721 standard covers that side.

Reading transfers with eth_getLogs

The classic approach is a direct JSON-RPC call. A filter that finds all transfers of one token into one wallet looks like this:

{
"method": "eth_getLogs",
"params": [{
"fromBlock": "0x10d4f00",
"toBlock": "0x10d4f64",
"address": "0xTokenContractAddress",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
null,
"0x000000000000000000000000YourDepositAddressWithoutThe0x"
]
}]
}

A null in a topic position means “any value”. Here we accept any sender and pin the recipient. Note the recipient is padded with zeros to 32 bytes.

If you prefer a library, a short walk-through such as this web3.py gist for ERC-20 transfer logs shows the same call wrapped in Python. For live updates instead of history, eth_subscribe with the logs type pushes new matches over a WebSocket.

Where eth_getLogs bites you

It looks simple, and it works in a tutorial. Production is different. The SQD write-up on eth_getLogs limits notes that standard node providers often cap the number of logs returned, and that querying a large block range can produce incomplete data without any error. Their guidance: paginate requests and use archive nodes for historical data. The same piece warns that relying on public endpoints for heavy production use is unreliable, because of bloom filter limitations and index gaps that differ from node to node.

The practical rules:

  • Query small block windows and move forward, rather than asking for one giant range.
  • Compare the count of results against what you expect. A silent truncation looks exactly like a quiet day.
  • Store the last processed block number, so a restart resumes where it stopped rather than skipping blocks.
  • Keep an overlap of a few blocks on each pass and deduplicate on transaction hash plus log index.

Confirmations, reorgs and idempotency

A log appearing in the latest block is not final. The chain can reorganize, and a block that held your transfer can be replaced by one that does not. A sound deposit pipeline treats the event as a claim, not a fact, until enough blocks have built on top of it.

A simple state machine works well:

  1. Seen. The log is detected. Record it, show “pending” to the user, and credit nothing.
  2. Confirmed. The block is older than your chosen threshold. Credit the balance once.
  3. Orphaned. The log disappeared after a reorg. Mark the record as invalid and alert whoever needs to know.

The threshold is a business decision. A small purchase tolerates fewer confirmations than a large withdrawal-eligible deposit. Choose it per chain and per amount, and write it down.

Whatever you choose, make crediting idempotent. The unique key for a log is the transaction hash plus the log index, not the transaction hash alone, because one transaction can emit several Transfer events (a swap, for example, emits at least two). If your handler receives the same event twice, from a retry or a second source, it must do nothing the second time.

Why you cannot rely on the recipient to notice

ERC-20 transfers are silent from the receiving side. A wallet address does not execute code, and a receiving contract is not told anything. The ethereum.org ERC-20 page points out that tokens are lost when sent to contracts that do not recognize the incoming transfer, because the standard has no native callback, and it states that over $83,656,418 worth of ERC-20 tokens had been lost this way as of June 20, 2024. A common mitigation is a require check in the token that blocks sending to the token contract itself.

Two takeaways for builders:

  • If you run a contract that should receive tokens, you need your own accounting. Standards like ERC-1363 style hooks exist for this, but you cannot assume the sender’s token supports them.
  • If you run a backend, the Transfer log is your only trigger. Missing one means a customer paid and you never knew.

There is a second gap: the event carries no payment reference. Nothing ties a transfer to invoice 4471. Most businesses solve it by giving each invoice or user a unique deposit address. A newer proposal, ERC-7699, would extend ERC-20 with a transfer reference so a message for the recipient travels with the payment, but it is a proposal and today’s popular tokens do not implement it.

Edge cases that break naive listeners

Native-style tokens. On some chains a stablecoin is built into the protocol rather than a normal contract. The Arc documentation on detecting deposits explains that native USDC movements there emit a standard ERC-20 Transfer log from a system address, so developers filter on that emitter rather than on the individual token contract. The lesson is general: read each chain’s deposit docs before assuming a contract address filter is enough.

Fee-on-transfer and rebasing tokens. The amount in the event can differ from what the recipient’s balance changes by, or balances can shift without any Transfer. If you accept unknown tokens, reconcile with balanceOf instead of trusting the event alone.

Zero-value transfers. The standard requires them to emit the event. Attackers use this for address poisoning, sending zero-value transfers that put a lookalike address in a victim’s history. Ignore zero-value logs in any deposit flow.

Spoofed tokens. Anyone can deploy a contract that emits a Transfer event with any token name. Filter on contract addresses you recognize, not on topic 0 alone.

Five common ERC-20 listener edge cases

Other chains. The same event shape appears on EVM-compatible networks such as Polygon, BNB Chain (BEP-20) and Arbitrum. TRON’s TRC-20 tokens follow the same idea with their own tooling; see our comparison of TRC10 and TRC20 for how they differ.

Build or buy: running your own listener

A homemade listener is a good project and a fair choice when blockchain data is your core product. For most teams accepting payments, it is a side quest with a long tail of maintenance. You own the node or the provider contract, the block cursor, the reorg handling, the retries, the decoding for each token, and the same stack again for every extra chain.

A rough way to decide:

QuestionIf yes, lean toward buildingIf no, lean toward an API
Is on-chain data your product?YesNo
Do you need custom log analytics beyond payments?YesNo
Do you have an engineer who will own node uptime and reorg logic?YesNo
Do you support several chains from day one?NoYes

If you land in the right-hand column, a managed API that pushes decoded events to you removes the whole indexing layer.

Perspective: what we see when teams build deposit detection

We build a unified API for crypto payments, so we see the same pattern repeatedly. A team starts with one eth_getLogs loop for one token on one chain. It works. Then they add a second chain, a second token standard, a retry job and a reorg patch, and the payment feature has quietly become an infrastructure project.

Our view is that the Transfer event is the right primitive but the wrong place to spend your engineering time. What your application needs is a clean statement: this address received this amount of this token in this transaction. That is why we deliver pre-decoded data rather than raw topics, and why notifications arrive over webhooks that are HMAC-signed, so your server can verify a message really came from us before it credits anyone. We also hold that you should not need to run your own nodes or juggle chain-specific endpoints to get there.

The advice we give regardless of tooling: use one deposit address per payer or invoice, credit idempotently, wait for confirmations that match the amount at stake, and verify the token contract address. Those four habits prevent most of the incidents we hear about.

— Bitblade

Getting transfer notifications without running indexers

Chaingateway gives developers a single REST API for wallets, token transfers and decoded transaction data across Ethereum, Bitcoin, Tron, Solana, Polygon, BNB Chain and Arbitrum. For this topic, the relevant piece is Chaingateway Webhooks: you subscribe an address, and when a token transfer touches it, a real-time notification reaches your endpoint with the data already decoded, so you skip topic hashing and padding. Our Ethereum API page shows how to send ERC-20 tokens over REST without web3.js, and the ERC-20 address guide covers address format and balance checks.

Plans are published on the pricing page: Plus is €49 per month, Pro €99, Premium €249 and Enterprise €499 per month, with yearly billing at €490, €990, €2,490 and €4,990 respectively. Access to the Blockchain API starts with a free trial.

A minimal handler, step by step

Whether you use raw logs or webhooks, the server-side logic is the same.

  1. Verify the source. For webhooks, check the HMAC signature on the raw request body before parsing it. For your own log reader, you already trust your node.
  2. Check the token. Compare the contract address with your allowlist.
  3. Check the recipient. Look up the to address in your database of deposit addresses. Ignore anything unknown.
  4. Skip noise. Drop zero-value transfers and mints or burns you do not care about.
  5. Deduplicate. Insert the transaction hash and log index under a unique constraint. If the insert fails, stop.
  6. Convert the amount. Divide the raw value by 10 to the power of the token’s decimals, using integer or decimal types, never floating point.
  7. Record as pending, then confirmed. Credit only after your confirmation threshold passes.
  8. Respond quickly. Acknowledge a webhook with a success status immediately and do the heavy work in a queue.

Sources

Frequently asked questions

It is the keccak256 hash of Transfer(address,address,uint256), which is 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. It always sits in topic 0 of the log. Topics 1 and 2 hold the sender and recipient.

Indexed arguments become topics that nodes can search, but each one adds log cost, and the standard keeps only the two addresses indexed. The value goes into the data field, so you decode it yourself and filter amounts in your own code.

The standard says it must, including zero-value transfers. In practice, compliant contracts do, but non-standard tokens may behave differently, and some chains handle native assets in special ways. Treat the event as the main signal and reconcile against balanceOf when the token is unfamiliar.

Only with care. Providers can cap returned logs, and large ranges may return incomplete data with no error. Paginate in small windows, track your last processed block, and use dedicated infrastructure or a notification service for anything that handles real money.

There is no universal number. It depends on the chain, the amount and your risk tolerance. Set a threshold per chain and value tier, and credit only once, after it passes.

Ready to build this yourself? Get your API key — 7-day trial, no card required — or see the Blockchain API for the full endpoint reference.

C
Chaingateway Team
Blockchain Experts

The Chaingateway team is dedicated to simplifying blockchain integration for developers worldwide.