How to Build an EVM Network and an Explorer for It

“Build your own EVM network” is three very different projects wearing one phrase — EVM here being the Ethereum Virtual Machine, the runtime that contracts run on — and the difference between them decides everything else. Before writing a line of code you have to know which one you mean:

What this post is about. The third of those three projects — the one that is really an indexer over someone else’s node — and what “building an explorer” really costs. You will learn what an EVM network is made of, what an indexer does all day, why a token swap is not a token transfer, and — through my own half-finished module — five concrete mistakes to avoid. The code excerpts are Rust, but every lesson applies in any language.

What you need to know first. A block is a batch of transactions, and the blockchain is the chain of those batches. An execution client (geth, reth, besu, erigon) is the program that stores chain state and runs contracts. An indexer is the program that reads the chain through that client’s API and writes what it finds into a database, so it can answer questions the node cannot answer quickly.

  • A dev chain. anvil, hardhat node, geth --dev — an execution client with a synthetic block producer. One transaction, one block, instantly. No reorgs — reorganisations, where the network replaces recently-seen blocks with different ones — no consensus, no peers. Perfect for building an explorer, useless as a model of a real network.
  • A private chain with real consensus. An execution client plus a sealer (the component that packages transactions into blocks), your own genesis.json (the file describing block zero: initial balances and rules), your own chain id (a number distinguishing your chain from all others), your own validator keys. Clique or Besu QBFT — two proof-of-authority rule sets where named signers take turns producing blocks — with two or three signers is the cheapest thing that is genuinely a network.
  • “A chain” that is really an indexer over someone else’s node. An RPC URL (the HTTP address of a node’s API) plus a decoder. This is what most people actually build, and it is the one I built.

The interesting part of this post is the third one, because it is the part you own: the indexer. And I have a useful piece of honesty to tell, because I wrote half of one and it is not enough.

What an EVM network is made of

Four layers, bottom up:

  1. The execution client (geth, reth, besu, erigon) — the state machine: world state trie (a hash tree that lets anyone verify the whole state from one root hash), accounts, storage, contract code, and the EVM interpreter. This is the only layer you strictly need for an explorer.
  2. Consensus and chain selection — decides which blocks are canonical (the official history everyone agrees on). Post-merge Ethereum runs a separate proof-of-stake client (Lighthouse, Teku) and a deposit contract; private chains usually seal in-process (geth clique, Besu qbft). This layer is the line between “a chain” and “a process that makes blocks”.
  3. P2P gossip (devp2p, discv4) — the peer-to-peer protocols nodes use to find each other. Irrelevant for a single node, mandatory for a real network.
  4. The JSON-RPC surface — the HTTP and WebSocket interface a node exposes, where you send JSON requests like eth_blockNumber. This is where the explorer lives.

A solo developer running an explorer for their own chain needs: one execution client, Postgres, an indexer, an HTTP API and a UI. Consensus is optional if dev mode is enough. That is exactly the shape of a Blockscout deployment — indexer, explorer, smart-contract-verifier, postgres, redis.

Why the indexer is the real product

An RPC gives you now. An explorer needs then. The moment you ask “what did this address look like in March”, you need a database, and the moment you have a database, you own the hard problems: ordering, reorgs, historical balances, and decoding.

The indexer’s job has three parts:

  1. Get new data. eth_subscribe over WebSocket (a persistent connection the node pushes new data through), or eth_getLogs polling over HTTP, or a hybrid.
  2. Stitch it together. A log — an event record a contract writes during a transaction — is not a transaction. A transaction is not a receipt (the per-transaction result record, which carries its logs). You fetch blocks and transactions by range, batch-fetch receipts (the logs come back with the receipts), merge the receipt fields into the transaction, and parse the logs into token transfers and internal transactions.
  3. Decode it. A transaction’s input is a 4-byte selector followed by ABI-encoded arguments — the ABI being the contract’s list of functions and argument types. You match the selector against a table of known method ids, then decode the arguments against the verified contract’s ABI.

What I wrote: half an indexer

My own code lives in bee-modules-eth, a datasource plugin for the bee pipeline runtime. Its own header comment is honest about the scope:

v0 supports a tiny surface: eth_blockNumber, eth_getLogs (log filter) — v0.5+ will add eth_subscribe (websockets) for real-time push. For now we poll, which works against any JSON-RPC endpoint.

The client is a plain reqwest (a Rust HTTP client library) call:

1
2
3
4
5
async fn block_number(&self) -> RpcResult<u64> {
let r: String = self.call("eth_blockNumber", json!([])).await?;
// r is hex like "0x1234"
Ok(u64::from_str_radix(r.trim_start_matches("0x"), 16).unwrap_or(0))
}

