For AI agents: the documentation index is at /llms.txt. Markdown versions of pages are available by appending .md to the URL.
Skip to main content
Tutorials

What Is Multichain Indexing? A Working Three-Chain Example

Author:Jordyn LaurierJordyn Laurier··Updated ·12 min read
Reviewed by:Jason Smythe
Cover Image What is Multichain Indexing?
TL;DR
  • Multichain indexing reads events from several blockchains into one database and serves them through one API, so your app runs one indexer instead of one per chain.
  • In HyperIndex you define a contract once and list each chain under chains in config.yaml, with its own start block and address. The same handlers run on every chain.
  • Each chain is processed in order, and chains don't wait for each other, so a slow chain doesn't hold back data from the others.
  • When one chain is missing events, check that chain's start block, address and reorg setting before the handler.
  • The three-chain indexer below tracks every Uniswap V3 pool on Ethereum, Polygon and Arbitrum in three files. Copy it, or hand it to your coding agent with our prompt.

We wanted to see how much work it takes to index one contract on three chains at once, so we built a small indexer that records every pool the Uniswap V3 factory has created on Ethereum, Polygon and Arbitrum. It came to three short files and one pnpm envio dev. The parts worth explaining are the ones that differ per chain, because that is where a multichain indexer needs the most care once it runs in production.

Multichain describes an app or protocol that lives on more than one blockchain. Multichain indexing is how you read its data. Rather than running one indexer and one API per chain and stitching the results together in your app, a single indexer reads every chain, writes to one database, and serves it all through one GraphQL API. New to indexers in general? Start with what a blockchain indexer does.

How One Indexer Reads Several Chains​

A HyperIndex config.yaml has two halves. The contracts section describes a contract once, with its events and handler file. The chains section lists each chain by ID, and each entry says where that contract lives on that chain and which block to start from. Your handlers don't change per chain. Every event carries event.chainId, so a handler always knows where it came from.

Each chain is read on its own. Events on one chain are processed in block order, and chains move independently of each other, so Polygon doesn't wait for Ethereum to finish a block before its events are indexed. That keeps the data close to each chain's head, which matters if you need real-time data from several chains at once. The trade-off is that events from two different chains are not processed in a guaranteed order relative to each other. The multichain docs explain why there is no ordered mode and show the pattern to use when two chains feed one record, such as a bridge message.

One indexer covers one chain family. EVM chains index together in the same indexer. Solana and Fuel are each their own indexer.

A Working Config for Ethereum, Polygon and Arbitrum​

The Uniswap V3 factory sits at the same address on all three chains, 0x1F98431c8aD98523631AE4a59f267346ea31F984, and emits PoolCreated each time a pool is deployed. We looked up the block of the first PoolCreated event on each chain with HyperSync and used those as the start blocks.

You need Node.js 22.15 or newer, Docker for the local Postgres and Hasura, and an API token.

mkdir uniswap-v3-pools-multichain && cd uniswap-v3-pools-multichain
pnpm init
pnpm pkg set type=module
pnpm add envio

config.yaml​

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: uniswap-v3-pools-multichain
disable_default_cross_chain: true
contracts:
- name: UniswapV3Factory
handler: src/EventHandlers.ts
events:
- event: "PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)"
chains:
- id: 1 # Ethereum
start_block: 12369739
contracts:
- name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
- id: 137 # Polygon
start_block: 22757913
contracts:
- name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
- id: 42161 # Arbitrum
start_block: 185
contracts:
- name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"

disable_default_cross_chain: true turns on per-chain data mode, which the docs recommend for multichain indexers. Every per-chain entity gets a chainId column, and the same ID on two chains is stored as two rows, so you don't need to prefix IDs with the chain.

schema.graphql​

type Pool {
id: ID!
token0: String!
token1: String!
fee: Int!
tickSpacing: Int!
createdAtBlock: Int!
}

src/EventHandlers.ts​

import { indexer } from "envio";

