Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Events and logs: eth_getLogs, bloom filters and public RPC limits

How smart-contract events work, how to read them via eth_getLogs, why public nodes cut the block range and how to build deal history without your own database.

5 min readUpdated:
In this article

A smart contract has two ways to tell the world what happened: change state (storage) and write an event. State shows "how it is now". Events tell "how it got there". For interfaces showing deal history, events are the main data source.

What an event is

An event is an entry in a transaction's journal. A contract does emit DisputeOpened(dealId, by, at), and a log appears in the transaction receipt: the contract address, a set of topics and data. The log's structure was covered in the article on ABI: topic0 is the event identifier, indexed arguments are the following topics, the rest is data.

Important properties of events:

  • Cheap. Writing an event is many times cheaper than writing to storage.
  • Unavailable to contracts. A contract cannot read its own or others' events - they are only for the outside world.
  • Permanent. Once written, a log stays in the chain's history.
  • Tied to the transaction. If a transaction is rolled back, so are its events.

Reading events: eth_getLogs

An interface gets events from a node with an eth_getLogs request and a filter:

json
{
  "address": "0x6462DDDc0a3D893A7C17f94410e2d27162a390aD",
  "fromBlock": "0x…",
  "toBlock": "0x…",
  "topics": [
    ["0x2241d0fd…", "0xd716fe62…"],
    "0x0000…0007"
  ]
}

Here topics[0] is a list of events of interest (several variants allowed), topics[1] is the value of the first indexed parameter, for example the deal number. Filtering by address and topics is the reason parameters are indexed: this is how a node finds what is needed quickly.

The reply is a list of logs with block number, transaction hash, topics and data. The interface then decodes them by the ABI and builds the feed: "created", "accepted", "dispute opened", "settled".

How a node searches fast: bloom filters

Scanning all blocks in a row would be painfully slow. Each block header carries a bloom filter - a compact structure that quickly answers "this block definitely has no events of this contract with this topic" or "there may be". The node discards empty blocks and checks only the likely ones. The filter allows false positives but never misses a real event.

A practical problem: public node limits

Here begins what textbooks rarely mention. Public RPC nodes are free, so they protect themselves from expensive requests. A log request over millions of blocks is exactly that. From our experience connecting four networks:

  • Some nodes limit the block range per request - from 2,000 to 50,000, depending on network and provider.
  • Some do not serve logs at all (for example one of the public BNB Chain nodes).
  • The trickiest: some nodes, given a range that is too wide, answer not with an error but with an empty list. The interface sees "no events" and shows an empty history though deals exist.
  • Public nodes often do not keep old state: you cannot ask "how it was 100,000 blocks ago".

Several rules for the developer follow.

Rule 1. Do not trust an empty reply

An empty list for a wide range can mean "no events" or "the node refused". Check on a known event when setting up a new node, and in code do not decide "no events" from one request.

Rule 2. Move in windows and adapt

If you need recent events, do not request the whole history. Go from fresh blocks to old ones in windows, and if the node returns a limit error, halve the window. Stop when you have found enough data or reached the contract's deployment block. A sketch of such a walk:

ts
// from fresh blocks to old ones, the window shrinks on error
let to = latest;
let span = 50_000n;
while (to > deployBlock && found.length < needed && budget-- > 0) {
  const from = to - span < deployBlock ? deployBlock : to - span;
  try {
    found.push(...(await getLogs({ fromBlock: from, toBlock: to })));
    to = from - 1n;
  } catch {
    span /= 2n;            // the node refused - narrow the window
    if (span < 1000n) break;
  }
}

Rule 3. Know the deployment block

There is no point scanning blocks before the contract existed. The deployment block is a configuration parameter that bounds the search.

Rule 4. Do not overbuild: a counter is often better than logs

For a "user's deals" list it is convenient for the contract to keep a dealCount counter. While deals are few, they can be enumerated by number with batched state reads (Multicall) - no logs needed at all. Logs come in when there are too many deals to enumerate. We use exactly this hybrid scheme: it works even on nodes that do not serve logs.

When logs are not enough: indexers

For large volumes and complex queries ("all of a user's deals over a year, sorted by amount") you need an indexer - a separate service that reads all events once into its own database and answers queries fast. Examples: The Graph, Ponder, home-grown solutions. It is not trusted blindly: its data is a copy of the chain, the blockchain remains the source of truth, and important actions should be re-checked by reading the contract directly.

Chain reorganisations

Recent blocks can be rewritten (a reorg): the network changed its mind about the latest blocks, and your transaction ended up in another block or in none. So interfaces do not treat an event as final until enough confirmations have passed, and indexers know how to "roll back" the latest blocks.

Now that it is clear how data gets from the blockchain into the interface, it is time to write a contract of your own: ERC-20 from scratch. Next, how signatures work in contracts: ECDSA and EIP-712, and then the escrow state machine in Solidity.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.