src/modules/source.rs:188. The node answers with a hex string; from_str_radix parses it as base 16 after stripping the 0x, and unwrap_or(0) falls back to zero on a parse failure. The rest of the client is one more method, get_logs, which builds a filter object (block range, address, topics) and sends it as eth_getLogs — wrapped in an array, json!([filter]), because JSON-RPC passes parameters as an array.

The polling loop keeps a cursor — a bookmark of the last block already processed:

1
2
3
4
5
6
let head = self.client.block_number().await?;
let cursor = self.cursor();
if head < cursor { self.set_cursor(head); return Ok(Vec::new()); }
let logs = self.client.get_logs(cursor, head, address, topics).await?;
self.set_cursor(head + 1);
Ok(logs)

src/modules/source.rs:311. This is the body of poll_once, whose doc comment promises: fetch logs between the cursor and the current head, advance the cursor, return the new logs, and return an empty list if the head is behind the cursor. That last branch is a guess about reorgs, and I come back to it below.

Why a Uniswap V3 swap is not a transfer

The one non-trivial thing the module does is decode Uniswap V3 Swap events — Uniswap V3 is the largest decentralised exchange, and a pool is one contract holding a pair of tokens. The decoding is positional, with no ABI crate, deliberately (“we avoid pulling in ethers-rs or alloy“):