indexer.onEvent(
{ contract: "UniswapV3Factory", event: "PoolCreated" },
async ({ event, context }) => {
context.Pool.set({
id: event.params.pool,
token0: event.params.token0,
token1: event.params.token1,
fee: Number(event.params.fee),
tickSpacing: Number(event.params.tickSpacing),
createdAtBlock: event.block.number,
});
},
);

fee and tickSpacing arrive as BigInt like every numeric event parameter, so they're wrapped in Number() to fit the Int! fields.

Running It​

Put your token in a .env file in the project root, then start the indexer.

echo "ENVIO_API_TOKEN=your_token_here" > .env
pnpm envio dev

The GraphQL endpoint is at http://localhost:8080/v1/graphql (the local Hasura admin secret is testing). The _meta query from the observability docs shows each chain's progress separately, which is the quickest way to see whether one chain is behind.

{
_meta {
chainId
progressBlock
eventsProcessed
isReady
}
}

To read one chain, filter on chainId.

{
Pool(where: { chainId: { _eq: 137 } }, limit: 2) {
id
chainId
token0
fee
}
}

When we ran it, all three chains reached the head and the indexer held more than 140,000 pools. To check nothing was dropped, we counted the factory's PoolCreated logs on each chain straight from HyperSync up to a fixed block and compared them with the indexer's rows below that block.

ChainCounted up to blockHyperSync logsIndexed pools
Ethereum26,256,00074,13974,139
Polygon96,043,00038,84238,842
Arbitrum517,570,00027,13627,136

Uniswap V3 pools created per chain, counted on 23rd October 2026.

Every chain matched exactly. Each pool address appears once per chain, and with per-chain data mode on, the same address on two chains would still be stored as two separate rows.

Getting Each Chain's Config Right​

Most of a multichain config is shared, so the settings that change from chain to chain are the ones to get right. The missing events checklist covers setup in general. These are the settings to check for each chain you add.

  • A start block copied from another chain. Block numbers don't line up across chains. Copy Ethereum's 12369739 onto Arbitrum in the config above and the indexer skips every pool Arbitrum created before its own block 12,369,739. Set start_block per chain, at or before that chain's deployment.
  • A different address on one chain. Some protocols deploy at the same address everywhere, many don't. Check every chain's address against the protocol's own deployment list.
  • One chain overwriting another. Without per-chain data mode, an entity ID that appears on two chains is one row, so the second chain's write replaces the first and it looks like events went missing. Turn on disable_default_cross_chain: true, or prefix IDs with event.chainId.
  • Reorgs on Arbitrum and OP Mainnet. Reorg rollback covers 200 blocks on most chains, but Arbitrum and OP Mainnet default to 0, so a reorg there isn't rolled back. Set max_reorg_depth on those chains if you want it.
  • An event signature that differs on one chain. If one chain runs an older version of the contract, its event can have a different signature or different indexed keywords, and those logs are skipped without an error. Add a separate contract entry for that version, with its own address and matching signature.

Adding a Chain to a Running Indexer​

Adding a chain is a config change. Add a new entry under chains with its ID, start block and contract address, and the handlers pick it up as they are.

On Envio Cloud, push the change to your deployment branch. That starts a new deployment that indexes every chain from its start block, including the new one, while the previous deployment keeps serving queries until the new one has synced, so the API your app reads from stays up throughout. Locally, restart with pnpm envio dev -r, which clears the database and indexes every chain from scratch.

Build It With Your Coding Agent​

If you'd rather hand this to a coding agent, give it current docs first. Connect the Envio MCP server or let it run envio tools search-docs, so it writes current HyperIndex syntax instead of what it remembers. Then paste the prompt below.

Show the full prompt
Build a HyperIndex multichain indexer that records every Uniswap V3 pool
created on Ethereum (1), Polygon (137) and Arbitrum (42161).

Before writing code, read the current HyperIndex docs through the Envio MCP
server or `envio tools search-docs`, especially the multichain indexing page
and the configuration file page. Do not rely on memory for config keys.

Setup
- Node.js 22.15 or newer and Docker are required.
- mkdir uniswap-v3-pools-multichain && cd uniswap-v3-pools-multichain
- pnpm init, pnpm pkg set type=module, pnpm add envio
- Put ENVIO_API_TOKEN=<token> in .env in the project root. Ask me for the
token if you don't have one.