1
2
3
4
5
6
7
8
pub fn decode(&self, log: &LogEntry) -> Option<Swap> {
if !log.address.eq_ignore_ascii_case(&self.pool.address) { return None; }
if log.topics.is_empty() || !log.topics[0].eq_ignore_ascii_case(SWAP_EVENT_TOPIC) { return None; }
// data layout (ABI-encoded, no padding):
// word 0: amount0 (int256) word 1: amount1 (int256)
// word 2: sqrtPriceX96 (uint160) word 3: liquidity (uint128)
// word 4: tick (int24)
if log.data.len() < 5 * 32 { return None; }

src/modules/uniswap_v3.rs:85. The first two lines are guards: is this log from our pool, and is its first topic — the indexed field whose hash identifies the event type — the Swap topic. The comment documents the data payload: five 32-byte words, so 5 * 32 (160 bytes) is the minimum length. The rest of the function slices those five words and casts them into the Swap fields.

An indexer needs this because a V3 swap is not a plain transfer, and this is the part that surprises people:

  • The pool contract emits Swap; the ERC-20 Transfer events (ERC-20 is the standard for interchangeable tokens) happen inside it, so a transfer-only indexer sees two transfers and no swap.
  • The price exists only in sqrtPriceX96 — it is a square root, scaled by 2⁹⁶, and the actual price is (√P)² · 10^(d0−d1).
  • The buy/sell side is the sign of amount0 versus amount1.
  • The USD volume needs both tokens’ decimals — a token’s number of decimal places, 6 for USDC and 18 for ETH — and my code hardcodes them:
1
2
3
4
5
6
let (amount_in, amount_out, side) = if amount0 > 0.0 {
(amount0 / 1e6, amount1.abs() / 1e18, "buy")
} else {
(amount1.abs() / 1e18, amount0.abs() / 1e6, "sell")
};
let price = price_of_swap(&swap, 6, 18);

src/factory.rs:381. Dividing by 10⁶ or 10¹⁸ converts raw token units into human units, and which side is positive decides whether the trade is a buy or a sell. The decimals are passed straight into price_of_swap — hardcoded, not read from the tokens.

What an explorer needs that the RPC does not have

This is the list I did not build, and every item on it is a real feature of Blockscout, which I have a checkout of on this machine.

  • History. Blockscout’s schema — its database tables — is the honest checklist: blocks, transactions, logs, token_transfers, internal_transactions, addresses, address_coin_balances, address_token_balances, token_instances, smart_contracts, contract_methods, transaction_actions, transaction_stats, validators, withdrawals, pending_block_operations, market_history, address_names, address_tags.
  • Token metadata. symbol() is 0x95d89b41, decimals() is 0x313ce567 — read with eth_call (a read-only contract query) at index time. You need an ERC-20 ABI for that; verified sources give you more.
  • Traces and internal transactions. A trace is the record of the internal calls one transaction made, and it is not in standard RPC. Geth offers debug_traceTransaction with a custom JS tracer; Besu and Nethermind offer trace_replayBlockTransactions. Blockscout ships one variant module per client.
  • Reorgs. The naive indexer assumes blocks are final. Blockscout’s answer: a consensus boolean on blocks (orphans — blocks that lost the race — are stored with consensus = false and the UI filters them), and a realtime fetcher that treats number <= previous_number as a reorg, sleeps five seconds, and re-imports.
  • Gas and price statistics. Precomputed into transaction_stats and an AverageBlockTime counter — not computed per request.
  • Balances at a historical block. The classic trap: eth_getBalance(addr, blockNumber) needs an archive node, one configured to keep state at every historical block. Blockscout refuses to depend on one. It keeps its own address_coin_balances rows keyed by block_number and answers “balance as of block N” with the last snapshot at or before N.

Concrete guidance

Polling versus subscribing. Polling eth_blockNumber plus eth_getLogs every one to two seconds is the right default: provider-agnostic, trivially restartable, no WebSocket reconnect logic. It lags by up to one interval and burns rate limit. eth_subscribe needs a WebSocket endpoint that hosted providers often disable, and newHeads gives you only the header — you still have to fetch the full block. The best answer is both, and Blockscout does exactly that: subscribe, and on a socket failure log it and keep polling, and re-schedule a poll on every event so a dead socket can never silently stall the indexer.

Chunk your log queries. Providers cap eth_getLogs by range and by result count. Always split.

Store and index like a database, not a cache. Primary key by hash; secondary indexes on block_number, (address_hash, block_number), (transaction_hash, log_index). Keep a last_indexed_block and a missing-ranges table, so a crash mid-range does not lose blocks; a catch-up worker fills the gaps back to genesis.

Never hit the node on a page view. The trick Blockscout uses: expose your own JSON-RPC endpoint that is database-backed — Explorer.EthRPC answers eth_blockNumber, eth_getBalance and eth_getLogs from Postgres — so wallets and dApps point at your explorer instead of at your node.

If the chain is yours, write the genesis. chainId (pick something unique), gasLimit, difficulty: 0, baseFeePerGas if you want EIP-1559 (the fee rules that replaced a single gas price), the shanghai/cancun timestamps, and an alloc that prefunds your deployer. Prefund in the genesis, not by mining — the alloc is what makes eth_getBalance work on block zero. With Hardhat, add a network entry tagged private_chain; my own ens-contracts config does exactly that for localhost, lixbtest (chain id 2888) and lixb (1888).

The five things my own client got wrong

I would rather list these than pretend the module is a foundation.

1. It is not an explorer and cannot become one. LogEntry carries block_number, log_index, address, topics, data — no block hash, no transaction hash, no receipt. There is no join key back to a transaction. Everything an explorer page shows is missing.

2. The reorg handling is a guess. poll_once resets the cursor when the head goes backwards, but it compares numbers, never hashes, so it cannot tell a reorg from a stall. Worse, the factory’s poll_loop only ever advances next_block, so if the head regresses, that range is skipped permanently and the swaps in it stay in the feed forever.

3. Nothing is persisted. The cursor is an in-memory Mutex<u64> — a value only one thread may touch at a time. A restart re-reads from the head. The bee project has a Postgres module, but the eth module never touches a database.

4. "tx" is not a transaction hash. The emitted JSON has "tx": format!("0x{:x}", swap.log_index) — that is the log index. There is no transaction hash in the data model to put there, and the field name lies about what it contains.

5. One hardcoded pool. The decoder is built for the USDC/WETH pair (a stablecoin and wrapped ether) at 0.05% and nothing else. Every other pool decodes to None and the pair label is literally "Unknown".

Also missing: no eth_subscribe, no traces, no ABI decoding of transaction input, no historical state, no balances, no token metadata (the decimals are hardcoded).

What I would do differently

Start from the queries, not from the chain. The right way to design an explorer is to write the SQL you want to answer first — “transfers to this address in this block range”, “balance as of block N”, “the internal call tree of this transaction” — and then work out what the indexer must store to make them cheap. I started from the RPC instead, and ended up with a module that answers one question well.

Store blocks, transactions, receipts and logs, and derive the rest. The derivation is where the product is: token transfers from logs, internal transactions from traces, balances from deltas, statistics from aggregates. Store the raw facts; the derived views can change.

Handle reorgs properly, with hashes. Compare block hashes, not heights. Keep orphaned blocks, mark them, and let the read path filter them. The height comparison in my code is the kind of shortcut that looks fine until the first time a chain reorgs at the exact moment you are indexing it.

And if you want a real explorer, do not write one. Blockscout exists, it is in Elixir, it is deployed on every chain you care about, and it is configured by a dozen environment variables. My own checkout of it has exactly two local edits — a @spec line and a SUBNETWORK=Coindex 2.0 setting. The interesting engineering is in using it, and in the narrow slice of chain-specific code around it.

What is worth writing yourself is the narrow slice: the module that decodes the protocol events your chain actually cares about, plugged into an indexer that already handles history, balances, traces and reorgs. That is the shape of my module — and it is a good shape, as long as it is honest about being a decoder and not an indexer.

References