Config (config.yaml in the project root, with `name: uniswap-v3-pools-multichain`)
- One contract, UniswapV3Factory, with `handler: src/EventHandlers.ts`, defined once under `contracts`, with the
event "PoolCreated(address indexed token0, address indexed token1,
uint24 indexed fee, int24 tickSpacing, address pool)". Keep every
`indexed` keyword exactly as written, or logs are skipped silently.
- Three entries under `chains`, all with address
0x1F98431c8aD98523631AE4a59f267346ea31F984 and these start blocks:
Ethereum 12369739, Polygon 22757913, Arbitrum 185. Do not reuse one
chain's start block on another.
- Set `disable_default_cross_chain: true` at the top level so entities are
stored per chain. This adds a `chainId` column to every entity, which
the queries below filter on. Do not prefix ids with the chain id.

Schema and handler (schema.graphql in the project root, handler in
src/EventHandlers.ts)
- One entity, Pool: id (pool address), token0, token1, fee (Int),
tickSpacing (Int), createdAtBlock (Int, from event.block.number).
- Use `indexer.onEvent` from "envio". Numeric params arrive as BigInt, so
wrap fee and tickSpacing in Number().

Run and check
- Start with `pnpm envio dev`. GraphQL is at http://localhost:8080/v1/graphql, admin
secret "testing". `_meta` can be missing for the first few seconds while
the tables are set up, so retry.
- Query `_meta { chainId progressBlock eventsProcessed isReady }` until all
three chains report isReady: true. If the run stops, `pnpm envio dev`
resumes from where it was. Only `-r` starts again from scratch.
- Report back the pool count per chain from
Pool_aggregate(where: { chainId: { _eq: <id> } }) { aggregate { count } }
and the block each chain reached. Report the numbers you actually got,
not figures from a blog. Stop the indexer with `pnpm envio stop` when
you're done.

Frequently Asked Questions​

Why Is My Multichain Indexer Missing Events on One Chain?​

Check that chain's entry in config.yaml first. The usual causes are a start block copied from another chain, a contract address that differs on that chain, or an event signature that doesn't match the version deployed there. If events seem to disappear after being indexed, two chains may be writing the same entity ID, which per-chain data mode prevents. On Arbitrum and OP Mainnet, reorg rollback is off by default.

How Do I Get Real-Time Data From Several Chains at Once?​

Run one HyperIndex indexer with every chain listed under chains. Each chain is processed as soon as its events are available, without waiting for the other chains, so each one stays close to its own head. The _meta GraphQL query shows how far each chain has got, which you can use to check that a chain has caught up before reading its data.

Can One HyperIndex Indexer Cover EVM Chains and Solana Together?​

No. One indexer covers one chain family. Any number of EVM chains can share an indexer, and Solana and Fuel each need their own.

Does Adding a Chain Mean Re-Indexing the Others?​

Yes. A new deployment on Envio Cloud indexes every chain from its start block, including the new one. The previous deployment keeps serving queries until the new one has synced, so your API stays up during the switch. Locally, pnpm envio dev -r clears the database and starts again.

Is There a Production Multichain Indexer I Can Study?​

The Uniswap V4 indexer that powers v4.xyz is the reference we point people to. It indexes the v4 PoolManager across many chains from one config, and the source is on GitHub.

How Does Envio's Multichain Support Compare With Other Indexers?​

Our comparison page covers multichain support alongside benchmarks and other capabilities, with every cell linked to its source. For HyperIndex configuration in depth, see the multichain docs.

Build With Envio​

Envio builds custom blockchain APIs and analytics. Teams use HyperIndex to turn contract activity across EVM chains, Solana and Fuel into a real-time GraphQL API, and run it on Envio Cloud, from a free development plan up to Dedicated deployments with an SLA. If you're building a multichain app, come talk to us about your data needs.

Subscribe to our newsletter

Website | X | Discord | Telegram | GitHub | YouTube | Reddit