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

HyperIndex Complete Documentation

This document contains all HyperIndex documentation consolidated into a single file for LLM consumption.


Key Facts - HyperIndex
What it isA blazing-fast, developer-friendly multichain blockchain indexer that transforms on-chain events into structured, queryable databases with GraphQL APIs
Data enginePowered by HyperSync - up to 2000x faster than traditional RPC endpoints
PerformanceRanked #1 fastest indexer in independent Sentio benchmarks (April 2025) - up to 6x faster than the nearest competitor, 63x faster than TheGraph
Supported chains70+ EVM chains and Fuel, with new networks added regularly; all EVM-compatible chains supported via RPC
LanguagesTypeScript, JavaScript, ReScript
Key filesconfig.yaml (indexer settings), schema.graphql (data schema), src/EventHandlers.* (event logic)
PrerequisitesNode.js v22+, pnpm v8+, Docker Desktop (local dev only)
DeploymentHosted service (managed, no API token needed) or self-hosted
API tokenRequired for local dev and self-hosted deployments from 3 November 2025 via ENVIO_API_TOKEN env variable
Query interfaceGraphQL API auto-generated from your schema
MultichainNative multichain indexing with unordered_multichain_mode support
Wildcard indexingIndex by event signature rather than contract address
MigrationStraightforward migration path from TheGraph subgraphs
Get startedpnpx envio init
SupportDiscord · GitHub

Overview

File: overview.md

HyperIndex is a blazing-fast, developer-friendly multichain indexer, optimized for both local development and reliable hosted deployment. It empowers developers to effortlessly build robust backends for blockchain applications. If you are new to indexing, see what a blockchain indexer is for the wider context.

HyperIndex & HyperSync

HyperIndex is Envio's full-featured blockchain indexing framework that transforms on-chain events into structured, queryable databases with GraphQL APIs.

HyperSync is the high-performance data engine that powers HyperIndex. It provides the raw blockchain data access layer, delivering up to 2000x faster performance than traditional RPC endpoints.

While HyperIndex gives you a complete indexing solution with schema management and event handling, HyperSync can be used directly for custom data pipelines and specialized applications.


Key Features

  • Quickstart templates – Rapidly bootstrap your indexer.
  • Real-time indexing – Instantly track blockchain events.
  • Multichain indexing – Support multiple blockchains simultaneously.
  • Local development – A full-featured local environment with Docker.
  • Reorg support – Gracefully handle blockchain reorganizations without sacrificing latency.
  • GraphQL API – Easily query indexed data.
  • Cross-platform support – Index any EVM-, SVM-, or Fuel-compatible blockchain.
  • High performance – Perform historical backfills at 30,000+ events per second.
  • Indexer auto-generation – Generate indexers directly from smart contract addresses.
  • Flexible language support – TypeScript, JavaScript, and ReScript.
  • Factory contract support – Index data from over 1M dynamically registered contracts seamlessly.
  • On-chain and off-chain data integration – Easily combine multiple data sources.
  • Self-hosted and managed options – Run your own setup or use Envio Cloud.
  • Detailed logging and observability – Debug and optimize with clarity.
  • External API actions – Trigger external services based on blockchain events.
  • Wildcard topic indexing – Flexibly index based on event topics.
  • Fallback RPC data sources – Enhance reliability with RPC connections.

Feature Roadmap

Upcoming features on our development roadmap:

  • Indexing 1,000,000+ events per second
  • Configurable Query/REST API layer
  • No-Code Indexers
  • Durable & Non-Blocking Effect API

Recently shipped: isolated multichain mode (v3.6, with per-chain rollbacks in v3.10) and stable Solana support (v3.11).


HyperSync API Token Requirements

HyperSync (the data engine powering HyperIndex) requires an API token for all requests. You can generate one in the Envio Cloud portal. Here's what you need to know:

  • Local Development: An API token is required. The CLI supports an automatic login flow to make this smoother.
  • Self-Hosted Deployments: API tokens are required for HyperSync access in self-hosted deployments. Set the token via the ENVIO_API_TOKEN environment variable in your indexer configuration. This can be read from the .env file in the root of your HyperIndex project.
  • Envio Cloud: Indexers deployed to Envio Cloud have special access that doesn't require a custom API token.
  • Pricing: Tiered packages are available for those self-hosting HyperIndex and using HyperSync. See the HyperSync pricing page for details, or reach out to us on Discord for preferred pricing based on your specific use case.

For more details about API tokens, including how to generate and implement them, see our API Tokens documentation.



Contract Import

File: contract-import.md

The Quickstart enables you to instantly autogenerate a powerful blockchain indexer and start querying blockchain data in minutes. This is the fastest and easiest way to begin using HyperIndex. If you are new to indexing, see what a blockchain indexer is for the wider context.

Example: Autogenerate an indexer for the Eigenlayer contract and index its entire history in less than 5 minutes by simply running pnpx envio init and providing the contract address from Etherscan.


Prerequisites

  • Node.js (v22 or newer recommended)
  • pnpm (recommended but not required)
  • Docker Desktop (required to run the Envio indexer locally)

Note: Docker is only required if you plan to run your indexer locally. You can skip installing Docker if you'll only be using Envio Cloud.

Additionally for Windows Users:

  • WSL Windows Subsystem for Linux

Getting Started

Run the following command to initialize your blockchain indexer:

pnpx envio init

You'll then follow interactive prompts to customize your indexer.


Video Tutorials


Indexer Initialization Options

During initialization, you'll be presented with two options:

  • Contract Import (recommended for existing smart contracts)
  • Template

Choose the Contract Import option to auto-generate indexers directly from smart contracts.

? Choose an initialization option
Template
> Contract Import
[↑↓ to move, enter to select]

Contract Import Methods

There are two convenient methods to import your contract:

  • Block Explorer (verified contracts on supported explorers like Etherscan and Blockscout)
  • Local ABI (custom or unverified contracts)

1. Block Explorer Import

This method uses a verified contract's address from a supported blockchain explorer (Etherscan, Routescan, etc.) to automatically fetch the ABI.

Steps:

a. Select the blockchain

? Which blockchain would you like to import a contract from?
> ethereum-mainnet
goerli
optimism
base
bsc
gnosis
polygon
[↑↓ to move, enter to select]
note

HyperIndex supports all EVM-compatible chains. If your desired chain is not listed, you can import via the local ABI method or manually adjust the config.yaml file after initialization.

b. Enter the contract address

? What is the address of the contract?
[Use proxy address if ABI is for a proxy implementation]
tip

If using a proxy contract, always specify the proxy address, not the implementation address.

c. Select events to index

? Which events would you like to index?
> [x] ClaimRewards(address indexed from, address indexed reward, uint256 amount)
[x] Deposit(address indexed from, uint256 indexed tokenId, uint256 amount)
[x] NotifyReward(address indexed from, address indexed reward, uint256 indexed epoch, uint256 amount)
[x] Withdraw(address indexed from, uint256 indexed tokenId, uint256 amount)
[space to select, → to select all, ← to deselect all]

d. Finish or add more contracts

You'll be prompted to continue adding more contracts or to complete the setup:

? Would you like to add another contract?
> I'm finished
Add a new address for same contract on same network
Add a new network for same contract
Add a new contract (with a different ABI)

2. Local ABI Import

Choose this method if the contract ABI is unavailable from a block explorer or you're using an unverified contract.

Steps:

a. Select Local ABI

? Would you like to import from a block explorer or a local abi?
Block Explorer
> Local ABI
[↑↓ to move, enter to select]

b. Specify ABI JSON file

Provide the path to your local ABI file (JSON format):

? What is the path to your json abi file?

c. Select events to index

? Which events would you like to index?
> [x] ClaimRewards(address indexed from, address indexed reward, uint256 amount)
[x] Deposit(address indexed from, uint256 indexed tokenId, uint256 amount)
[space to select, → to select all, ← to deselect all]

d. Choose blockchain

Specify the blockchain your contract is deployed on:

? Choose network:
> ethereum-mainnet
goerli
optimism
base
bsc
gnosis
[Custom Network ID]
[↑↓ to move, enter to select]

e. Enter contract details

  • Contract name
? What is the name of this contract?
  • Contract address
? What is the address of the contract?
[Use proxy address if ABI is for a proxy implementation]

f. Finish or add more contracts

Complete the import process or continue adding contracts:

? Would you like to add another contract?
> I'm finished
Add a new address for same contract on same network
Add a new network for same contract
Add a new contract (with a different ABI)

Generated Files & Configuration

The Quickstart automatically generates key files:

1. config.yaml

Automatically configured parameters include:

  • Network ID
  • Start Block
  • Contract Name
  • Contract Address
  • Event Signatures

By default, all selected events are included, but you can manually adjust the file if needed. See the detailed guide on config.yaml.

2. GraphQL Schema

  • Entities are automatically generated for each selected event.
  • Fields match the event parameters emitted.

See more details in the schema file guide.

3. Event Handlers

  • Handlers are autogenerated for each event.
  • Handlers create event-specific entities.

Learn more in the event handlers guide.


Congratulations! Your HyperIndex indexer is now ready to run and query data!

Next step: Running your Indexer locally or Deploying to Envio Cloud.


Other Ways to Start

Contract Import is the recommended path, but you can also bootstrap an indexer from:

  • Templates - pre-built ERC20 or Greeter projects, selectable from the pnpx envio init interactive prompt.
  • Examples - copy and adapt an existing indexer from our Examples, our Tutorials, or the GitHub repositories.

Quickstart With Ai

File: quickstart-with-ai.md

Build an Envio HyperIndex indexer end-to-end with an AI coding assistant.

Most developers now reach for an AI coding assistant before they open a file. This guide walks through an AI-centric flow for creating, developing, and deploying a HyperIndex indexer. It is semi-generic, so any capable AI coding assistant (Cursor, Windsurf, Copilot Agent, Continue, etc.) will work. That said, we've seen the best results with Claude Code and recommend starting there.

Prefer the interactive flow?

If you'd rather drive the CLI yourself, see the Quickstart.


Prerequisites


Step 1. Initialize The Indexer

Open Claude/Cursor/Codex and prompt:

pnpx envio init

Built for AI Agents

When we notice a command is run by an agent instead of interactively, we output an AI-friendly prompt with the available options and step-by-step instructions on what to do next.

We also provide tools and recommendations an agent can use to get the result, like envio tools search-docs, with more coming soon.

After the project is initialized, we provide a curated set of skills that guide an agent through the codebase. Together with our testing framework, they let it iterate quickly on indexer changes while keeping quality high.

tip

Upgrading Envio or have stale skills? Run envio skills update to pull the latest skills into your project.

About Envio API Token

The Envio API token is your HyperSync API token. A few things to know:

  • The token can't currently be created programmatically. You generate one by logging in to envio.dev/app/api-tokens and copying it into ENVIO_API_TOKEN in your indexer's .env.
  • It's only required for local development and self-hosted deployments. Indexers running on Envio Cloud get special access and don't need a custom token.
  • It's required when using Envio as the data provider (HyperSync). If you only use an external RPC as the data source, no token is needed - you can pass an empty string to skip the prompt.
  • To run pnpm dev locally, generate a token from the link above and set ENVIO_API_TOKEN in .env before starting the indexer.

See API Tokens and Environment Variables for full details.


Step 2. The Development Loop

The skills cover config, schema, handlers, loaders, dynamic contracts, testing, and migration checklists, so an agent can read them directly instead of inventing patterns. A productive loop looks like:

  1. Describe the behavior you want in plain English.
  2. Let the assistant edit config.yaml, schema.graphql, and src/handlers.
  3. Have it follow a test-driven loop: write a failing test with createTestIndexer(), implement the handler, then run pnpm test to capture and lock in snapshots. See the Testing guide for the full TDD workflow.
  4. Iterate on failures together.

The three files your agent will spend most of its time in:

  • config.yaml: chains, contracts, events
  • schema.graphql: entities and relationships
  • src/handlers: per-event logic

Step 3. Migrating an Existing Indexer

If you're porting from The Graph, Ponder, or another indexing framework, start with the AI migration workflow. It scales much better than hand-editing handlers.

  • Migrate Using AI: the recommended assistant-driven flow. It's written around subgraphs, but the same monorepo-plus-phased-prompt pattern works for Ponder and other frameworks. Point the assistant at the source project plus a freshly scaffolded HyperIndex indexer and let the skills guide it.
  • Migrate from The Graph (manual)
  • Migrate from Ponder
  • Migrate from Alchemy

Step 4. Deploy Programmatically with envio-cloud

Once your indexer runs locally, the envio-cloud CLI lets an assistant (or a CI job) deploy and manage the hosted indexer without opening the dashboard.

npm install -g envio-cloud

envio-cloud login --token $ENVIO_GITHUB_TOKEN
envio-cloud indexer add --name my-indexer --repo my-repo
envio-cloud deployment status my-indexer <commit> --watch-till-synced
envio-cloud deployment logs my-indexer <commit> --follow

Every command supports -o json, which makes it easy for assistants and scripts to parse results. Full reference: Envio Cloud CLI.


  • MCP Server
  • LLM-friendly docs bundle
  • Envio CLI reference
  • Envio Cloud CLI
  • Migrate Using AI
  • HyperIndex v3 migration

What's New in HyperIndex V3

File: whats-new-in-v3.md

15 full months have passed since the official HyperIndex v2.0.0. Since then, we have shipped 32 minor releases and multiple patches with zero breaking changes to the documented API. We also received PRs from 6 external contributors, grew from 1 GitHub star to over 470, and saw many big projects rely on HyperIndex.

HyperIndex V3 focuses on modernizing the codebase and laying the foundation for many more months of development. This page describes everything that's new. To upgrade an existing project from V2, follow the Migrate to V3 guide.

New Features

Unified Handlers API

In V3 all handler registrations now happen through a single indexer value. Contract-specific exports (ERC20.Transfer.handler, UniV3.PoolFactory.contractRegister, etc.) have been removed in favor of indexer.onEvent, indexer.contractRegister, and indexer.onBlock.

Event handlers with indexer.onEvent:


indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [
{ from: chain.Safe.addresses },
{ to: chain.Safe.addresses },
],
}),
},
async ({ event, context }) => {
// Handler logic
},
);

Dynamic contracts with indexer.contractRegister:


indexer.contractRegister(
{
contract: "UniV3",
event: "PoolFactory",
},
async ({ event, context }) => {
context.chain.Pool.add(event.params.poolAddress);
},
);

Block handlers with indexer.onBlock consolidate across chains in a single call:


indexer.onBlock(
{ name: "EveryBlock" },
async ({ block, context }) => {
// Handler logic
},
);

For chain-specific or interval-based block handlers, use the where callback:

indexer.onBlock(
{
name: "Ranges",
where: ({ chain }) => {
if (chain.id !== 1) return false;
return {
block: {
number: {
_gte: 20_000_000,
_lte: 22_000_000,
_every: 100,
},
},
};
},
},
async ({ block, context }) => {
// Handler logic
},
);

Per-Event Start Block

Handlers can specify custom start blocks per chain via where.block.number._gte, overriding contract and chain configuration:

indexer.onEvent(
{
contract: "UniV4",
event: "Pool",
where: ({ chain }) => {
let startBlock: number;
switch (chain.id) {
case 1:
startBlock = 18_000_000;
break;
case 8453:
startBlock = 2_000_000;
break;
default: {
const _exhaustive: never = chain.id;
return false;
}
}
return {
block: { number: { _gte: startBlock } },
};
},
},
async ({ event, context }) => {
// Handler logic
},
);

CommonJS → ESM

We migrated HyperIndex from CommonJS-only to ESM-only. This enables:

  • Using the latest versions of libraries that have long since abandoned CommonJS support
  • Top-level await in handler files

Top-Level Await

Thanks to the migration to ESM, you can now use await directly in handler and other files:


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

// Load data before registering handlers
const addressesFromServer = await loadWhitelistedAddresses();

indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: {
params: [
{ from: ZERO_ADDRESS, to: addressesFromServer },
{ from: addressesFromServer, to: ZERO_ADDRESS },
],
},
},
async ({ event, context }) => {
// ... your handler logic
},
);

3x Historical Backfill Performance

Achieved by adding chunking logic to request events across multiple ranges at once. This also fixed overfetching for contracts with a much later start_block in the config, as well as speeding up dynamic contract registration. If you had data fetching as a bottleneck, 25k events per second is now a standard.

Automatic Handler Registration (src/handlers)

We introduced automatic registration of handler files located in src/handlers.

Previously, you needed to specify an explicit path to a handler file for every contract in config.yaml. Now you can remove all of the paths from config.yaml and simply move the files to src/handlers. You can name the files however you want, but we suggest using contract names and having a file per contract.

If you don't like src/handlers, use the handlers option in config.yaml to customize it.

note

The explicit handler field in config.yaml still works, so you don't need to change anything immediately.

RPC for Realtime Indexing

Built by an external contributor @cairoeth to allow specifying realtime mode for an RPC data source to embrace low-latency head tracking:

rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: realtime

In this case, the RPC won't be used for historical sync but will be used as the primary source once the indexer enters realtime mode.

Chain State on Context

The Handler Context object provides chain state via the chain property:


indexer.onEvent(
{ contract: "ERC20", event: "Approval" },
async ({ context }) => {
console.log(context.chain.id); // 1 - The chain id of the event
console.log(context.chain.isRealtime); // true - Whether the indexer entered realtime mode
},
);

Indexer State & Config

As a replacement for the deprecated and removed getGeneratedByChainId, we introduce the indexer value. It provides nicely typed chains and contract data from your config, as well as the current indexing state, such as isRealtime and addresses. Use indexer either at the top level of the file or directly from handlers. It returns the latest indexer state.

With this change, we also introduce new official types: Indexer, EvmChainId, FuelChainId, and SvmChainId.


indexer.name; // "uniswap-v4-indexer"
indexer.description; // "Uniswap v4 indexer"
indexer.chainIds; // [1, 42161, 10, 8453, 137, 56]
indexer.chains[1].id; // 1
indexer.chains[1].startBlock; // 0
indexer.chains[1].endBlock; // undefined
indexer.chains[1].isRealtime; // false
indexer.chains[1].PoolManager.name; // "PoolManager"
indexer.chains[1].PoolManager.abi; // unknown[]
indexer.chains[1].PoolManager.addresses; // ["0x000000000004444c5dc75cB358380D2e3dE08A90"]

On indexer restart, reading indexer at the top level of a handler file returns values restored from the database - including dynamically registered contract addresses - rather than only what's declared in config.yaml:


// Includes initial + dynamically registered addresses persisted in the DB
console.log(indexer.chains.eth.Pool.addresses);

Conditional Event Handlers

Now it's possible to return a boolean value from the where function to disable or enable the handler conditionally.


indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: ({ chain }) => {
// Skip all ERC20 on Polygon
if (chain.id === 137) {
return false;
}

// Track all ERC20 on Ethereum Mainnet
if (chain.id === 1) {
return true;
}

// Track only whitelisted addresses on other chains
return {
params: [
{ from: ZERO_ADDRESS, to: WHITELISTED_ADDRESSES[chain.id] },
{ from: WHITELISTED_ADDRESSES[chain.id], to: ZERO_ADDRESS },
],
};
},
},
async ({ event, context }) => {
// ... your handler logic
},
);

Automatic Contract Configuration

Started automatically configuring all globally defined contracts. This fixes an issue where addContract crashed because the contract was defined globally but not linked for a specific chain. Now it's done automatically:

contracts:
- name: UniswapV3Factory
events: # ...
- name: UniswapV3Pool
events: # ...
chains:
- id: 1
start_block: 0
contracts:
- name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
# UniswapV3Pool no longer needed here - auto-configured from global contracts
- id: 10
start_block: 0
contracts:
- name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
# UniswapV3Pool no longer needed here - auto-configured from global contracts

ClickHouse Storage (Experimental)

HyperIndex can now run with multiple storage backends at the same time. Postgres remains the primary database, and entities can additionally be written to a ClickHouse database that is restart- and reorg-resistant. Prometheus metrics carry a storage-name label so you can distinguish backends.

Enable backends in config.yaml and route each entity explicitly via the @storage directive in schema.graphql:

storage:
postgres: true
clickhouse: true
# Stored in both Postgres and ClickHouse
type Transfer @storage(postgres: true, clickhouse: true) {
id: ID!
from: String!
to: String!
value: BigInt!
}

# Stored only in ClickHouse
type Snapshot @storage(clickhouse: true) {
id: ID!
blockNumber: BigInt!
}

Per-entity routing is more verbose but lets you write some entities to Postgres and others to ClickHouse only.

envio dev automatically spins up a ClickHouse Docker container for local development with playground-friendly defaults so you can connect to it without configuring a password. For envio start, provide your own connection via the environment variables ENVIO_CLICKHOUSE_HOST, ENVIO_CLICKHOUSE_DATABASE, ENVIO_CLICKHOUSE_USERNAME, and ENVIO_CLICKHOUSE_PASSWORD.

Envio Cloud currently supports ClickHouse on the Dedicated Plan.

For high-availability ClickHouse setups, HyperIndex supports two additional environment variables:

  • ENVIO_CLICKHOUSE_REPLICATED - set to true to use replicated table engines.
  • ENVIO_CLICKHOUSE_DATABASE_ENGINE - override the database engine (for example, Replicated).
warning

Do not run multiple indexers writing to the same ClickHouse database at the same time.

HyperSync Source Improvements

Multiple updates on the HyperSync side to achieve smaller latency and less traffic:

  • Server-Sent Events instead of polling to get updates about new blocks
  • CapnProto instead of JSON for query serialization
  • Cache for queries with repetitive filters - huge egress saving when indexing thousands of addresses
  • Improved connection establishment behind a proxy
  • Configurable log level support via ENVIO_HYPERSYNC_LOG_LEVEL environment variable
  • Automatic rate-limiting handling on the client side
  • Better reconnection logic, logging, and fallbacks for HyperSync SSE and RPC WebSocket height streaming for more stable indexing at the chain head

Fuel Block Handler Support

Block handlers are now supported for Fuel indexing.

Solana Support

HyperIndex now supports Solana. Solana exposes its block-stream handler as indexer.onSlot (rather than onBlock) to match Solana's slot-based model, and program instructions are indexed with indexer.onInstruction over a HyperSync source.

To initialize a Solana project:

pnpx envio init svm

Solana support shipped experimental in v3.0 and became stable in v3.11. See the Solana documentation for more details.

pnpx envio init Improvements

  • Removed language selection to prefer TypeScript by default
  • Cleaned up templates to follow the latest good practices
  • Added new templates to highlight HyperIndex features: Feature: Factory Contract, Feature: External Calls
  • Pre-configured GitHub Actions workflow for running tests and initialized git repository
  • Generated projects include Cursor/Claude skills to support agent-driven development

Block Handler Only Indexers

Now it's possible to create indexers with only block handlers. Previously, it was required to have at least one event handler for it to work. The contracts field became optional in config.yaml.

Flexible Entity Fields

We no longer have restrictions on entity field names, such as type and others. Shape your entities any way you want. There are also improvements in generating database columns in the same order as they are defined in the schema.graphql.

Unordered Multichain Mode Only

Unordered multichain mode is now the only mode in V3 - events from different chains are processed in parallel without strict cross-chain ordering, which provides better performance for most use cases. The V2 unordered_multichain_mode option and the multichain: ordered opt-in have been removed.

Preload Optimization by Default

Preload optimization is now enabled by default, replacing the previous loaders and preload_handlers options. This improves historical sync performance automatically.

TUI Improvements

We gave our TUI some love, making it look more beautiful and compact. It also consumes fewer resources, shares a link to the Hasura playground, and dynamically adjusts to the terminal width.

The TUI now shows an events-per-second indicator during backfill so you can see indexing throughput at a glance.

The TUI is also auto-disabled in CI environments and when running under AI agents, so logs stay clean without manual configuration. The legacy TUI_OFF=true environment variable was renamed to ENVIO_TUI=false.

New Testing Framework

HyperIndex ships a purpose-built testing framework powered by createTestIndexer(). Write tests against the same indexer that runs in production - no database, no Docker, no manual mock wiring.

The framework integrates with Vitest, replacing the previous mocha/chai setup with a single package that doesn't require configuration by default and includes snapshot testing out-of-the-box. It also provides typed test assertions and utilities to read/write entities in-between processing runs.

Three ways to feed events

1. Auto-exit - processes the first block with matching events, then exits. Each subsequent call continues where the last one stopped. Zero config needed.



describe("ERC20 indexer", () => {
it("processes the first block with events", async (t) => {
const indexer = createTestIndexer();

const result = await indexer.process({ chains: { 1: {} } });

// Auto-filled by Vitest on first run — just review and commit
t.expect(result).toMatchInlineSnapshot(`
{
"changes": [
{
"Transfer": {
"sets": [
{
"blockNumber": 10861674,
"from": "0x0000000000000000000000000000000000000000",
"id": "1-10861674-23",
"to": "0x41653c7d61609D856f29355E404F310Ec4142Cfb",
"transactionHash": "0x4b37d2f343608457ca...",
"value": 1000000000000000000000000000n,
},
],
},
"block": 10861674,
"chainId": 1,
"eventsProcessed": 1,
},
],
}
`);
});
});

2. Explicit block range - pin to specific blocks for deterministic CI snapshots.

const result = await indexer.process({
chains: {
1: {
startBlock: 10_861_674,
endBlock: 10_861_674,
},
},
});

3. Simulate - feed typed synthetic events for pure unit tests. No network, no block ranges.

await indexer.process({
chains: {
137: {
simulate: [
{
contract: "Greeter",
event: "NewGreeting",
params: { greeting: "Hello", user: "0x123..." },
},
],
},
},
});

Key capabilities

  • Snapshot-driven assertions - result.changes captures every entity set/delete per block. Pair with toMatchInlineSnapshot for auto-generated, reviewable snapshots.
  • Direct entity access - indexer.Entity.get(), .getOrThrow(), .getAll(), and .set() for reading and presetting state.
  • Real pipeline, real confidence - tests exercise the full indexer pipeline including dynamic contract registration, multi-chain support, and handler context.
  • Parallel test execution via worker thread isolation.

The test indexer also exposes chain information:

const indexer = createTestIndexer();
indexer.chainIds; // [1, 42161]
indexer.chains[1].id; // 1
indexer.chains[1].startBlock; // 0
indexer.chains[1].ERC20.addresses; // ["0x..."]

// Read/write entities between processing runs
await indexer.Account.set({ id: "0x123...", balance: 100n });
const account = await indexer.Account.get("0x123...");

See the Testing documentation for more details.

Podman Support

Beyond Docker, HyperIndex now supports Podman for local development environments. This provides an alternative container runtime for developers who prefer Podman or have it available in their environment.

Nested Tuples for Contract Import

The envio init command now supports contracts with nested tuples in event signatures, which was previously a limitation when importing contracts.

PostgreSQL Update for Local Docker Compose

The local development Docker Compose setup now uses PostgreSQL 18.1 (upgraded from 17.5).

contractName and eventName on Event

Events now include contractName and eventName fields, making it easier to identify which contract and event you're working with in handlers:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event }) => {
console.log(event.contractName); // "ERC20"
console.log(event.eventName); // "Transfer"
},
);

New Official Exported Types

Generated code now exports official generic types for entities, enums, and events. These replace the previous contract-specific type exports:

import type {
MyEntity, // Still exported but Entity<"MyEntity"> is preferred
Entity, // Generic entity type — use as Entity<"MyEntity">
Enum, // Generic enum type — use as Enum<"MyEnum"> (replaces direct MyEnum export)
EvmEvent, // Generic event type — use as EvmEvent<"ERC20", "Transfer">
// Access specific fields: EvmEvent<"ERC20", "Transfer">["block"]
} from "envio";

Support for DESC Indices

A nice way to improve your query performance as well:

type PoolDayData
@index(fields: ["poolId", ["date", "DESC"]]) {
id: ID!
poolId: String!
date: Timestamp!
}

RPC Source Improvements

Added polling_interval option for RPC source configuration. Also added missing support for receipt-only fields (gasUsed, cumulativeGasUsed, effectiveGasPrice) that are not available via eth_getTransactionByHash. HyperIndex will additionally perform the eth_getTransactionReceipt request when one of the fields is added in field_selection.

WebSocket Support (Experimental)

Experimental WebSocket support for RPC source to improve head latency. Please create a GitHub issue if you come across any problems.

chains:
- id: 1
rpc:
url: ${ENVIO_RPC_ENDPOINT}
ws: ${ENVIO_WS_ENDPOINT}
for: realtime

Prometheus Metrics for Data Providers

Added a Prometheus metric to track requests to data providers, providing better observability into your indexer's data fetching patterns.

GraphQL-Style getWhere API

The getWhere query API has been redesigned using GraphQL-style syntax:

// Before
const transfers = await context.Transfer.getWhere.from.eq("0x123...");

// After
const transfers = await context.Transfer.getWhere({ from: { _eq: "0x123..." } });

Additionally, three new filter operators are available following Hasura-style conventions:

context.Entity.getWhere({ amount: { _gte: 100n } })
context.Entity.getWhere({ amount: { _lte: 500n } })
context.Entity.getWhere({ status: { _in: ["active", "pending"] } })

Direct RPC Client

Replaced Ethers.js with a direct RPC client implementation, reducing dependencies and improving performance.

Block Lag Configuration

A per-chain block_lag option to index behind the chain head by a specified number of blocks. Replaces the global ENVIO_INDEXING_BLOCK_LAG environment variable. Defaults to 0. This is for advanced use cases - only use it if you know what you're doing.

chains:
- id: 1
block_lag: 5

Official /metrics Endpoint

Prometheus metrics are now official. We cleaned up metric names, switched time units to seconds instead of milliseconds, and followed Prometheus naming conventions more closely. Metrics also cover data points previously available only via the --bench feature. A separate /metrics/runtime endpoint with a dedicated Prometheus registry is available for runtime metrics, isolated from the default /metrics endpoint.

Starting from the v3.0.0 release, Prometheus metrics will follow semver and be documented.

Breaking changes:

  • Cleaned up metric names and switched time units from milliseconds to seconds
  • Removed --bench support - use the /metrics endpoint instead

Use the new envio metrics CLI command to fetch the Prometheus metrics of a locally running indexer without curling the endpoint manually.

Continue on Config Change

HyperIndex can now keep indexing through some config.yaml changes - rpc configuration is the first to land - instead of erroring out on every restart. Where a change is incompatible, the CLI prints exactly which fields were touched and offers two clear options (revert, or envio dev -r to wipe and re-index). More flexibility will be unlocked over time; open a GitHub issue if you need a specific field supported.

Double Handler Registration

It's now possible to register multiple handlers for the same event with similar filters:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Your logic here
},
);

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// And here
},
);

Improved Multiple Data-Sources Support

After switching to a fallback source, HyperIndex now attempts to recover to the primary source 60 seconds later. Previously, it would stay on the fallback until the fallback was down or the indexer was restarted. The source selection logic has also been improved for better indexing resilience and stricter enforcement of the realtime mode configuration.

Updated Dev Docker Flow

envio dev no longer uses a generated Docker Compose file and manages containers, network, and volumes directly for greater flexibility. For example, disabling Hasura with ENVIO_HASURA now prevents envio dev from pulling the Hasura image. Use envio dev --restart (or -r) to forcefully clear the database even if there are no config changes detected.

Envio Dev Update

envio dev no longer automatically resets the database on incompatible config or schema changes. Use envio dev -r to explicitly allow this.

Envio Start Update

envio start now has a clear role: to run HyperIndex in the production environment. Use envio dev for local development to enable debugging with Dev Console.

Optimized envio codegen

envio codegen is now near-instant. We no longer run pnpm i for the generated package, and we no longer recompile ReScript every time you change config.yaml or schema.graphql. The output is also a lot quieter.

envio skills update Command

Pull the latest Claude/Cursor skills into your project so agent-driven development stays in sync with the latest HyperIndex APIs:

pnpx envio skills update

envio config view Command (Experimental)

Inspect your fully resolved indexer configuration as JSON - useful for debugging configuration issues and for tooling that needs to consume the resolved config:

pnpx envio config view

Improved TypeScript Error Messages

When generated types are missing, the TypeScript error now explicitly suggests running envio codegen instead of leaving you to puzzle out the cause.

Smaller envio Package (-88MB)

By eliminating dynamically generated ReScript code, we no longer need to ship or run a ReScript compiler at runtime. The published npm package shrank from 141MB to 53MB.

No Hard pnpm Requirement

Internal use of pnpm is gone. The generated package no longer has its own dependency tree, so HyperIndex works with whichever package manager you prefer.

Bun Support

Run HyperIndex on Bun:

bun --bun envio dev

Choose Your Package Manager on envio init

envio init now accepts --package-manager=pnpm|npm|bun|yarn so you can scaffold projects without committing to pnpm.

Better Tuples Developer Experience

Solidity struct components used to be generated as positional tuples in handler params, which made handler code awkward. They are now generated as objects with named fields:

struct CreateEventCommon {
address funder;
address sender;
address recipient;
Lockup.CreateAmounts amounts;
IERC20 token;
bool cancelable;
bool transferable;
Lockup.Timestamps timestamps;
string shape;
address broker;
}

event CreateLockupTranchedStream(
uint256 indexed streamId,
Lockup.CreateEventCommon commonParams,
LockupTranched.Tranche[] tranches
);
// Before
event.params.commonParams[5];
event.params.commonParams[3][0];

// After
event.params.commonParams.cancelable;
event.params.commonParams.amounts.deposit;

Improved Multichain Backfill

For large multichain indexers, HyperIndex now throttles chains that have already reached the head so they don't compete for resources while the rest finish backfilling. Once every chain has caught up, throttling is lifted and all chains continue indexing equally.

Toolchain Upgrades

  • ReScript upgraded from v11 to v12 (internally and in envio init templates)
  • TypeScript upgraded from v5 to v6 (internally and in envio init templates)

2x Cheaper and 2.5x Faster (v3.1)

HyperIndex now requires up to 2x fewer HyperSync queries during backfill and is 2.5x faster in many indexing cases. If you had data fetching as a bottleneck, this comes for free on upgrade.

Descriptions on Entities, Fields, and Relationships (v3.1)

You can now document your entities, fields, and relationships directly in schema.graphql using string descriptions. These are exposed through the GraphQL API and appear in introspection:

"""
A token transfer between two accounts
"""
type Transfer {
id: ID!
"The address the tokens were sent from"
from: String!
"The address the tokens were sent to"
to: String!
"The amount transferred, in wei"
value: BigInt!
}

Only string descriptions ("""...""" or "...") are exposed. Hash (#) comments are ignored by the GraphQL parser and do not appear in introspection.

Skip Chains From Indexing (v3.1)

A new skip field in config.yaml lets you exclude a specific chain from indexing and database migrations without removing it from your config:

chains:
- id: 1
start_block: 0
contracts: # ...
- id: 137
skip: true
start_block: 0
contracts: # ...

Improved Agentic Indexer Development (v3.1)

New CLI subcommands make it easier to build indexers with AI agents:

envio tools search-docs <query>  # Search the HyperIndex documentation
envio tools fetch-docs <url> # Fetch documentation from a URL
envio metrics runtime # Fetch runtime metrics of a local indexer

The skills shipped by envio init and envio skills update were also cleaned up.

Rate-Limit Info in TUI and Logs (v3.1)

HyperSync rate-limit handling was improved, and rate-limit information is now surfaced in the TUI and logs so you can see when you're being throttled.

Filter by Multiple Fields with getWhere (v3.2)

getWhere now supports filtering by multiple fields simultaneously in a single call:

await context.Account.getWhere({
id: { _eq: "0x123..." },
balance: { _gte: 1_000_000n, _lte: 10_000_000n },
});

Single _eq or _in filters were also optimized to reduce database round trips.

Default Storage (v3.2)

When running with multiple storage backends, you can now mark a storage as default so entities are automatically assigned to it without needing a @storage attribute on every entity:

storage:
postgres:
default: true
clickhouse:
default: true

Configurable Column Name Format (v3.2)

You can configure HyperIndex to automatically convert database column names to snake_case while keeping the original names in GraphQL and your handler types:

storage:
postgres:
column_name_format: snake_case

Huge Multichain & Factory Indexers (v3.3)

Faster backfill, lower memory usage, better stability and lower latency at the head for indexers with many chains or many dynamically registered contracts.

Per-Chain Effects (v3.3)

Effects share one cache and one rate-limit budget across every chain by default. Set crossChain: false to scope an effect to the chain that called it - each chain then gets its own cache entries and rate-limit budget, and the handler can read context.chain.id. Use it when the same input means different things on different chains, such as a token address.

Read more in Per-Chain Effects.

Custom HTTP Headers for RPC (v3.3)

RPC entries accept a headers option, so you can use providers that gate access behind an Authorization header instead of a key in the URL. Read more in Custom HTTP Headers.

RPC Source Improvements (v3.3)

  • where filters support OR conditions on RPC sources - pass an array to params to match any of several conditions. See Multiple Filters.
  • An RPC-backed indexer can register multiple wildcard events. Previously only one was allowed.

Nested Environment Variable Interpolation (v3.3)

config.yaml supports nested fallback values in defaults, so an RPC URL can fall back to another variable rather than only to a literal.

Unlimited onEvent Handlers (v3.4)

You can register any number of handlers for the same event, each with a different filter - previously only handlers with matching filters could coexist. This lets you keep global logic and contract-specific logic in separate handlers instead of branching inside one. Handlers run in registration order.

Read more in Multiple Handlers for One Event.

Per-Entity ClickHouse Tuning (v3.4)

The @storage directive's clickhouse argument accepts an options object that tunes that entity's ClickHouse table - partitionBy, orderBy and ttl. Read more in Per-Entity ClickHouse Tuning.

Factories with Billions of Addresses (v3.5)

HyperIndex used to struggle past roughly 8 million registered addresses. That ceiling is gone, and no configuration is needed to benefit. Read more in Scaling to Very Large Factories.

Deferred Postgres Index Creation (v3.5)

Indexes are no longer created up front - they're built in one pass once the backfill completes, which gives a 2.5x backfill speedup for some users. A getWhere query on a field with no index also creates the index on demand, so getWhere now works on any entity field without declaring @index first.

Read more in Deferred Index Creation.

Bottleneck Observability (v3.5)

New Prometheus metrics attribute indexing stalls to a specific cause instead of leaving you to infer it:

  • envio_processing_stalled_on_fetch_seconds - waiting for events to be fetched
  • envio_processing_stalled_on_storage_write_seconds - waiting for pending writes to drain
  • envio_process_metric_time_seconds and envio_process_elapsed_seconds - process timing, useful for turning cumulative counters into a share of the run

Read more in Finding the bottleneck.

Numeric Entity IDs (v3.5)

Entities can be keyed on Int or BigInt instead of ID, and relationship fields automatically adopt the referenced entity's id type. Read more in Numeric Entity IDs.

Chain IDs Above 2^31 (v3.5)

Database column types scale automatically for chains whose id exceeds 2^31, so no configuration is needed to index them.

Per-Chain Entities and Effects (v3.6)

By default one entity id means one row shared by every chain, which is why multichain indexers have always had to prefix ids with the chain id. Set disable_default_cross_chain: true - recommended for multichain indexers - and entities and effect caches become per-chain instead:

name: my-indexer
disable_default_cross_chain: true

Entity tables then get a composite (id, chainId) primary key, so the same id on two chains is two independent rows in memory, in Postgres and in ClickHouse, with entity history and reorg rollback scoped per chain too. Handler code doesn't change - a handler always runs on one chain, so context.Token.get(id) reads that chain's row and the id prefixing goes away. Sharing becomes explicit with the @crossChain directive on an entity, or crossChain: true on an effect.

Read more in Per-Chain Data Mode.

Per-Handler Field Selection (v3.7)

A registration now names the block and transaction fields its handler reads, instead of every handler paying for one global field_selection:

indexer.onEvent(
{
contract: "MyContract",
event: "Transfer",
fields: { transaction: ["hash", "from"], block: ["timestamp"] },
},
async ({ event, context }) => {
event.transaction.hash; // string
event.block.timestamp; // number
event.transaction.gasUsed; // Type error - not listed in fields
},
);

Reading a field the registration didn't list is a type error, and two handlers on the same event can select different fields. field_selection in config.yaml still works, and a handler's fields overrides it.

Read more in Selecting Block and Transaction Fields.

More Fields on the RPC Source (v3.7)

Indexers running on an RPC data source gained 20 transaction fields (gas, nonce, v, r, s, yParity, type, maxFeePerBlobGas, blobVersionedHashes, cumulativeGasUsed, effectiveGasPrice, gasUsed, logsBloom, root, status, and the l1* L2 fields) and 15 block fields (sha3Uncles, logsBloom, transactionsRoot, receiptsRoot, totalDifficulty, size, uncles, blobGasUsed, excessBlobGas, parentBeaconBlockRoot, withdrawalsRoot, mixHash, and the Arbitrum l1BlockNumber / sendCount / sendRoot).

Hidden Entities with @internal (v3.8)

Mark an entity @internal to keep it out of the GraphQL API while still using it normally in handlers - cursors, checkpoints, dedup markers, or anything you don't want on the public API surface. Read more in Hiding Entities from the API.

ClickHouse Skipping Indexes (v3.8)

The @storage(clickhouse: {...}) options gained skippingIndexes, so an entity's history table can carry minmax, bloom_filter or set data skipping indexes for the columns your analytics queries filter on. Read more in Per-Entity ClickHouse Tuning.

Per-Chain Entities in Postgres and ClickHouse (v3.9)

With disable_default_cross_chain: true, per-chain entities are split into Postgres partitions by chain, which speeds up both reads and writes for chain-scoped queries. The generated chainId column can also be used in an entity's ClickHouse orderBy.

Clearer schema.graphql Validation Errors (v3.9)

Schema validation errors now point at the exact location, describe the problem in one line, and suggest the fix - for example Invalid `@index` on `Second`: `missing` is not a column of the entity.

Any Nullability with @derivedFrom (v3.9)

Derived fields accept every nullability combination ([Child!]!, [Child!], [Child]!, [Child]), matching subgraph behavior, so a schema ported from The Graph doesn't need editing here.

One Address, Multiple Contracts (v3.9)

The same address can now be registered for more than one contract. This matters for factory patterns and proxies where you don't control the deployed address and need to decode it against several ABIs.

Isolated Multichain Rollbacks (v3.10)

When disable_default_cross_chain: true is set and no entity is marked @crossChain, a reorg rolls back only the chain that triggered it rather than every chain - better isolation, and less work on every rollback.

ClickHouse Storage Rewritten in Rust (v3.10)

The ClickHouse backend was rewritten in Rust: faster writes, fewer stability issues, and a simpler codebase to build on.

Bytes as Uint8Array (v3.10)

bytes_type chooses how the Bytes scalar reaches handlers and storage:

bytes_type: uint8array # default: hex

hex keeps 0x-prefixed strings stored as text; uint8array gives handlers Uint8Array values and stores raw bytes (BYTEA in Postgres), roughly halving the storage taken by addresses and hashes. Available on EVM and Fuel; Solana is always Uint8Array. Read more in Bytes Representation.

Height Stream Reliability (v3.10)

Better stream-keep-alive logic and fallback-to-polling for tracking new blocks, which cuts latency at the head. Two Prometheus metrics make the stream observable: envio_source_height_stream_connects_total and envio_source_height_stream_disconnects_total{reason}. Read more in Available metrics.

Start From Latest Block (v3.11)

start_block (and Solana's start_slot) accepts the literal "latest", so an indexer can start at the chain head without looking up a block number:

chains:
- id: 1
start_block: latest

The head is resolved once, when the indexer is first deployed, and the concrete block is persisted - so a normal resume backfills the downtime instead of skipping it. Read more in Start Block.

Solana Support Is Stable (v3.11)

The SVM API - configuration, handlers, and payload types - is final and follows semver like the rest of HyperIndex. Solana indexers are configured with ecosystem: svm, a chain id of solana or solana-devnet, and a top-level programs list; instructions are decoded from an Anchor or Codama IDL (or an inline layout) and handled with indexer.onInstruction, with indexer.onSlot for slot handlers. Postgres, ClickHouse, the GraphQL API and Envio Cloud hosting all work the same as on EVM.

pnpx envio init svm

Read more in the Solana documentation.

Fixes

  • Fixed an issue where the indexer stops progressing without any error (PostgreSQL client update)
  • Fixed checksum for addresses returned by RPC in lowercase
  • Fixed incorrect validation of transactions to field returned by RPC
  • Fixed OOM error on RPC request crashing loop
  • Fixed an edge case where a multichain indexer could freeze during a rollback on reorg (also backported to v2.32.10)
  • Fixed external Postgres database support via ENVIO_PG_HOST
  • Fixed S.nullable schema type to be T | null instead of T | undefined

Release Notes

For detailed release notes, see:


Benchmarks

File: benchmarks.md

{"@context":"https://schema.org","@type":"Dataset","name":"HyperIndex Blockchain Indexer Benchmarks","description":"Independent, reproducible performance benchmarks comparing Envio HyperIndex against other blockchain indexers, including The Graph, Ponder, and Subsquid, across real-world indexing scenarios. Based on benchmarks conducted by Sentio.","url":"https://docs.envio.dev/docs/HyperIndex/benchmarks","keywords":["blockchain indexer","indexing benchmark","Envio HyperIndex","The Graph","Ponder","Subsquid","EVM"],"publisher":{"@id":"https://envio.dev/#organization"},"isAccessibleForFree":true,"sameAs":["https://github.com/enviodev/sentio-benchmark","https://github.com/enviodev/open-indexer-benchmark"]}

HyperIndex Performance Benchmarks

Overview

HyperIndex delivers industry-leading performance for blockchain data indexing. Independent benchmarks have consistently shown Envio's HyperIndex to be the fastest blockchain indexing solution available, with dramatic performance advantages over competitive offerings.

Recent Independent Benchmarks

The most comprehensive and up-to-date benchmarks were conducted by Sentio in April 2025 and are available in the sentio-benchmark repository. These benchmarks compare Envio's HyperIndex against other popular blockchain indexers across multiple real-world scenarios:

Key Performance Highlights

CaseDescriptionEnvioNearest CompetitorThe GraphPonder
LBTC Token TransfersEvent handling, No RPC calls, Write-only3m8m - 2.6x slower (Sentio)3h9m - 3780x slower1h40m - 2000x slower
LBTC Token with RPC callsEvent handling, RPC calls, Read-after-write1m6m - 6x slower (Sentio)1h3m - 63x slower45m - 45x slower
Ethereum Block Processing100K blocks with Metadata extraction7.9s1m - 7.5x slower (Subsquid)10m - 75x slower33m - 250x slower
Ethereum Transaction Gas UsageTransaction handling, Gas calculations1m 26s7m - 4.8x slower (Subsquid)N/A33m - 23x slower
Uniswap V2 Swap Trace AnalysisTransaction trace handling, Swap decoding41s2m - 3x slower (Subsquid)8m - 11x slowerN/A
Uniswap V2 FactoryEvent handling, Pair and swap analysis8s2m - 15x slower (Subsquid)19m - 142x slower21m - 157x slower

The independent benchmark results demonstrate that HyperIndex consistently outperforms all competitors across every tested scenario. This includes the most realistic real-world indexing scenario LBTC Token with RPC calls - where HyperIndex was up to 6x faster than the nearest competitor and over 63x faster than The Graph.

For a wider, benchmark-backed comparison across the rest of the category, see Best Blockchain Indexers in 2026, which covers Envio, The Graph, Goldsky, SubQuery, Subsquid, Ormi, and Ponder side by side.

Historical Benchmarking Results

Our internal benchmarking from October 2023 showed similar performance advantages. When indexing the Uniswap V3 ETH-USDC pool contract on Ethereum Mainnet, HyperIndex achieved:

  • 2.1x faster indexing than the nearest competitor
  • Over 100x faster indexing than some popular alternatives

You can read the full details in our Indexer Benchmarking Results blog post.

Verify For Yourself

We encourage developers to run their own benchmarks. You can also use the templates provided in the Open Indexer Benchmark repository.


How to Migrate Using AI

File: migrate-with-ai.md

HyperIndex v3 includes built-in Claude skills that guide AI programming assistants through the full subgraph migration process, from understanding your existing logic to converting handlers and running quality checks. This is the recommended way to migrate complex subgraphs.

Prerequisites

  • An AI programming assistant (Cursor or Claude Code)
  • pnpm installed
  • HyperIndex v3 (Claude skills are available in v3)

Step 1: Initialize a Boilerplate HyperIndex Indexer

Create a new HyperIndex indexer that indexes the same contracts and events as the subgraph you are migrating. Run the following in a new directory:

pnpx envio init

Follow the CLI prompts to set up the boilerplate indexer with the same contracts and events as your existing subgraph.

caution

The Claude skills are only available in HyperIndex v3. See the v3 migration guide for current install guidance.

Step 2: Set Up a Monorepo Structure

Create a parent directory that contains both your new HyperIndex boilerplate indexer and the existing subgraph repo you want to migrate:

my-migration/
├── my-subgraph/ # Your existing subgraph repo
└── my-hyperindex-indexer/ # The boilerplate HyperIndex indexer from Step 1

This structure gives your assistant visibility into both projects so it can read and understand your subgraph logic while writing the HyperIndex implementation.

Step 3: Run Your AI Programming Assistant

Open the monorepo root with your AI programming assistant running there (for example, run Claude Code in the monorepo root or open the monorepo in Cursor). Put your assistant in plan mode first, then provide a prompt like the following (replace the repo names with your own):

<context>
This monorepo contains two indexers:
- `my-subgraph/` — an existing Graph Protocol subgraph indexer (source of truth)
- `my-hyperindex-indexer/` — a HyperIndex boilerplate scaffolded from the same
contracts (migration target)
</context>

<task>
Migrate the subgraph indexer to a fully working HyperIndex indexer.
Follow these phases in order:

Phase 1 — Plan
- Produce a migration plan mapping each subgraph component to its HyperIndex
equivalent.
- Flag anything that has no direct equivalent and propose a workaround.
- Do NOT write code yet.

Phase 2 — Implement
- Migrate the entire subgraph following the plan and skill guides.
- Process one handler file at a time.
- After each file, run `pnpm envio codegen` to validate, and verify it against
the migration checklist before moving on.

Phase 3 — Verify
- Walk through every checklist item from the migration skill and confirm it
passes.
- Run any available build or type check commands.
- List any items you could not complete and why.
</task>

<rules>
- Only modify files in `my-hyperindex-indexer/`. Do not change the subgraph repo.
- Preserve all entity fields and event mappings from the subgraph.
- Do not skip or summarize checklist items — execute every one.
- If you are uncertain about a migration decision, pause and ask me.
</rules>
tip
  • After migration, run pnpm dev to verify the indexer runs correctly
  • Use the Indexer Migration Validator to compare outputs between your subgraph and the new HyperIndex indexer

Manual Migration

For a detailed manual migration guide covering the step by step conversion of subgraph.yaml, schema, and event handlers, see Migrate from The Graph.


Migrate from The Graph to Envio

File: migration-guide.md

info

Please reach out to our team on Discord for personalized migration assistance.

Already on HyperIndex V2?

This page covers migrating from The Graph to Envio, with all examples shown in current HyperIndex V3 syntax (indexer.onEvent(...), chains:). If instead you are upgrading an existing HyperIndex project from V2 to V3, follow the Migrate to V3 guide.

Introduction

Migrating your existing subgraph to Envio's HyperIndex is designed to be a developer-friendly process. HyperIndex draws strong inspiration from The Graph’s subgraph architecture, which makes the migration simple, especially with the help of coding assistants like Cursor and AI tools (don't forget to use our ai friendly docs).

The process is simple but requires a good understanding of the underlying concepts. If you are new to HyperIndex, we recommend starting with the Quickstart guide.

Prefer AI-assisted migration?

If you want an assistant-led workflow, see How to Migrate Using AI for a guided process that works in both Cursor and Claude Code.

Why Migrate to HyperIndex?

  • Superior Performance: Up to 100x faster indexing speeds
  • Lower Costs: Reduced infrastructure requirements and operational expenses
  • Better Developer Experience: Simplified configuration and deployment
  • Advanced Features: Access to capabilities not available in other indexing solutions
  • Seamless Integration: Easy integration with existing GraphQL APIs and applications

If you are still deciding, our comparison of the best blockchain indexers in 2026 covers HyperIndex against The Graph, Goldsky, SubQuery, Subsquid, Ormi, and Ponder with side-by-side benchmarks.

Subgraph to HyperIndex Migration Overview

Migration consists of three major steps:

  1. Subgraph.yaml migration
  2. Schema migration - near copy paste
  3. Event handler migration

At any point in the migration run

pnpm envio codegen

to verify the config.yaml and schema.graphql files are valid.

or run

pnpm dev

to verify the indexer is running and indexing correctly.

0.5 Use pnpx envio init to generate a boilerplate

As a first step, we recommend using pnpx envio init to generate a boilerplate for your project. This will handle the creation of the config.yaml file and a basic schema.graphql file with generic handler functions.

1. subgraph.yamlconfig.yaml

pnpx envio init will generate this for you. It's a simple configuration file conversion. Effectively specifying which contracts to index, which chains to index (multiple chains can be specified with envio) and which events from those contracts to index.

Take the following conversion as an example, where the subgraph.yaml file is converted to config.yaml the below comparisons is for the Uniswap v4 pool manager subgraph.

The Graph - subgraph.yaml

specVersion: 0.0.4
description: Uniswap is a decentralized protocol for automated token exchange on Ethereum.
repository: https://github.com/Uniswap/v4-subgraph
schema:
file: ./schema.graphql
features:
- nonFatalErrors
- grafting
- kind: ethereum/contract
name: PositionManager
network: mainnet
source:
abi: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
startBlock: 21689089
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/mappings/index.ts
entities:
- Position
abis:
- name: PositionManager
file: ./abis/PositionManager.json
eventHandlers:
- event: Subscription(indexed uint256,indexed address)
handler: handleSubscription
- event: Unsubscription(indexed uint256,indexed address)
handler: handleUnsubscription
- event: Transfer(indexed address,indexed address,indexed uint256)
handler: handleTransfer

HyperIndex - config.yaml

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: uni-v4-indexer
chains:
- id: 1
start_block: 21689089
contracts:
- name: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
events:
- event: Subscription(uint256 indexed tokenId, address indexed subscriber)
- event: Unsubscription(uint256 indexed tokenId, address indexed subscriber)
- event: Transfer(address indexed from, address indexed to, uint256 indexed id)

For any potential hurdles, please refer to the Configuration File documentation.

2. Schema migration

copy & paste the schema from the subgraph to the HyperIndex config file.

Small nuance differences:

  • You can remove the @entity directive
  • Enums
  • BigDecimals
  • GraphQL interface types are not supported

3. Event handler migration

This consists of two parts

  1. Converting assemblyscript to typescript
  2. Converting the subgraph syntax to HyperIndex syntax

3.1 Converting Assemblyscript to Typescript

The subgraph uses assemblyscript to write event handlers. The HyperIndex syntax is usually in typescript. Since assemblyscript is a subset of typescript, it's quite simple to copy and paste the code, especially so for pure functions.

3.2 Converting the subgraph syntax to HyperIndex syntax

There are some subtle differences in the syntax of the subgraph and HyperIndex. Including but not limited to the following:

  • Replace Entity.save() with context.Entity.set()
  • Convert to async handler functions
  • Use await for loading entities const x = await context.Entity.get(id)
  • Use dynamic contract registration to register contracts

The below code snippets can give you a basic idea of what this difference might look like.

The Graph - eventHandler.ts

export function handleSubscription(event: SubscriptionEvent): void {
const subscription = new Subscribe(event.transaction.hash + event.logIndex);

subscription.tokenId = event.params.tokenId;
subscription.address = event.params.subscriber.toHexString();
subscription.logIndex = event.logIndex;
subscription.blockNumber = event.block.number;
subscription.position = event.params.tokenId;

subscription.save();
}

HyperIndex - eventHandler.ts


indexer.onEvent(
{ contract: "PoolManager", event: "Subscription" },
async ({ event, context }) => {
const entity = {
id: event.transaction.hash + event.logIndex,
tokenId: event.params.tokenId,
address: event.params.subscriber,
blockNumber: event.block.number,
logIndex: event.logIndex,
position: event.params.tokenId,
};

context.Subscription.set(entity);
},
);

Extra tips

HyperIndex is a powerful tool that can be used to index any contract. There are some features that are especially powerful that go above subgraph implementations and so in some cases you may want to optimise your migration to HyperIndex further to take advantage of these features. Here are some useful tips:

  • Use field_selection to opt into optional transaction and block fields (e.g. hash, status, gasUsed) that are not included by default, see Transaction receipts for a migration-focused example and the field selection docs for the full list.
  • Multichain indexing in V3 always runs in unordered mode, which is the most common need and provides better performance - see Multichain Indexing. (In V2 this required setting unordered_multichain_mode: true; in V3 there is no opt-in, and the V2 multichain: ordered mode has been removed.)
  • Use wildcard indexing to index by event signatures rather than by contract address.
  • HyperIndex uses the standard GraphQL query language, whereas TheGraph uses a custom GraphQL syntax. You can read about the differences and how to convert queries in our Query Conversion Guide. We also provide a query converter tool for backwards compatibility with existing TheGraph queries.
  • Preload Optimization is always on in V3 and speeds up historical sync by batching the entity reads in your handlers and running external calls in parallel. You can read more about it here.
  • HyperIndex is very flexible and can be used to index offchain data too or send messages to a queue etc for fetching external data, you can further optimise the fetching by using the effects api

Transaction receipts

In The Graph, you opt into receipt data per-handler with receipt: true in subgraph.yaml:

eventHandlers:
- event: Transfer(indexed address,indexed address,indexed uint256)
handler: handleTransfer
receipt: true

This makes event.receipt available inside the handler with fields like status, gasUsed, and logs.

In HyperIndex, receipt-level fields are part of transaction_fields and must be requested via field_selection in config.yaml. There is no separate receipt object - the fields are accessed directly on event.transaction:

field_selection:
transaction_fields:
- hash
- status # 1 = success, 0 = reverted
- gasUsed
- cumulativeGasUsed
- contractAddress # non-null for contract-creation transactions
- logsBloom

indexer.onEvent(
{ contract: "MyContract", event: "Transfer" },
async ({ event, context }) => {
const { status, gasUsed } = event.transaction;
// ...
},
);

See the full list of available transaction_fields in the Configuration File docs.

Validating Your Migration

After completing your migration, it's important to verify that your HyperIndex indexer produces the same data as your original subgraph. Use the Indexer Migration Validator CLI tool to compare results between both endpoints and identify any discrepancies. The tool automatically generates entity configs from your GraphQL schema and provides detailed field-level analysis of differences.

Share Your Learnings

If you discover helpful tips during your migration, we'd love contributions! Open a PR to this guide and help future developers.

Getting Help

Join Our Discord: The fastest way to get personalized help is through our Discord community.


Migrate from Ponder to HyperIndex

File: migrate-from-ponder.md

Need help? Reach out on Discord for personalized migration assistance.

Migrating from Ponder to HyperIndex is straightforward - both frameworks use TypeScript, index EVM events, and expose a GraphQL API. The key differences are the config format, schema syntax, and entity operation API.

If you are new to HyperIndex, start with the Quickstart guide first. If you are new to the category entirely, see what a blockchain indexer is for the wider context.

Prefer AI-assisted migration?

For an assistant-led workflow, see How to Migrate Using AI, which includes a shared process for Cursor and Claude Code.

Why Migrate to HyperIndex?

  • Up to 158x faster historical sync via HyperSync
  • Multichain by default - index any number of chains in one config
  • Same language - your TypeScript logic transfers directly

Migration Overview

Migration has three steps:

  1. ponder.config.tsconfig.yaml
  2. ponder.schema.tsschema.graphql
  3. Event handlers - adapt syntax and entity operations

At any point, run:

pnpm envio codegen   # validate config + schema, regenerate types
pnpm dev # run the indexer locally

Step 0: Bootstrap the Project

pnpx envio init

This generates a config.yaml, a starter schema.graphql, and handler stubs. Use your Ponder project as the source of truth for contract addresses, ABIs, and events, then fill in the generated files.


Step 1: ponder.config.tsconfig.yaml

Ponder


export default createConfig({
chains: {
mainnet: { id: 1, rpc: process.env.PONDER_RPC_URL_1 },
},
contracts: {
MyToken: {
abi: myTokenAbi,
chain: "mainnet",
address: "0xabc...",
startBlock: 18000000,
},
},
});

HyperIndex (v3)

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: my-indexer

contracts:
- name: MyToken
abi_file_path: ./abis/MyToken.json
events:
- event: Transfer
- event: Approval

chains:
- id: 1
start_block: 0
contracts:
- name: MyToken
address:
- 0xabc...
start_block: 18000000

v2 note: HyperIndex v2 uses networks instead of chains. See the v2→v3 migration guide.

Key differences:

ConceptPonderHyperIndex
Config formatponder.config.ts (TypeScript)config.yaml (YAML)
Chain referenceNamed + viem objectNumeric chain ID
RPC URLIn configENVIO_RPC_URL_<chainId> env var
ABI sourceTypeScript importJSON file (abi_file_path)
Events to indexInferred from handlersExplicit events: list
Handler fileInferredExplicit handler: per contract

Convert your ABI: Ponder uses TypeScript ABI exports (as const). HyperIndex needs a plain JSON file in abis/. Strip the export const ... = wrapper and as const and save as .json.

Field selection - accessing transaction and block fields

By default, only a minimal set of fields is available on event.transaction and event.block. Fields like event.transaction.hash are undefined unless explicitly requested.

events:
- event: Transfer
field_selection:
transaction_fields:
- hash

Or declare once at the top level to apply to all events:

name: my-indexer

field_selection:
transaction_fields:
- hash

contracts:
# ...

See the full list of available fields in the Configuration File docs.


Step 2: ponder.schema.tsschema.graphql

Ponder


export const token = onchainTable("token", (t) => ({
address: t.hex().primaryKey(),
symbol: t.text().notNull(),
balance: t.bigint().notNull(),
}));

export const transferEvent = onchainTable(
"transfer_event",
(t) => ({
id: t.text().primaryKey(),
from: t.hex().notNull(),
to: t.hex().notNull(),
amount: t.bigint().notNull(),
timestamp: t.integer().notNull(),
}),
(table) => ({
fromIdx: index().on(table.from),
}),
);

HyperIndex

type Token {
id: ID!
symbol: String!
balance: BigInt!
}

type TransferEvent {
id: ID!
from: String! @index
to: String!
amount: BigInt!
timestamp: Int!
}

Type mapping:

PonderHyperIndex GraphQL
t.hex()String!
t.text()String!
t.bigint()BigInt!
t.integer()Int!
t.boolean()Boolean!
t.real() / t.doublePrecision()Float!
t.hex().array()Json!

Primary keys: HyperIndex requires a single id field on every entity, typed ID!, String!, Int!, or BigInt!. For composite PKs (e.g. owner + spender), construct the ID string manually: `${owner}_${spender}`.

Indexes: Replace index().on(column) with an @index directive on the field.

Relations: Replace Ponder's relations() call with @derivedFrom on the parent entity:

type Token {
id: ID!
transfers: [TransferEvent!]! @derivedFrom(field: "token")
}

type TransferEvent {
id: ID!
token: Token!
}

@derivedFrom(field:) names the relationship field on the other entity, without the _id suffix. Codegen then exposes that field to your handlers as token_id.

See the full Schema docs.

Step 3: Event Handlers

Handler registration

Ponder


ponder.on("MyToken:Transfer", async ({ event, context }) => {
// ...
});

HyperIndex


indexer.onEvent(
{ contract: "MyToken", event: "Transfer" },
async ({ event, context }) => {
// ...
},
);

Event data access

DataPonderHyperIndex
Event parametersevent.args.nameevent.params.name
Contract addressevent.log.addressevent.srcAddress
Chain IDcontext.chain.idevent.chainId
Block numberevent.block.numberevent.block.number
Block timestampevent.block.timestamp (bigint)event.block.timestamp (number)
Tx hashevent.transaction.hashevent.transaction.hash ⚠️ needs field_selection

Entity operations

IntentPonderHyperIndex
Insertcontext.db.insert(t).values({...})context.Entity.set({ id, ...fields })
Updatecontext.db.update(t, pk).set({...})get → spread → context.Entity.set({ ...existing, ...changes })
Upsert.insert().values().onConflictDoUpdate()context.Entity.getOrCreate({ id, ...defaults })set
Read (nullable)context.db.find(table, pk)context.Entity.get(id)
Read (throws)manual null checkcontext.Entity.getOrThrow(id)

Full handler example

Ponder

ponder.on("MyToken:Transfer", async ({ event, context }) => {
await context.db.insert(transferEvent).values({
id: event.id,
from: event.args.from,
to: event.args.to,
amount: event.args.amount,
timestamp: Number(event.block.timestamp),
});

await context.db
.update(token, { address: event.args.to })
.set((row) => ({ balance: row.balance + event.args.amount }));
});

HyperIndex


indexer.onEvent(
{ contract: "MyToken", event: "Transfer" },
async ({ event, context }) => {
context.TransferEvent.set({
id: `${event.transaction.hash}_${event.logIndex}`,
from: event.params.from,
to: event.params.to,
amount: event.params.amount,
timestamp: event.block.timestamp,
});

const token = await context.Token.getOrThrow(event.params.to);
context.Token.set({
...token,
balance: token.balance + event.params.amount,
});
},
);

Important: Entity objects from context.Entity.get() are read-only. Always spread (...existing) and set new fields - never mutate directly.

See the Event Handlers docs for the full API reference.

Extra Tips

Factory contracts (dynamic registration)

Replace Ponder's factory() helper in config with a contractRegister handler:


// Registers each newly deployed contract for indexing
indexer.contractRegister(
{ contract: "MyFactory", event: "ContractCreated" },
({ event, context }) => {
context.chain.MyContract.add(event.params.contractAddress);
},
);

In config.yaml, omit the address field for the dynamically registered contract.

External calls

Replace context.client.readContract(...) with the Effect API to safely isolate external calls from the sync path:


export const getSymbol = createEffect(
{
name: "getSymbol",
input: S.schema({ address: S.string, chainId: S.number }),
output: S.string,
cache: true,
},
async ({ input }) => {
/* viem call here */
},
);

// In handler:
const symbol = await context.effect(getSymbol, {
address,
chainId: event.chainId,
});

Multichain

Add multiple entries under chains: and namespace your entity IDs by chain to prevent collisions:

const id = `${event.chainId}_${event.params.tokenId}`;

See Multichain Indexing for configuration details.

Wildcard indexing

HyperIndex supports wildcard indexing - index events by signature across all contracts on a chain without specifying addresses.

Validating Your Migration

Use the Indexer Migration Validator CLI to compare entity data between your Ponder and HyperIndex endpoints field-by-field.

Getting Help

  • Discord: discord.gg/envio - fastest way to get help
  • Docs: the HyperIndex documentation
  • AI-friendly docs: HyperIndex complete reference

Migrate From Alchemy

File: migrate-from-alchemy.md

Migrating Alchemy subgraphs to Envio’s HyperIndex is a simple and developer-friendly process. Alchemy subgraphs follow The Graph’s model and HyperIndex uses a very similar structure, so most of your existing setup can carry over cleanly.

If you're familiar with The Graph’s libraries, the migration process should be straightforward. You can also utilize tools like Cursor to speed things up. If you are new to HyperIndex, we strongly recommend starting with our Quickstart guide before you begin your migration from Alchemy. If you are new to the category entirely, see what a blockchain indexer is for the wider context.

Why Migrate to Envio’s HyperIndex?

  • High-Speed Performance: 142x faster than subgraphs
  • Lower Costs: Reduced infrastructure requirements and operational expenses
  • Better Developer Experience: Simplified configuration and deployment
  • Multichain Native: Index data across multiple EVM chains through a single HyperIndex project
  • Local Development: Run your indexers locally for fast iteration and easier debugging
  • White Glove Migration Support: Get direct support from the Envio team for a smoother migration.
  • GitOps Ready Deployments: Link your GitHub repo and manage multiple deployments in a clean unified workflow
  • Advanced Features: Access to features like external calls and block handlers
  • Seamless Integration: Easily integrate existing GraphQL APIs and applications

How to Migrate from Alchemy to Envio in 4 easy steps

This Migration consists of 4 major steps:

  1. Create a HyperIndex Project
  2. subgraph.yaml Migration to config.yaml
  3. schema.graphql Migration
  4. Event Handler Migration

Create a HyperIndex Project

Start by spinning up a basic HyperIndex project with this command:

pnpx envio init template --name alchemy-migration --directory alchemy-migration --template  greeter --api-token "YOUR_ENVIO_API_KEY"

Once the project is created, drop your API key into the .env file and you’re good to go.

subgraph.yaml Migration to config.yaml

In HyperIndex, all project configuration lives in config.yaml. This is where you define contract addresses, the chains you want to index, and the specific events you want to track from those contracts.

Below is an example showing how a Uniswap V4 subgraph.yaml maps to a HyperIndex config.yaml in a real migration.

The Graph - subgraph.yaml

specVersion: 0.0.4
description: Uniswap is a decentralized protocol for automated token exchange on Ethereum.
repository: https://github.com/Uniswap/v4-subgraph
schema:
file: ./schema.graphql
features:
- nonFatalErrors
- grafting
- kind: ethereum/contract
name: PositionManager
network: mainnet
source:
abi: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
startBlock: 21689089
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/mappings/index.ts
entities:
- Position
abis:
- name: PositionManager
file: ./abis/PositionManager.json
eventHandlers:
- event: Subscription(indexed uint256,indexed address)
handler: handleSubscription
- event: Unsubscription(indexed uint256,indexed address)
handler: handleUnsubscription
- event: Transfer(indexed address,indexed address,indexed uint256)
handler: handleTransfer

HyperIndex - config.yaml

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: uni-v4-indexer
chains:
- id: 1
start_block: 21689089
contracts:
- name: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
events:
- event: Subscription(uint256 indexed tokenId, address indexed subscriber)
- event: Unsubscription(uint256 indexed tokenId, address indexed subscriber)
- event: Transfer(address indexed from, address indexed to, uint256 indexed id)

If you hit any issues, check the Configuration File docs or reach out to our team in Discord.

schema.graphql Migration

This step is simple. You keep the entire file as is, with one small change: remove all @entity directives from your entities. Everything else stays the same.

Event Handler Migration

This is the final step of the migration which consists of two parts:

  • Moving from AssemblyScript to TypeScript
  • Updating Subgraph syntax to HyperIndex syntax

AssemblyScript to TypeScript

HyperIndex uses TypeScript instead of AssemblyScript. Since AssemblyScript is a subset of TypeScript, you can simply copy most of your code over without worrying about major syntax changes.

Subgraph to HyperIndex

The HyperIndex workflow is very similar to Subgraphs, but there are a few important differences to keep in mind:

  • Replace ENTITY.save() with context.ENTITY.set(VALUES)
  • Handlers need to be async
  • Use await when loading entities

As you start using HyperIndex, you’ll pick up the differences quickly.

Here is a code snippet to give you a sense of what these changes look like in practice.

The Graph - eventHandler.ts

export function handleSubscription(event: SubscriptionEvent): void {
const subscription = new Subscribe(event.transaction.hash + event.logIndex);

subscription.tokenId = event.params.tokenId;
subscription.address = event.params.subscriber.toHexString();
subscription.logIndex = event.logIndex;
subscription.blockNumber = event.block.number;
subscription.position = event.params.tokenId;

subscription.save();
}

HyperIndex - eventHandler.ts

import { indexer } from "envio";

indexer.onEvent(
{ contract: "PoolManager", event: "Subscription" },
async ({ event, context }) => {
const entity = {
id: event.transaction.hash + event.logIndex,
tokenId: event.params.tokenId,
address: event.params.subscriber,
blockNumber: event.block.number,
logIndex: event.logIndex,
position: event.params.tokenId,
};

context.Subscription.set(entity);
},
);

For a few extra tips on migrating from Alchemy to Envio, check out our other migration guide in our docs.

Share Your Learnings

If you come across anything useful during your migration, please feel free to contribute. Simply open a PR to this guide and help future developers.

Getting Help

Join our Discord if you need support. It is the fastest way to get direct help from the team and the community.


Migrate to HyperIndex V3

File: migrate-to-v3.md

This guide covers every change required to upgrade a HyperIndex V2 project to V3. For new V3 capabilities, see What's New in V3.

Easiest path - prompt your AI tool (Claude/Cursor/Codex):

Upgrade my indexer to V3 by following the migration instructions step by step https://docs.envio.dev/docs/HyperIndex/migrate-to-v3

While still on V2:

  1. Upgrade to envio@^2.32.6.
  2. Set preload_handlers: true in config.yaml.
  3. If using loaders, migrate them per Migrating from Loaders.
  4. Verify with pnpm dev.

Step 1: Update Node.js

Use Node.js 22+ (24 recommended). Earlier versions are unsupported.

Step 2: Update package.json

  • Add "type": "module" (required - without it the project fails to start with ESM errors).
  • Set engines.node to >=22.0.0.
  • Update envio to the latest v3 release.
  • Remove optionalDependencies.generated - the local generated package no longer exists.
{
"type": "module",
"engines": { "node": ">=22.0.0" },
"dependencies": { "envio": "3.0.0" },
"devDependencies": {
"@types/node": "24.12.2",
"typescript": "6.0.3",
"vitest": "4.1.0"
}
}

If you used ts-node for the start script, replace it with "start": "envio start".

Test runner

Option A - Vitest (recommended).

pnpm remove ts-mocha ts-node mocha chai @types/mocha @types/chai
pnpm add -D vitest@4.0.16

Set "test": "vitest run", then move test/Test.tssrc/indexer.test.ts and update imports:

// Before (mocha/chai)


// After (vitest)


Option B - Keep Mocha. Replace ts-mocha/ts-node with tsx:

pnpm remove ts-mocha ts-node
pnpm add -D tsx@4.21.0
{
"scripts": {
"mocha": "tsc --noEmit && NODE_OPTIONS='--no-warnings --import tsx' mocha --exit test/**/*.ts"
}
}

Step 3: Update tsconfig.json

Update for ESM (copy-paste the file as-is, comments included):

{
/* For details: https://www.totaltypescript.com/tsconfig-cheat-sheet */
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,

/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,

/* For running Envio: */
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,

/* Code doesn't run in the DOM: */
"lib": ["es2022"],
"types": ["node"]
}
}
tip

verbatimModuleSyntax and noUncheckedIndexedAccess are optional extra strictness - disable them to simplify migration.

Step 4: Update config.yaml

Renames:

  • networkschains
  • confirmed_block_thresholdmax_reorg_depth
  • rpc_configrpc (now supports multiple URLs, for: sync | realtime | fallback, and WebSocket config)

Remove if present:

  • unordered_multichain_mode and any multichain: ordered - unordered is the only mode in V3.
  • loaders, preload_handlers - Preload Optimization is always enabled.
  • preRegisterDynamicContracts - no longer needed.
  • event_decoder - the Rust decoder is the only implementation.
  • output - types always emitted to .envio/.

Env var → config: replace the MAX_BATCH_SIZE env var with full_batch_size: 5000.

Optional (recommended): move handler files to src/handlers/ and drop the explicit handler paths (the handler field still works).

Step 5: Update Environment Variables

Add - if using HyperSync (the default), set ENVIO_API_TOKEN (get a free token at envio.dev/app/api-tokens).

Remove:

  • UNSTABLE__TEMP_UNORDERED_HEAD_MODE
  • UNORDERED_MULTICHAIN_MODE
  • MAX_BATCH_SIZE (use full_batch_size in config.yaml)
  • ENVIO_INDEXING_BLOCK_LAG (use per-chain block_lag)

Rename:

  • TUI_OFF=trueENVIO_TUI=false (TUI also auto-disabled in CI and under AI agents)
  • ENVIO_PG_PUBLIC_SCHEMAENVIO_PG_SCHEMA (old name supported until v4)

Step 6: Update Handler Code

Contract-specific exports are removed. Register handlers through the unified indexer from the envio package, which replaces generated.

Event handlers

// Before

ERC20.Transfer.handler(
async ({ event, context }) => {},
{
wildcard: true,
eventFilters: ({ chainId }) => [
{ from: ZERO_ADDRESS, to: WHITELIST[chainId] },
],
}
);

// After

indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [{ from: ZERO_ADDRESS, to: WHITELIST[chain.id] }],
}),
},
async ({ event, context }) => {},
);
  • eventFilterswhere. Callback receives { chain } (not { chainId }) and returns false, true, or { params: [...], block?: { number: { _gte, _lte, _every } } }.
  • The top-level array shorthand is gone - wrap it in { params: [...] }.

Filtering by the contract's own addresses - V2's eventFilters addresses argument becomes chain.<ContractName>.addresses (kept in sync with context.chain.<ContractName>.add(...)):

// Before

Safe.Transfer.handler(async ({ event, context }) => {}, {
wildcard: true,
eventFilters: ({ addresses }) => [{ from: addresses }, { to: addresses }],
});

// After

indexer.onEvent(
{
contract: "Safe",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [{ from: chain.Safe.addresses }, { to: chain.Safe.addresses }],
}),
},
async ({ event, context }) => {},
);

Dynamic contract registration

// Before

UniV3.PoolFactory.contractRegister(async ({ event, context }) => {
context.addPool(event.params.poolAddress);
});

// After

indexer.contractRegister(
{ contract: "UniV3", event: "PoolFactory" },
async ({ event, context }) => {
context.chain.Pool.add(event.params.poolAddress);
},
);

context.add<ContractName>(addr)context.chain.<ContractName>.add(addr).

Block handlers

Behavior change. V2's onBlock ran on one chain (its chain option) with top-level interval/startBlock/endBlock. V3's indexer.onBlock runs on every chain by default. To restore V2's single-chain + range + interval behavior, pass a where callback that returns false for unwanted chains and { block: { number: { _gte, _lte, _every } } } for the range/interval.

// Before — only chain 1, every 100 blocks, fixed range

onBlock(
{ name: "Ranges", chain: 1, startBlock: 20_000_000, endBlock: 22_000_000, interval: 100 },
async ({ block, context }) => {},
);

// After

indexer.onBlock(
{
name: "Ranges",
where: ({ chain }) => {
if (chain.id !== 1) return false;
return { block: { number: { _gte: 20_000_000, _lte: 22_000_000, _every: 100 } } };
},
},
async ({ block, context }) => {},
);

To run on every chain (the new default), omit where. Inside the handler, block.chainIdcontext.chain.id.

getWhere API

Switch to GraphQL-style filter syntax (new operators: _gte, _lte, _in):

// Before
await context.Transfer.getWhere.from.eq("0x123...");
await context.Transfer.getWhere.value.gt(1000n);

// After
await context.Transfer.getWhere({ from: { _eq: "0x123..." } });
await context.Transfer.getWhere({ value: { _gt: 1000n } });

Rename and removal cheat sheet

V2 (removed)V3
Contract.Event.handler(...)indexer.onEvent({ contract, event, ...options }, handler)
Contract.Event.contractRegister(...)indexer.contractRegister({ contract, event }, handler)
onBlock({ chain, ... }, handler)indexer.onBlock({ name, where? }, handler)
context.add<Contract>(addr)context.chain.<Contract>.add(addr)
eventFilters optionwhere callback returning { params: [...] }
experimental_createEffectcreateEffect
block.chainId (in block handlers)context.chain.id
transaction.kindtransaction.type
transaction.chainIdcontext.chain.id or event.chainId
chain typeChainId (now a union type)
getGeneratedByChainId(...)indexer.chains[chainId]
Entity.getWhere.field.eq(value)Entity.getWhere({ field: { _eq: value } })
Entity.getWhere.field.gt(value)Entity.getWhere({ field: { _gt: value } })
Entity.getWhere.field.lt(value)Entity.getWhere({ field: { _lt: value } })
Lowercased entity types (e.g. transfer)Capitalized (Transfer)
ERC20_Transfer_eventLogEvmEvent<"ERC20", "Transfer">
ERC20_Transfer_blockEvmEvent<"ERC20", "Transfer">["block"]
MyEnum (direct export)Enum<"MyEnum">
MyEntity (direct export)Entity<"MyEntity"> (preferred; direct still exported)

Other type changes: Address is now `0x${string}` (was string); entity array fields are readonly; S.nullable returns T | null (was T | undefined); the internal ContractType enum was removed.

Step 7: Remove generated

The generated package is no longer needed - remove it. Import everything from "envio" instead. This works via envio-env.d.ts, which is linked automatically (no tsconfig.json change needed).

Step 8: Update Tests

MockDb is removed. Use createTestIndexer() with simulate.

-import { TestHelpers, type User } from "generated";
-const { MockDb, Greeter, Addresses } = TestHelpers;
+import { createTestIndexer, type User, TestHelpers } from "envio";
+const { Addresses } = TestHelpers;

it("A NewGreeting event creates a User entity", async (t) => {
- const mockDbInitial = MockDb.createMockDb();
+ const indexer = createTestIndexer();
const userAddress = Addresses.defaultAddress;
const greeting = "Hi there";

- const mockNewGreetingEvent = Greeter.NewGreeting.createMockEvent({
- greeting: greeting,
- user: userAddress,
- });
-
- const updatedMockDb = await Greeter.NewGreeting.processEvent({
- event: mockNewGreetingEvent,
- mockDb: mockDbInitial,
- });
+ await indexer.process({
+ chains: {
+ 137: {
+ simulate: [
+ { contract: "Greeter", event: "NewGreeting", params: { greeting, user: userAddress } },
+ ],
+ },
+ },
+ });

const expectedUserEntity: User = {
id: userAddress,
latestGreeting: greeting,
numberOfGreetings: 1,
greetings: [greeting],
};

- const actualUserEntity = updatedMockDb.entities.User.get(userAddress);
+ const actualUserEntity = await indexer.User.getOrThrow(userAddress);
t.expect(actualUserEntity).toEqual(expectedUserEntity);
});
Old (MockDb)New (createTestIndexer)
MockDb.createMockDb()createTestIndexer()
Contract.Event.createMockEvent({...})Inline in simulate: [{ contract, event, params }]
Contract.Event.processEvent({event,mockDb})indexer.process({ chains: { id: { simulate } } })
mockDb.entities.Entity.get(id)await indexer.Entity.getOrThrow(id)
mockDb.entities.Entity.set({...})indexer.Entity.set({...})
Manual handler threading & event chainingAutomatic - pass multiple events in simulate

Step 9: Update CLI Usage

  • envio dev no longer auto-resets the DB - use envio dev -r (--restart) if you relied on that.
  • envio start is now production-only; use envio dev for local development.
  • Handler file changes no longer trigger codegen on pnpm dev.

Step 10: Run Codegen and Verify

pnpm envio codegen
pnpm dev

Postgres column type changes (raw_events.event_id: NUMERICBIGINT, raw_events.serial: SERIALBIGSERIAL, envio_chains.events_processed: INTEGERBIGINT, envio_checkpoints.id: INTEGERBIGINT) apply automatically. The deprecated envio_chains._num_batches_fetched always returns 0.

Step 11: Update Agent Skills

Refresh the bundled agent skills so agent-driven development stays aligned with V3:

pnpx envio skills update

This populates .claude/skills (consumed by Claude, Cursor, and other agentic tooling). Re-run it on each new HyperIndex release.

Getting Help

Issues during migration? Join our Discord community.


Configuration File

File: Guides/configuration-file.mdx

The config.yaml file defines your indexer's behavior, including which blockchain events to index, contract addresses, which chains to index, and various advanced indexing options. It is a crucial step in configuring your HyperIndex setup.

tip

Whenever you make changes in config.yaml that should affect generated types (e.g. adding events, contracts, or chains), run pnpm codegen to regenerate types and code for your event handlers.


Example

This is a basic ERC-20 config.yaml - it's enough on its own to get an indexer running across Ethereum Mainnet and Gnosis, tracking Approval and Transfer events on the UNI token.

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: erc20-indexer
description: ERC-20 Indexer
contracts:
- name: ERC20
events:
- event: "Approval(address indexed owner, address indexed spender, uint256 value)"
- event: "Transfer(address indexed from, address indexed to, uint256 value)"
chains:
- id: 1 # Ethereum Mainnet
start_block: 0
contracts:
- name: ERC20
address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984" # UNI
- id: 100 # Gnosis Mainnet
start_block: 0
contracts:
- name: ERC20
address: "0x4537e328Bf7e4eFA29D05CAeA260D7fE26af9D74" # UNI

Next steps:

  • Define how each event updates your data → src/handlers
  • Shape the entities you'll query → schema.graphql
  • Need more than the basics? Keep reading for the full set of configuration options below.

Contracts Definition

The top-level contracts block defines each contract once - its events and per-event options. Each chain then references these contracts by name and supplies chain-specific values like the on-chain address (see Contracts (per chain)).

contracts:
- name: Greeter
events:
- event: "NewGreeting(address user, string greeting)"
- event: "ClearGreeting(address user)"

chains:
- id: 1
start_block: 0
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"

Events Selection

The recommended way to declare events is by their human-readable signature directly under events:

contracts:
- name: Greeter
events:
- event: "NewGreeting(address user, string greeting)"
- event: "ClearGreeting(address user)"

Only the events listed here are indexed. To stop indexing an event, remove its entry.

Custom Event Names

You can assign custom names to events in config.yaml. This is handy when two events share the same name but have different signatures, or when you want a more descriptive name in your Envio project.

events:
- event: Assigned(address indexed recipientId, uint256 amount, address token)
- event: Assigned(address indexed recipientId, uint256 amount, address token, address sender)
name: AssignedWithSender

Using an ABI File

If you'd rather reference a JSON ABI file (e.g. when you have one already and want to pick a subset of events from it), use abi_file_path and refer to events by name:

contracts:
- name: Greeter
abi_file_path: ./abis/greeter.json
events:
- event: NewGreeting # signature comes from the ABI file

Event signatures are still the recommended default - they keep config.yaml self-contained and easier to review.

Field Selection

To improve indexing performance and reduce credits usage, the block and transaction fields on events contain only a subset of the fields available on the blockchain.

Since v3.7 the recommended way to ask for more is the handler's own fields option, which lists the fields next to the code that reads them and applies to that registration only.

field_selection in config.yaml is the older, coarser alternative: it applies to every handler of an event. Specify it under the event entry:

events:
- event: "Assigned(address indexed user, uint256 amount)"
field_selection:
transaction_fields:
- transactionIndex
block_fields:
- timestamp

See all possible options in the Config File Reference or use IDE autocomplete for your help.

Global Field Selection

You can also specify fields globally for all events in the root of the config file:

field_selection:
transaction_fields:
- hash
- gasUsed
block_fields:
- parentHash

Try to use this option sparingly as it can cause redundant Data Source calls and increased credits usage. A handler's fields option overrides field_selection for that registration.


Chains

Everything under the top-level chains field configures the chains your indexer connects to - data sources, start/end blocks, reorg behavior, and multichain indexing semantics.

RPC

The rpc option configures an RPC data source per chain. It supports multiple URLs with explicit roles via the for field:

  • sync – use this RPC for historical sync.
  • realtime – use this RPC for low-latency head tracking once the indexer enters realtime mode. WebSocket endpoints (wss://...) are supported here.
  • fallback – use as a fallback when the primary source is unavailable.
Optional for HyperSync chains

For chains supported by HyperSync, rpc is not required - HyperSync is used as the primary data source out of the box. You can still add an RPC entry as a fallback for extra reliability. For chains without HyperSync, rpc is required and acts as the primary data source.

chains:
- id: 1
rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: sync
- url: wss://eth-mainnet.your-rpc-provider.com
for: realtime
- url: https://fallback.example.com
for: fallback

A short form is also supported when you only need a single RPC URL:

chains:
- id: 1
rpc: https://eth-mainnet.your-rpc-provider.com

After switching to a fallback source, HyperIndex automatically attempts to recover to the primary source 60 seconds later.

See the Rpc reference for advanced tuning options such as initial_block_interval, polling_interval, query_timeout_millis, and backoff parameters.

Custom Headers

Since v3.3, headers sets custom HTTP headers on an RPC entry - for endpoints gated behind an auth header rather than a key in the URL. Values support ${ENV_VAR} interpolation:

chains:
- id: 1
rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: sync
headers:
Authorization: "Bearer ${RPC_API_KEY}"

See Custom HTTP Headers for details.

WebSocket Height Streaming

Pair wss:// URLs with for: realtime to improve head latency by streaming new block heights over a WebSocket connection.

chains:
- id: 1
rpc:
url: ${ENVIO_RPC_ENDPOINT}
ws: ${ENVIO_WS_ENDPOINT}
for: realtime
Experimental

WebSocket support for RPC sources is experimental. Please open a GitHub issue if you hit any problems.

Start Block

Set start_block on a chain to control the block at which the indexer begins ingesting data. Setting it to 0 is a safe default for HyperSync - it will automatically skip ahead to the first block that contains data for your configured contracts.

chains:
- id: 1
start_block: 0 # HyperSync will fast-forward to the first relevant block
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"

Since v3.11 start_block also accepts the literal "latest", which starts the indexer from the chain's current head instead of a block you have to look up:

chains:
- id: 1
start_block: latest # resolved once, when the indexer is first deployed

A chain on latest can't also carry per-contract start_block overrides.

note

The head is resolved once, when the indexer is first deployed, and the block is stored. Resuming reuses it, so downtime is backfilled rather than skipped. Restarting with -r resolves latest again.

For finer-grained control, see Per-Contract Start Block Override and the per-event start block tip below it.

End Block

Set end_block on a chain to stop indexing once that block is reached. Useful for backfills with a known cutoff or one-off snapshots.

chains:
- id: 1
start_block: 18000000
end_block: 19000000
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
Don't use this for tests

If you're capping the range to make a deterministic test run, prefer the built-in testing framework instead - it lets you pin block ranges or feed synthetic events without spinning up a real indexer.

Contracts (per chain)

Inside each chain you list the contracts you want to index on that chain - referenced by name from your top-level Contracts Definition. The chain-level entry is where you pin the on-chain address (or list of addresses) and, optionally, a per-contract start_block.

note

Addresses can be provided in checksum format or in lowercase. Envio accepts both and normalizes them internally.

Addresses

Single address:

chains:
- id: 1
start_block: 0
contracts:
- name: MyContract
address: "0xContractAddress"

Multiple addresses for the same contract:

chains:
- id: 1
start_block: 0
contracts:
- name: MyContract
address:
- "0xAddress1"
- "0xAddress2"

The same address for several contracts:

Since v3.9, one address can be registered for more than one contract, so a single deployment can be decoded against several ABIs. This matters for proxies and factory patterns where you don't control the deployed address:

chains:
- id: 1
start_block: 0
contracts:
- name: ERC20
address: "0xAddress1"
- name: ERC4626Vault
address: "0xAddress1"
tip

If using a proxy contract, always use the proxy address, not the implementation address.

Per-Contract Start Block Override

By default, contracts use the chain start_block. You can also set a per-contract start_block to override it. Handy when:

  • Contracts were deployed at different blocks
  • You only need data from a contract starting at a specific block
  • You want to skip unnecessary historical data for some contracts
  • Works nicely with Dynamic Contract Registration
chains:
- id: 1 # ethereum-mainnet
start_block: 18000000 # Default start block for all contracts on this chain
contracts:
- name: ERC20
address:
- "0x1111111111111111111111111111111111111111"
- "0x2222222222222222222222222222222222222222"
start_block: 18500000 # Override for this contract
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
# Uses chain default (18000000)
Per-event start block

For even finer control, you can also specify a start block per event from your handler using the where.block.number._gte filter on indexer.onEvent. See Event Handlers for the full API.

Indexer Without Contracts

The contracts field is optional in V3. You can run an indexer that only uses block handlers (indexer.onBlock) without declaring any contracts:

name: BlockHandlerOnly
chains:
- id: 1
start_block: 18000000

Storage

How indexed data is persisted - which backends to use and whether to keep raw event records around.

Storage Backends

By default, HyperIndex writes entities to Postgres. In V3 you can additionally enable ClickHouse as a second storage backend (experimental):

storage:
postgres: true
clickhouse: true

When both backends are enabled, you must route each entity explicitly via the @storage directive in schema.graphql:

# Stored in both Postgres and ClickHouse
type Transfer @storage(postgres: true, clickhouse: true) {
id: ID!
from: String!
to: String!
value: BigInt!
}

# Stored only in ClickHouse
type Snapshot @storage(clickhouse: true) {
id: ID!
blockNumber: BigInt!
}

Alternatively, mark one or both backends as default so every entity is written there automatically, and reach for the @storage directive only when you need to override routing for a specific entity:

storage:
postgres:
default: true
clickhouse:
default: true
note

The default storage option was added in HyperIndex v3.2.

envio dev automatically spins up a ClickHouse Docker container for local development. For envio start, provide the connection via the environment variables ENVIO_CLICKHOUSE_HOST, ENVIO_CLICKHOUSE_DATABASE, ENVIO_CLICKHOUSE_USERNAME, and ENVIO_CLICKHOUSE_PASSWORD.

warning

Do not run multiple indexers writing to the same ClickHouse database at the same time.

Envio Cloud currently supports ClickHouse on the Dedicated Plan.

Since v3.4, the @storage directive's clickhouse argument also accepts an options object for per-entity table tuning - partitioning, sorting key and TTL. See Per-Entity ClickHouse Tuning.

Column Name Format

Set column_name_format: snake_case on a backend to store column names in snake_case while keeping the original names in the GraphQL API and your handler types. It works for both Postgres and ClickHouse:

storage:
postgres:
column_name_format: snake_case
clickhouse:
column_name_format: snake_case
note

Added in HyperIndex v3.2.


Address Format

Use address_format to control how every address surfaced by the indexer (event fields like event.srcAddress / event.transaction.from, chain.<Contract>.addresses, addresses embedded in entity ids, etc.) is formatted.

address_format: lowercase # default: checksum
  • checksum (default) – EIP-55 checksummed mixed-case addresses.
  • lowercase – every address is lowercased globally. Useful when joining against another data source that stores addresses in lowercase, or when you want byte-for-byte deterministic ids without per-handler .toLowerCase() calls.

You can still call .toLowerCase() ad-hoc inside a handler when you only need a single value lowercased.


Bytes Representation

bytes_type controls how the Bytes scalar from schema.graphql reaches handlers and storage.

bytes_type: uint8array # default: hex
  • hex (default) – 0x-prefixed hex strings, stored as text.
  • uint8arrayUint8Array values in handlers, stored as raw bytes (BYTEA in Postgres, String in ClickHouse). Roughly halves the storage taken by addresses and hashes.

Available on EVM and Fuel. Solana always uses Uint8Array and has no option. Changing the setting changes the underlying column types, so switch it with a fresh sync.

note

Added in HyperIndex v3.10.


Per-Chain Entities

By default one entity id means one row shared by every chain. Set disable_default_cross_chain: true (recommended for multichain indexers) to make entities and effect caches per-chain instead:

name: my-indexer
disable_default_cross_chain: true

Entity tables then get a composite (id, chainId) primary key, so the same id on two chains is two independent rows, and sharing becomes explicit through the @crossChain directive. See Per-Chain Data Mode for the full behavior.

note

Added in HyperIndex v3.6.


Ecosystem

ecosystem selects which chain family your indexer targets. EVM is the default and what every example above assumes, but the same config.yaml schema is shared with Fuel and Solana (SVM).

ecosystem: evm # default — also: fuel, svm

Most projects don't set this field explicitly. Use pnpx envio init fuel or pnpx envio init svm to scaffold non-EVM indexers - the generated config.yaml will already have ecosystem set correctly. See the Fuel and Solana guides for the ecosystem-specific options.


Environment Variables

Environment variable interpolation is supported anywhere in config.yaml, which is useful for keeping RPC URLs, addresses, or chain IDs out of source control.

chains:
- id: ${ENVIO_CHAIN_ID:-ethereum-mainnet}
contracts:
- name: Greeter
address: "${ENVIO_GREETER_ADDRESS}"

Run your indexer with custom environment variables:

ENVIO_CHAIN_ID=optimism ENVIO_GREETER_ADDRESS=0xYourContractAddress pnpm dev

Interpolation syntax:

  • ${ENVIO_VAR} – Use the value of ENVIO_VAR
  • ${ENVIO_VAR:-default} – Use ENVIO_VAR if set, otherwise use default

For more detailed information about environment variables, see our Environment Variables Guide.


Advanced

note

In ~95% of cases you don't need to touch any of these - the defaults are tuned for the common path. Reach for them only when you have a specific reason.

Handler File Path

Handlers are auto-discovered from src/handlers/. Override the directory with the top-level handlers option, or set a per-contract handler path when needed:

handlers: ./src/my-handlers # optional override of src/handlers
contracts:
- name: Greeter
handler: ./src/GreeterHandler.ts # optional per-contract path

Schema File Path

You can customize the path to the schema file using the schema option:

schema: ./path/to/schema.graphql

By default, the schema.graphql is expected to be in the root directory of your project.

Block Lag

Set block_lag on a chain to keep the indexer a fixed number of blocks behind the chain head. Defaults to 0.

chains:
- id: 1
start_block: 0
block_lag: 5
warning

Only set block_lag if you understand the trade-off - it intentionally trades head latency for additional reorg safety.

Rollback on Reorg

HyperIndex automatically handles blockchain reorganizations by default. To disable or customize this behavior, set the rollback_on_reorg flag in your config.yaml:

rollback_on_reorg: true # default is true

See detailed configuration options here.

Full Batch Size

Set full_batch_size to control how many events are processed in a single batch.

full_batch_size: 5000

Raw Events Storage

By default, HyperIndex doesn't store raw event data in the database to optimize performance and reduce storage requirements. However, you can enable this feature for debugging purposes or if you need to access the original event data.

To enable storage of raw events, add the following to your config.yaml:

raw_events: true

When enabled, all indexed events will be stored in the raw_events table in the database, which you can view through the Hasura interface. This is particularly useful for:

  • Debugging event processing issues
  • Verifying that events are being captured correctly
  • Creating custom queries against raw blockchain data

Note that enabling this option will increase database storage requirements and may slightly impact indexing performance.

Skip Chain

Set skip: true on a chain to exclude it from indexing and migrations without removing it from your config - handy for temporarily disabling a chain.

chains:
- id: 137
skip: true
start_block: 0
note

Added in HyperIndex v3.1.


Configuration Schema Reference

Explore detailed configuration schema parameters here:

  • See the full, deep-linkable reference: Config Schema Reference
For AI/LLM Systems

Recommended: Use the Config Schema Reference for programmatic access to schema information. The interactive viewer below is optimized for human users.

📋 Hierarchical Interactive Schema Explorer (Click to expand - For human reference only)


Now your configuration file is set, you're ready to start indexing with HyperIndex!


Schema File

File: Guides/schema-file.md

The schema.graphql file defines the data model for your HyperIndex indexer. Each entity type defined in this schema corresponds directly to a database table, with your event handlers responsible for creating and updating the records. HyperIndex automatically generates a GraphQL API based on these entity types, allowing easy access to the indexed data.


Defining Entity Types

Entities in your schema are defined as GraphQL object types:

Example:

type User {
id: ID!
greetings: [String!]!
latestGreeting: String!
numberOfGreetings: Int!
}

Requirements:

  • Every entity must have a unique id field, using one of these scalar types:
    • ID!, String!, Int!, or BigInt!
  • The id field must be non-nullable, must not be a list, and cannot be a @derivedFrom field.

Numeric Entity IDs

ID is the usual choice and behaves as a string. Since v3.5, you can also key an entity on Int or BigInt, which is a better fit when the identifier is genuinely a number - a block number, an auction id, a sequential position:

type Auction {
id: BigInt! # the on-chain auction id, not a stringified copy of it
seller: String!
bids: [Bid!]! @derivedFrom(field: "auction")
}

type Bid {
id: ID!
auction: Auction! # inferred as BigInt to match Auction.id
amount: BigInt!
}

Relationship fields adopt the referenced entity's id type automatically. Codegen exposes Bid.auction above to your handlers as auction_id, typed bigint rather than string. You don't declare the foreign key type. Keep the two sides in sync by changing the referenced entity's id.


Scalar Types

Scalar types represent basic data types and map directly to JavaScript, TypeScript, or ReScript types.

GraphQL ScalarDescriptionJavaScript/TypeScriptReScript
IDUnique identifierstringstring
StringUTF-8 character sequencestringstring
IntSigned 32-bit integernumberint
FloatSigned floating-point numbernumberfloat
Booleantrue or falsebooleanbool
BytesByte string (see bytes_type)string or Uint8Arraystring
BigIntSigned integer (int256 in Solidity)bigintbigint
BigDecimalArbitrary-size floating-pointBigDecimal (imported)BigDecimal.t
TimestampTimestamp with timezoneDateJs.Date.t
JsonJSON objectJsonJs.Json.t

Learn more about GraphQL scalars here.

Bytes Representation

Since v3.10, the bytes_type option in config.yaml decides how Bytes reaches handlers and storage: hex (the EVM and Fuel default) gives a 0x-prefixed string stored as text, uint8array gives a Uint8Array stored as raw bytes (BYTEA in Postgres), which roughly halves the storage taken by addresses and hashes. Solana is always Uint8Array.


Working with BigDecimal

The BigDecimal scalar type in HyperIndex is based on the bignumber.js library, which provides arbitrary-precision decimal arithmetic. This is essential for financial calculations and handling numeric values that exceed JavaScript's native number precision.

Importing BigDecimal

// JavaScript/TypeScript

// ReScript
open BigDecimal;

Creating BigDecimal Instances

// From string (recommended for precision)
const price = new BigDecimal("123.456789");

// From number (may lose precision for very large values)
const amount = new BigDecimal(123.45);

// From other BigDecimal
const copy = new BigDecimal(price);

Arithmetic Operations

BigDecimal instances are immutable. Operations return new BigDecimal instances:

// Basic arithmetic
const a = new BigDecimal("123.45");
const b = new BigDecimal("67.89");

const sum = a.plus(b); // 191.34
const difference = a.minus(b); // 55.56
const product = a.times(b); // 8,381.03
const quotient = a.div(b); // 1.81839...

// Power
const squared = a.pow(2); // 15,239.9025

// Square root
const root = a.sqrt(); // 11.11...

// Absolute value
const abs = new BigDecimal("-123.45").abs(); // 123.45

Comparison Methods

const x = new BigDecimal("10.5");
const y = new BigDecimal("10.5");
const z = new BigDecimal("9.9");

x.eq(y); // true (equal)
x.gt(z); // true (greater than)
x.gte(y); // true (greater than or equal)
x.lt(z); // false (less than)
x.lte(y); // true (less than or equal)

// Check for special values
x.isZero(); // false
x.isPositive(); // true
x.isNegative(); // false
x.isFinite(); // true

Rounding and Formatting

const value = new BigDecimal("123.456789");

// Get with specific decimal places
value.dp(2); // 123.46 (rounded)
value.dp(2, 1); // 123.45 (rounded down)

// Format as string
value.toString(); // "123.456789"
value.toFixed(2); // "123.46"
value.toExponential(2); // "1.23e+2"
value.toPrecision(5); // "123.46"

Working with Schema-Defined BigDecimal Fields

When you've defined a BigDecimal field in your schema:

type TokenPair {
id: ID!
name: String!
price: BigDecimal!
volume: BigDecimal!
}

You can use it in your handlers:

// In your event handler
context.TokenPair.set({
id: event.params.pairId,
name: event.params.name,
price: new BigDecimal(event.params.price),
volume: new BigDecimal("0"), // Start with zero volume
});

// Updating a field
const tokenPair = await context.TokenPair.get(pairId);
if (tokenPair) {
const newVolume = tokenPair.volume.plus(new BigDecimal(tradeAmount));
context.TokenPair.set({
...tokenPair,
volume: newVolume,
});
}

Example: Financial Calculation

function calculateFee(amount: BigDecimal, feeRate: BigDecimal): BigDecimal {
// Calculate fee with proper rounding
return amount.times(feeRate).dp(2);
}

const tradeAmount = new BigDecimal("1250.75");
const feeRate = new BigDecimal("0.0025"); // 0.25%
const fee = calculateFee(tradeAmount, feeRate); // 3.13

Best Practices for BigDecimal

  1. Always use strings for initialization when precision matters:

    // Preferred
    const value = new BigDecimal("123.456789");

    // May lose precision
    const value = new BigDecimal(123.456789);
  2. Set precision explicitly when doing division:

    // Set to 8 decimal places for crypto prices
    const price = totalValue.div(tokenAmount).dp(8);
  3. Handle rounding appropriately for financial calculations:

    // Round down (floor) for user-favorable calculations
    const userReceives = amount.dp(2, 1); // ROUND_DOWN

    // Round up (ceil) for protocol-favorable calculations
    const protocolFee = amount.dp(2, 0); // ROUND_UP
  4. Compare with equals method instead of == or ===:

    // Correct
    if (value.eq(new BigDecimal(0))) {
    /* ... */
    }

    // Incorrect - compares object references
    if (value === new BigDecimal(0)) {
    /* ... */
    }
  5. Chain operations carefully, remembering that each operation returns a new instance:

    // Calculate (a + b) * c with proper precision
    const result = a.plus(b).times(c).dp(8);

Enum Types

Enums allow fields to accept only a predefined set of values.

Example:

enum AccountType {
ADMIN
USER
}

type User {
id: ID!
balance: Int!
accountType: AccountType!
}

Enums translate to string unions (TypeScript/JavaScript) or polymorphic variants (ReScript):

TypeScript Example:


let user = {
id: event.params.id,
balance: event.params.balance,
accountType: "USER" satisfies Enum<"AccountType">, // enum as string
};

ReScript Example:

let user: Types.userEntity = {
id: event.params.id,
balance: event.params.balance,
accountType: #USER, // polymorphic variant
};

Relationships: One-to-Many (@derivedFrom)

Define relationships between entities using the @derivedFrom directive, known as reverse lookups.

Example:

type NftCollection {
id: ID!
contractAddress: Bytes!
name: String!
symbol: String!
maxSupply: BigInt!
currentSupply: Int!
tokens: [Token!]! @derivedFrom(field: "collection")
}

type Token {
id: ID!
tokenId: BigInt!
collection: NftCollection!
owner: User!
}
  • The tokens field in NftCollection is a virtual field, populated automatically when querying the API.
  • Set relationships in your handlers by assigning <field>_id with the related entity's id. For example, create or update a Token entity with collection_id: collectionId.

Field Indexing (@index)

Add an index to a field for optimized queries and loader performance:

type Token {
id: ID!
tokenId: BigInt!
collection: NftCollection!
owner: User! @index
}
  • All id fields and fields referenced via @derivedFrom are indexed automatically.
  • Declare @index for the fields your GraphQL consumers filter and sort by. You don't need it for fields your handlers query with getWhere - since v3.5 HyperIndex creates those indices itself. See Indices created on demand.

Choosing a Storage Backend (@storage)

When you enable more than one storage backend in config.yaml, the @storage directive controls where each entity is written:

# Queryable over GraphQL and mirrored into ClickHouse for analytics
type Transfer @storage(postgres: true, clickhouse: true) {
id: ID!
amount: BigInt!
}

Since v3.2 you can mark a backend as default in config.yaml, and entities without a @storage directive go there - you no longer need the directive on every entity. See storage.

Per-Entity ClickHouse Tuning

Since v3.4, the clickhouse argument also accepts an options object that tunes that entity's ClickHouse history table:

type Transfer
@storage(
postgres: true
clickhouse: {
partitionBy: "toYYYYMM(timestamp)"
orderBy: ["timestamp"]
ttl: "timestamp + INTERVAL 2 YEAR"
skippingIndexes: [
{ name: "idx_amount", expr: "amount", type: "minmax", granularity: 4 }
]
}
) {
id: ID!
timestamp: Timestamp!
amount: BigInt!
}
OptionTypeDescription
partitionByClickHouse expressionEmitted as PARTITION BY <expr>. Keeps queries and TTL deletes inside a partition instead of scanning the whole table.
orderBylist of entity field namesFields that lead the table's sorting key, ahead of the default id.
ttlClickHouse expressionEmitted as TTL <expr>. Ages rows out automatically.
skippingIndexeslist of index objectsSince v3.8. Data skipping indexes emitted into the history table DDL as INDEX <name> <expr> TYPE <type> GRANULARITY <granularity>. Each entry takes name, expr, type (e.g. minmax, bloom_filter, set) and an optional granularity, which defaults to ClickHouse's own default.

A few constraints, all caught at envio codegen rather than at runtime:

  • orderBy takes entity field names, not expressions - unlike partitionBy and ttl, which are ClickHouse expressions passed through as written.
  • orderBy can't list id (already the default sorting key), nor nullable, list or @derivedFrom fields, which ClickHouse doesn't allow in a sorting key.
  • An entity can carry only one @storage directive, and it must enable at least one backend.
  • With disable_default_cross_chain: true, orderBy can also list the generated chainId column (v3.9).

Sharing Entities Across Chains (@crossChain)

Entities are shared by every chain by default: one id means one row, whichever chain wrote it. Setting disable_default_cross_chain: true in config.yaml (v3.6, recommended for multichain indexers) flips that - entities become per-chain, keyed on (id, chainId) - and @crossChain marks the individual entities that should stay shared:

type Counter {
# per-chain: one row per (id, chain)
id: ID!
count: BigInt!
}

type GlobalCounter @crossChain {
# one row shared by every chain
id: ID!
count: BigInt!
}

The directive is only valid when disable_default_cross_chain: true is set - without it entities are already cross-chain, and envio codegen rejects the directive. Per-chain entities also reserve the chainId column name (chain_id under column_name_format: snake_case), so a schema field can't claim it.


Hiding Entities from the API (@internal)

Since v3.8.0, mark an entity with @internal to keep it out of the GraphQL API. The entity is stored and usable in handlers exactly as normal - set, get, getWhere and getOrThrow all work - but it is never exposed through GraphQL: no queries, no relationships, no introspection entry.

# Bookkeeping state the indexer needs, but API consumers shouldn't see
type SwapCheckpoint @internal {
id: ID!
lastProcessedLogIndex: Int!
pendingAmount: BigInt!
}

Use it for internal indexing state (cursors, checkpoints, deduplication markers, intermediate aggregates) or for data you don't want on the public API surface.

A few rules, enforced at envio codegen:

  • An exposed entity can't reference an @internal one - neither through an object reference nor a @derivedFrom field - because the relationship could never be served over GraphQL. Either mark the referencing entity @internal too, or store a plain id field (e.g. checkpointId: String!) instead of a relationship.
  • The reverse is fine: an @internal entity may freely reference exposed entities.

Advanced: Precision and Scale (@config Directive)

Customize the precision and scale for BigInt and BigDecimal fields using @config.

Syntax:

  • BigInt (precision only):
amount: BigInt @config(precision: 76)
  • BigDecimal (precision and scale):
price: BigDecimal @config(precision: 10, scale: 2)

Example:

type Payment {
id: ID!
amount: BigInt @config(precision: 76)
price: BigDecimal @config(precision: 10, scale: 2)
}

This controls PostgreSQL storage allocation and numerical accuracy.

Detailed Example with Arrays

type AdvancedEntity {
exampleBigInt: BigInt @config(precision: 76)
exampleBigIntRequired: BigInt! @config(precision: 77)
exampleBigIntArray: [BigInt!] @config(precision: 78)
exampleBigIntArrayRequired: [BigInt!]! @config(precision: 79)
exampleBigDecimal: BigDecimal @config(precision: 10, scale: 5)
exampleBigDecimalRequired: BigDecimal! @config(precision: 12, scale: 4)
}

Documenting Entities, Fields, and Relationships

You can document your entities, fields, and relationships directly in schema.graphql using GraphQL string descriptions. These descriptions are exposed through the generated GraphQL API and appear in introspection, making your API self-documenting.

"""
A token transfer between two accounts
"""
type Transfer {
id: ID!
"The address the tokens were sent from"
from: String!
"The address the tokens were sent to"
to: String!
"The amount transferred, in wei"
value: BigInt!
}

Both single-line ("...") and multi-line ("""...""") descriptions are supported.

note

Only string descriptions are exposed in introspection. Hash (#) comments are ignored by the GraphQL parser and do not appear in the API. Descriptions on entities, fields, and relationships were added in HyperIndex v3.1.


Generating Types

Once you've defined your schema, run this command to generate these entity types that can be accessed in your event handlers:

pnpm envio codegen

Best Practices

  • Use camelCase for field names (latestGreeting, numberOfGreetings).
  • Keep entity and field names clear, descriptive, and intuitive.

You're now ready to define powerful schemas and efficiently query your indexed data with HyperIndex!


Event Handlers

File: Guides/event-handlers.mdx

Registration

A handler is a function that receives blockchain data, processes it, and inserts it into the database. You can register handlers in the file defined in the handler field in your config.yaml file. By default this is src/handlers file.


indexer.onEvent(
{ contract: "<CONTRACT_NAME>", event: "<EVENT_NAME>" },
async ({ event, context }) => {
// Your logic here
},
);
note

The envio module exposes the unified indexer value along with types based on your config.yaml and schema.graphql files. Run pnpm codegen whenever you change these files to regenerate the types in .envio/.

Basic Example

Here's a handler example for the NewGreeting event. It belongs to the Greeter contract from our beginners Greeter Tutorial:


// Handler for the NewGreeting event
indexer.onEvent(
{ contract: "Greeter", event: "NewGreeting" },
async ({ event, context }) => {
const userId = event.params.user; // The id for the User entity
const latestGreeting = event.params.greeting; // The greeting string that was added
const currentUserEntity = await context.User.get(userId); // Optional user entity that may already exist

// Update or create a new User entity
const userEntity: User = currentUserEntity
? {
id: userId,
latestGreeting,
numberOfGreetings: currentUserEntity.numberOfGreetings + 1,
greetings: [...currentUserEntity.greetings, latestGreeting],
}
: {
id: userId,
latestGreeting,
numberOfGreetings: 1,
greetings: [latestGreeting],
};

context.User.set(userEntity); // Set the User entity in the DB
},
);

Multiple Handlers for One Event

Since v3.4, you can register as many handlers as you like for the same event, each with its own where filter. Earlier versions only accepted handlers whose filters matched.

This lets you keep broad and narrow logic in separate functions instead of branching inside one handler:


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

// Track every ERC20 transfer on the chain
indexer.onEvent(
{ contract: "ERC20", event: "Transfer", wildcard: true },
async ({ event, context }) => {
// Global accounting
},
);

// ...and run extra logic for mints, where tokens come from the zero address
indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: () => ({ params: [{ from: ZERO_ADDRESS }] }),
},
async ({ event, context }) => {
// Mint-specific logic
},
);

An event matching more than one registration is delivered to each of them, in registration order.

Preload Optimization

Important! Preload optimization makes your handlers run twice.

Preload optimization is always enabled in HyperIndex V3 - there is no config flag to toggle it.

This optimization enables HyperIndex to efficiently preload entities used by handlers through batched database queries, while ensuring events are processed synchronously in their original order. When combined with the Effect API for external calls, this feature delivers performance improvements of multiple orders of magnitude compared to other indexing solutions.

Read more in the dedicated guides:

  • How Preload Optimization Works
  • Double-Run Footgun
  • Effect API

Advanced Use Cases

HyperIndex provides many features to help you build more powerful and efficient indexers. There's definitely the one for you:

  • Handle Factory Contracts with Dynamic Contract Registration (with nested factories support)
  • Perform external calls to decide which contract address to register using Async Contract Register
  • Index all ERC20 token transfers with Wildcard Indexing
  • Use Topic Filtering to ignore irrelevant events
    • With multiple filters for single event
    • With different filters per chain
    • With filter by dynamicly registered contract addresses (eg Index all ERC20 transfers to/from your Contract)
  • Access Contract State directly from handlers
  • Perform external calls from handlers by following the IPFS Integration guide

Event Object

Each handler receives an event object containing details about the emitted event, including parameters and blockchain metadata.

Accessing Event Parameters

Event parameters are accessed via:

event.params.<PARAMETER_NAME>

Example usage:

const sender = event.params.sender;
const amount = event.params.amount;

Additional Event Information

The event object also contains additional metadata:

  • event.chainId – Chain ID of the chain emitting the event.
  • event.srcAddress – Contract address emitting the event.
  • event.logIndex – Index of the log within the block.
  • event.block – Block fields (By default: number, timestamp, hash).
  • event.transaction – Transaction fields (eg hash, gasUsed, etc. Empty by default).
tip

By default, all addresses returned in the event object, such as event.transaction.from, event.transaction.to, and event.srcAddress, are EIP-55 checksummed. Use .toLowerCase() on a single value if you specifically need it in lowercase, or set address_format: lowercase in config.yaml to switch every address that the indexer surfaces (events, chain.<Contract>.addresses, entity ids, etc.) to lowercase globally.

note

Which fields event.block and event.transaction carry is up to you - see Selecting Block and Transaction Fields.

Selecting Block and Transaction Fields

Since v3.7, a registration names the block and transaction fields its handler reads with the fields option:

indexer.onEvent(
{
contract: "MyContract",
event: "Transfer",
fields: { transaction: ["hash", "from"], block: ["timestamp"] },
},
async ({ event, context }) => {
event.transaction.hash; // string
event.block.timestamp; // number
event.transaction.gasUsed; // Type error - not listed in fields
},
);

Reading a field you didn't list is a type error, so the selection stays honest as handlers change. event.block.number is always available without listing it.

fields works on both indexer.onEvent and indexer.contractRegister, and two handlers on the same event can select different fields - the indexer only fetches and decodes what's actually asked for.

Write the selection inline, as above. To share one across registrations, declare it as const so its element types still name the fields:

const txFields = { transaction: ["hash", "from"] } as const;

indexer.onEvent(
{ contract: "MyContract", event: "Transfer", fields: txFields },
handler,
);

The config.yaml field_selection option still works and applies to every handler of an event (or of every event, at the root level). A handler's fields overrides it. Prefer fields: the list sits next to the code that reads it, and it doesn't make every other handler pay for the fetch.

See FieldSelection in the Config Schema Reference for the full list of available transaction and block fields.

Example event type definition:

type Event<Params, TransactionFields, BlockFields> = {
params: Params;
chainId: 1 | 137;
srcAddress: `0x${string}`;
logIndex: number;
transaction: TransactionFields;
block: BlockFields;
};

Context Object

The handler context provides methods to interact with entities stored in the database.

Retrieving Entities

Retrieve entities from the database using context.Entity.get where Entity is the name of the entity you want to retrieve, which is defined in your schema.graphql file.

await context.Entity.get(entityId);

It'll return Entity object or undefined if the entity doesn't exist.

Use context.Entity.getOrThrow to conveniently throw an error if the entity doesn't exist:

const pool = await context.Pool.getOrThrow(poolId);
// Will throw: Entity 'Pool' with ID '...' is expected to exist.

// Or you can pass a custom message as a second argument:
const pool = await context.Pool.getOrThrow(
poolId,
`Pool with ID ${poolId} is expected.`
);

Or use context.Entity.getOrCreate to automatically create an entity with default values if it doesn't exist:

const pool = await context.Pool.getOrCreate({
id: poolId,
totalValueLockedETH: 0n,
});

// Which is equivalent to:
let pool = await context.Pool.get(poolId);
if (!pool) {
pool = {
id: poolId,
totalValueLockedETH: 0n,
};
context.Pool.set(pool);
}

Retrieving Entities by Field

indexer.onEvent(
{ contract: "ERC20", event: "Approval" },
async ({ event, context }) => {
// Find all approvals for this specific owner
const currentOwnerApprovals = await context.Approval.getWhere({
owner_id: { _eq: event.params.owner },
});

// Process all the owner's approvals efficiently
for (const approval of currentOwnerApprovals) {
// Process each approval
}
},
);

Beyond _eq, you can filter by value using comparison operators like _gt, _gte, _lt, _lte, and _in.

To narrow results further, combine several conditions in a single call - they all have to match (AND):

// Find accounts matching a specific id AND a balance within a range
const accounts = await context.Account.getWhere({
id: { _eq: event.params.account },
balance: { _gte: 1_000_000n, _lte: 10_000_000n },
});
note

Multi-field filtering with getWhere was added in HyperIndex v3.2.

Important:

  • Preload Optimization is always enabled in V3 and powers getWhere. See How Preload Optimization Works.

  • Works with any entity field. Since v3.5, HyperIndex creates whatever database index the query needs, so you don't have to declare @index for it.

  • Potential Memory Issues: Very large getWhere queries might cause memory overflows.

  • Tip: Try to put the getWhere query to the top of the handler, to make sure it's being preloaded. Read more about how Preload Optimization works.

Modifying Entities

Use context.Entity.set to create or update an entity:

context.Entity.set({
id: entityId,
...otherEntityFields,
});
note

Both context.Entity.set and context.Entity.deleteUnsafe methods use the In-Memory Storage under the hood and don't require await in front of them.

Referencing Linked Entities

When your schema defines a field that links to another entity type, set the relationship using <field>_id with the referenced entity's id. You are storing the ID, not the full entity object.

type A {
id: ID!
b: B!
}

type B {
id: ID!
}
context.A.set({
id: aId,
b_id: bId, // ID of the linked B entity
});

HyperIndex automatically resolves A.b based on the stored b_id when querying the API.

Deleting Entities (Unsafe)

To delete an entity:

context.Entity.deleteUnsafe(entityId);
warning

The deleteUnsafe method is experimental and unsafe. You need to manually handle all entity references after deletion to maintain database consistency.

Updating Specific Entity Fields

Use the following approach to update specific fields in an existing entity:

const pool = await context.Pool.get(poolId);
if (pool) {
context.Pool.set({
...pool,
totalValueLockedETH: pool.totalValueLockedETH.plus(newDeposit),
});
}

context.log

The context object also provides a logger that you can use to log messages to the console. Compared to console.log calls, these logs will be displayed on our Envio Cloud runtime logs page.

Read more in the Observability Guide.

context.isPreload

If you need to skip the preload phase for CPU-intensive operations or to perform certain actions only once per event, you can use context.isPreload.

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Load existing data efficiently
const [sender, receiver] = await Promise.all([
context.Account.getOrThrow(event.params.from),
context.Account.getOrThrow(event.params.to),
]);

// Skip expensive operations during preload
if (context.isPreload) {
return;
}

// CPU-intensive calculations only happen once
const complexCalculation = performExpensiveOperation(event.params.value); // Placeholder function for demonstration

// Create or update sender account
context.Account.set({
id: event.params.from,
balance: sender.balance - event.params.value,
computedValue: complexCalculation,
});
// Create or update receiver account
context.Account.set({
id: event.params.to,
balance: receiver.balance + event.params.value,
});
},
);

Note: While context.isPreload can be useful for bypassing double execution, it's recommended to use the Effect API for external calls instead, as it provides automatic batching and memoization benefits.

External Calls

Envio indexer runs using Node.js runtime. This means that you can use fetch or any other library like viem to perform external calls from your handlers.

Note that with Preload Optimization all handlers run twice. But with Effect API this behavior makes your external calls run in parallel, while keeping the processing data consistent.

Check out our IPFS Integration, Accessing Contract State and Effect API guides for more information.

context.effect

Define an effect and use it in your handler with context.effect:


// Define an effect that will be called from the handler.
const getMetadata = createEffect(
{
name: "getMetadata",
input: S.string,
output: {
description: S.string,
value: S.bigint,
},
rateLimit: {
calls: 5,
per: "second",
},
cache: true, // Optionally persist the results in the database
},
({ input }) => {
const response = await fetch(`https://api.example.com/metadata/${input}`);
const data = await response.json();
return {
description: data.description,
value: data.value,
};
}
);

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Load metadata for the token.
// This will be executed in parallel for all events in the batch.
// The call is automatically memoized, so you don't need to worry about duplicate requests.
const sender = await context.effect(getMetadata, event.params.from);

// Process the transfer with the pre-loaded data
},
);

Accessing config.yaml Data in Handlers

You can read your indexer configuration and live indexing state from the indexer value - either at the top level of a handler file or inside a handler. Use indexer.chains[chainId] (or one of the named entries on indexer.chains) to inspect a specific chain:


indexer.onEvent(
{ contract: "Greeter", event: "NewGreeting" },
async ({ event, context }) => {
const chain = indexer.chains[event.chainId];
chain.id; // chain id
chain.startBlock; // configured start block
chain.endBlock; // configured end block (or undefined)
chain.isRealtime; // true once this chain has reached the head
chain.Greeter.name; // contract name
chain.Greeter.abi; // parsed ABI
chain.Greeter.addresses; // initial + dynamically registered addresses
},
);

Top-level fields like indexer.name, indexer.description, and indexer.chainIds are also available. After restart, addresses on chain.<Contract>.addresses include any contracts that were dynamically registered in previous runs, not just those declared in config.yaml.


Performance Considerations

For performance optimization and best practices, refer to:

  • Benchmarking
  • Preload Optimization

These guides offer detailed recommendations on optimizing entity loading and indexing performance.



Block Handlers

File: Guides/block-handlers.md

Run logic on every block or an interval.


indexer.onBlock lets you run logic on every block or an interval. This is useful for aggregations and time-series logic.

To get started, import the indexer value from envio and call onBlock in one of your handler files.


indexer.onBlock(
{
name: "MyBlockHandler",
},
async ({ block, context }) => {
context.log.info(`Processing block ${block.number}`);
}
);

Block handlers don't require any config changes as well as codegen runs.

In the example above, the handler runs on every chain configured in config.yaml (the V3 default). To restrict it to a single chain, pass a where callback - see below.

Options

indexer.onBlock accepts an options object as the first argument with the following properties:

  • name (required) - The name of the block handler. It's used for logging, debugging and metrics.
  • where (optional) - A callback ({ chain }) => false | true | { block: { number: { _gte?, _lte?, _every? } } } that decides which chains the handler runs on and over which block range/interval. Omit it to run on every chain on every block.

To express the V2-era chain, startBlock, endBlock, and interval options, return them from where:


indexer.onBlock(
{
name: "MyBlockHandler",
where: ({ chain }) => {
if (chain.id !== 1) return false;
return {
block: {
number: {
_gte: 19_000_000, // start block (inclusive)
_lte: 20_000_000, // end block (inclusive)
_every: 100, // run every Nth block
},
},
};
},
},
async ({ block, context }) => {
context.log.info(`Processing block ${block.number}`);
}
);

Handler Function

note

Preload Optimization is always enabled in HyperIndex V3 and powers Block Handlers. Don't forget that it makes your handlers run twice.

The second argument is a handler function that receives the block object and the handler context.

  • block - The block object.
    • number - The block number.
    • More fields will be added in the future. Let us know in Discord if you need any specific fields. You can also use Effect API to get the data from RPC.
  • context - Exactly the same as the Event Handlers Context Object. Use context.chain.id to read the current chain ID inside the handler.

Multichain

By default indexer.onBlock runs on every chain in your config. To run different parameters per chain, branch inside the where callback:


const perChain = {
1: { startBlock: 19783636, interval: (60 * 60) / 12 }, // Every 60 minutes (12s block time)
10: { startBlock: 119534316, interval: (60 * 60) / 2 }, // Every 60 minutes (2s block time)
} as const;

indexer.onBlock(
{
name: "HourlyPrice",
where: ({ chain }) => {
const cfg = perChain[chain.id as keyof typeof perChain];
if (!cfg) return false;
return {
block: {
number: { _gte: cfg.startBlock, _every: cfg.interval },
},
};
},
},
async ({ block, context }) => {
context.log.info(`Processing block ${block.number} on chain ${context.chain.id}`);
}
);

Time Interval

The _every option is a number of blocks. But quite often you want to run some logic on a time interval. To convert time interval to blocks, you can use the following formula:

// Every 60 minutes
const timeIntervalInSeconds = 60 * 60;
// 12 seconds per block
const secondsPerBlock = 12;
// 300 blocks per 60 minutes
const blockInterval = timeIntervalInSeconds / secondsPerBlock;

Different Historical and Realtime Intervals

Here's the recipe to speed up your historical sync by increasing the interval for historical blocks.

You can achieve this by registering multiple block handlers with the same handler, but different _gte, _lte, and _every values.


const realtimeBlocks = {
1: 19783636,
10: 119534316,
} as const;

indexer.onBlock(
{
name: "HistoricalBlockHandler",
where: ({ chain }) => {
const realtime = realtimeBlocks[chain.id as keyof typeof realtimeBlocks];
if (!realtime) return false;
return { block: { number: { _lte: realtime - 1, _every: 1000 } } };
},
},
async ({ block, context }) => {
context.log.info(`Processing block ${block.number}`);
}
);

indexer.onBlock(
{
name: "RealtimeBlockHandler",
where: ({ chain }) => {
const realtime = realtimeBlocks[chain.id as keyof typeof realtimeBlocks];
if (!realtime) return false;
return { block: { number: { _gte: realtime } } };
},
},
async ({ block, context }) => {
context.log.info(`Processing block ${block.number}`);
}
);

In this case we'll run the historical handler on every 1000 blocks and from the realtime block we'll start running the second handler on every block.

We recommend exploring the approach together with HyperSync client to effectively query data for big block ranges.

Preset Handler

This is not an official feature, but a creative way to use block handlers. You can define a block handler that runs on a single block at the start of the chain and use it to populate the database with the initial data.


indexer.onBlock(
{
name: "Preset",
where: ({ chain }) => {
if (chain.id !== 1) return false;
return { block: { number: { _gte: 0, _lte: 0 } } };
},
},
async ({ block, context }) => {
// You don't need preload optimization here,
// so don't forget to disable it to prevent double-run.
if (context.isPreload) return;

const users = await fetch("https://api.example.com/users");
users.forEach((user) => {
context.User.set({
id: user.id,
address: user.address,
name: user.name,
});
});
}
);

Current Limitations

  • Only block number is provided in the block object. We'll definitely add more fields in the future.

Understanding Multichain Indexing

File: Advanced/multichain-indexing.mdx

For a conceptual overview of what multichain indexing is and when to use it, see What is multichain indexing?. This page covers the HyperIndex configuration and patterns.

Multichain indexing allows you to monitor and process events from contracts deployed across multiple chains within a single indexer instance. This capability is essential for applications that:

  • Track the same contract deployed across multiple chains
  • Need to aggregate data from different chains into a unified view
  • Monitor cross-chain interactions or state

How It Works

With multichain indexing, events from contracts deployed on multiple chains can be used to create and update entities defined in your schema file. Your indexer will process events from all configured chains, maintaining proper synchronization across them.

Configuration Requirements

To implement multichain indexing, you need to:

  1. Populate the chains section in your config.yaml file for each chain
  2. Specify contracts to index from each chain
  3. Create event handlers for the specified contracts

Real-World Example: Uniswap V4 Multichain Indexer

For a comprehensive, production-ready example of multichain indexing, we recommend exploring our Uniswap V4 Multichain Indexer. This official reference implementation:

  • Indexes Uniswap V4 deployments across 10 different blockchain chains
  • Powers the official v4.xyz interface with real-time data
  • Demonstrates best practices for high-performance multichain indexing
  • Provides a complete, production-grade implementation you can study and adapt

The Uniswap V4 indexer showcases how to effectively structure a multichain indexer for a complex DeFi protocol, handling high volumes of data across multiple chains while maintaining performance and reliability.

Config File Structure for Multichain Indexing

The config.yaml file for multichain indexing contains three key sections:

  1. Global contract definitions - Define contracts, ABIs, and events once
  2. Chain-specific configurations - Specify chain IDs and starting blocks
  3. Contract instances - Reference global contracts with chain-specific addresses
# Example structure (simplified)
contracts:
- name: ExampleContract
abi_file_path: ./abis/example-abi.json
events:
- event: ExampleEvent

chains:
- id: 1 # Ethereum Mainnet
start_block: 0
contracts:
- name: ExampleContract
address: "0x1234..."
- id: 137 # Polygon
start_block: 0
contracts:
- name: ExampleContract
address: "0x5678..."

Key Configuration Concepts

  • The global contracts section defines the contract interface, ABI, handlers, and events once
  • The chains section lists each blockchain chain you want to index
  • Each chain entry references the global contract and provides the chain-specific address
  • This structure allows you to reuse the same handler functions and event definitions across chains

Per-Chain Data Mode

By default every entity row and effect cache is shared by all chains, so the same entity id on two chains resolves to a single row. That's why multichain indexers traditionally namespace ids with the chain id (user-1, user-137).

Since v3.6 you can instead make entities per-chain, which is the recommended setup for a multichain indexer:

name: my-indexer
disable_default_cross_chain: true

With it on:

  • Entity tables get a composite (id, chainId) primary key. The same id on two chains is two independent rows - in memory, in Postgres and in ClickHouse - and entity history and reorg rollback are scoped per chain too.
  • Effects that don't state a crossChain option get one cache per chain. See Per-Chain Effects.
  • Handler code is unchanged. A handler always runs on one chain, so context.Token.get(id) reads that chain's row and you can drop the id prefixing.

Sharing becomes explicit. Mark an entity @crossChain when its rows should stay shared by every chain:

type Counter {
# per-chain: one row per (id, chain)
id: ID!
count: BigInt!
}

type GlobalCounter @crossChain {
# one row shared by every chain
id: ID!
count: BigInt!
}

@crossChain is only valid together with disable_default_cross_chain: true - without the flag entities are already cross-chain and codegen rejects the directive.

Per-chain entities reserve the chainId column name (chain_id under column_name_format: snake_case), so a schema field can't claim it.

Outside a handler there is no chain in context, so the test framework's chain-agnostic operations take one explicitly:

indexer.Counter.set({ id: "1", count: 0n, chainId: 1 });

// Throws if the id exists on more than one chain
indexer.Counter.get("1");

// Narrow it instead
indexer.Counter.getWhere({ chainId: { _eq: 1 } });

Since v3.10, rollbacks are isolated too: when disable_default_cross_chain: true is set and no entity is marked @crossChain, a reorg rolls back only the chain that triggered it instead of every chain.

Multichain Event Ordering

In V3 the indexer always processes multichain events in unordered mode. Events from different chains are processed as soon as they're available, without waiting for the other chains, which keeps latency low.

  • Events are still processed in order within each individual chain.
  • Events across different chains may be processed out of order.
  • Processing happens as soon as events are emitted, so you don't wait for the slowest chain's block time.

This is ideal when:

  • Operations on your entities are commutative (order doesn't matter).
  • Entities from different chains never interact with each other.
  • Processing speed matters more than guaranteed cross-chain ordering.

The V2 unordered_multichain_mode option, the multichain: ordered opt-in, and the UNORDERED_MULTICHAIN_MODE / UNSTABLE__TEMP_UNORDERED_HEAD_MODE environment variables have all been removed in V3 - there is nothing to configure.

Ordered Multichain Mode

HyperIndex V3 doesn't offer an ordered multichain mode, and it's a deliberate choice rather than a feature gap. Ordered mode worked by pausing every chain until events from the slowest chain had caught up, so the moment one chain hiccuped (RPC rate limits, a slow block, a brief reorg) every other chain stalled with it. In practice that meant a multichain indexer was only ever as reliable and as fast as its worst-performing chain, and even healthy chains paid for that coupling with significantly higher latency at the head.

You almost always get better reliability and lower latency by keeping every chain unordered and modeling the cross-chain relationship in your schema instead. Each chain writes a small temporary entity when its side of a cross-chain interaction happens, and the second chain (whichever arrives last) reads those temporary entities and finalizes the unified entity. Because each chain progresses independently, none of them are blocked on each other.

A typical pattern for an A → B cross-chain message:

type CrossChainMessage {
id: ID! # The shared cross-chain message id (e.g. nonce + originChainId)
sourceChainId: Int
sourceTxHash: String
destinationChainId: Int
destinationTxHash: String
status: String! # "sent" | "delivered"
}

// Chain A: the message was emitted. Create or update the entity with the
// "sent" side of the data. The destination handler may run before or after.
indexer.onEvent(
{ contract: "Bridge", event: "MessageSent" },
async ({ event, context }) => {
const id = `${event.params.originChainId}-${event.params.nonce}`;
const existing = await context.CrossChainMessage.get(id);
context.CrossChainMessage.set({
id,
sourceChainId: event.chainId,
sourceTxHash: event.transaction.hash,
destinationChainId: existing?.destinationChainId,
destinationTxHash: existing?.destinationTxHash,
status: existing?.destinationTxHash ? "delivered" : "sent",
});
},
);

// Chain B: the message was delivered. Read the (maybe-already-existing)
// entity and fill in the destination side, regardless of which side arrived
// first.
indexer.onEvent(
{ contract: "Bridge", event: "MessageDelivered" },
async ({ event, context }) => {
const id = `${event.params.originChainId}-${event.params.nonce}`;
const existing = await context.CrossChainMessage.get(id);
context.CrossChainMessage.set({
id,
sourceChainId: existing?.sourceChainId,
sourceTxHash: existing?.sourceTxHash,
destinationChainId: event.chainId,
destinationTxHash: event.transaction.hash,
status: existing?.sourceTxHash ? "delivered" : "sent",
});
},
);

The same shape works for any "rendezvous" where two chains contribute parts of one logical record (bridges, cross-chain governance, multichain user profiles, etc.). The "temporary" entity is just a regular entity that gets progressively completed as each chain's events arrive - there's no special API to learn, and the indexer stays fast and resilient because no chain ever waits on another.

If you have a use case that genuinely cannot be expressed this way, reach out on Discord - we'd like to hear it.

Best Practices for Multichain Indexing

1. Keep Chains' Data Distinct

Set disable_default_cross_chain: true so entities are per-chain, and mark the few entities that should be shared with @crossChain. Prefer that over prefixing entity IDs with ${event.chainId}-: the prefix has to be repeated at every read and write, it leaks the chain into ids your API consumers see, and one handler that forgets it silently merges two chains' data. Per-chain mode keeps the ids clean and makes the separation the database's job.

2. Error Handling

Implement robust error handling for chain-specific issues. A failure on one chain shouldn't prevent indexing from continuing on other chains.

3. Testing

  • Test your indexer with realistic scenarios across all chains
  • Use testnet deployments for initial validation
  • Verify entity updates work correctly across chains

4. Performance Considerations

  • Consider your indexing frequency based on the block times of each chain.
  • Monitor resource usage, as indexing multiple chains increases load.
  • Adding more chains does not linearly degrade performance - chains are indexed in parallel.

5. Adding a New Chain to an Existing Indexer

To add a new chain to a running indexer:

  1. Add the new chain entry to your config.yaml with the appropriate start_block and contract addresses
  2. Push the updated code to your deployment branch (for Envio Cloud) or restart locally with pnpm envio dev -r

On Envio Cloud, this creates a new deployment that re-indexes all chains (including the new one). Your previous deployment continues serving queries with zero downtime until the new deployment is fully synced. See the deployment guide for details.

Locally, adding a new chain requires a restart and will re-index all chains from their respective start blocks. Note that in V3, envio dev no longer auto-resets the database - pass -r (or --restart) explicitly when you want a fresh sync. envio start is now production-only.

Troubleshooting Common Issues

  1. Entity Conflicts: If you see one chain's updates overwriting another's, either turn on per-chain data mode or verify that your entity IDs are properly namespaced with chain IDs.

  2. Memory Usage: If your indexer uses excessive memory, consider optimizing your entity structure and implementing pagination in your queries.

Next Steps

  • Explore our Uniswap V4 Multichain Indexer for a complete implementation
  • Review performance optimization techniques for your indexer

Testing

File: Guides/testing.mdx

Introduction

Envio ships with a built-in testing library that doubles as a development loop. createTestIndexer() runs your real handlers in-process, so you can iterate on logic and validate behavior without deploying anywhere. It's designed for:

  • TDD: Write a failing test, implement the handler, capture the snapshot, commit
  • Unit tests: Feed synthetic events directly into handlers to exercise edge cases in isolation
  • E2E tests against real blockchain data: Pin a block range or let the indexer auto-detect the first block with events, and run your full handler pipeline end-to-end
  • Regression-proof assertions: Inspect entities and per-block change sets, then lock in expected output with toMatchInlineSnapshot

The library integrates well with Vitest (recommended) and any other JavaScript-based testing framework.

Getting Started

The simplest way to start is auto-exit mode - no block ranges, no mock events. The indexer automatically finds the first block with events and processes it.



describe("Indexer Testing", () => {
it("Should process first two blocks with events", async (t) => {
const indexer = createTestIndexer();

t.expect(
await indexer.process({ chains: { 1: {} } }),
"Should find the first block with an event on chain 1 and process it."
).toMatchInlineSnapshot(``);

t.expect(
await indexer.process({ chains: { 1: {} } }),
"Should find the second block with an event on chain 1 and process it."
).toMatchInlineSnapshot(``);
});
});

Run pnpm test - Vitest auto-fills the snapshots on first run. Review and commit them.


Process API

indexer.process({ chains }) is the single entry point for driving the indexer. The shape of each chain entry determines the mode.

Processes the first block with matching events for each chain, then exits. Each subsequent call continues from where the previous one stopped.

const result = await indexer.process({
chains: {
1: {}, // auto-detect first block with events on chain 1
8453: {}, // same for chain 8453
},
});

Explicit block range

Process a specific block range. Use when you need deterministic, pinned snapshots.

const result = await indexer.process({
chains: {
1: { startBlock: 10_000_000, endBlock: 10_000_100 },
},
});

Simulate (mock events)

Feed synthetic events without hitting the network. Best for unit-testing handler logic.

await indexer.process({
chains: {
1: {
simulate: [
{
contract: "ERC20",
event: "Transfer",
params: { from: addr1, to: addr2, value: 100n },
},
],
},
},
});

You can pass multiple events in a single simulate array - they will be processed in order, just like in production.

You can optionally specify detailed event metadata per simulated event using the same block / transaction / srcAddress / logIndex shape that real events expose. See field_selection for the full list of overridable fields.

result.changes

result.changes is an array of per-block change objects. Each entry has block, chainId, eventsProcessed, plus entity names as keys with sets arrays of created/updated entities. Dynamic contract registrations appear under addresses.sets.


Entity State API

Preset state before processing and read entities after.

// Preset state before processing
indexer.EntityName.set({ id: "...", field: value });

// Read state after processing
await indexer.EntityName.get("id"); // returns entity | undefined
await indexer.EntityName.getOrThrow("id"); // throws if not found
await indexer.EntityName.getAll(); // returns all entities of this type
Per-chain entities need a chainId

With disable_default_cross_chain: true there is no chain in context outside a handler, so these chain-agnostic operations take one:

indexer.Counter.set({ id: "1", count: 0n, chainId: 1 });

// Throws if the id exists on more than one chain
await indexer.Counter.get("1");

// Narrow it instead
await indexer.Counter.getWhere({ chainId: { _eq: 1 } });

Assertions

The testing library works with any JavaScript assertion library. The examples below use Vitest's built-in expect.

// Snapshot (recommended — captures full output, auto-filled on first run)
t.expect(result.changes).toMatchInlineSnapshot(`...`);

// Entity assertions
const pool = await indexer.Pool.getOrThrow(poolId);
t.expect(pool).toEqual({ id: poolId, token0_id: "0xabc..." });

// Count
t.expect(result.changes[0]?.Pair?.sets).toHaveLength(1);

// Contract addresses (after dynamic registration)
t.expect(indexer.chains[1].MyContract.addresses).toContain("0x1234...");

TDD Workflow

  1. Write a failing test with expected entity output
  2. Implement the handler until the test passes
  3. Capture the snapshot - run pnpm test to fill toMatchInlineSnapshot
  4. Review and commit the snapshot for regression testing
warning

Do not add tests which simply restate the implementation. These provide zero confidence.

Running Tests

pnpm test              # Run all tests
pnpm test -- -u # Update snapshots

File: Guides/navigating-hasura.md

This page is only relevant when testing on a local machine or using a self-hosted version of Envio that uses Hasura.

Introduction

Hasura is a GraphQL engine that provides a web interface for interacting with your indexed blockchain data. When running HyperIndex locally, Hasura serves as your primary tool for:

  • Querying indexed data via GraphQL
  • Visualizing database tables and relationships
  • Testing API endpoints before integration with your frontend
  • Monitoring the indexing process

This guide explains how to navigate the Hasura dashboard to effectively work with your indexed data.

Accessing Hasura Console

When running HyperIndex locally, Hasura Console is automatically available at:

http://localhost:8080

You can access this URL in any web browser to open the Hasura console.

note

When prompted for authentication, use the password: testing

Key Dashboard Areas

The Hasura dashboard has several tabs, but we'll focus on the two most important ones for HyperIndex developers:

API Tab

The API tab lets you execute GraphQL queries and mutations on indexed data. It serves as a GraphQL playground for testing your API calls.

Features

  • Explorer Panel: The left panel shows all available entities defined in your schema.graphql file
  • Query Builder: The center area is where you write and execute GraphQL queries
  • Results Panel: The right panel displays query results in JSON format

Available Entities

By default, you'll see:

  • All entities defined in your schema.graphql file
  • dynamic_contracts (for dynamically added contracts)
  • raw_events table (Note: This table is no longer populated by default to improve performance. To enable storage of raw events, add raw_events: true to your config.yaml file as described in the Raw Events Storage section)

Example Query

Try a simple query to test your blockchain indexer:

query MyQuery {
User(limit: 5) {
id
latestGreeting
numberOfGreetings
}
}

Click the "Play" button to execute the query and see the results.

For more advanced GraphQL query options, see Hasura's quickstart guide.

Data Tab

The Data tab provides direct access to your database tables and relationships, allowing you to view the actual indexed data.

Features

  • Schema Browser: View all tables in the database (left panel)
  • Table Data: Examine and browse data within each table
  • Relationship Viewer: See how different entities are connected

Working with Tables

  1. Select any table from the "public" schema to view its contents
  2. Use the "Browse Rows" tab to see all data in that table
  3. Check the "Insert Row" tab to manually add data (useful for testing)
  4. View the "Modify" tab to see the table structure

Verifying Indexed Data

To confirm your blockchain indexer is working correctly:

  1. Check entity tables to ensure they contain the expected data
  2. Run the _meta indexing status query to see each chain's latest processed block and confirm the indexer is making progress

Common Tasks

Checking Indexing Status

To verify your blockchain indexer is actively processing new blocks:

  1. Run the _meta indexing status query to see each chain's latest processed block
  2. Monitor those values over time to ensure they're advancing

(Note the TUI is also an easy way to monitor this)

Troubleshooting Missing Data

If expected data isn't appearing:

  1. Check if you've enabled raw events storage (raw_events: true in config.yaml) and then examine the raw_events table to confirm events were captured
  2. Verify your event handlers are correctly processing these events
  3. Examine your GraphQL queries to ensure they match your schema structure
  4. Check console logs for any processing errors

Resetting Indexed Data

When testing, you may need to reset your database:

  1. Stop your indexer
  2. Reset your database (refer to the development guide for commands)
  3. Restart your indexer to begin processing from the configured start block

Best Practices

  • Regular Verification: Periodically check both the API and Data tabs to ensure your blockchain indexer is functioning correctly
  • Query Testing: Test complex queries in the API tab before implementing them in your application
  • Schema Validation: Use the Data tab to verify that relationships between entities are correctly established
  • Performance Monitoring: Watch for tables that grow unusually large, which might indicate inefficient indexing

Aggregations: local vs hosted (avoid the foot‑gun)

When developing locally with Hasura, you may notice that GraphQL aggregate helpers (for example, count/sum-style aggregations) are available. On Envio Cloud, these aggregate endpoints are intentionally not exposed. Aggregations over large datasets can be very slow and unpredictable in production.

The recommended approach is to compute and store aggregates at indexing time, not at query time. In practice this means maintaining counters, sums, and other rollups in entities as part of your event handlers, and then querying those precomputed values.

Example: indexing-time aggregation

schema.graphql

# singleton; you hardcode the id and load it in and out
type GlobalState {
id: ID! # "global-state"
count: Int!
}

type Token {
id: ID! # incremental number
description: String!
}

EventHandler.ts


const globalStateId = "global-state";

indexer.onEvent(
{ contract: "NftContract", event: "Mint" },
async ({ event, context }) => {
const globalState = await context.GlobalState.get(globalStateId);

if (!globalState) {
context.log.error("global state doesn't exist");
return;
}

const incrementedTokenId = globalState.count + 1;

context.Token.set({
id: incrementedTokenId,
description: event.params.description,
});

context.GlobalState.set({
...globalState,
count: incrementedTokenId,
});
},
);

This pattern scales: you can keep per-entity counters, rolling windows (daily/hourly entities keyed by date), and top-N caches by updating entities as events arrive. Your queries then read these precomputed values directly, avoiding expensive runtime aggregations.

Exceptional cases

If runtime aggregate queries are a hard requirement for your use case, please reach out and we can evaluate options for your project on Envio Cloud. Contact us on Discord.

Disable Hasura for Self-Hosted Blockchain Indexers

Set the ENVIO_HASURA environment variable to false to disable Hasura integration for self-hosted blockchain indexers.


For more information on using GraphQL with your indexed data, refer to the Hasura GraphQL documentation.


Environment Variables

File: Guides/environment-variables.md

Environment variables are a crucial part of configuring your Envio blockchain indexer. They allow you to manage sensitive information and configuration settings without hardcoding them in your codebase.

Naming Convention

All environment variables used by Envio must be prefixed with ENVIO_. This naming convention:

  • Prevents conflicts with other environment variables
  • Makes it clear which variables are used by the Envio indexer
  • Ensures consistency across different environments

Envio API Token (required for HyperSync)

To ensure continued access to HyperSync, set an Envio API token in your environment.

  • Use ENVIO_API_TOKEN to provide your token at runtime
  • See the API Tokens guide for how to generate a token: API Tokens
  • A token is only required when using Envio as the data provider (HyperSync). Indexers that source data from an external RPC don't need one.

Envio-specific environment variables

The following variables are used by HyperIndex:

  • ENVIO_API_TOKEN: API token for HyperSync access (required when indexing via HyperSync - get one at envio.dev/app/api-tokens)

  • ENVIO_HASURA: Set to false to disable Hasura integration for self-hosted blockchain indexers

  • ENVIO_TUI: Set to false to disable the terminal UI (replaces the V2 TUI_OFF=true flag; the TUI is also auto-disabled in CI and under AI agents)

  • ENVIO_INDEXER_PORT: Port for the indexer's HTTP server, which serves /metrics, /metrics/runtime, and /healthz (default: 9898) - see Observability

  • ENVIO_PG_HOST: Postgres host (self-hosted; defaults to localhost in dev)

  • ENVIO_PG_PORT: Port for the Postgres service used by HyperIndex during local development

  • ENVIO_PG_PASSWORD: Postgres password (self-hosted)

  • ENVIO_PG_USER: Postgres username (self-hosted)

  • ENVIO_PG_DATABASE: Postgres database name (self-hosted)

  • ENVIO_PG_SCHEMA: Postgres schema name override for the generated/public schema (replaces ENVIO_PG_PUBLIC_SCHEMA; the old name is still accepted until v4)

  • ENVIO_PG_SSL_MODE: Postgres SSL mode - require, allow, prefer or verify-full (disabled by default in local dev)

When the indexer also writes to ClickHouse (storage.clickhouse in config.yaml), envio start reads the connection from ENVIO_CLICKHOUSE_HOST, ENVIO_CLICKHOUSE_DATABASE, ENVIO_CLICKHOUSE_USERNAME and ENVIO_CLICKHOUSE_PASSWORD, plus the optional ENVIO_CLICKHOUSE_REPLICATED and ENVIO_CLICKHOUSE_DATABASE_ENGINE for high-availability setups. envio dev spins up its own container and injects these for you.

note

The V2 variables MAX_BATCH_SIZE, ENVIO_INDEXING_BLOCK_LAG, UNORDERED_MULTICHAIN_MODE, and UNSTABLE__TEMP_UNORDERED_HEAD_MODE have been removed in V3. Use the full_batch_size config option in config.yaml instead of MAX_BATCH_SIZE, and use the per-chain block_lag option instead of ENVIO_INDEXING_BLOCK_LAG. Unordered multichain processing is now the default.

Example Environment Variables

Here are some commonly used environment variables:

# Envio API Token (required for HyperSync access)
ENVIO_API_TOKEN=your-secret-token

# Blockchain RPC URL
ENVIO_RPC_URL=https://arbitrum.direct.dev/your-api-key

# Coingecko API key
ENVIO_COINGECKO_API_KEY=api-key

# Disable the terminal UI
ENVIO_TUI=false

Setting Environment Variables

Local Development

For local development, you can set environment variables in several ways:

  1. Using a .env file in your project root:
# .env
ENVIO_API_TOKEN=your-secret-token
ENVIO_RPC_URL=https://arbitrum.direct.dev/your-api-key
  1. Directly in your terminal:
export ENVIO_API_TOKEN=your-secret-token
export ENVIO_RPC_URL=https://arbitrum.direct.dev/your-api-key

Envio Cloud

When using Envio Cloud, you can configure environment variables through the Envio platform's dashboard. Remember that all variables must still be prefixed with ENVIO_.

For more information about environment variables in Envio Cloud, see the Envio Cloud documentation.

Configuration File

For use of environment variables in your configuration file, read the docs here: Configuration File.

Best Practices

  1. Never commit sensitive values: Always use environment variables for sensitive information like API keys and database credentials
  2. Never commit or use private keys: Never commit or use private keys in your codebase
  3. Use descriptive names: Make your environment variable names clear and descriptive
  4. Document your variables: Keep a list of required environment variables in your project's README
  5. Use different values: Use different environment variables for development, staging, and production environments
  6. Validate required variables: Check that all required environment variables are set before starting your blockchain indexer

Troubleshooting

If you encounter issues with environment variables:

  1. Verify that all required variables are set
  2. Check that variables are prefixed with ENVIO_
  3. Ensure there are no typos in variable names
  4. Confirm that the values are correctly formatted

For more help, see our Observability Guide.


Uniswap V4 Multichain Indexer

File: Examples/example-uniswap-v4.md

The following blockchain indexer example is a reference implementation and can serve as a starting point for applications with similar logic.

This official Uniswap V4 indexer is a comprehensive implementation for the Uniswap V4 protocol using Envio HyperIndex. This is the same indexer that powers the v4.xyz website, providing real-time data for the Uniswap V4 interface.

Key Features

  • Multichain Support: Indexes Uniswap V4 deployments across every supported blockchain network in real-time
  • Complete Pool Metrics: Tracks pool statistics including volume, TVL, fees, and other critical metrics
  • Swap Analysis: Monitors swap events and liquidity changes with high precision
  • Hook Integration: In-progress support for Uniswap V4 hooks and their events
  • Production Ready: Powers the official v4.xyz interface with production-grade reliability
  • Ultra-Fast Syncing: Processes massive amounts of blockchain data significantly faster than alternative blockchain indexing solutions, reducing sync times from days to minutes

Technical Overview

This indexer is built using TypeScript and provides a unified GraphQL API for accessing Uniswap V4 data across all supported chains. The architecture is designed to handle high throughput and maintain consistency across different chains.

Performance Advantages

The Envio-powered Uniswap V4 indexer offers extraordinary performance benefits:

  • 10-100x Faster Sync Times: Leveraging Envio's HyperSync technology, this indexer can process historical blockchain data orders of magnitude faster than traditional solutions
  • Real-time Updates: Maintains low latency for new blocks while efficiently managing historical data

Use Cases

  • Power analytics dashboards and trading interfaces
  • Monitor DeFi positions and protocol health
  • Track historical performance of Uniswap V4 pools
  • Build custom notifications and alerts
  • Analyze hook interactions and their impact

Getting Started

To use this indexer, you can:

  1. Clone the repository
  2. Follow the installation instructions in the README
  3. Run the indexer locally or deploy it to a production environment
  4. Access indexed data through the GraphQL API

Contribution

The Uniswap V4 indexer is actively maintained and welcomes contributions from the community. If you'd like to contribute or report issues, please visit the GitHub repository.

note

This is an official reference implementation that powers the v4.xyz website. While extensively tested in production, remember to validate the data for your specific use case. The indexer is continuously updated to support the latest Uniswap V4 features and optimizations.


Sablier Protocol Indexers

File: Examples/example-sablier.md

The following blockchain indexers serve as exceptional reference implementations for the Sablier protocol, showcasing professional development practices and efficient multichain data processing.

Overview

Sablier is a token streaming protocol that enables real-time finance on the blockchain, allowing tokens to be streamed continuously over time. These official Sablier indexers track streaming activity across many EVM-compatible chains, providing comprehensive data through a unified GraphQL API.

Professional Indexer Suite

Sablier maintains two public indexers, each targeting a specific part of their protocol:

1. Streams Indexer

Tracks Sablier's payment-stream data across both the Lockup and Flow products. Lockup covers streams with fixed durations and amounts (creation, cancellation, and withdrawal events), while Flow covers open-ended streaming with dynamic flow rates. For more detail, see the Lockup indexer docs and the Flow indexer docs.

2. Airdrops Indexer

Tracks Sablier's Merkle airdrop campaigns, which enable efficient batch stream creation using cryptographic proofs. This indexer captures data about campaign creation, claims, and related activity, powering both Airstreams and Instant Airdrops. For more detail, see the Airdrops indexer docs.

Key Features

  • Comprehensive Multichain Support: Indexes data across many EVM-compatible chains
  • Professionally Maintained: Used in production by the Sablier team and their partners
  • Extensive Test Coverage: Includes comprehensive testing to ensure data accuracy
  • Optimized Performance: Implements efficient data processing techniques
  • Well-Documented: Clear code structure with extensive comments
  • Backward Compatibility: Carefully manages schema evolution and contract upgrades
  • Cross-chain Architecture: Envio promotes efficient cross-chain indexing where all chains share the same indexer endpoint

Best Practices Showcase

These blockchain indexers demonstrate several development best practices:

  • Modular Code Structure: Well-organized code with clear separation of concerns
  • Consistent Naming Conventions: Professional and consistent naming throughout
  • Efficient Event Handling: Optimized processing of blockchain events
  • Comprehensive Entity Relationships: Well-designed data model with proper relationships
  • Thorough Input Validation: Robust error handling and input validation
  • Detailed Changelogs: Documentation of breaking changes and migrations
  • Preload Optimization: Envio indexers benefit from always-on Preload Optimization, which batches entity reads and runs external calls in parallel through the Effect API

Getting Started

To use these indexers as a reference for your own development:

  1. Clone the indexer that matches your needs:
  2. Review the file structure and implementation patterns
  3. Examine the event handlers for efficient data processing techniques
  4. Study the schema design for effective entity modeling

For complete API documentation and usage examples, see:

note

These are official indexers maintained by the Sablier team and represent production-quality implementations. They serve as an excellent example of professional blockchain indexer development and are regularly updated to support the latest protocol features.


Velodrome & Aerodrome DEX Indexer

File: Examples/example-aerodrome-velodrome.md

This blockchain indexer provides a comprehensive implementation for tracking data from the popular Velodrome and Aerodrome decentralized exchanges across multiple chains.

Overview

The Velodrome & Aerodrome Indexer demonstrates how to:

  • Create a multichain indexer that works across Superchain chains
  • Index core DEX functionality including pools, swaps, and liquidity positions
  • Unify data access through a single GraphQL API
  • Build efficient handlers using TypeScript

Key Features

  • Complete tracking of liquidity pools and trading activity
  • Real-time synchronization across multiple chains
  • Well-documented codebase suitable as a starting point for similar DEX indexers
  • Making ad-hoc RPC requests for additional data (use with caution)

For implementation details, usage examples, and setup instructions, see the project's README.

note

This indexer was built by Envio partners and community builders and serves as a valuable reference implementation. As with any indexer, perform appropriate testing and data validation before using in production environments.


Tutorial Op Bridge Deposits

File: Tutorials/tutorial-op-bridge-deposits.md

Introduction

This tutorial will guide you through indexing Optimism Standard Bridge deposits in under 5 minutes using Envio HyperIndex's no-code contract import feature.

The Optimism Standard Bridge enables the movement of ETH and ERC-20 tokens between Ethereum and Optimism. We'll index bridge deposit events by extracting the DepositFinalized logs emitted by the bridge contracts on both chains.

Prerequisites

Before starting, ensure you have the following installed:

  • Node.js (v22 or newer recommended)
  • pnpm (recommended but not required)
  • Docker Desktop (required to run the Envio indexer locally)

Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.

Step 1: Initialize Your Indexer

  1. Open your terminal in an empty directory and run:
pnpx envio init
  1. Name your indexer (we'll use "optimism-bridge-indexer" in this example):

  2. Choose your preferred language (TypeScript, JavaScript, or ReScript):

Step 2: Import the Optimism Bridge Contract

  1. Select Contract ImportBlock ExplorerOptimism

  2. Enter the Optimism bridge contract address:

    0x4200000000000000000000000000000000000010

    View on Optimistic Etherscan

  3. Select the DepositFinalized event:

    • Navigate using arrow keys (↑↓)
    • Press spacebar to select the event

Tip: You can select multiple events to index simultaneously.

Step 3: Add the Ethereum Mainnet Bridge Contract

  1. When prompted, select Add a new contract

  2. Choose Block ExplorerEthereum Mainnet

  3. Enter the Ethereum Mainnet gateway contract address:

    0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1

    View on Etherscan

  4. Select the ETHDepositInitiated event

  5. When finished adding contracts, select I'm finished

Step 4: Start Your Indexer

  1. If you have any running indexers, stop them first:
pnpm envio stop
  1. Start your new indexer:
pnpm dev

This command:

  • Starts the required Docker containers
  • Sets up your database
  • Launches the indexing process
  • Opens the Hasura GraphQL interface

Step 5: Understanding the Generated Code

Let's examine the key files that Envio generated:

1. config.yaml

This configuration file defines:

  • Networks to index (Optimism and Ethereum Mainnet)
  • Starting blocks for each network
  • Contract addresses and ABIs
  • Events to track

2. schema.graphql

This schema defines the data structures for our selected events:

  • Entity types based on event data
  • Field types matching the event parameters
  • Relationships between entities (if applicable)

3. src/handlers

This file contains the business logic for processing events:

  • Functions that execute when events are detected
  • Data transformation and storage logic
  • Entity creation and relationship management

Step 6: Exploring Your Indexed Data

Now you can interact with your indexed data:

Accessing Hasura

  1. Open Hasura at http://localhost:8080
  2. When prompted, enter the admin password: testing

Monitoring Indexing Progress

  1. Click the Data tab in the top navigation
  2. Find the _events_sync_state table to check indexing progress
  3. Observe which blocks are currently being processed

Note: Thanks to Envio's HyperSync, indexing happens significantly faster than with standard RPC methods.

Querying Indexed Events

  1. Click the API tab
  2. Construct a GraphQL query to explore your data

Here's an example query to fetch the 10 largest bridge deposits:

query LargestDeposits {
DepositFinalized(limit: 10, order_by: { amount: desc }) {
l1Token
l2Token
from
to
amount
blockTimestamp
}
}
  1. Click the Play button to execute your query

Conclusion

Congratulations! You've successfully created an indexer for Optimism Bridge deposits across both Ethereum and Optimism.

What You've Learned

  • How to initialize a multi-network indexer using Envio
  • How to import contracts from different blockchains
  • How to query and explore indexed blockchain data

Next Steps

  • Try customizing the event handlers to add additional logic
  • Create relationships between events on different networks
  • Deploy your indexer to Envio Cloud

For more tutorials and advanced features, check out our documentation or watch our video walkthroughs on YouTube.


Tutorial Erc20 Token Transfers

File: Tutorials/tutorial-erc20-token-transfers.md

Introduction

In this tutorial, you'll learn how to index ERC20 token transfers on the Base chain using Envio HyperIndex. By leveraging the no-code contract import feature, you'll be able to quickly analyze USDC transfer activity, including identifying the largest transfers.

We'll create an indexer that tracks all USDC token transfers on Base by extracting the Transfer events emitted by the USDC contract. The entire process takes less than 5 minutes to set up and start querying data.

Prerequisites

Before starting, ensure you have the following installed:

  • Node.js (v22 or newer recommended)
  • pnpm (recommended but not required)
  • Docker Desktop (required to run the Envio indexer locally)

Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.

Step 1: Initialize Your Indexer

  1. Open your terminal in an empty directory and run:
pnpx envio init
  1. Name your indexer (we'll use "usdc-base-transfer-indexer" in this example):

  2. Choose your preferred language (TypeScript, JavaScript, or ReScript):

Step 2: Import the USDC Token Contract

  1. Select Contract ImportBlock ExplorerBase

  2. Enter the USDC token contract address on Base:

    0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913

    View on BaseScan

  3. Select the Transfer event:

    • Navigate using arrow keys (↑↓)
    • Press spacebar to select the event

Tip: You can select multiple events to index simultaneously if needed.

  1. When finished adding contracts, select I'm finished

Step 3: Start Your Indexer

  1. If you have any running indexers, stop them first:
pnpm envio stop

Note: You can skip this step if this is your first time running an indexer.

  1. Start your new indexer:
pnpm dev

This command:

  • Starts the required Docker containers
  • Sets up your database
  • Launches the indexing process
  • Opens the Hasura GraphQL interface

Step 4: Understanding the Generated Code

Let's examine the key files that Envio generated:

1. config.yaml

This configuration file defines:

  • Chain to index (Base)
  • Starting block for indexing
  • Contract address and ABI details
  • Events to track (Transfer)

2. schema.graphql

This schema defines the data structures for the Transfer event:

  • Entity types based on event data
  • Field types for sender, receiver, and amount
  • Any relationships between entities

3. src/handlers

This directory contains the business logic for processing events:

  • Functions that execute when Transfer events are detected
  • Data transformation and storage logic
  • Entity creation and relationship management

Step 5: Exploring Your Indexed Data

Now you can interact with your indexed USDC transfer data:

Accessing Hasura

  1. Open Hasura at http://localhost:8080
  2. When prompted, enter the admin password: testing

Monitoring Indexing Progress

  1. Click the Data tab in the top navigation
  2. Find the _events_sync_state table to check indexing progress
  3. Observe which blocks are currently being processed

Note: Thanks to Envio's HyperSync, you can index millions of USDC transfers in just minutes rather than hours or days with traditional methods.

Querying Indexed Events

  1. Click the API tab
  2. Construct a GraphQL query to explore your data

Here's an example query to fetch the 10 largest USDC transfers:

query LargestTransfers {
FiatTokenV2_2_Transfer(limit: 10, order_by: { value: desc }) {
from
to
value
blockTimestamp
}
}
  1. Click the Play button to execute your query

Conclusion

Congratulations! You've successfully created an indexer for USDC token transfers on Base. In just a few minutes, you've indexed over 3.6 million transfer events and can now query this data in real-time.

What You've Learned

  • How to initialize an indexer using Envio's contract import feature
  • How to index ERC20 token transfers on the Base chain
  • How to query and analyze token transfer data using GraphQL

Next Steps

  • Try customizing the event handlers to add additional logic
  • Create aggregated statistics about token transfers
  • Add more tokens or events to your indexer
  • Deploy your indexer to Envio Cloud

For more tutorials and advanced features, check out our documentation or watch our video walkthrough on YouTube.


Tutorial Indexing Fuel

File: Tutorials/tutorial-indexing-fuel.md

HyperIndex supports any EVM-compatible blockchain and the Fuel Network.

Blockchain indexers are vital to the success of any dApp. In this tutorial, we will create an Envio indexer for the Fuel dApp Sway Farm step by step.

Sway Farm is a simple farming game and for the sake of a real-world example, let's create the indexer for a leaderboard of all farmers 🧑‍🌾

About Fuel

Fuel is an operating system purpose-built for Ethereum rollups. Fuel's unique architecture allows rollups to solve for PSI (parallelization, state minimized execution, interoperability). Powered by the FuelVM, Fuel aims to expand Ethereum's capability set without compromising security or decentralization.

Website | X | Discord

Prerequisites

Environment tooling

  • Node.js (v22 or newer recommended)
  • pnpm (recommended but not required)
  • Docker Desktop (required to run the Envio indexer locally)

Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.

Initialize the project

Now that you have installed the prerequisite packages let's begin the practical steps of setting up the indexer.

Open your terminal in an empty directory and initialize a new indexer by running the command:

pnpx envio init

In the following prompt, choose the directory where you want to set up your project. The default is the current directory, but in the tutorial, I'll use the indexer name:

? Specify a folder name (ENTER to skip): sway-farm-indexer

Then, choose a language of your choice for the event handlers. TypeScript is the most popular one, so we'll stick with it:

? Which language would you like to use?
JavaScript
> TypeScript
ReScript
[↑↓ to move, enter to select, type to filter]

Next, we have the new prompt for a blockchain ecosystem. Previously Envio supported only EVM, but now it's possible to choose between Evm, Fuel and other VMs in the future:

? Choose blockchain ecosystem
Evm
> Fuel
[↑↓ to move, enter to select, type to filter]

In the following prompt, you can choose an initialization option. There's a Greeter template for Fuel, which is an excellent way to learn more about HyperIndex. But since we have an existing contract, the Contract Import option is the best way to create an indexer:

? Choose an initialization option
Template
> Contract Import
[↑↓ to move, enter to select, type to filter]

A separate Tutorial page provides more details about the Greeter template.

Next it'll ask us for an ABI file. You can find it in the ./out/debug directory after building your Sway contract with forc build:

? What is the path to your json abi file? ./sway-farm/contract/out/debug/contract-abi.json

After the ABI file is provided, Envio parses all possible events you can use for indexing:

? Which events would you like to index?
> [x] NewPlayer
[x] PlantSeed
[x] SellItem
[x] InvalidError
[x] Harvest
[x] BuySeeds
[x] LevelUp
[↑↓ to move, space to select one, → to all, ← to none, type to filter]

Let's select the events we want to index. I opened the code of the contract file and realized that for a leaderboard we need only events which update player information. Hence, I left only NewPlayer, LevelUp, and SellItem selected in the list. We'd want to index more events in real life, but this is enough for the tutorial.

? Which events would you like to index?
> [x] NewPlayer
[ ] PlantSeed
[x] SellItem
[ ] InvalidError
[ ] Harvest
[ ] BuySeeds
[x] LevelUp
[↑↓ to move, space to select one, → to all, ← to none, type to filter]

📖 For the tutorial we only need to index LOG_DATA receipts, but you can also index Mint, Burn, Transfer and Call receipts. Read more about Supported Event Types.

Just a few simple questions left. Let's call our contract SwayFarm:

? What is the name of this contract? SwayFarm

Set an address for the deployed contract:

? What is the address of the contract? 0xf5b08689ada97df7fd2fbd67bee7dea6d219f117c1dc9345245da16fe4e99111
[Use the proxy address if your abi is a proxy implementation]

Finish the initialization process:

? Would you like to add another contract?
> I'm finished
Add a new address for same contract on same network
Add a new contract (with a different ABI)
[Current contract: SwayFarm, on network: Fuel]

If you see the following line, it means we are already halfway through 🙌

Please run `cd sway-farm-indexer` to run the rest of the envio commands

Let's open the indexer in an IDE and start adjusting it for our farm 🍅

Walk through initialized indexer

At this point, we should already have a working indexer. You can start it by running pnpm dev, which we cover in more detail later in the tutorial.

Everything is configured by modifying the 3 files below. Let's walk through each of them.

  • config.yaml Guide
  • schema.graphql Guide
  • EventHandlers.* Guide

(* depending on the language chosen for the indexer)

config.yaml

The config.yaml outlines the specifications for the indexer, including details such as chain and contract specifications and the event information to be used in the indexing process.

name: sway-farm-indexer
ecosystem: fuel
chains:
- id: 0
start_block: 0
contracts:
- name: SwayFarm
address:
- "0xf5b08689ada97df7fd2fbd67bee7dea6d219f117c1dc9345245da16fe4e99111"
abi_file_path: abis/swayfarm-abi.json
events:
- name: SellItem
logId: "11192939610819626128"
- name: LevelUp
logId: "9956391856148830557"
- name: NewPlayer
logId: "169340015036328252"

In the tutorial, we don't need to adjust it in any way. But later you can modify the file and add more events for indexing.

As a nice to have, you can use a Sway struct name without specifying a logId, like this:

- name: SellItem
- name: LevelUp
- name: NewPlayer

schema.graphql

The schema.graphql file serves as a representation of your application's data model. It defines entity types that directly correspond to database tables, and the event handlers you create are responsible for creating and updating records within those tables. Additionally, the GraphQL API is automatically generated based on the entity types specified in the schema.graphql file, to allow access to the indexed data.

🧠 A separate Guide page provides more details about the schema.graphql file.

For the leaderboard, we need only one entity representing the player. Let's create it:

type Player {
id: ID!
farmingSkill: BigInt!
totalValueSold: BigInt!
}

We will use the user address as an ID. The fields farmingSkill and totalValueSold are u64 in Sway, so to safely map them to JavaScript value, we'll use BigInt.

EventHandlers.ts

The event handlers generated by contract import are quite simple and only add an entity to a DB when a related event is indexed.

/*
* Please refer to https://docs.envio.dev for a thorough guide on all Envio indexer features
*/

indexer.onEvent(
{ contract: "SwayFarm", event: "SellItem" },
async ({ event, context }) => {
const entity: Entity<"SwayFarm_SellItem"> = {
id: `${event.chainId}_${event.block.height}_${event.logIndex}`,
};

context.SwayFarm_SellItem.set(entity);
},
);

Let's modify the handlers to update the Player entity instead. But before we start, we need to run pnpm codegen to generate utility code and types for the Player entity we've added.

pnpm codegen

It's time for a little bit of coding. The indexer is very simple; it requires us only to pass event data to an entity.


/**
Registers a handler that processes NewPlayer event
on the SwayFarm contract and stores the players in the DB
*/
indexer.onEvent(
{ contract: "SwayFarm", event: "NewPlayer" },
async ({ event, context }) => {
// Set the Player entity in the DB with the initial values
context.Player.set({
// The address in Sway is a union type of user Address and ContractID. Envio supports most of the Sway types, and the address value was decoded as a discriminated union 100% typesafe
id: event.params.address.payload.bits,
// Initial values taken from the contract logic
farmingSkill: 1n,
totalValueSold: 0n,
});
},
);

indexer.onEvent(
{ contract: "SwayFarm", event: "LevelUp" },
async ({ event, context }) => {
const playerInfo = event.params.player_info;
context.Player.set({
id: event.params.address.payload.bits,
farmingSkill: playerInfo.farming_skill,
totalValueSold: playerInfo.total_value_sold,
});
},
);

indexer.onEvent(
{ contract: "SwayFarm", event: "SellItem" },
async ({ event, context }) => {
const playerInfo = event.params.player_info;
context.Player.set({
id: event.params.address.payload.bits,
farmingSkill: playerInfo.farming_skill,
totalValueSold: playerInfo.total_value_sold,
});
},
);

Without overengineering, simply set the player data into the database. What's nice is that whenever your ABI or entities in graphql.schema change, Envio regenerates types and shows the compilation error.

🧠 You can find the indexer repo created during the tutorial on GitHub.

Starting the Indexer

📢 Make sure you have docker open

The following commands will start the docker and create databases for indexed data. Make sure to re-run pnpm dev if you've made some changes.

pnpm dev

Nice, we indexed 1,721,352 blocks containing 58,784 events in 10 seconds, and they continue coming in.

View the indexed results

Let's check indexed players on the local Hasura server.

open http://localhost:8080

The Hasura admin-secret / password is testing, and the tables can be viewed in the data tab or queried from the playground.

Now, we can easily get the top 5 players, the number of inactive and active players, and the average sold value. What's left is a nice UI for the Sway Farm leaderboard, but that's not the tutorial's topic.

🧠 A separate Guide page provides more details about navigating Hasura.

Deploy the indexer to Envio Cloud

Once you have verified that the indexer is working for your contracts, then you are ready to deploy the indexer to Envio Cloud.

Deploying an indexer to Envio Cloud allows you to extract information via graphQL queries into your front-end or some back-end application.

Navigate to the Envio Cloud to start deploying your indexer and refer to this documentation for more information on deploying your indexer.

What next?

Once you have successfully finished the tutorial, you are ready to become a blockchain indexing wizard!

Join our Discord channel to make sure you catch all new releases.


Greeter Tutorial

File: Tutorials/greeter-tutorial.md

Introduction

This tutorial provides a step-by-step guide to indexing a simple Greeter smart contract deployed on multiple blockchains. You'll learn how to set up and run a multichain indexer using Envio's template system.

What is the Greeter Contract?

The Greeter contract is a straightforward smart contract that allows users to store greeting messages on the blockchain. For this tutorial, we'll be indexing instances of this contract deployed on both Polygon and Linea.

What You'll Build

By the end of this tutorial, you'll have:

  • A functioning multichain indexer that tracks greeting events
  • The ability to query these events through a GraphQL endpoint
  • Experience with Envio's core indexing functionality

Prerequisites

Before starting, ensure you have the following installed:

  • Node.js (v22 or newer recommended)
  • pnpm (recommended but not required)
  • Docker Desktop (required to run the Envio indexer locally)

Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.

Step 1: Initialize Your Project

First, let's create a new project using Envio's Greeter template:

  1. Open your terminal and run:
pnpx envio init
  1. When prompted for a directory, you can press Enter to use the current directory or specify another path:
? Set the directory: (.) .
  1. Choose your preferred programming language for event handlers:
? Which language would you like to use?
> JavaScript
TypeScript
ReScript
  1. Select the Template initialization option:
? Choose an initialization option
> Template
Contract Import
  1. Choose the Greeter template:
? Which template would you like to use?
> Greeter
Erc20

After completing these steps, Envio will generate all the necessary files for your indexer project.

Step 2: Understanding the Generated Files

Let's examine the key files that were created:

config.yaml

This configuration file defines which chains and contracts to index:

# Partial example
chains:
- id: 137 # Polygon
# ... Polygon chain settings
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
# ... contract settings
- id: 59144 # Linea
# ... Linea chain settings
contracts:
- name: Greeter
address: "0xdEe21B97AB77a16B4b236F952e586cf8408CF32A"
# ... contract settings

schema.graphql

This schema defines the data structures for the indexed events:

type Greeting {
id: ID!
user: String!
greeting: String!
blockNumber: Int!
blockTimestamp: Int!
transactionHash: String!
}

type User {
id: ID!
latestGreeting: String!
numberOfGreetings: Int!
greetings: [String!]!
}

src/handlers (or .ts/.res)

This file contains the logic to process events emitted by the Greeter contract.

Step 3: Start Your Indexer

Important: Make sure Docker Desktop is running before proceeding.

  1. Start the indexer with:
pnpm dev

This command:

  • Launches Docker containers for the database and Hasura
  • Sets up your local development environment
  • Begins indexing data from the specified contracts
  • Opens a terminal UI to monitor indexing progress

The indexer will retrieve data from both Polygon and Linea blockchains, starting from the blocks specified in your config.yaml file.

Step 4: Interact with the Contracts

To see your indexer in action, you can write new greetings to the blockchain:

For Polygon:

  1. Visit the contract on Polygonscan
  2. Connect your wallet
  3. Use the setGreeting function to write a new greeting
  4. Submit the transaction

For Linea:

  1. Visit the contract on Lineascan
  2. Connect your wallet
  3. Use the setGreeting function to write a new greeting
  4. Submit the transaction

Since this is a multichain example, you can interact with both contracts to see how Envio handles data from different blockchains simultaneously.

Step 5: Query the Indexed Data

Now you can explore the data your indexer has captured:

  1. Open Hasura at http://localhost:8080
  2. When prompted for authentication, use the password: testing
  3. Navigate to the Data tab to browse the database tables
  4. Or use the API tab to write GraphQL queries

Example Query

Try this query to see the latest greetings:

query GetGreetings {
Greeting(limit: 10, order_by: { blockTimestamp: desc }) {
id
user
greeting
blockNumber
blockTimestamp
transactionHash
}
}

Step 6: Deploy to Production (Optional)

When you're ready to move from local development to production:

  1. Visit Envio Cloud
  2. Follow the steps to deploy your indexer
  3. Get a production GraphQL endpoint for your application

For detailed deployment instructions, see the Envio Cloud documentation.

What You've Learned

By completing this tutorial, you've learned:

  • How to initialize an Envio project from a template
  • How indexers process data from multiple blockchains
  • How to query indexed data using GraphQL
  • The basic structure of an Envio indexing project

Next Steps

Now that you've mastered the basics, you can:

  • Try the Contract Import feature to index any deployed contract
  • Customize the event handlers to implement more complex indexing logic
  • Add relationships between entities in your schema
  • Explore Preload Optimization for faster handlers
  • Create aggregated statistics from your indexed data

For more tutorials and examples, visit the Envio Documentation or join our Discord community for support.


Getting Price Data in Your Indexer

File: Tutorials/price-data.md

Introduction

Many blockchain applications require price data to calculate values such as:

  • Historical token transfer values in USD
  • Total value locked (TVL) in DeFi protocols over time
  • Portfolio valuations at specific points in time

This tutorial explores three different approaches to incorporating price data into your Envio indexer, using a real-world example of tracking ETH deposits into a Uniswap V3 liquidity pool on the Blast blockchain.

TL;DR: The complete code for this tutorial is available in this GitHub repository.

What You'll Learn

In this tutorial, you'll:

  • Compare three different methods for accessing token price data
  • Analyze the tradeoffs between accuracy, decentralization, and performance
  • Implement a multi-source price feed in an Envio indexer
  • Build a practical example indexing Uniswap V3 liquidity events with price context

Price Data Methods Compared

There are three primary methods to access price data within your indexer:

MethodDescriptionSpeedAccuracyDecentralization
OraclesOn-chain price feeds (e.g., API3, Chainlink)FastMediumMedium
DEX PoolsSwap events from decentralized exchangesFastMedium-HighHigh
Off-chain APIsExternal services (e.g., CoinGecko)SlowHighLow

Let's explore each method in detail.

Method 1: Using Oracle Price Feeds

Oracle networks provide on-chain price data through specialized smart contracts. For this tutorial, we'll use API3 price feeds on Blast.

How Oracles Work

Oracle services like API3 maintain a network of data providers that push price updates to on-chain contracts. These updates typically occur:

  • At regular time intervals
  • When price deviations exceed a predefined threshold (e.g., 1%)
  • When manually triggered by network participants

Finding the Right Oracle Feed

To locate the ETH/USD price feed using API3 on Blast:

  1. Identify the API3 contract address: 0x709944a48cAf83535e43471680fDA4905FB3920a

  2. Find the data feed ID for ETH/USD:

    • The dAPI name "ETH/USD" as bytes32: 0x4554482f55534400000000000000000000000000000000000000000000000000
    • Using the dapiNameToDataFeedId function, this maps to 0x3efb3990846102448c3ee2e47d22f1e5433cd45fa56901abe7ab3ffa054f70b5
  3. Monitor the UpdatedBeaconSetWithBeacons events with this data feed ID to get price updates

Oracle Advantages and Limitations

Advantages:

  • Fast indexing (no external API calls required)
  • Moderate decentralization
  • Generally reliable data

Limitations:

  • Updates only on significant price changes
  • Limited token coverage (mainly high-liquidity pairs)
  • Minor accuracy tradeoffs

Method 2: Using DEX Pool Swap Events

Decentralized exchanges like Uniswap provide price data through swap events. We'll use the USDB/WETH pool on Blast to derive ETH pricing.

Locating the Right DEX Pool

First, we need to find the specific Uniswap V3 pool for USDB/WETH:



const usdb = "0x4300000000000000000000000000000000000003";
const weth = "0x4300000000000000000000000000000000000004";
const factoryAddress = "0x792edAdE80af5fC680d96a2eD80A44247D2Cf6Fd";
const factoryAbi = parseAbi([
"function getPool( address tokenA, address tokenB, uint24 fee ) external view returns (address pool)",
]);

const providerUrl = "https://rpc.ankr.com/blast";
const poolBips = 3000; // 0.3%. This is measured in hundredths of a bip

const client = createPublicClient({
chain: blast,
transport: http(providerUrl),
});

const factoryContract = getContract({
abi: factoryAbi,
address: factoryAddress,
client: client,
});

(async () => {
const poolAddress = await factoryContract.read.getPool([
usdb,
weth,
poolBips,
]);
console.log(poolAddress);
})();

Tip: You can also manually find the pool address using the getPool function on a block explorer.

Running this code reveals the USDB/WETH pool is at 0xf52B4b69123CbcF07798AE8265642793b2E8990C.

Getting Price Data From Swap Events

Uniswap V3 emits Swap events containing price information in the sqrtPriceX96 field. To convert this to a price, we'll use a formula in our event handler.

DEX Advantages and Limitations

Advantages:

  • Very decentralized
  • High update frequency
  • Wide token coverage

Limitations:

  • Susceptible to price impact and manipulation (especially in low-liquidity pools)
  • Requires extra calculations to derive prices
  • May require multiple pools for cross-pair calculations

Method 3: Using Off-chain APIs

External price APIs like CoinGecko provide comprehensive token price data but require HTTP calls from your indexer.

Making API Requests

Here's a simple function to fetch historical ETH prices from CoinGecko:

const COIN_GECKO_API_KEY = process.env.COIN_GECKO_API_KEY;

async function fetchEthPriceFromUnix(
unix: number,
token = "ethereum"
): Promise<number> {
// convert unix to date dd-mm-yyyy
const _date = new Date(unix * 1000);
const date = _date.toISOString().slice(0, 10).split("-").reverse().join("-");
return fetchEthPrice(date.slice(0, 10), token);
}

async function fetchEthPrice(
date: string,
token = "ethereum"
): Promise<number> {
const options = {
method: "GET",
headers: {
accept: "application/json",
"x-cg-demo-api-key": COIN_GECKO_API_KEY,
},
};

return fetch(
`https://api.coingecko.com/api/v3/coins/${token}/history?date=${date}&localization=false`,
options as any
)
.then((res) => res.json())
.then((res: any) => {
const usdPrice = res.market_data.current_price.usd;
console.log(`ETH price on ${date}: ${usdPrice}`);
return usdPrice;
})
.catch((err) => console.error(err));
}

export default fetchEthPriceFromUnix;

Note: The free CoinGecko API only provides daily price data (at 00:00 UTC), not block-by-block precision. For production use, consider a paid API with more granular historical data.

Off-chain API Advantages and Limitations

Advantages:

  • Highest accuracy (with paid APIs)
  • Most comprehensive token coverage
  • No susceptibility to on-chain manipulation

Limitations:

  • Significantly slows indexing speed due to API calls
  • Centralized data source
  • May require paid subscriptions for full functionality

Building a Multi-Source Price Feed Indexer

Now let's build an indexer that compares all three methods when tracking Uniswap V3 liquidity pool deposits.

Step 1: Initialize Your Indexer

Create a new Envio indexer project:

pnpx envio init

Step 2: Configure Your Indexer

Edit your config.yaml file to track both the API3 oracle and the Uniswap V3 pool:

# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: envio-indexer
chains:
- id: 81457
start_block: 11000000
contracts:
- name: Api3ServerV1
address:
- "0x709944a48cAf83535e43471680fDA4905FB3920a"
events:
- event: UpdatedBeaconSetWithBeacons(bytes32 indexed beaconSetId, int224 value, uint32 timestamp)
- name: UniswapV3Pool
address:
- "0xf52B4b69123CbcF07798AE8265642793b2E8990C"
events:
- event: Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)
- event: Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1)
field_selection:
transaction_fields:
- "hash"

Important: The field_selection section is needed to include transaction hashes in your indexed data.

Step 3: Define Your Schema

Create a schema that captures price data from all three sources:

type OraclePoolPrice {
id: ID!
value: BigInt!
timestamp: BigInt!
block: Int!
}

type UniswapV3PoolPrice {
id: ID!
sqrtPriceX96: BigInt!
timestamp: Int!
block: Int!
}

type EthDeposited {
id: ID!
timestamp: Int!
block: Int!
oraclePrice: Float!
poolPrice: Float!
offChainPrice: Float!
offchainOracleDiff: Float!
depositedPool: Float!
depositedOffchain: Float!
depositedOracle: Float!
txHash: String!
}

Step 4: Implement Event Handlers

Create event handlers to process data from all three sources:



let latestOraclePrice = 0;
let latestPoolPrice = 0;

indexer.onEvent(
{ contract: "Api3ServerV1", event: "UpdatedBeaconSetWithBeacons" },
async ({ event, context }) => {
// Filter out the beacon set for the ETH/USD price
if (
event.params.beaconSetId !=
"0x3efb3990846102448c3ee2e47d22f1e5433cd45fa56901abe7ab3ffa054f70b5"
) {
return;
}

const entity: Entity<"OraclePoolPrice"> = {
id: `${event.chainId}-${event.block.number}-${event.logIndex}`,
value: event.params.value,
timestamp: event.params.timestamp,
block: event.block.number,
};

latestOraclePrice = Number(event.params.value) / Number(10 ** 18);

context.OraclePoolPrice.set(entity);
},
);

indexer.onEvent(
{ contract: "UniswapV3Pool", event: "Swap" },
async ({ event, context }) => {
const entity: Entity<"UniswapV3PoolPrice"> = {
id: `${event.chainId}-${event.block.number}-${event.logIndex}`,
sqrtPriceX96: event.params.sqrtPriceX96,
timestamp: event.block.timestamp,
block: event.block.number,
};

latestPoolPrice = Number(
(2n ** 192n) /
(BigInt(event.params.sqrtPriceX96) * BigInt(event.params.sqrtPriceX96))
);

context.UniswapV3PoolPrice.set(entity);
},
);

indexer.onEvent(
{ contract: "UniswapV3Pool", event: "Mint" },
async ({ event, context }) => {
const offChainPrice = await fetchEthPriceFromUnix(event.block.timestamp);

const ethDepositedUsdPool =
(latestPoolPrice * Number(event.params.amount1)) / 10 ** 18;
const ethDepositedUsdOffchain =
(offChainPrice * Number(event.params.amount1)) / 10 ** 18;
const ethDepositedUsdOracle =
(latestOraclePrice * Number(event.params.amount1)) / 10 ** 18;

const ethDeposited: Entity<"EthDeposited"> = {
id: `${event.chainId}-${event.block.number}-${event.logIndex}`,
timestamp: event.block.timestamp,
block: event.block.number,
oraclePrice: round(latestOraclePrice),
poolPrice: round(latestPoolPrice),
offChainPrice: round(offChainPrice),
depositedPool: round(ethDepositedUsdPool),
depositedOffchain: round(ethDepositedUsdOffchain),
depositedOracle: round(ethDepositedUsdOracle),
offchainOracleDiff: round(
((ethDepositedUsdOffchain - ethDepositedUsdOracle) /
ethDepositedUsdOffchain) *
100
),
txHash: event.transaction.hash,
};

context.EthDeposited.set(ethDeposited);
},
);

function round(value: number) {
return Math.round(value * 100) / 100;
}

Step 5: Run Your Indexer

Start your indexer with:

pnpm dev

This will begin indexing data from block 11,000,000 on Blast.

Step 6: Analyze the Results

After running your indexer, you can query the data in Hasura to compare the three price data sources:

query ComparePrices {
EthDeposited(order_by: { block: desc }, limit: 10) {
block
timestamp
oraclePrice
poolPrice
offChainPrice
depositedPool
depositedOffchain
depositedOracle
offchainOracleDiff
txHash
}
}

Results Analysis

When comparing our three price data sources, we found:

Looking at the offchainOracleDiff column, we can see that oracle and off-chain prices typically align closely but can deviate by as much as 17.98% in some cases.

For the highlighted transaction (0xe7e79ddf29ed2f0ea8cb5bb4ffdab1ea23d0a3a0a57cacfa875f0d15768ba37d), we can compare our calculated values:

  • Actual value (from block explorer): $2,358.27
  • DEX pool value (depositedPool): $2,117.07
  • Off-chain API value (depositedOffchain): $2,156.15

This demonstrates that even the most accurate methods have limitations.

Conclusion: Choosing the Right Method

Based on our analysis, here are some recommendations for choosing a price data method:

Use Oracle or DEX Pools when:

  • Indexing speed is critical
  • Absolute precision isn't required
  • You're working with high-liquidity tokens

Use Off-chain APIs when:

  • Price accuracy is paramount
  • Indexing speed is less important
  • You can implement effective caching

For maximum accuracy while maintaining performance:

  • Combine multiple methods and aggregate results
  • Use high-volume DEX pools on major chains
  • Cache API results to avoid redundant calls

Next Steps

To further enhance your price data indexing:

  1. Implement caching for off-chain API calls
  2. Cross-reference multiple DEX pools for better accuracy
  3. Consider time-weighted average prices (TWAP) instead of spot prices
  4. Use multichain indexing to access higher-liquidity pools on major chains

By carefully choosing and implementing the right price data strategy, you can build robust indexers that provide accurate financial data for your blockchain applications.


Scaffold-Eth-2 Envio Extension

File: Tutorials/tutorial-scaffold-eth-2.md

Introduction

The Scaffold-ETH 2 Envio extension makes indexing your deployed smart contracts as simple as possible. Generate a boilerplate indexer for your deployed contracts with a single click and start indexing their events immediately.

With this extension, you get:

  • 🔍 Automatic indexer generation from your deployed contracts
  • 📊 Status dashboard with links to Envio metrics and database
  • 🔄 One-click regeneration to update the indexer when you deploy new contracts
  • 📈 GraphQL API for querying your indexed blockchain data

Prerequisites

Before starting, ensure you have the following installed:

Step 1: Create a New Scaffold-ETH 2 Project with Envio Extension

To create a new Scaffold-ETH 2 project with the Envio extension already integrated:

npx create-eth@latest -e enviodev/scaffold-eth-2-extension

Step 2: Start the Local Blockchain

Navigate to your project directory and start the local blockchain:

cd your-project-name
yarn chain

This will start a local blockchain node for development.

Step 3: Deploy Your Contracts

In a new terminal window, navigate to your project directory and deploy the default smart contracts:

cd your-project-name
yarn deploy

This will deploy the default contracts to the local blockchain. This step is optional and can also be done once you've created your own smart contracts and deployed them using yarn deploy.

Step 4: Start Scaffold-ETH Frontend

From your project directory, start the Scaffold-ETH frontend:

yarn start

This will start the Scaffold-ETH frontend at http://localhost:3000.

Step 5: Generate the Indexer

Navigate to the Envio page in your Scaffold-ETH frontend at http://localhost:3000/envio and click the "Generate" button. This should only be done once you've created a smart contract and ran yarn deploy. This will create the boilerplate indexer from your deployed contracts.

The Envio page also includes a helpful "How to Use" section with step-by-step instructions.

Step 6: Start the Indexer

Navigate to the Envio package directory and start the indexer:

cd packages/envio
pnpm dev

This will begin indexing your contract events.

Regenerating the Indexer

When you deploy new contracts or make changes to existing ones, you'll need to regenerate the indexer:

Via Frontend Dashboard

  1. Go to the Envio page at http://localhost:3000/envio
  2. Click "Generate" to regenerate the boilerplate indexer

Via Command Line

cd packages/envio
pnpm update
pnpm codegen

Note: Regenerating will overwrite any custom handlers, config, and schema changes, creating a fresh boilerplate indexer based on your deployed contracts. After regenerating, you'll need to stop the running indexer (Ctrl+C) and restart it with pnpm dev for the changes to take effect.


Dynamic Contracts

File: Advanced/dynamic-contracts.md

Introduction

Many blockchain systems use factory patterns where new contracts are created dynamically. Common examples include:

  • DEXes like Uniswap where each trading pair creates a new contract
  • NFT platforms that deploy new collection contracts
  • Lending protocols that create new markets as isolated contracts

When indexing these systems, you need a way to discover and track these dynamically created contracts. Envio provides powerful tools to handle this use case.

Contract Registration Handler

Instead of a template based approach, we've introduced a contractRegister handler that can be added to any event.

This allows you to easily:

  • Register contracts from any event handler.
  • Use conditions and any logic you want to register contracts.
  • Have nested factories which are registered by other factories.

indexer.contractRegister(
{ contract: "<contract-name>", event: "<event-name>" },
({ event, context }) => {
context.chain.<your-contract-name>.add(<address-of-the-contract>);
},
);

Example: NFT Factory Pattern

Let's look at a complete example using an NFT factory pattern.

Scenario

  • NftFactory contract creates new SimpleNft contracts
  • We want to index events from all NFTs created by this factory
  • Each time a new NFT is created, the factory emits a SimpleNftCreated event

1. Configure Your Contracts in config.yaml

name: nftindexer
description: NFT Factory
chains:
- id: 1337
start_block: 0
contracts:
- name: NftFactory
abi_file_path: abis/NftFactory.json
address: "0x4675a6B115329294e0518A2B7cC12B70987895C4" # Factory address is known
events:
- event: SimpleNftCreated (string name, string symbol, uint256 maxSupply, address contractAddress)

- name: SimpleNft
abi_file_path: abis/SimpleNft.json
# No address field - we'll discover these addresses from events
events:
- event: Transfer (address from, address to, uint256 tokenId)

Note that:

  • The NftFactory contract has a known address specified in the config
  • The SimpleNft contract has no address, as we'll register instances dynamically

2. Create the Contract Registration Handler

In your src/handlers/<ContractName>.ts file:


// Register SimpleNft contracts whenever they're created by the factory
indexer.contractRegister(
{ contract: "NftFactory", event: "SimpleNftCreated" },
({ event, context }) => {
// Register the new NFT contract using its address from the event
context.chain.SimpleNft.add(event.params.contractAddress);

context.log.info(
`Registered new SimpleNft at ${event.params.contractAddress}`
);
},
);

// Handle Transfer events from all SimpleNft contracts
indexer.onEvent(
{ contract: "SimpleNft", event: "Transfer" },
async ({ event, context }) => {
// Your event handling logic here
context.log.info(
`NFT Transfer at ${event.srcAddress} - Token ID: ${event.params.tokenId}`
);

// Example: Store transfer information in the database
// ...
},
);

Async Contract Register

As of version 2.21, you can use async contract registration.

This is a unique feature of Envio that allows you to perform an external call to determine the address of the contract to register.


indexer.contractRegister(
{ contract: "NftFactory", event: "SimpleNftCreated" },
async ({ event, context }) => {
const version = await getContractVersion(event.params.contractAddress);
if (version === "v2") {
context.chain.SimpleNftV2.add(event.params.contractAddress);
} else {
context.chain.SimpleNft.add(event.params.contractAddress);
}
},
);

Coming from TheGraph?

If you're migrating from a subgraph that uses data source templates (DataSource.create()), the equivalent in Envio is the contractRegister handler.

TheGraphEnvio (HyperIndex)
Define a template in subgraph.yamlDefine the contract in config.yaml without an address
Call MyTemplate.create(address) in a mappingCall context.chain.MyContract.add(address) in a contractRegister handler
Templates are triggered from other mappingscontractRegister runs before the event handler, on any event

The key difference is that Envio's contractRegister is more flexible - you can add conditional logic, perform async calls, and register contracts from any event, not just from a dedicated factory mapping.

For a step-by-step migration guide, see Migrating from a Subgraph.

When to Use Dynamic Contract Registration

Use dynamic contract registration when:

  • Your system includes factory contracts that deploy new contracts over time
  • You want to index events from all instances of a particular contract type
  • The addresses of these contracts aren't known at the time you create your indexer

Important Notes

  • Block Coverage: When a dynamic contract is registered, Envio will index all events from that contract in the same block where it was created, even if those events happened in transactions before the registration event. This is particularly useful for contracts that emit events during their construction.

  • Triggering Event: Any event can trigger a registration - it doesn't have to be the one that created the contract. You might register a token when you see it added to a registry, or when it's first used, rather than at deployment.

Scaling to Very Large Factories

There is no practical ceiling on how many addresses a factory can register. Before v3.5, HyperIndex started to struggle at around 8 million addresses. That limit is gone - indexers with billions of registered addresses are supported.

You don't need to configure anything for this. Once a contract accumulates enough addresses, HyperIndex automatically changes how it filters events for that contract: instead of asking the data source for a specific address list, it fetches events more broadly and filters them locally. The switch is per contract, so one large factory doesn't affect how your other contracts are indexed.

The trade-off is that a very large factory fetches more data than its handlers ultimately use, since the filtering happens after the fetch rather than before it. That's what keeps indexing fast at this scale.

To see how many addresses a chain has registered, watch envio_indexing_addresses on the metrics endpoint.

Debugging Tips

  • Use logging in your contractRegister function to confirm contracts are being registered.
  • If you're not seeing events from your dynamic contracts, verify they're being properly registered in database.

For more information on writing event handlers, see the Event Handlers Guide.


Wildcard Indexing

File: Advanced/wildcard-indexing.mdx

Wildcard indexing is a feature that allows you to index all events matching a specified event signature without requiring the contract address from which the event was emitted. This is useful in cases such as indexing contracts deployed through factories, where the factory contract does not emit any events upon contract creation. It also enables indexing events from all contracts implementing a standard (e.g. all ERC20 transfers).

note

Wildcard Indexing is supported on HyperSync, HyperFuel, and RPC data sources. Since v3.3, an RPC-backed indexer can register multiple wildcard events - earlier versions allowed only one.

Index all ERC20 transfers

As an example, let's say we want to index all ERC20 Transfer events. Start with a config.yaml file:

name: transefer-indexer
chains:
- id: 1
start_block: 0
contracts:
- name: ERC20
events:
- event: Transfer(address indexed from, address indexed to, uint256 value)

Let's also define some entities in schema.graphql file, so our handlers can store the processed data:

type Transfer {
id: ID!
from: String!
to: String!
}

And the last bit is to register an event handler in the src/handlers. Note how we pass the wildcard: true option to enable wildcard indexing:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer", wildcard: true },
async ({ event, context }) => {
context.Transfer.set({
id: `${event.chainId}_${event.block.number}_${event.logIndex}`,
from: event.params.from,
to: event.params.to,
});
},
);

After running your indexer with pnpm dev you will have all ERC20 Transfer events indexed, regardless of the contract address from which the event was emitted.

Topic Filtering

Indexing all ERC20 Transfer events can be noisy. Use Topic Filtering to keep only the events you need.

When registering an event handler or a contract registration handler, provide the where option (formerly eventFilters in V2). Filter by each indexed event parameter by returning { params: [...] }.

Let's say you only want to index Mint events where the from address is equal to ZERO_ADDRESS:


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: () => ({ params: [{ from: ZERO_ADDRESS }] }),
},
async ({ event, context }) => {
//... your handler logic
},
);

Multiple Filters

If you want to index both Mint and Burn events you can provide multiple filters as an array, and an event is indexed if it matches any one of them. Within a single filter, all parameters must match. Also, every parameter can accept an array to filter by multiple possible values. We'll use it to filter by a group of whitelisted addresses in the example below:


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

const WHITELISTED_ADDRESSES = [
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
];

indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: () => ({
params: [
{ from: ZERO_ADDRESS, to: WHITELISTED_ADDRESSES },
{ from: WHITELISTED_ADDRESSES, to: ZERO_ADDRESS },
],
}),
},
async ({ event, context }) => {
//... your handler logic
},
);
note

On RPC data sources, passing an array requires v3.3 or later. HyperSync supports it on all V3 versions.

Different Filters per Chain

For Multichain Indexers the where callback receives { chain } and you can read chain.id to filter by different values per chain:


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

const WHITELISTED_ADDRESSES = {
1: ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"],
137: [
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
],
};

indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [
{ from: ZERO_ADDRESS, to: WHITELISTED_ADDRESSES[chain.id] },
{ from: WHITELISTED_ADDRESSES[chain.id], to: ZERO_ADDRESS },
],
}),
},
async ({ event, context }) => {
//... your handler logic
},
);

Index all ERC20 transfers to your Contract

Besides chain.id you can also read the contract's configured (and dynamically registered) addresses from chain.<ContractName>.addresses.

For example, if you have a Safe contract, you can index all ERC20 transfers sent specifically to/from your Safe contracts. The where callback can read chain.Safe.addresses, so we need to define the Transfer event on the Safe contract:

name: locker
chains:
- id: 1
start_block: 0
contracts:
- name: Safe
events:
- event: Transfer(address indexed from, address indexed to, uint256 value)
address:
- "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
- "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
- "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"

indexer.onEvent(
{
contract: "Safe",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [
{ from: chain.Safe.addresses },
{ to: chain.Safe.addresses },
],
}),
},
async ({ event, context }) => {},
);

This example is not much different from using a WHITELISTED_ADDRESSES constant, but this becomes much more powerful when the Safe contract addresses are registered dynamically by a factory contract:

name: locker
chains:
- id: 1
start_block: 0
contracts:
- name: SafeRegistry
events:
- event: NewSafe(address safe)
address:
- "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
- name: Safe
events:
- event: Transfer(address indexed from, address indexed to, uint256 value)

indexer.contractRegister(
{ contract: "SafeRegistry", event: "NewSafe" },
async ({ event, context }) => {
context.chain.Safe.add(event.params.safe);
},
);

indexer.onEvent(
{
contract: "Safe",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [
{ from: chain.Safe.addresses },
{ to: chain.Safe.addresses },
],
}),
},
async ({ event, context }) => {},
);

Assert ERC20 Transfers in Handler

After you got all ERC20 Transfers relevant to your contracts, you can additionally filter them in the handler. For example, to get only USDC transfers:


const USDC_ADDRESS = {
84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
11155111: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
};

indexer.onEvent(
{
contract: "Safe",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [
{ from: chain.Safe.addresses },
{ to: chain.Safe.addresses },
],
}),
},
async ({ event, context }) => {
// Filter and store only the USDC transfers that involve a Safe address
if (event.srcAddress === USDC_ADDRESS[event.chainId]) {
context.Transfer.set({
id: `${event.chainId}_${event.block.number}_${event.logIndex}`,
from: event.params.from,
to: event.params.to,
});
}
},
);

Contract Register Example

The same where option can be applied to indexer.contractRegister. Here is an example where we only register Uniswap pools that contain DAI token:


const DAI_ADDRESS = "0x6B175474E89094C44Da98b954EedeAC495271d0F";

indexer.contractRegister(
{
contract: "UniV3Factory",
event: "PoolCreated",
where: () => ({
params: [{ token0: DAI_ADDRESS }, { token1: DAI_ADDRESS }],
}),
},
async ({ event, context }) => {
const poolAddress = event.params.pool;
context.chain.UniV3Pool.add(poolAddress);
},
);
note

Wildcard registrations are matched by event signature. If the same signature is declared on more than one contract in your config.yaml, set wildcard: true on just one of them.


Preload Optimization

File: Advanced/preload-optimization.md

Important! Preload optimization makes your handlers run twice.

In HyperIndex V3, preload optimization is always on - there is no flag to enable or disable it.

This optimization enables HyperIndex to efficiently preload entities used by handlers through batched database queries, while ensuring events are processed synchronously in their original order. When combined with the Effect API for external calls, this feature delivers performance improvements of multiple orders of magnitude compared to other indexing solutions.

Configure

Nothing to configure. Previously, V2 required the preload_handlers: true flag in config.yaml. In V3 the flag has been removed and the optimization is always active. If your project still has preload_handlers: in config.yaml, delete it - V3 will reject the field.

Why Preload?

To ensure reliable data, HyperIndex guarantees that all events will be processed in the same order as they occurred on-chain.

This guarantee is crucial as it allows you to build indexers that depend on the sequential order of events.

However, this leads to a challenge: Handlers must run one at a time, sequentially for each event. Any asynchronous operations will block the entire process.

To solve this, we introduced Preload Optimization.

It combines in-memory storage, batching, deduplication, and the Effect API to parallelize asynchronous operations across batches of events.

How It Works?

With Preload Optimization handlers run twice per event:

  1. First Run (Preload Phase): All event handlers run concurrently for the whole batch of events. During the phase all DB write operations are skipped and only DB read operations and external calls are performed.
  2. Second Run (Processing Phase): Each event handler runs sequentially in the on-chain order. During the phase it'll get the data from the in-memory store, reflecting changes made by previously processed events.

This double execution pattern ensures that entities created by earlier events in the batch are available to later events.

The Database I/O Problem

Consider this common pattern of getting entities in event handlers:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);
// Process the transfer...
},
);

Without Preload Optimization: If you're processing 5,000 transfer events, each with unique from and to addresses, this results in 10,000 total database roundtrips-one for each sender and receiver lookup (2 per event × 5,000 events). This creates a significant bottleneck that slows down your entire indexing process.

With Preload Optimization: During the Preload Phase, all 5,000 events are processed in parallel. HyperIndex batches database reads that occur simultaneously into single database queries - one query for sender lookups and one for receiver lookups. The loaded accounts are cached in memory. After the Preload Phase completes, the second processing phase begins. This phase runs handlers sequentially in on-chain order, but instead of making database calls, it retrieves the data from the in-memory cache.

For our example of 5,000 transfer events, this optimization reduces database roundtrips from 10,000 calls to just 2!

Optimizing for Concurrency

You can further optimize performance by requesting multiple entities concurrently:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Request sender and receiver concurrently for maximum efficiency
const [sender, receiver] = await Promise.all([
context.Account.get(event.params.from),
context.Account.get(event.params.to),
]);
// Process the transfer...
},
);

This approach can reduce the database roundtrips to just 1 for the entire batch of events!

The External Calls Problem

Let's say you want to populate your indexer with offchain data:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Without Preload: Blocking external calls
const metadata = await fetch(
`https://api.example.com/metadata/${event.params.from}`
);

// Process the transfer...
},
);

Without Preload Optimization: If you're processing 5,000 transfer events, each with an external call, this results in 5,000 sequential external calls-each waiting for the previous one to complete. This can turn a fast indexing process into a slow, sequential crawl.

With Preload Optimization: Since handlers run twice for each event, making direct external calls can be problematic. The Effect API provides a solution. During the Preload Phase, it batches all external calls and runs them in parallel. Then during the Processing Phase, it runs the handlers sequentially, retrieving the already requested data from the in-memory store.


const fetchMetadata = createEffect(
{
name: "fetchMetadata",
input: {
from: S.string,
},
output: {
decimals: S.number,
symbol: S.string,
},
rateLimit: {
calls: 5,
per: "second",
},
},
async ({ input }) => {
const metadata = await fetch(
`https://api.example.com/metadata/${input.from}`
);
return metadata;
}
);

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// With Preload: Performs the call in parallel
const metadata = await context.effect(fetchMetadata, {
from: event.params.from,
});

// Process the transfer...
},
);

Assuming an average call takes 200ms, this optimization reduces the total processing time for 5,000 events from ~16 minutes to ~200 milliseconds - making it 5,000 times faster!

Learn more about the Effect API in our dedicated guide.

Preload Phase Behavior

The Preload Phase is a special phase that runs before the actual event processing. It's designed to preload data that will be used during event processing.

Key characteristics of the Preload Phase:

  • It runs in parallel for all events in the batch
  • Exceptions won't crash the indexer but will silently abort the Preload Phase for that specific event
  • All storage updates are ignored
  • All context.log calls are ignored

During the second run (Processing Phase), all operations become fully enabled:

  • Exceptions will crash the indexer if not handled
  • Entity setting operations will persist to the database
  • Logging will output to the console

This two-phase design allows the Preload Phase to optimistically attempt loading data that may not exist yet, while ensuring data consistency during the Processing Phase when all operations are executed normally.

If you're using an earlier version of envio, we strongly recommend upgrading to the latest version using pnpm install envio@latest to benefit from this improved Preload Phase behavior.

Double-Run Footgun

As mentioned above, the Preload Phase gives a lot of benefits for the event processing, but also it means that you must be aware of its table run nature:

  • Never call fetch or other external calls directly in the handler.
    • Use the Effect API instead.
    • Or use context.isPreload to guarantee that the code will run once.

Due to the optimistic nature of the Preload Phase, the Effect API may occasionally execute with stale data, leading to redundant external calls. If you need to ensure that external calls are made with the most up-to-date data, you can use the context.isPreload check to restrict execution to only the processing phase.

Note: This will disable the Preload Optimization for the external calls.


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
const sender = await context.Account.get(event.params.from);

if (context.isPreload) {
return;
}

const metadata = await fetch(
`https://api.example.com/metadata/${sender.metadataId}`
);
},
);

Best Practices

  • Use Promise.all to load multiple entities concurrently for better performance
  • Place database reads and external calls at the beginning of your handler to maximize the benefits of Preload Optimization
  • Consider using context.isPreload to exit early from the Preload Phase after loading required data

Migrating from Loaders

The Preload Optimization for handlers was born from a concept we had before called Loaders. The handlerWithLoader API has been removed in V3 - move the loader code into the handler and rely on the always-on Preload Phase.

// V2 — removed in V3
ERC20.Transfer.handlerWithLoader({
loader: async ({ event, context }) => {
// Load sender and receiver accounts efficiently
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);

// Return the loaded data to the handler
return {
sender,
receiver,
};
},

handler: async ({ event, context, loaderReturn }) => {
const { sender, receiver } = loaderReturn;

// Process the transfer with the pre-loaded data
// No database lookups needed here!
},
});

// V3

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Load sender and receiver accounts efficiently
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);

// To imitate the behavior of the loader,
// we can use `context.isPreload` to make next code run only once.
// Note: This is not required, but might be useful for CPU-intensive operations.
if (context.isPreload) {
return;
}

// Process the transfer with the pre-loaded data
},
);

Effect Api

File: Advanced/effect-api.md

The Effect API provides a powerful and convenient way to perform external calls from your handlers. It's especially effective when used with Preload Optimization:

  • Automatic batching: Calls of the same kind are automatically batched together
  • Intelligent memoization: Calls are memoized, so you don't need to worry about the handler function being called multiple times
  • Deduplication: Calls with the same arguments are deduplicated to prevent overfetching
  • Persistence: Built-in support for result persistence for indexer reruns (opt-in via cache: true)
  • Future enhancements: We're working on automatic retry logic and enhanced caching workflows 🏗️

To use the Effect API, you first need to define an effect using createEffect function from the envio package:


export const getMetadata = createEffect(
{
name: "getMetadata",
input: S.string,
output: {
description: S.string,
value: S.bigint,
},
rateLimit: {
calls: 5,
per: "second",
},
cache: true,
},
async ({ input, context }) => {
const response = await fetch(`https://api.example.com/metadata/${input}`);
const data = await response.json();
context.log.info(`Fetched metadata for ${input}`);
return {
description: data.description,
value: data.value,
};
}
);

The first argument is an options object that describes the effect:

  • name (required) - the name of the effect used for debugging and logging
  • input (required) - the input type of the effect
  • output (required) - the output type of the effect
  • rateLimit (required) - the maximum calls allowed per timeframe, or false to disable
  • cache (optional) - save effect results in the database to prevent duplicate calls
  • crossChain (optional) - whether the cache and rate limit are shared across all chains (default: true, or false when the indexer sets disable_default_cross_chain). See Per-Chain Effects

The second argument is a function that will be called with the effect's input.

Note: For type definitions, you should use S from the envio package, which uses Sury library under the hood.

After defining an effect, you can use context.effect to call it from your handler or another effect.

The context.effect function accepts an effect as the first argument and the effect's input as the second argument:


indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
const metadata = await context.effect(getMetadata, event.params.from);
// Process the event with the metadata
},
);

Reading On-Chain State (eth_call)

The Effect API is how you perform eth_call-style reads from your handlers - for example, reading a token balance, fetching a contract's name, or querying any view function at a specific block.

Viem Transport Batching

You can use viem or any other blockchain client inside your effect functions. When doing so, it's highly recommended to enable the batch option to group all effect calls into fewer RPC requests:

// Create a public client to interact with the blockchain
const client = createPublicClient({
chain: mainnet,
// Enable batching to group calls into fewer RPC requests
transport: http(rpcUrl, { batch: true }),
});

// Get the contract instance for your contract
const lbtcContract = getContract({
abi: erc20Abi,
address: "0x8236a87084f8B84306f72007F36F2618A5634494",
client: client,
});

// Effect to get the balance of a specific address at a specific block
export const getBalance = createEffect(
{
name: "getBalance",
input: {
address: S.string,
blockNumber: S.optional(S.bigint),
},
output: S.bigint,
rateLimit: {
calls: 5,
per: "second",
},
cache: true,
},
async ({ input, context }) => {
try {
// If blockNumber is provided, use it to get balance at that specific block
const options = input.blockNumber
? { blockNumber: input.blockNumber }
: undefined;
const balance = await lbtcContract.read.balanceOf(
[input.address as `0x${string}`],
options
);

return balance;
} catch (error) {
context.log.error(`Error getting balance for ${input.address}: ${error}`);
// Return 0 on error to prevent processing failures
return BigInt(0);
}
}
);

Persistence

By default, effect results are not persisted in the database. This means if the effect with the same input is called again, the function will be executed the second time.

To persist effect results, you can set the cache option to true when creating the effect. This will save the effect results in the database and reuse them in future indexer runs. You can also override caching for a specific call by setting context.cache = false, which prevents storing results for that execution, especially useful when handling failed responses.

Example setting cache to false with context.cache:

export const getBalance = createEffect(
{
// effect options
cache: true,
},
async ({ input, context }) => {
try {
// your effect logic
} catch (_) {
// Don't cache failed response
context.cache = false;
return undefined;
}
}
);

Every effect cache creates a new table in the database envio_effect_${effectName}. You can see it and query in Hasura console with admin secret.

Also, use our Development Console to track the cache size and see number of calls which didn't hit the cache.

Reuse Effect Cache on Indexer Reruns

To prevent invalid data we don't keep the effect cache on indexer reruns. But you can explicitly configure cache, which should be preloaded when the indexer is rerun.

Open Development Console of the running indexer which accumulated the cache. You'll be able to see the Sync Cache button right at the Effects section. Clicking the button will load the cache from the indexer database to the .envio/cache directory in your indexer project.

When the indexer is rerun by using envio dev or envio start -r call, the initial cache will be loaded from the .envio/cache directory and used for the indexer run.

Note: This doesn't support rollbacks on reorgs. The support for reorgs will be added in the future.

Cache on Envio Cloud

Envio Cloud provides built-in cache management for Effect API results, allowing you to save and restore caches directly from the dashboard without committing files to your repository.

Key Features:

  • Save Cache: Capture effect data from any deployment with one click via Quick Actions
  • Cache Settings: Manage caches in Settings > Cache - enable/disable caching and select which cache to use
  • Automatic Restore: New deployments automatically preload effect data from your selected cache

This eliminates the need to commit .envio/cache to your repository and removes file size limitations.

For detailed instructions, see the Effect API Cache documentation.

Rate Limit

The rateLimit option controls how frequently an effect can run within a given timeframe. You can set it to false to disable rate limiting or define a custom limit such as calls per second, minute, or a duration in milliseconds.

// Effect to get the balance of a specific address at a specific block
export const getBalance = createEffect(
{
name: "getBalance",
input: {
address: S.string,
blockNumber: S.optional(S.bigint),
},
output: S.bigint,
// rateLimit: false, // you can set rateLimit to false if needed
rateLimit: {
calls: 5,
per: "second", // also supports "minute" or a duration in milliseconds
},
cache: true,
},
async ({ input, context }) => {
// your effect logic
}
);

Watch the following video to learn more about createEffect and other updates introduced in v2.32.0.

Per-Chain Effects

By default an effect is cross-chain: one cache and one rate-limit budget are shared by every chain. That's correct when the input fully identifies the result - an IPFS hash means the same thing whichever chain asked for it.

It's wrong when the same input means different things per chain. A token address on Ethereum and the same address on Base are different contracts, so a shared cache would serve one chain's result to the other.

Since v3.3, set crossChain: false to scope an effect to the chain that called it:

(With disable_default_cross_chain: true the default flips: an effect that doesn't state crossChain gets one cache per chain, and crossChain: true opts it back into a shared one.)


export const getTokenName = createEffect(
{
name: "getTokenName",
input: S.string,
output: S.string,
// Each chain gets its own 5 calls/second budget
rateLimit: {
calls: 5,
per: "second",
},
cache: true,
crossChain: false,
},
async ({ input, context }) => {
// context.chain is only available on `crossChain: false` effects
const client = clientsByChainId[context.chain.id];
return await client.readContract({
address: input,
abi: erc20Abi,
functionName: "name",
});
},
);

Setting crossChain: false changes three things:

  • Cache - entries are keyed per chain, so two chains calling with the same input each get their own result.
  • Rate limit - each chain gets its own budget rather than competing for a shared one. An effect limited to 5 calls/second across 3 chains now allows 15 calls/second in total.
  • context.chain - the handler gains context.chain.id, the chain the effect was called on. Accessing context.chain on a cross-chain effect throws.
warning

crossChain is part of the effect's cache identity. Changing it on an effect with cache: true invalidates the existing cached results, and they will be refetched on the next run.

Sending Notifications (Webhooks)

You can use the Effect API to send push notifications or webhook calls when specific events occur. This is useful for alerting systems, Discord/Slack bots, or triggering downstream workflows.


export const sendWebhook = createEffect(
{
name: "sendWebhook",
input: {
event: S.string,
data: S.string,
},
output: S.boolean,
rateLimit: {
calls: 10,
per: "second",
},
// Don't cache webhook calls - we want them to fire every time
cache: false,
},
async ({ input, context }) => {
try {
await fetch("https://your-webhook-url.com/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: input.event, data: input.data }),
});
return true;
} catch (error) {
context.log.error(`Webhook failed: ${error}`);
return false;
}
}
);

Then call it from your handler:


indexer.onEvent(
{ contract: "MyContract", event: "LargeTransfer" },
async ({ event, context }) => {
await context.effect(sendWebhook, {
event: "large_transfer",
data: JSON.stringify({
from: event.params.from,
to: event.params.to,
amount: event.params.value.toString(),
}),
});
},
);
warning

Webhook effects will fire on every indexer re-run unless you set cache: true. If you cache them, the webhook will only fire once per unique input. Consider which behavior is appropriate for your use case.

Migrate from Experimental

If you're migrating from experimental_createEffect to createEffect, remove the experimental_ prefix and add the rateLimit option, which is now required. In experimental_createEffect, the rateLimit option was optional and defaulted to false.

- export const getBalance = experimental_createEffect(
+ export const getBalance = createEffect(
{
name: "getBalance",
input: {
address: S.string,
blockNumber: S.optional(S.bigint),
},
output: S.bigint,
+ rateLimit: {
+ calls: 5,
+ per: "second",
+ },
cache: true,
},
async ({ input, context }) => {
// your effect logic
}
);

Accessing Contract State in Event Handlers

File: Guides/contract-state.md

Example Repository: The complete code for this guide can be found here

Introduction

This guide demonstrates how to access on-chain contract state from your event handlers. You'll learn how to:

  1. Make RPC calls to external contracts within your event handlers
  2. Batch multiple calls using multicall for efficiency
  3. Learn about Preload Optimisation and how it makes your indexer thousands of times faster
  4. Use Effect API with built-in caching and Viem transport level batching
  5. Handle common edge cases that arise when accessing token contract data

The Challenge: Token Data from Pool Creation Events

Scenario

We want to track token information (name, symbol, decimals) for every token involved in a Uniswap V3 pool creation event.

Problem

The Uniswap V3 factory PoolCreated event only provides token addresses, not their metadata:

PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)

To get the token name, symbol, and decimals, we need to:

  1. Extract the token addresses from the event
  2. Make RPC calls to each token's contract
  3. Store this data alongside our pool information

Prerequisites

This guide assumes:

  • Basic familiarity with Envio indexing
  • Understanding of the viem library for making contract calls
  • Access to an Ethereum RPC endpoint (dRPC recommended)

For a gentle introduction to viem with a similar example, check out this medium article.

Implementation Steps

Step 1: Setup the Indexer Configuration

First, create a new indexer:

pnpx envio init

When prompted, enter the Ethereum mainnet Uniswap V3 Factory address: 0x1F98431c8aD98523631AE4a59f267346ea31F984

Then modify your configuration to focus only on the PoolCreated event:

# config.yaml
name: uniswap-v3-factory-token-indexer
chains:
- id: 1
start_block: 0
contracts:
- name: UniswapV3Factory
address:
- "0x1F98431c8aD98523631AE4a59f267346ea31F984"
events:
- event: PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)

Step 2: Define the Schema

Create a schema that captures both pool and token information:

# schema.graphql
type Token {
id: ID! # token address
name: String!
symbol: String!
decimals: Int!
}

type Pool {
id: ID! # unique identifier
token0: Token!
token1: Token!
fee: BigInt!
tickSpacing: BigInt!
pool: String! # pool address
}

Step 3: Implement the Event Handler

The event handler needs to:

  1. Create a Pool entity from the event data
  2. Make RPC calls to fetch token information for both token0 and token1
  3. Create Token entities with the retrieved data

Important! Preload optimization makes your handlers run twice. So instead of direct RPC calls, we're doing it through context.effect - the Effect API.

Learn how Preload Optimization works in a dedicated guide. It might be a new mental model for you, but this is what can make indexing thousands of times faster.

// src/handlers


indexer.onEvent(
{ contract: "UniswapV3Factory", event: "PoolCreated" },
async ({ event, context }) => {
// Create Pool entity
context.Pool.set({
id: `${event.chainId}_${event.block.number}_${event.logIndex}`,
token0_id: event.params.token0,
token1_id: event.params.token1,
fee: event.params.fee,
tickSpacing: event.params.tickSpacing,
pool: event.params.pool,
});

// Fetch and store token0 information
try {
const tokenMetadata0 = await context.effect(getTokenMetadata, {
tokenAddress: event.params.token0,
chainId: event.chainId,
});
context.Token.set({
id: event.params.token0,
name: tokenMetadata0.name,
symbol: tokenMetadata0.symbol,
decimals: tokenMetadata0.decimals,
});
} catch (error) {
context.log.error("Failed to fetch token0 metadata", {
tokenAddress: event.params.token0,
chainId: event.chainId,
pool: event.params.pool,
err: error,
});
return;
}

// Fetch and store token1 information
try {
const tokenMetadata1 = await context.effect(getTokenMetadata, {
tokenAddress: event.params.token1,
chainId: event.chainId,
});
context.Token.set({
id: event.params.token1,
name: tokenMetadata1.name,
symbol: tokenMetadata1.symbol,
decimals: tokenMetadata1.decimals,
});
} catch (error) {
context.log.error("Failed to fetch token1 metadata", {
tokenAddress: event.params.token1,
chainId: event.chainId,
pool: event.params.pool,
err: error,
});
return;
}
},
);

Step 4: Create the Token Metadata Effect

This is where the magic happens. We need to:

  1. Make RPC calls to token contracts
  2. Use multicall to batch multiple calls for efficiency
  3. Handle edge cases like non-standard ERC20 implementations
  4. Cache results to avoid redundant calls
// src/tokenDetails.ts




const RPC_URL = process.env.ENVIO_RPC_URL;

const client = createPublicClient({
chain: mainnet,
batch: { multicall: true }, // Enable multicall batching for efficiency
transport: http(RPC_URL, { batch: true }), // Thanks to automatic Effect API batching, we can also enable batching for Viem transport level
});

// Use Sury library to define the schema
const tokenMetadataSchema = S.schema({
name: S.string,
symbol: S.string,
decimals: S.number,
});

// Infer the type from the schema
type TokenMetadata = S.Infer<typeof tokenMetadataSchema>;

export const getTokenMetadata = createEffect(
{
name: "getTokenMetadata",
input: {
tokenAddress: S.string,
chainId: S.number,
},
output: tokenMetadataSchema,
rateLimit: {
calls: 5,
per: "second",
},
// Enable caching to avoid duplicated calls
cache: true,
},
async ({ input, context }) => {
const { tokenAddress, chainId } = input;

// Prepare contract instances for different token standard variations
const erc20 = getERC20Contract(tokenAddress as `0x${string}`);
const erc20Bytes = getERC20BytesContract(tokenAddress as `0x${string}`);
let results: [number, string, string];
try {
// Try standard ERC20 interface first (most common)
results = await client.multicall({
allowFailure: false,
contracts: [
{
...erc20,
functionName: "decimals",
},
{
...erc20,
functionName: "name",
},
{
...erc20,
functionName: "symbol",
},
],
});
} catch (error) {
try {
// Some tokens use bytes32 for name/symbol instead of string
const alternateResults = await client.multicall({
allowFailure: false,
contracts: [
{
...erc20Bytes,
functionName: "decimals",
},
{
...erc20Bytes,
functionName: "name",
},
{
...erc20Bytes,
functionName: "symbol",
},
],
});
results = [
alternateResults[0],
hexToString(alternateResults[1]).replace(/\u0000/g, ""), // Remove null byte padding
hexToString(alternateResults[2]).replace(/\u0000/g, ""), // Remove null byte padding
];
} catch (alternateError) {
results = [0, "unknown", "unknown"]; // Fallback for completely non-standard tokens
}
}

const [decimals, name, symbol] = results;

return {
name,
symbol,
decimals,
};
}
);

Important: The hexToString method from Viem adds byte padding to the string. We remove this padding with replace(/\u0000/g, '') to avoid errors when writing to the database.

Note: Read more about Effect API and caching in the Effect API guide.

Key Considerations

Understanding Current vs. Historical State

Standard RPC requests return the current state of a contract, not the state at a specific historical block. For token metadata (name, symbol, decimals), this isn't typically an issue since these values rarely change.

However, if you need historical state (like an account balance at a specific block), you would need a specialized RPC method like eth_getBalanceAt.

Handling Rate Limiting

RPC providers often limit the number of requests per time period. To avoid hitting rate limits:

  1. Use multicall (as shown in our example) to batch multiple contract calls into a single RPC request
  2. Learn about Preload Optimization to make your indexer thousands of times faster
  3. Enable caching to avoid redundant requests
  4. Use a paid, unthrottled RPC provider for production indexers
  5. Implement request throttling to space out requests when needed
  6. Use multiple RPC providers and rotate between them for high-volume indexing

Conclusion

Accessing contract state from your event handlers opens up powerful possibilities for enriching your indexed data. By following the patterns in this guide, you can efficiently retrieve and store contract state while maintaining good performance.

For more advanced techniques, explore:

  • Implementing retry logic for failed RPC calls
  • Handling complex contract interactions beyond basic ERC20 tokens

Indexing IPFS Data with Envio

File: Guides/ipfs.md

Example Repository: The complete code for this guide can be found here

Introduction

This guide demonstrates how to fetch and index data stored on IPFS within your Envio indexer. We'll use the Bored Ape Yacht Club NFT collection as a practical example, showing you how to retrieve and store token metadata from IPFS.

IPFS (InterPlanetary File System) is commonly used in blockchain applications to store larger data like images and metadata that would be prohibitively expensive to store on-chain. By integrating IPFS fetching capabilities into your indexers, you can provide a more complete data model that combines on-chain events with off-chain metadata.

Implementation Overview

Our implementation will follow these steps:

  1. Create a basic indexer for Bored Ape Yacht Club NFT transfers
  2. Extend the indexer to fetch and store metadata from IPFS
  3. Handle IPFS connection issues with fallback gateways

Step 1: Setting Up the Basic NFT Indexer

First, let's create a basic indexer that tracks NFT ownership:

Initialize the Indexer

pnpx envio init

When prompted, enter the Bored Ape Yacht Club contract address: 0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D

Configure the Indexer

Modify the configuration to focus on the Transfer events:

# config.yaml
name: bored-ape-yacht-club-nft-indexer
chains:
- id: 1
start_block: 0
end_block: 12299114 # Optional: limit blocks for development
contracts:
- name: BoredApeYachtClub
address:
- "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"
events:
- event: Transfer(address indexed from, address indexed to, uint256 indexed tokenId)

Define the Schema

Create a schema to store NFT ownership data:

# schema.graphql
type Nft {
id: ID! # tokenId
owner: String!
}

Implement the Event Handler

Track ownership changes by handling Transfer events:

// src/EventHandler.ts

const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

indexer.onEvent(
{ contract: "BoredApeYachtClub", event: "Transfer" },
async ({ event, context }) => {
if (event.params.from === ZERO_ADDRESS) {
// mint
context.Nft.set({
id: event.params.tokenId.toString(),
owner: event.params.to,
});
} else {
// transfer
const nft = await context.Nft.getOrThrow(event.params.tokenId.toString());
context.Nft.set({
...nft,
owner: event.params.to,
});
}
},
);

Run your indexer with pnpm dev and visit http://localhost:8080 to see the ownership data:

Step 2: Fetching IPFS Metadata

Now, let's enhance our indexer to fetch metadata from IPFS:

Update the Schema

Extend the schema to include metadata fields:

# schema.graphql
type Nft {
id: ID! # tokenId
owner: String!
image: String!
attributes: String! # JSON string of attributes
}

Create IPFS Effect

Important! Preload optimization makes your handlers run twice. So instead of direct RPC calls, we're doing it through the Effect API.

Learn how Preload Optimization works in a dedicated guide. It might be a new mental model for you, but this is what can make indexing thousands of times faster.

Let's create the getIpfsMetadata effect in the src/utils/ipfs.ts file:


// Define the schema for the IPFS metadata
// It uses Sury library to define the schema
const nftMetadataSchema = S.schema({
image: S.string,
attributes: S.string,
});

// Infer the type from the schema
type NftMetadata = S.Infer<typeof nftMetadataSchema>;

// Unique identifier for the BoredApeYachtClub IPFS tokenURI
const BASE_URI_UID = "QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq";

const endpoints = [
// Try multiple endpoints to ensure data availability
// Optional paid gateway (set in .env)
...(process.env.PINATA_IPFS_GATEWAY ? [process.env.PINATA_IPFS_GATEWAY] : []),
"https://cloudflare-ipfs.com/ipfs",
"https://ipfs.io/ipfs",
];

async function fetchFromEndpoint(
context: EffectContext,
endpoint: string,
tokenId: string
): Promise<NftMetadata | null> {
try {
const response = await fetch(`${endpoint}/${BASE_URI_UID}/${tokenId}`);
if (response.ok) {
const metadata: any = await response.json();
return {
image: metadata.image,
attributes: JSON.stringify(metadata.attributes),
};
} else {
context.log.warn(`IPFS didn't return 200`, { tokenId, endpoint });
return null;
}
} catch (e) {
context.log.warn(`IPFS fetch failed`, { tokenId, endpoint, err: e });
return null;
}
}

export const getIpfsMetadata = createEffect(
{
name: "getIpfsMetadata",
input: S.string,
output: nftMetadataSchema,
rateLimit: {
calls: 5,
per: "second",
},
},
async ({ input: tokenId, context }) => {
for (const endpoint of endpoints) {
const metadata = await fetchFromEndpoint(context, endpoint, tokenId);
if (metadata) {
return metadata;
}
}

// ⚠️ Dangerous: Sometimes it's better to crash, to prevent corrupted data
// But we're going to use a fallback value, to keep the indexer process running.
// Both approaches have their pros and cons.
context.log.warn(
"Unable to fetch IPFS. Continuing with fallback metadata.",
{
tokenId,
}
);
return { attributes: `["unknown"]`, image: "unknown" };
}
);

Update the Event Handler

Let's modify the event handler to fetch and store metadata using the getIpfsMetadata effect:

// src/handlers


const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";

indexer.onEvent(
{ contract: "BoredApeYachtClub", event: "Transfer" },
async ({ event, context }) => {
if (event.params.from === ZERO_ADDRESS) {
// mint
const metadata = await context.effect(
getIpfsMetadata,
event.params.tokenId.toString()
);
context.Nft.set({
id: event.params.tokenId.toString(),
owner: event.params.to,
image: metadata.image,
attributes: metadata.attributes,
});
} else {
// transfer
const nft = await context.Nft.getOrThrow(event.params.tokenId.toString());
context.Nft.set({
...nft,
owner: event.params.to,
});
}
},
);

When you run the indexer now, it will populate both ownership data and token metadata:

Best Practices for IPFS Integration

When working with IPFS in your indexers, consider these best practices:

1. Use Multiple Gateways

IPFS gateways can be unreliable, so always implement multiple fallback options:

const endpoints = [
...(process.env.PAID_IPFS_GATEWAY ? [process.env.PAID_IPFS_GATEWAY] : []),
"https://cloudflare-ipfs.com/ipfs",
"https://ipfs.io/ipfs",
"https://gateway.pinata.cloud/ipfs",
];

2. Handle Failures Gracefully

Always include error handling and provide fallback values:

try {
// IPFS fetch logic
} catch (error) {
context.log.error(`Failed to fetch from IPFS`, error as Error);
return { attributes: [], image: "default-image-url" };
}

3. Implement Local Caching (For Local Development)

Follow the Effect API Persistence guide to implement caching for local development. This should allow you to avoid repeatedly fetching the same data.

export const getIpfsMetadata = createEffect(
{
name: "getIpfsMetadata",
input: S.string,
output: nftMetadataSchema,
rateLimit: {
calls: 5,
per: "second",
},
cache: true, // Enable caching
},
async ({ input: tokenId, context }) => {...}
);

Important: While the example repository includes SQLite-based caching, this approach is outdated and leads to many indexing issues.

Note: We're working on a better integration with Envio Cloud. Currently, due to the cache size, it's not recommended to commit the .envio/cache directory to the GitHub repository.

4. Learn about Preload Optimization

Learn how Preload Optimization works and the Double-Run Footgun in a dedicated guide. It might be a new mental model for you, but this is what can make indexing thousands of times faster.

Understanding IPFS

What is IPFS?

IPFS (InterPlanetary File System) is a distributed system for storing and accessing files, websites, applications, and data. It works by:

  1. Splitting files into chunks
  2. Creating content-addressed identifiers (CIDs) based on the content itself
  3. Distributing these chunks across a network of nodes
  4. Retrieving data based on its CID rather than its location

Common Use Cases with Smart Contracts

IPFS is frequently used alongside smart contracts for:

  • NFTs: Storing images, videos, and metadata while the contract manages ownership
  • Decentralized Identity Systems: Storing credential documents and personal information
  • DAOs: Maintaining governance documents, proposals, and organizational assets
  • dApps: Hosting front-end interfaces and application assets

IPFS Challenges

IPFS integration comes with several challenges:

  1. Slow Retrieval Times: IPFS data can be slow to retrieve, especially for less widely replicated content
  2. Gateway Reliability: Public gateways can be inconsistent in their availability
  3. Data Persistence: Content may become unavailable if nodes stop hosting it

To mitigate these issues:

  • Use pinning services like Pinata or Infura to ensure data persistence
  • Implement multiple gateway fallbacks
  • Consider paid gateways for production applications

Using HyperSync as Your Indexing Data Source

File: Advanced/hypersync.md

"Beam me up, Scotty!" 🖖 - Just like the Star Trek transporter, HyperSync delivers your blockchain data at warp speed.

What is HyperSync?

HyperSync is a purpose built data-node that helps powers the exceptional performance of HyperIndex. It's a specialized data source optimized for indexing that provides:

  • 2000x faster sync speeds compared to traditional RPC methods
  • Cost-effective data retrieval with optimized resource usage
  • Flexibility with the ability to fetch multiple data points in a single round trip with more complex filtering

How HyperSync Powers Your Indexers

The Performance Advantage

Traditional blockchain indexing relies on RPC (Remote Procedure Call) endpoints to query blockchain data. While functional, RPCs become highly inefficient when:

  • Indexing millions of events
  • Processing historical blockchain data
  • Extracting data across multiple chains
  • Working with thousands of contracts

HyperSync addresses these limitations by providing a streamlined data access layer that dramatically reduces sync times from days to minutes.

Default Enablement

HyperSync is used by default as the data source for all HyperIndex chains. This means:

  • No additional configuration is required to benefit from its speed
  • No need to worry about RPC rate limiting
  • No management of multiple RPC providers
  • No costs for external RPC services
info

Starting in V3, HyperSync requires an API token. Create a free token at envio.dev/app/api-tokens and expose it to your indexer as ENVIO_API_TOKEN:

export ENVIO_API_TOKEN=your_token_here

Using HyperSync in Your Projects

Configuration

To use HyperSync (the default), simply don't set an RPC for historical sync in your config. HyperIndex will automatically use HyperSync for supported chains:

name: Greeter
description: Greeter indexer
chains:
- id: 137 # Polygon
start_block: 0 # With HyperSync, you can use 0 regardless of contract deployment time
contracts:
- name: PolygonGreeter
abi_file_path: abis/greeter-abi.json
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
events:
- event: NewGreeting
- event: ClearGreeting

Smart Block Detection

When using HyperSync, you can specify start_block: 0 in your configuration. HyperSync will automatically:

  1. Detect the first block where your contract was deployed
  2. Begin indexing from that block
  3. Skip unnecessary processing of earlier blocks

This feature eliminates the need to manually determine the deployment block of your contract, saving setup time and reducing configuration errors.

Hosting and Support

HyperSync is maintained and hosted by Envio for all supported chains. We handle the infrastructure, allowing you to focus on building your indexer logic.

Supported Chains

HyperSync supports numerous EVM chains including Ethereum, Unichain, Arbitrum, Optimism, and more. For a complete and up-to-date list of supported chains, see the HyperSync Supported Networks documentation.

Alternative Data Sources

HyperSync data source is vendorlock-free. While HyperSync is recommended for optimal performance, you can always switch to RPCs without the need to change your indexer code. For information on configuring RPC-based indexing, visit the RPC Data Source documentation.

Improving Resilience with RPC fallback

For production deployments, it’s recommended to use HyperSync as the primary data source and have RPCs as fallback to improve reliability.You can read more about it in the RPC Fallback section.

Performance Comparison

MetricTraditional RPCHyperSync
Indexing 1M EventsHours to daysMinutes
Resource UsageHighOptimized
Network CallsMany individual callsBatched for efficiency
Rate LimitingCommon issueNot applicable
CostPay per API callIncluded with Envio Cloud

Summary

HyperSync provides a significant competitive advantage for Envio indexers by dramatically reducing sync times, lowering costs, and simplifying configuration. By using HyperSync as your default data source, you'll experience:

  • Faster indexing performance
  • Support for previously impossible indexing cases
  • Enhanced reliability
  • Reduced operational complexity

To learn more about HyperSync's underlying technology, visit the HyperSync documentation.


Using RPC as Your Indexing Data Source

File: Advanced/rpc-sync.md

HyperIndex supports indexing any EVM blockchain using RPC (Remote Procedure Call) as the data source. This page explains when and how to use RPC for your indexing needs.

When to Use RPC

While HyperSync is the recommended and default data source for optimal performance, there are scenarios where you might need to use RPC instead:

  1. Unsupported Chains: When indexing a blockchain that isn't yet supported by HyperSync
  2. Custom Requirements: When you need specific RPC functionality not available in HyperSync
  3. Private Chains: When working with private or development EVM chains

Note: For chains that HyperSync supports, we strongly recommend using HyperSync rather than RPC. HyperSync provides significantly faster indexing performance (up to 100x) and doesn't require managing RPC endpoints or worrying about rate limits.

Configuring RPC in Your Indexer

Basic Configuration

In V3 the V2 rpc_config field has been replaced with rpc, which accepts a single URL, a single Rpc object, or a list of Rpc objects. Each entry can declare what it's for: sync (historical), realtime (head, including WebSocket URLs), or fallback.

To use RPC as the primary historical data source, add an rpc entry with for: sync to your chain configuration in config.yaml:

chains:
- id: 1 # Ethereum Mainnet
rpc:
- url: https://eth-mainnet.your-rpc-provider.com # Your RPC endpoint
for: sync
start_block: 15000000
contracts:
- name: MyContract
address: "0x1234..."
# Additional contract configuration...

The presence of an RPC marked for: sync tells HyperIndex to use RPC instead of HyperSync for historical sync on this chain. You can also add a for: realtime WebSocket endpoint to follow the head:

chains:
- id: 1
rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: sync
- url: wss://eth-mainnet.your-rpc-provider.com
for: realtime

Advanced RPC Configuration

For more control over how your indexer interacts with the RPC endpoint, you can configure additional parameters:

chains:
- id: 1
rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: sync
initial_block_interval: 10000 # Initial number of blocks to fetch in each request
backoff_multiplicative: 0.8 # Factor to scale back block request size after errors
acceleration_additive: 2000 # How many more blocks to request when successful
interval_ceiling: 10000 # Maximum blocks to request in a single call
backoff_millis: 5000 # Milliseconds to wait after an error
query_timeout_millis: 20000 # Milliseconds before timing out a request
start_block: 15000000
# Additional chain configuration...

Configuration Parameters Explained

ParameterDescriptionRecommended Value
urlYour RPC endpoint URLDepends on provider
initial_block_intervalStarting block batch size1,000 - 10,000
backoff_multiplicativeHow much to reduce batch size after errors0.5 - 0.9
acceleration_additiveHow much to increase batch size on success500 - 2,000
interval_ceilingMaximum blocks per request5,000 - 10,000
backoff_millisWait time after errors (ms)1,000 - 10,000
query_timeout_millisRequest timeout (ms)10,000 - 30,000

The optimal values depend on your RPC provider's performance and limits, as well as the complexity of your contracts and the data being indexed.

Custom HTTP Headers

Some providers gate their endpoints behind an Authorization header or a custom API-key header rather than putting the key in the URL. Since v3.3, headers sets arbitrary HTTP headers on every request to an RPC endpoint:

chains:
- id: 1
rpc:
- url: https://eth-mainnet.your-rpc-provider.com
for: sync
headers:
Authorization: "Bearer ${RPC_API_KEY}"
X-Custom-Header: "my-value"
start_block: 15000000

Header values support ${ENV_VAR} interpolation, so keep credentials in environment variables rather than committing them to config.yaml.

Event Filtering on RPC

RPC sources support the same where filtering as HyperSync, and since v3.3 that includes:

  • OR conditions - passing an array to params matches an event if any entry in the array matches. See Multiple Filters.
  • Multiple wildcard events - an RPC-backed indexer can register more than one wildcard event. Earlier versions allowed only one.

RPC Best Practices

Selecting an RPC Provider

When choosing an RPC provider, consider:

  • Rate limits: Most providers have limits on requests per second/minute
  • Node performance: Some providers offer faster nodes for premium tiers
  • Archive nodes: Required if you need historical state (e.g., balances at past blocks)
  • Geographic location: Choose nodes closest to your indexer deployment

Performance Optimization

To get the best performance when using RPC:

  1. Start from a recent block if possible, rather than indexing from genesis
  2. Tune batch parameters based on your provider's capabilities
  3. Use a paid service for better reliability and higher rate limits
  4. Consider multiple fallback RPCs for redundancy

Improving resilience with RPC fallback

HyperIndex allows you to configure additional RPC providers as fallback data sources. This redundancy is recommended for production deployments to ensure continuous operation of your indexer. If HyperSync experiences any interruption, your indexer will automatically switch to the fallback RPC provider.

Adding an RPC fallback provides these benefits:

  • High availability: Your indexer continues to function even during temporary HyperSync outages
  • Automatic failover: The system detects issues and switches to fallback RPC without manual intervention
  • Operational control: You can specify which RPC providers to use as fallbacks based on your requirements

Configure a fallback RPC by adding the rpc field to your chain configuration:

name: Greeter
description: Greeter indexer
chains:
- id: 137 # Polygon
+ # Short and simple
+ rpc: https://polygon.your-rpc-provider.com?API_KEY={ENVIO_POLYGON_API_KEY}
+ # Or provide multiple RPC endpoints with more flexibility
+ rpc:
+ - url: https://polygon.your-rpc-provider.com?API_KEY={ENVIO_POLYGON_API_KEY}
+ for: fallback
+ - url: https://polygon.your-free-rpc-provider.com
+ for: fallback
+ initial_block_interval: 1000
start_block: 0 # With HyperSync, you can use 0 regardless of contract deployment time
contracts:
- name: PolygonGreeter
abi_file_path: abis/greeter-abi.json
address: 0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c
events:
- event: NewGreeting
- event: ClearGreeting
info

The fallback RPC is activated only when a primary data source doesn't receive a new block for more than 20 seconds.

Enhanced RPC with eRPC

For more robust RPC usage, you can implement eRPC - a fault-tolerant EVM RPC proxy with advanced features like caching and failover.

What eRPC Provides

  • Permanent caching: Stores historical responses to reduce redundant requests
  • Auto failover: Automatically switches between multiple RPC providers
  • Re-org awareness: Properly handles blockchain reorganizations
  • Auto-batching: Optimizes requests to minimize network overhead
  • Load balancing: Distributes requests across multiple providers

Setting Up eRPC

  1. Create your eRPC configuration file (erpc.yaml):
logLevel: debug
projects:
- id: main
upstreams:
# Add HyperRPC as primary source
- endpoint: evm+envio://rpc.hypersync.xyz
# Add fallback RPC endpoints
- endpoint: https://eth-mainnet-provider1.com
- endpoint: https://eth-mainnet-provider2.com
- endpoint: https://eth-mainnet-provider3.com
  1. Run eRPC using Docker:
docker run -v $(pwd)/erpc.yaml:/root/erpc.yaml -p 4000:4000 -p 4001:4001 ghcr.io/erpc/erpc:latest

Or add it to your existing Docker Compose setup:

services:
# Your existing services...

erpc:
image: ghcr.io/erpc/erpc:latest
platform: linux/amd64
volumes:
- "${PWD}/erpc.yaml:/root/erpc.yaml"
ports:
- 4000:4000
- 4001:4001
restart: always
  1. Configure HyperIndex to use eRPC in your config.yaml:
chains:
- id: 1
rpc:
- url: http://erpc:4000/main/evm/1 # eRPC endpoint for Ethereum Mainnet
for: sync
start_block: 15000000
# Additional chain configuration...

For more detailed configuration options, refer to the eRPC documentation.

Comparing HyperSync and RPC

FeatureHyperSyncRPC
Speed10-100x fasterBaseline
ConfigurationMinimalRequires tuning
Rate LimitsNoneDepends on provider
CostIncluded with Envio CloudPay per request/subscription
Chain SupportSupported chainsAny EVM chain
MaintenanceManaged by EnvioSelf-managed

Summary

While RPC provides the flexibility to index any EVM blockchain, it comes with performance limitations and configuration complexity. For supported chains, we recommend using HyperSync as your data source for optimal performance.

If you must use RPC:

  • Choose a reliable provider
  • Configure your indexer for optimal performance
  • Consider implementing eRPC for enhanced reliability and performance
  • Start from recent blocks when possible to reduce indexing time

For any questions about using RPC with HyperIndex, please contact the Envio team.


Config Schema Reference

File: Advanced/config-schema-reference.md

Static, deep-linkable reference for the V3 config.yaml schema.

Tip: Use the Table of Contents to jump to a field or definition.

Top-level Properties

name

Name of the project

  • type: string

Example (config.yaml):

name: MyIndexer

description

Description of the project

  • type: string | null

Example (config.yaml):

description: Greeter indexer

schema

Custom path to schema.graphql file

  • type: string | null

Example (config.yaml):

schema: ./schema.graphql

handlers

Optional relative path to handlers directory for auto-loading. Defaults to 'src/handlers' if not specified.

  • type: string | null

full_batch_size

Target number of events to be processed per batch. Set it to smaller number if you have many Effect API calls which are slow to resolve and can't be batched. (Default: 5000)

  • type: integer | null
  • bounds: min: 0, format: uint64

Example (config.yaml):

full_batch_size: 5000

storage

Storage backends the indexer writes data to. Defaults to Postgres when omitted. Set clickhouse: true to additionally sync the indexed data to ClickHouse. Mark a backend with default: true to store entities that don't have an @storage directive in the schema, e.g. clickhouse: {default: true}.

  • type: anyOf(object<StorageConfig> | null)

Variants:

Example (config.yaml):

storage:
postgres:
default: true
column_name_format: snake_case
clickhouse: true

disable_default_cross_chain

Make entities and effect caches per-chain instead of shared across every chain (recommended). Sharing then becomes explicit - add @crossChain to an entity in schema.graphql or crossChain: true to an effect. (default: false)

  • type: boolean | null

ecosystem

Ecosystem of the project.

  • type: anyOf(enum (1 values) | null)

Variants:

Example (config.yaml):

ecosystem: evm

contracts

Global contract definitions that must contain all definitions except addresses. You can share a single handler/abi/event definitions for contracts across multiple chains.

  • type: array | null

Example (config.yaml):

contracts:
- name: Greeter
events:
- event: "NewGreeting(address user, string greeting)"

chains

Configuration of the blockchain chains that the project is deployed on.

  • type: array<object<Chain>>
  • items: object<Chain>
  • items ref: Chain

Example (config.yaml):

chains:
- id: 1
start_block: 0
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"

rollback_on_reorg

A flag to indicate if the indexer should rollback to the last known valid block on a reorg. This currently incurs a performance hit on historical sync and is recommended to turn this off while developing (default: true)

  • type: boolean | null

Example (config.yaml):

rollback_on_reorg: true

save_full_history

A flag to indicate if the indexer should save the full history of events. This is useful for debugging but will increase the size of the database (default: false)

  • type: boolean | null

Example (config.yaml):

save_full_history: false

field_selection

Select the block and transaction fields to include in all events globally

  • type: anyOf(object<FieldSelection> | null)

Variants:

Example (config.yaml):

field_selection:
transaction_fields:
- hash
block_fields:
- miner

raw_events

If true, the indexer will store the raw event data in the database. This is useful for debugging, but will increase the size of the database and the amount of time it takes to process events (default: false)

  • type: boolean | null

Example (config.yaml):

raw_events: true

address_format

Address format for Ethereum addresses: 'checksum' or 'lowercase' (default: checksum)

  • type: anyOf(enum (2 values) | null)

Variants:

bytes_type

How the Bytes scalar in schema.graphql is represented. hex keeps 0x-prefixed hex strings stored as text, uint8array exposes Uint8Array values in handlers and stores raw bytes (BYTEA in Postgres, String in ClickHouse). (default: hex)

  • type: anyOf(oneOf(const hex | const uint8array) | null)

Variants:

Definitions

StorageConfig

  • type: object

Properties:

  • postgres: anyOf(boolean | null | object) – Whether to use Postgres as a storage backend (default: true). Accepts a boolean or an options object (the object form implies the backend is enabled).
  • clickhouse: anyOf(boolean | null | object) – Whether to additionally sync the indexed data to ClickHouse. Requires Postgres to be enabled (default: false). Accepts a boolean or an options object (the object form implies the backend is enabled).

Example (config.yaml):

storage:
postgres:
# Entities without an @storage directive land here
default: true
# Columns become snake_case in the database, while GraphQL and
# handler types keep the schema.graphql casing
column_name_format: snake_case
clickhouse:
default: false

EcosystemTag

  • type: enum (1 values)
  • allowed: evm

Example (config.yaml):

ecosystem: evm

GlobalContract

  • type: object
  • required: name, events

Properties:

  • name: string – A unique project-wide name for this contract (no spaces)
  • abi_file_path: string | null – Relative path (from config) to a json abi. If this is used then each configured event should simply be referenced by its name
  • handler: string | null – Optional relative path to a file where handlers are registered for the given contract. If not provided, handlers can be auto-loaded from src directory.
  • events: array<object<EventConfig>> – A list of events that should be indexed on this contract

Example (config.yaml):

contracts:
- name: Greeter
events:
- event: "NewGreeting(address user, string greeting)"

EventConfig

  • type: object
  • required: event

Properties:

  • event: string – The human readable signature of an event 'eg. Transfer(address indexed from, address indexed to, uint256 value)' OR a reference to the name of an event in a json ABI file defined in your contract config. A provided signature will take precedence over what is defined in the json ABI
  • name: string | null – Name of the event in the HyperIndex generated code. When ommitted, the event field will be used. Should be unique per contract
  • field_selection: anyOf(object<FieldSelection> | null) – Select the block and transaction fields to include in the specific event

Example (config.yaml):

contracts:
- name: Greeter
events:
- event: "Assigned(address indexed recipientId, uint256 amount, address token)"
name: Assigned
field_selection:
transaction_fields:
- transactionIndex

FieldSelection

  • type: object

Properties:

  • transaction_fields: array | null – The transaction fields to include in the event, or in all events if applied globally
    • Available values: transactionIndex, hash, from, to, gas, gasPrice, maxPriorityFeePerGas, maxFeePerGas, cumulativeGasUsed, effectiveGasPrice, gasUsed, input, nonce, value, v, r, s, contractAddress, logsBloom, root, status, yParity, accessList, maxFeePerBlobGas, blobVersionedHashes, type, l1Fee, l1GasPrice, l1GasUsed, l1FeeScalar, gasUsedForL1, authorizationList
  • block_fields: array | null – The block fields to include in the event, or in all events if applied globally
    • Available values: parentHash, nonce, sha3Uncles, logsBloom, transactionsRoot, stateRoot, receiptsRoot, miner, difficulty, totalDifficulty, extraData, size, gasLimit, gasUsed, uncles, baseFeePerGas, blobGasUsed, excessBlobGas, parentBeaconBlockRoot, withdrawalsRoot, l1BlockNumber, sendCount, sendRoot, mixHash

Example (config.yaml):

events:
- event: "Assigned(address indexed user, uint256 amount)"
# can be within an event as shown here, or globally for all events
field_selection:
transaction_fields:
- transactionIndex
block_fields:
- miner

Chain

  • type: object
  • required: id, start_block

Properties:

  • id: integer – The public blockchain chain ID.
  • skip: boolean | null – Excludes the chain from indexing and migrations. Code generation is unaffected. For testing, prefer using a test framework instead.
  • rpc: anyOf(anyOf(string | object<Rpc> | array<object<Rpc>>) | null) – RPC configuration for your indexer. If not specified otherwise, for chains supported by HyperSync, RPC serves as a fallback for added reliability. For others, it acts as the primary data-source. HyperSync offers significant performance improvements, up to a 1000x faster than traditional RPC.
  • hypersync_config: anyOf(object<HypersyncConfig> | null) – Optional HyperSync Config for additional fine-tuning
  • max_reorg_depth: integer | null – The number of blocks from the head that the indexer should account for in case of reorgs.
  • block_lag: integer | null – The number of blocks behind the chain head that the indexer should lag. Useful for avoiding reorg issues by indexing slightly behind the tip.
  • start_block: anyOf(integer | enum (1 values)) – The block at which the indexer should start ingesting data, or "latest" to start from the chain's current head block when the indexer is first deployed. Once resolved, the concrete block is persisted and reused every time the indexer resumes normally (for example recovering from a crash), so downtime is backfilled instead of skipped. Running envio start/dev with -r (--restart) resets this like any other config change: "latest" resolves again, against the head at that time.
  • end_block: integer | null – The block at which the indexer should terminate.
  • contracts: array | null – All the contracts that should be indexed on the given chain

Example (config.yaml):

chains:
- id: 1
start_block: 0
end_block: 19000000
contracts:
- name: Greeter
address: "0x1111111111111111111111111111111111111111"
# Excluded from indexing and migrations, but still code-generated
- id: 137
skip: true
start_block: 0

RpcSelection

  • type: anyOf(string | object<Rpc> | array<object<Rpc>>)

Variants:

  • 1: string
  • 2: Rpc
  • 3: array<object<Rpc>>

Rpc

  • type: object
  • required: url

Properties:

  • url: string – The RPC endpoint URL.
  • for: anyOf(oneOf(const sync | const fallback | const realtime) | null) – Determines if this RPC is for historical sync, real-time chain indexing, or as a fallback. If not specified, defaults to "fallback" when HyperSync is available for the chain, or "sync" otherwise.
  • ws: string | null – Optional WebSocket endpoint URL (wss:// or ws://) for real-time block header notifications via eth_subscribe("newHeads"). Provides lower latency than HTTP polling for detecting new blocks.
  • headers: object | null – Optional HTTP headers sent with every request to this RPC endpoint, e.g. an Authorization bearer token for gated endpoints. Values support ${ENV_VAR} interpolation.
  • initial_block_interval: integer | null – The starting interval in range of blocks per query
  • backoff_multiplicative: number | null – After an RPC error, how much to scale back the number of blocks requested at once
  • acceleration_additive: integer | null – Without RPC errors or timeouts, how much to increase the number of blocks requested by for the next batch
  • interval_ceiling: integer | null – Do not further increase the block interval past this limit
  • backoff_millis: integer | null – After an error, how long to wait before retrying
  • fallback_stall_timeout: integer | null – If a fallback RPC is provided, the amount of time in ms to wait before kicking off the next provider
  • query_timeout_millis: integer | null – How long to wait before cancelling an RPC request
  • polling_interval: integer | null – How frequently (in milliseconds) to check for new blocks in realtime. Default is 1000ms. Note: Setting this higher than block time does not reduce RPC usage as every block is still fetched to check for reorgs.

Example (config.yaml):

chains:
- id: 1
rpc:
- url: https://eth.llamarpc.com
for: sync
headers:
Authorization: "Bearer ${RPC_API_KEY}"
- url: wss://eth.llamarpc.com
for: realtime
- url: https://fallback.example.com
for: fallback

For

  • type: oneOf(const sync | const fallback | const realtime)

Variants:

  • 1: const sync
  • 2: const fallback
  • 3: const realtime

HypersyncConfig

  • type: object
  • required: url

Properties:

  • url: string – URL of the HyperSync endpoint (default: The most performant HyperSync endpoint for the network)

Example (config.yaml):

chains:
- id: 1
hypersync_config:
url: https://eth.hypersync.xyz

StartBlock

A chain's configured start block: either a concrete block number or the literal "latest". Config parsing never touches the network, so Latest stays unresolved here - it's resolved once at runtime, right before the indexer's first-ever persisted state is written, and never re-resolved on a normal resume. Note: the -r/--restart CLI flag wipes the DB and re-deploys from scratch, so it re-resolves "latest" too - "resume" here means the opposite: recovering from a crash or process restart without -r.

  • type: anyOf(integer | enum (1 values))

Variants:

StartBlockTag

  • type: enum (1 values)
  • allowed: latest

ChainContract

  • type: object
  • required: name

Properties:

  • name: string – A unique project-wide name for this contract if events and handler are defined OR a reference to the name of contract defined globally at the top level
  • address: anyOf(anyOf(string | integer) | array<anyOf(string | integer)>) – A single address or a list of addresses to be indexed. This can be left as null in the case where this contracts addresses will be registered dynamically.
  • start_block: integer | null – The block at which the indexer should start ingesting data for this specific contract. If not specified, uses the chain start_block. Can be greater than the chain start_block for more specific indexing.
  • abi_file_path: string | null – Relative path (from config) to a json abi. If this is used then each configured event should simply be referenced by its name
  • handler: string | null – Optional relative path to a file where handlers are registered for the given contract. If not provided, handlers can be auto-loaded from src directory.
  • events: array<object<EventConfig>> – A list of events that should be indexed on this contract

Example (config.yaml):

chains:
- id: 1
start_block: 0
contracts:
- name: Greeter
address:
- "0x1111111111111111111111111111111111111111"
events:
- event: Transfer(address indexed from, address indexed to, uint256 value)

Addresses

  • type: anyOf(anyOf(string | integer) | array<anyOf(string | integer)>)

Variants:

  • 1: anyOf(string | integer)
  • 2: array<anyOf(string | integer)>

Example (config.yaml):

chains:
- id: 1
contracts:
- name: Greeter
address:
- "0x1111111111111111111111111111111111111111"
- "0x2222222222222222222222222222222222222222"

AddressFormat

  • type: enum (2 values)
  • allowed: checksum, lowercase

BytesType

How the schema.graphql Bytes scalar reaches handlers and storage.

  • type: oneOf(const hex | const uint8array)

Variants:

  • 1: const hex
  • 2: const uint8array

Removed in V3

The following V2 options have been removed and are no longer accepted in config.yaml:

  • output - generated types are always emitted to .envio/.
  • unordered_multichain_mode - unordered is now the only mode. The V2 multichain: ordered opt-in has also been removed.
  • event_decoder - the Rust-based decoder is the only implementation.
  • loaders - Preload Optimization is now always on.
  • preload_handlers - now always enabled.
  • preRegisterDynamicContracts - no longer needed.
  • rpc_config - replaced by rpc (see above).
  • networks - renamed to chains.
  • confirmed_block_threshold - renamed to max_reorg_depth.

Cli Commands

File: Guides/cli-commands.md

Envio Command Line Interface

This comprehensive reference guide covers all available commands and options in the Envio CLI tool for HyperIndex V3. Use this documentation to explore the full capabilities of the envio command and its subcommands for managing your blockchain indexing projects.

Envio Cloud CLI

Looking to manage your hosted indexers from the command line? See the Envio Cloud CLI for deployment, monitoring, and management commands for Envio Cloud.

Getting Started

The Envio CLI provides a powerful set of tools for creating, developing, and managing your blockchain indexers. Whether you're starting a new project, running a development server, or deploying to production, the CLI offers commands to simplify and automate your workflow.

The fastest way to get going is pnpx envio init, which scaffolds a project interactively. From there, envio dev runs your indexer locally while you iterate, and envio start runs it in production.

Command Overview:

envio

Usage: envio [OPTIONS] <COMMAND>

Subcommands:
  • init - Create a new indexer
  • dev - Development commands for starting, stopping, and restarting the indexer. Runs codegen automatically before launching
  • stop - Stop the local environment - delete the database and stop all processes (including Docker) for the current directory
  • codegen - Generate indexing code from user-defined configuration & schema files
  • local - Prepare local environment for envio testing
  • start - Start the indexer. Runs codegen automatically before launching so the on-disk types stay in sync with config.yaml and schema.graphql
  • metrics - Fetch raw Prometheus metrics from the running indexer's /metrics endpoint
  • skills - Manage Envio-provided Claude Code skills under .claude/skills/
  • tools - Tools for people and AI agents (search-docs, fetch-docs). Run envio tools help for details
  • config - Inspect the indexer config
Options:
  • -d, --directory <DIRECTORY> - The directory of the project. Defaults to current dir ("./")

  • --config <CONFIG> - The config file path, resolved relative to the project directory. It can also be set via the ENVIO_CONFIG environment variable

    Default value: config.yaml

envio init

Create a new indexer

Quick start - run with no arguments:

pnpx envio init

Guided step-by-step for humans and AI agents. Reach for the subcommands below only when you already know exactly what you want.

Usage: envio init [OPTIONS] [COMMAND]

Subcommands:
  • contract-import - [Advanced] Initialize Evm indexer by importing config from a contract for a given chain
  • template - [Advanced] Initialize Evm indexer from an example template
  • svm - Initialization option for creating Svm indexer
  • fuel - Initialization option for creating Fuel indexer
Options:
  • -n, --name <NAME> - The name of your project

  • -l, --language <LANGUAGE> - The language used to write handlers

    Possible values: typescript, rescript

  • --package-manager <PACKAGE_MANAGER> - The package manager used for install and post-init build steps (default: pnpm)

    Possible values: pnpm, npm, yarn, bun

  • --api-token <API_TOKEN> - The Envio API token to be initialized in your templates .env file. Falls back to the ENVIO_API_TOKEN environment variable. Create one at https://envio.dev/app/api-tokens

envio init contract-import

[Advanced] Initialize Evm indexer by importing config from a contract for a given chain

Usage: envio init contract-import [OPTIONS] [COMMAND]

Subcommands:
  • explorer - Initialize by pulling the contract ABI from a block explorer
  • local - Initialize from a local json ABI file
Options:
  • -c, --contract-address <CONTRACT_ADDRESS> - Contract address to generate the config from
  • --single-contract - If selected, prompt will not ask for additional contracts/addresses/chains
  • --all-events - If selected, prompt will not ask to confirm selection of events on a contract

envio init contract-import explorer

Initialize by pulling the contract ABI from a block explorer

Usage: envio init contract-import explorer [OPTIONS]

Options:
  • -b, --blockchain <BLOCKCHAIN> - Network to import the contract from

    Possible values: abstract, amoy, arbitrum-nova, arbitrum-one, arbitrum-sepolia, arbitrum-testnet, aurora, aurora-testnet, avalanche, b2-testnet, base, base-sepolia, berachain, blast, blast-sepolia, boba, bsc, bsc-testnet, celo, celo-alfajores, celo-baklava, citrea-testnet, crab, curtis, ethereum-mainnet, etherlink, evmos, fantom, fantom-testnet, fhenix-helium, flare, fraxtal, fuji, galadriel-devnet, gnosis, gnosis-chiado, goerli, harmony, holesky, hoodi, hyperliquid, kroma, linea, linea-sepolia, lisk, lukso, lukso-testnet, manta, mantle, mantle-testnet, megaeth-testnet2, metis, mode, mode-sepolia, monad, monad-testnet, moonbase-alpha, moonbeam, moonriver, morph, morph-testnet, neon-evm, opbnb, optimism, optimism-sepolia, plasma, poa-core, poa-sokol, polygon, polygon-zkevm, polygon-zkevm-testnet, robinhood, rsk, saakuru, scroll, scroll-sepolia, sei, sei-testnet, sepolia, shimmer-evm, sonic, sonic-testnet, sophon, swell, taiko, tangle, unichain, unichain-sepolia, worldchain, xdc, xdc-testnet, zeta, zksync-era, zora, zora-sepolia

  • --api-token <API_TOKEN> - API token for the block explorer

  • --single-contract - If selected, prompt will not ask for additional contracts/addresses/chains

  • --all-events - If selected, prompt will not ask to confirm selection of events on a contract

envio init contract-import local

Initialize from a local json ABI file

Usage: envio init contract-import local [OPTIONS]

Options:
  • -a, --abi-file <ABI_FILE> - The path to a json abi file
  • --contract-name <CONTRACT_NAME> - The name of the contract
  • -b, --blockchain <BLOCKCHAIN> - Name or ID of the contract network
  • -r, --rpc-url <RPC_URL> - The rpc url to use if the network id used is unsupported by our hypersync
  • -s, --start-block <START_BLOCK> - The start block to use on this network
  • --single-contract - If selected, prompt will not ask for additional contracts/addresses/chains
  • --all-events - If selected, prompt will not ask to confirm selection of events on a contract

envio init template

[Advanced] Initialize Evm indexer from an example template

Usage: envio init template [OPTIONS]

Options:
  • -t, --template <TEMPLATE> - Name of the template to be used in initialization

    Possible values: greeter, erc20, feature-external-calls, feature-factory

envio init svm

Initialization option for creating Svm indexer

Usage: envio init svm [COMMAND]

Subcommands:
  • template - Initialize Svm indexer from an example template

envio init svm template

Initialize Svm indexer from an example template

Usage: envio init svm template [OPTIONS]

Options:
  • -t, --template <TEMPLATE> - Name of the template to be used in initialization

    Possible values: usdc-transfers

envio init fuel

Initialization option for creating Fuel indexer

Usage: envio init fuel [COMMAND]

Subcommands:
  • contract-import - Initialize Fuel indexer by importing config from a contract for a given chain
  • template - Initialize Fuel indexer from an example template

envio init fuel contract-import

Initialize Fuel indexer by importing config from a contract for a given chain

Usage: envio init fuel contract-import [OPTIONS] [COMMAND]

Subcommands:
  • local - Initialize from a local json ABI file
Options:
  • -c, --contract-address <CONTRACT_ADDRESS> - Contract address to generate the config from
  • --single-contract - If selected, prompt will not ask for additional contracts/addresses/chains
  • --all-events - If selected, prompt will not ask to confirm selection of events on a contract

envio init fuel contract-import local

Initialize from a local json ABI file

Usage: envio init fuel contract-import local [OPTIONS]

Options:
  • -a, --abi-file <ABI_FILE> - The path to a json abi file

  • --contract-name <CONTRACT_NAME> - The name of the contract

  • -b, --blockchain <BLOCKCHAIN> - Which Fuel network to use

    Possible values: mainnet, testnet

  • --single-contract - If selected, prompt will not ask for additional contracts/addresses/chains

  • --all-events - If selected, prompt will not ask to confirm selection of events on a contract

envio init fuel template

Initialize Fuel indexer from an example template

Usage: envio init fuel template [OPTIONS]

Options:
  • -t, --template <TEMPLATE> - Name of the template to be used in initialization

    Possible values: greeter

envio dev

Development commands for starting, stopping, and restarting the indexer. Runs codegen automatically before launching

Usage: envio dev [OPTIONS]

Options:
  • -r, --restart - Force restart: clear the database and re-index from scratch. Required when config/schema/ABI changes are incompatible with the existing indexer state

envio stop

Stop the local environment - delete the database and stop all processes (including Docker) for the current directory

Usage: envio stop

envio codegen

Generate indexing code from user-defined configuration & schema files

Usage: envio codegen

envio local

Prepare local environment for envio testing

Usage: envio local <COMMAND>

Subcommands:
  • docker - Local Envio environment commands
  • db-migrate - Local Envio database commands

envio local docker

Local Envio environment commands

Usage: envio local docker <COMMAND>

Subcommands:
  • up - Start Docker containers (Postgres + Hasura) for local environment
  • down - Stop and remove Docker containers for local environment

envio local docker up

Start Docker containers (Postgres + Hasura) for local environment

Usage: envio local docker up

envio local docker down

Stop and remove Docker containers for local environment

Usage: envio local docker down

envio local db-migrate

Local Envio database commands

Usage: envio local db-migrate <COMMAND>

Subcommands:
  • up - Migrate latest schema to database
  • down - Drop database schema
  • setup - Setup database by dropping schema and then running migrations

envio local db-migrate up

Migrate latest schema to database

Usage: envio local db-migrate up

envio local db-migrate down

Drop database schema

Usage: envio local db-migrate down

envio local db-migrate setup

Setup database by dropping schema and then running migrations

Usage: envio local db-migrate setup

envio start

Start the indexer. Runs codegen automatically before launching so the on-disk types stay in sync with config.yaml and schema.graphql

Usage: envio start [OPTIONS]

Options:
  • -r, --restart - Clear your database and restart indexing from scratch

envio metrics

Fetch raw Prometheus metrics from the running indexer's /metrics endpoint

Usage: envio metrics [COMMAND]

Subcommands:
  • runtime - Fetch runtime metrics from the running indexer's /metrics/runtime endpoint

envio metrics runtime

Fetch runtime metrics from the running indexer's /metrics/runtime endpoint

Usage: envio metrics runtime

envio skills

Manage Envio-provided Claude Code skills under .claude/skills/

Usage: envio skills <COMMAND>

Subcommands:
  • update - Re-extract every skill shipped by this CLI version, overwriting the matching directories under <cwd>/.claude/skills/. Skills not shipped by envio are left untouched

envio skills update

Re-extract every skill shipped by this CLI version, overwriting the matching directories under <cwd>/.claude/skills/. Skills not shipped by envio are left untouched

Usage: envio skills update

envio tools

Tools for people and AI agents (search-docs, fetch-docs). Run envio tools help for details

Usage: envio tools <COMMAND>

Subcommands:
  • search-docs - Full-text search over Envio docs; prints matching titles, URLs, and snippets. Pair with fetch-docs to read a hit in full
  • fetch-docs - Print the full markdown of a docs page by URL. Use a URL returned by search-docs

envio tools search-docs

Full-text search over Envio docs; prints matching titles, URLs, and snippets. Pair with fetch-docs to read a hit in full

Usage: envio tools search-docs <QUERY>

Arguments:
  • <QUERY> - The search query

envio tools fetch-docs

Print the full markdown of a docs page by URL. Use a URL returned by search-docs

Usage: envio tools fetch-docs <URL>

Arguments:
  • <URL> - The full URL of the documentation page to fetch

envio config

Inspect the indexer config

Usage: envio config <COMMAND>

Subcommands:
  • view - Print the resolved indexer config as JSON

envio config view

Print the resolved indexer config as JSON

Usage: envio config view


Understanding and Handling Chain Reorganizations

File: Advanced/reorgs-support.md

What Are Chain Reorganizations?

Chain reorganizations (reorgs) occur when the blockchain temporarily forks and then resolves to a single chain, causing some previously confirmed blocks to be replaced by different blocks. This is a normal part of blockchain consensus mechanisms, especially in proof-of-work chains.

When a reorg happens:

  • Transactions that were previously considered confirmed may be dropped
  • New transactions may be added to the blockchain
  • The order of transactions might change

For indexers, this presents a challenge: data that was previously indexed may no longer be valid, requiring a rollback and reprocessing of the affected blocks.

Automatic Reorg Handling in HyperIndex

HyperIndex includes built-in support for handling chain reorganizations, ensuring your indexed data remains consistent with the blockchain's canonical state. This feature is enabled by default to protect your data integrity.

Configuration Options

Enabling or Disabling Reorg Support

You can control reorg handling through the rollback_on_reorg flag in your config.yaml file:

# Enable reorg handling (default)
rollback_on_reorg: true
chains:
# chain configurations...

# OR

# Disable reorg handling (not recommended for production)
rollback_on_reorg: false
chains:
# chain configurations...

Configuring Confirmation Thresholds

You can customize the number of blocks required before considering a block "confirmed" and no longer subject to reorgs:

rollback_on_reorg: true
chains:
- id: 137 # Polygon
max_reorg_depth: 150
- id: 1 # Ethereum
# Using default threshold

The max_reorg_depth field (renamed from V2's confirmed_block_threshold) defines how many blocks below the chain head are considered safe from reorganizations. Any reorg deeper than this threshold won't trigger a rollback in your indexer.

Default Confirmation Thresholds

Currently, all chains default to a threshold of 200 blocks. In future releases, these thresholds will be tailored per chain based on their specific characteristics and historical reorg depths.

Chain TypeDefault ThresholdNotes
All Chains200 blocksWill be customized per chain in future releases

Technical Details and Limitations

Guaranteed Detection

Reorg detection is guaranteed when using HyperSync as your data source. HyperSync's architecture ensures that any reorganization in the blockchain will be properly detected and handled.

RPC Limitations

When using a custom RPC endpoint as your data source, there are some edge cases where reorgs might go undetected, depending on the RPC provider's implementation and your indexing pattern.

Scope of Rollbacks

During a reorg-triggered rollback:

What is rolled back:

  • All entities defined in your schema
  • All data that your handlers read or write to the database

What is not rolled back:

  • Side effects in your handler code (API calls, external services)
  • Custom caching mechanisms outside of HyperIndex
  • Logs or external files written by your handlers

Best Practices

  1. Keep reorg support enabled for production indexers
  2. Use HyperSync when possible for guaranteed reorg detection
  3. Avoid external side effects in your handlers that cannot be rolled back
  4. Consider higher thresholds for high-value applications or chains with historically deep reorgs

Example Configuration

Here's a complete example showing reorg handling configuration for multiple chains:

rollback_on_reorg: true
chains:
- id: 1 # Ethereum Mainnet
max_reorg_depth: 250 # Higher threshold for Ethereum
# other chain config...

- id: 137 # Polygon
max_reorg_depth: 150 # Lower threshold for Polygon
# other chain config...

- id: 42161 # Arbitrum One
# Using default threshold (200)
# other chain config...

By properly configuring reorg support, you ensure that your indexed data remains consistent with the blockchain, even when the chain reorganizes.

Using HyperSync Directly? Handle Reorgs with the Rollback Guard

If you use HyperSync directly, without HyperIndex, you have to handle reorg detection and rollback yourself using the rollback guard returned on each query response.

HyperSync validates block parent hashes internally and re-syncs when it detects a fork, so it always serves canonical chain data. Data you have already fetched can still go stale after a reorg, though. To detect that, compare the first_parent_hash of the current response against the hash you stored from the previous response. If they differ, a reorg has occurred and you need to re-fetch the affected range.

For full details, including a pseudocode example, see the HyperSync Rollback Guard documentation.

tip

HyperIndex automates all of this: it fetches recent block hashes to pinpoint exactly where a reorg occurred and automatically rolls back database state. Unless you need the full flexibility of raw HyperSync, HyperIndex saves significant implementation effort.



Understanding Generated Indexing Files

File: Advanced/generated-files.md

Overview

In V3, the local generated package is gone. Code generation now writes a single ambient declaration file at .envio/types.d.ts (git-ignored) and wires it into your project through a small envio-env.d.ts file at the project root. Everything you used to import from generated is now exported from the envio package.

These generated declarations form the type-level backbone of your blockchain indexer, translating your configuration, schema, and event handlers into the strongly-typed runtime values exposed by envio.

Important: The contents of .envio/ should never be manually edited. Any changes will be overwritten the next time code generation runs.

What V3 Emits

File / locationPurpose
.envio/types.d.tsAmbient TypeScript declarations describing your contracts, events, entities, enums, and chains.
envio-env.d.ts (root)Tiny shim that references .envio/types.d.ts so the compiler picks it up.
.envio/cache/ (optional)Local cache of Effect API results, populated via the dev console.

The generated/ directory used by V2 (with ReScript sources, JS shims, and a per-project package.json) is no longer produced.

Purpose of Generated Files

Generated files serve several critical functions:

  1. Type-Safe Data Access - They provide strongly-typed interfaces to interact with your defined entities through envio.
  2. Event Processing - They describe each contract's events so indexer.onEvent({ contract, event }, ...) is fully type-checked.
  3. Database Interactions - They generate the entity types and helper signatures used by context.<Entity> and indexer.<Entity>.
  4. Runtime Orchestration - They feed into the indexer value (chains, contracts, entities) that orchestrates indexing.

Real-World Example: Uniswap V4 Indexer

Let's examine how specific elements from a real Uniswap V4 indexer translate into generated declarations.

From Schema to Generated Types

For a schema entity like this:

type Pool {
id: ID!
chainId: BigInt!
currency0: String!
currency1: String!
fee: BigInt!
tickSpacing: BigInt!
hooks: String!
numberOfSwaps: BigInt! @index
createdAtTimestamp: BigInt!
createdAtBlockNumber: BigInt!
}

The codegen process emits a TypeScript type you can import from envio:


// Equivalent to importing the `Pool` named type directly.
type Pool = Entity<"Pool">;

// Shape of the generated entity:
// {
// id: string;
// chainId: bigint;
// currency0: string;
// currency1: string;
// fee: bigint;
// tickSpacing: bigint;
// hooks: string;
// numberOfSwaps: bigint;
// createdAtTimestamp: bigint;
// createdAtBlockNumber: bigint;
// }

You read and write Pool entities through the type-safe context and indexer APIs:

// Inside a handler
const pool = await context.Pool.get(id);
context.Pool.set({ id, chainId, currency0, currency1, fee, tickSpacing, hooks, numberOfSwaps, createdAtTimestamp, createdAtBlockNumber });

// Inside a test or script
await indexer.Pool.set({ /* ... */ });
const stored = await indexer.Pool.getOrThrow(id);

From Config to Generated Event Handlers

Given a contract event in config.yaml:

contracts:
- name: PoolManager
events:
- event: Swap(bytes32 indexed id, address indexed sender, int128 amount0, int128 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick, uint24 fee)

Codegen widens the indexer.onEvent overloads so that the following call is fully typed end-to-end (event params, return type, context.chain, etc.):



indexer.onEvent(
{ contract: "PoolManager", event: "Swap" },
async ({ event, context }) => {
const { id, sender, amount0, amount1, sqrtPriceX96, liquidity, tick, fee } = event.params;
// ...
},
);

// The full event payload type is also available as a generic:
type PoolManagerSwapEvent = EvmEvent<"PoolManager", "Swap">;
type PoolManagerSwapBlock = EvmEvent<"PoolManager", "Swap">["block"];

From Multi-Chain Config to Generated Chain Handlers

Your config has multiple chains:

chains:
- id: 1 # Ethereum Mainnet
# ...
- id: 10 # Optimism
# ...
- id: 42161 # Arbitrum
# ...

Codegen turns the chain set into a literal ChainId union and exposes per-chain helpers under indexer.chains:



// ChainId is `1 | 10 | 42161`
const mainnet = indexer.chains[1];
const optimism = indexer.chains[10];
const arbitrum = indexer.chains[42161];

// `chain.id` is also typed inside handler/where callbacks:
indexer.onBlock(
{
name: "Heartbeat",
where: ({ chain }) => {
// chain.id is narrowed to the configured ChainId union
return chain.id === 1;
},
},
async ({ block, context }) => {
// context.chain.id is typed as well
},
);

getGeneratedByChainId(...) from V2 has been replaced by indexer.chains[chainId].

When to Run Code Generation

You should run code generation using the Envio CLI whenever you:

pnpm envio codegen

Codegen should be run after:

  1. Modifying your config.yaml file
  2. Changing your GraphQL schema
  3. Adding or updating event handlers
  4. Switching to a new contract or ABI
  5. After pulling changes from version control

Note: changes to handler files in V3 no longer trigger automatic codegen on pnpm dev.

Troubleshooting Generation Errors

When code generation fails, the errors typically point to issues in your setup files. Here are common error patterns and their solutions:

Configuration Errors

Error messages containing Config validation failed typically mean there's an issue in your config.yaml file:

  • Check for syntax errors in YAML formatting
  • Verify that all required fields are present
  • Ensure contract addresses are in the correct format
  • Confirm that referenced chains are valid

For example, if you see an error about invalid chain IDs, check that all chain IDs in your config are valid:

chains:
- id: 1 # Valid Ethereum mainnet
- id: 10 # Valid Optimism
- id: 999 # Might be invalid if this chain ID isn't recognized

Schema Errors

Errors mentioning Schema parsing error point to issues in your GraphQL schema:

  • Check for invalid GraphQL syntax
  • Ensure entity names match those referenced in handlers
  • Verify that relationships between entities are properly defined
  • Check for unsupported types or directives

For example, if you're using the @index directive as in your Pool entity's numberOfSwaps field, make sure it's correctly placed:

type Pool {
id: ID!
numberOfSwaps: BigInt! @index # Correct placement of @index directive
}

Handler Errors

If you see Handler validation failed errors:

  • Check that handler function signatures match expected patterns
  • Ensure all referenced entities exist in your schema
  • Verify proper import syntax for entities and contract events (everything comes from envio)

Relationship with Setup Files

The generated declarations directly reflect the structure defined in your setup files:

  • config.yaml → Determines which chains, contracts, and events are indexed
  • schema.graphql → Defines the entities and relationships that are generated
  • handlers in src/handlers/ → Provide the business logic that the generated types describe

Best Practices

  1. Never modify generated files directly - Always change the source files
  2. Run codegen before starting your indexer - Ensure all declarations are up to date
  3. Check error messages carefully - They often pinpoint issues in your setup files
  4. Commit envio-env.d.ts but ignore .envio/ - The shim is part of the project; the generated artifacts are not.

Summary

Generated declarations form the critical bridge between your indexing specifications and the actual runtime execution. While you shouldn't modify them directly, understanding their structure and purpose can help you debug issues and optimize your indexing process.

If you encounter persistent errors related to generated files, ensure your configuration, schema, and handlers follow Envio's best practices, or contact support for assistance.



Observability

File: Advanced/observability.mdx

HyperIndex gives you several complementary ways to understand what your indexer is doing - while you develop locally and once it's running in production:

  • Terminal UI - a live dashboard in your terminal.
  • Logs - structured logs from the runtime and your handlers.
  • Metrics - Prometheus metrics at the /metrics endpoint.
  • Health checks - a /healthz endpoint for liveness probes.
  • Indexing status - per-chain progress via the _meta GraphQL query.
  • Dev Console - a web UI for debugging local development.
  • Envio Cloud - managed dashboards, alerts, and metrics for hosted indexers.
Endpoints & port

When the indexer starts it serves an HTTP server (default port 9898) that exposes /metrics, /metrics/runtime, and /healthz. Change the port with ENVIO_INDEXER_PORT:

ENVIO_INDEXER_PORT=9899 envio start

Terminal UI

Running envio dev (or envio start) launches a live Terminal UI (TUI) that shows the state of your indexer at a glance:

  • Per-chain sync status and block numbers
  • Events processed and indexing progress
  • The GraphQL (Hasura) endpoint, and the Dev Console URL when running envio dev
  • HyperSync rate-limit information when you're being throttled

Disabling the TUI

The TUI is enabled by default in interactive terminals. It is automatically disabled when the process is non-interactive - when stdout is not a TTY, in CI (CI is set), under AI coding agents, or when TERM=dumb.

To disable it explicitly (for example, to capture plain logs), set ENVIO_TUI or pass the CLI flag:

export ENVIO_TUI=false   # or: envio dev --tui-off
Keep the TUI while capturing logs

The TUI can sometimes obscure errors. To keep the dashboard and write full logs to a file at the same time:

export LOG_STRATEGY="both-prettyconsole"
export LOG_FILE="./debug.log"

Logs

HyperIndex uses pino for high-performance structured logging. Logs come from two sources: the indexer runtime, and your own handler code.

Log levels

The runtime supports the following levels, in ascending order of severity. Levels prefixed with u are user-level logs emitted from your handlers and loaders via the context.log API.

LevelDescription
traceMost verbose; detailed tracing information
debugDebugging information for developers
infoGeneral information about system operation (default)
udebugUser-level debug logs
uinfoUser-level info logs
uwarnUser-level warning logs
uerrorUser-level error logs
warnSystem warnings
errorSystem errors
fatalCritical errors causing shutdown

Configure levels with environment variables:

LOG_LEVEL="info"        # Level for console output (default: info)
FILE_LOG_LEVEL="trace" # Level for file output (default: trace)

Use LOG_LEVEL="silent" to suppress console logs entirely.

Log format

The default format is human-readable with color-coded levels. Switch formats with LOG_STRATEGY - the ECS formats are convenient for shipping logs to the Elastic Stack (Kibana):

LOG_STRATEGY="console-pretty"      # Default: human-readable, colored console logs
LOG_STRATEGY="console-raw" # Raw pino JSON to console
LOG_STRATEGY="ecs-file" # ECS-formatted JSON to a file
LOG_STRATEGY="ecs-console" # ECS-formatted JSON to console
LOG_STRATEGY="file-only" # pino JSON to a file (most efficient)
LOG_STRATEGY="both-prettyconsole" # Pretty console + pino JSON to a file

# Where file-based strategies write to (default: logs/envio.log)
LOG_FILE="./logs/envio.log"

User logs

Inside your handlers, use the logging methods on the context object. They map to the u* levels above:

// Inside your handler
context.log.debug(`Processing event in block: ${event.block.number}`);
context.log.info(`Handled transfer of ${event.params.value}`);
context.log.warn(`Unexpected state for ${event.params.from}`);
context.log.error(`Failed to process event: ${event.transaction.hash}`);

// Pass an Error as the second argument to capture a stack trace:
context.log.error("Failed to process event", new Error("boom"));

// Or pass an object for structured logging:
context.log.info("Processing blockchain event", {
blockNumber: event.block.number,
contract: "ERC20",
data: { from: event.params.from, to: event.params.to },
});

For production analytics, pair the ECS log strategies with a tool like Kibana to build dashboards and alerts from your logs.

Performance

Logging isn't free - excessive logging (especially per-event debug/trace logs on high-throughput chains) can slow indexing down. Keep only the logs that serve a real observability need, and lean on metrics rather than logs for high-frequency signals.

Metrics

HyperIndex exposes Prometheus metrics so you can plug into your existing monitoring stack (Prometheus, Grafana, Datadog, etc.).

Stability

As of v3.0.0 the /metrics endpoint is official: metric names follow Prometheus conventions, time is measured in seconds, and the metric surface is covered by semver and documented here. The one exception to the seconds convention is envio_progress_latency, which is reported in milliseconds.

The /metrics endpoint

The running indexer serves Prometheus metrics at:

http://localhost:9898/metrics

A separate /metrics/runtime endpoint exposes Node.js process metrics (CPU, memory, garbage collection, event-loop lag) from a dedicated registry, isolated from the index metrics:

http://localhost:9898/metrics/runtime

Fetching metrics from the CLI

You don't need to curl the endpoint by hand - the CLI fetches metrics from a locally running indexer for you:

envio metrics          # raw Prometheus metrics from /metrics
envio metrics runtime # runtime metrics from /metrics/runtime

The command talks to the indexer on the port set by ENVIO_INDEXER_PORT, falling back to 9898.

Available metrics

Every metric is prefixed with envio_. Counters and gauges are labeled where noted (e.g. by chainId, contract, event).

Progress & sync status

MetricTypeDescription
envio_progress_blockgaugeLatest block number processed and stored in the database. Labeled by chainId.
envio_progress_eventsgaugeNumber of events processed and reflected in the database. Labeled by chainId.
envio_progress_readygaugeWhether the chain is fully synced to the head (1 = synced). Labeled by chainId.
envio_progress_latencygaugeMilliseconds between the latest processed event being created on chain and being written to storage. Labeled by chainId.

Event processing

MetricTypeDescription
envio_processing_secondscounterCumulative time spent executing event handlers during batch processing.
envio_processing_handler_secondscounterCumulative time spent inside individual event handler executions. Labeled by contract and event.
envio_processing_handler_totalcounterTotal number of individual event handler executions. Labeled by contract and event.
envio_processing_max_batch_sizegaugeMaximum number of items to process in a single batch.
envio_processing_stalled_on_fetch_secondscounterTime the indexer had nothing to process while waiting for events to be fetched. Waiting at the chain head for new blocks is not counted.
envio_processing_stalled_on_storage_write_secondscounterTime the indexer paused processing because too many changes were still waiting to be written.

Entity preloading

MetricTypeDescription
envio_preload_secondscounterCumulative time spent preloading entities during batch processing.
envio_preload_handler_secondscounterWall-clock time spent inside individual preload handler executions. Labeled by contract and event.
envio_preload_handler_seconds_totalcounterCumulative time spent in preload handlers (can exceed wall-clock time due to parallel execution). Labeled by contract and event.
envio_preload_handler_totalcounterTotal number of individual preload handler executions. Labeled by contract and event.

Storage

MetricTypeDescription
envio_storage_write_secondscounterCumulative time spent writing batch data to storage. Labeled by storage.
envio_storage_write_totalcounterTotal number of batch writes to storage. Labeled by storage.
envio_storage_load_secondscounterTime spent loading data from storage. Labeled by operation and storage.
envio_storage_load_seconds_totalcounterCumulative time spent loading data from storage during indexing. Labeled by operation and storage.
envio_storage_load_totalcounterNumber of successful storage load operations. Labeled by operation and storage.
envio_storage_load_sizecounterCumulative number of records loaded from storage. Labeled by operation and storage.
envio_storage_load_where_sizecounterCumulative number of filter conditions (where items) used in storage load operations. Labeled by operation and storage.

Data source & fetching

MetricTypeDescription
envio_fetching_block_range_secondscounterCumulative time spent fetching block ranges. Labeled by chainId.
envio_fetching_block_range_totalcounterTotal number of block range fetch operations. Labeled by chainId.
envio_fetching_block_range_events_totalcounterCumulative number of events fetched across all block range operations. Labeled by chainId.
envio_fetching_block_range_sizecounterCumulative number of blocks covered across all fetch operations. Labeled by chainId.
envio_fetching_block_range_parse_secondscounterCumulative time spent parsing block range fetch responses. Labeled by chainId.
envio_source_request_totalcounterNumber of requests made to data sources. Labeled by source, chainId, and method.
envio_source_request_seconds_totalcounterCumulative time spent on data source requests. Labeled by source, chainId, and method.
envio_source_height_stream_connects_totalcounterNumber of times a source's height subscription connected. Labeled by source and chainId. One more connect than disconnects means the stream is up; equal counts mean it is down and the indexer is polling instead. Zero connects is normal while a chain is still backfilling - subscriptions only open once a chain reaches the head.
envio_source_height_stream_disconnects_totalcounterNumber of times a source's height subscription lost a connection. Labeled by source, chainId and reason, and absent until the first disconnect. rotated is a connection that served its time, unsubscribed is the source being benched; any other reason ended a connection early. Failed retries aren't counted, so this measures outages rather than their length.
envio_source_known_heightgaugeLatest known block number reported by the data source. Labeled by source and chainId.

Indexing pipeline

MetricTypeDescription
envio_indexing_known_heightgaugeLatest known block number reported by the active indexing source. Labeled by chainId.
envio_indexing_concurrencygaugeNumber of executing concurrent queries to the chain data source. Labeled by chainId.
envio_indexing_buffer_sizegaugeCurrent number of items in the indexing buffer. Labeled by chainId.
envio_indexing_buffer_blockgaugeHighest block number fully fetched by the indexer. Labeled by chainId.
envio_indexing_idle_secondscounterTime the indexer source syncing has been idle. A high value may indicate a bottleneck. Labeled by chainId.
envio_indexing_partitionsgaugeNumber of partitions used to split fetching logic. Labeled by chainId.
envio_indexing_addressesgaugeNumber of address registrations on chain (static and dynamic). Labeled by chainId. An address shared by N contracts counts N times.
envio_indexing_contract_addressesgaugeNumber of address registrations per contract on chain (static and dynamic). Labeled by chainId and contract.
envio_indexing_target_buffer_sizegaugeIndexer-wide target buffer size shared across all chains. The queue may exceed it, but the indexer always tries to keep the buffer filled up to this target.
envio_indexing_end_blockgaugeThe block number to stop indexing at (inclusive). Labeled by chainId.
envio_indexing_source_querying_secondscounterTime spent performing queries to the chain data source. Labeled by chainId.
envio_indexing_source_waiting_secondscounterTime the indexer has been waiting for new blocks. Labeled by chainId.

Reorgs & rollbacks

MetricTypeDescription
envio_reorg_detected_totalcounterTotal number of reorgs detected.
envio_reorg_detected_blockgaugeBlock number where a reorg was last detected.
envio_reorg_thresholdgaugeWhether indexing is currently within the reorg threshold.
envio_rollback_enabledgaugeWhether rollback on reorg is enabled.
envio_rollback_totalcounterNumber of successful rollbacks on reorg.
envio_rollback_secondscounterTotal time spent on rollbacks.
envio_rollback_eventscounterNumber of events rolled back on reorg.
envio_rollback_target_blockgaugeBlock number the last reorg was rolled back to. Labeled by chainId.
envio_rollback_history_prune_totalcounterNumber of successful entity history prunes. Labeled by entity.
envio_rollback_history_prune_secondscounterTotal time spent pruning entity history outside the reorg threshold. Labeled by entity.

Effect API

Metrics for the Effect API.

MetricTypeDescription
envio_effect_call_secondscounterProcessing time taken to call the Effect function. Labeled by effect and scope.
envio_effect_call_seconds_totalcounterCumulative time spent calling the Effect function during indexing. Labeled by effect and scope.
envio_effect_call_totalcounterCumulative number of resolved Effect function calls. Labeled by effect and scope.
envio_effect_active_callsgaugeNumber of Effect function calls currently running. Labeled by effect and scope.
envio_effect_cachegaugeNumber of items in the effect cache. Labeled by effect and scope.
envio_effect_cache_invalidationscounterNumber of effect cache invalidations. Labeled by effect.
envio_effect_queuegaugeNumber of effect calls waiting in the rate-limit queue. Labeled by effect and scope.
envio_effect_queue_wait_secondscounterTime spent waiting in the rate-limit queue. Labeled by effect.

System info

MetricTypeDescription
envio_infogaugeInformation about the indexer. Labeled by version.
envio_process_start_time_secondsgaugeStart time of the process since the Unix epoch, in seconds.
envio_process_metric_time_secondsgaugeThe time these metrics were collected. Use it to tell how fresh a snapshot is, or to measure rates between two snapshots.
envio_process_elapsed_secondsgaugeHow long the indexer has been running. Divide a cumulative seconds metric by this to get the share of the run it took.

Scraping with Prometheus

Add a scrape job to your prometheus.yml:

scrape_configs:
- job_name: "envio-indexer"
metrics_path: "/metrics"
static_configs:
- targets: ["localhost:9898"] # use your ENVIO_INDEXER_PORT if you set one

Key metrics to watch

  • envio_progress_ready - confirm your indexer is caught up to the chain head.
  • envio_progress_block - track indexing progress over time.
  • envio_processing_handler_seconds - identify slow event handlers by contract and event.
  • envio_indexing_idle_seconds - a high value may indicate the data source sync is a bottleneck.
  • envio_reorg_detected_total - track how frequently chain reorganizations occur.

Finding the bottleneck

A slow indexer is usually waiting on one of two things: fetching events, or writing them. Since v3.5, two counters attribute that wait directly, so you no longer have to infer it from handler timings:

  • envio_processing_stalled_on_fetch_seconds - the indexer had nothing to process because events hadn't arrived yet. A high rate means fetching is the bottleneck: check data-source latency and whether it can take more concurrency. Time spent waiting at the chain head for new blocks is deliberately excluded, so a fully synced indexer doesn't look stalled.
  • envio_processing_stalled_on_storage_write_seconds - the indexer paused because too many changes were still queued for writing. A high rate means storage writes are the bottleneck: cross-check envio_storage_write_seconds and your database performance.

Both are cumulative counters, so read them as a rate. To turn one into a share of total runtime, divide by envio_process_elapsed_seconds:

# Fraction of the last 5 minutes spent stalled on fetches (0 to 1)
rate(envio_processing_stalled_on_fetch_seconds[5m])

# Same, but across the whole run so far
envio_processing_stalled_on_fetch_seconds / envio_process_elapsed_seconds

The same division works for any cumulative seconds metric - envio_processing_seconds / envio_process_elapsed_seconds gives the share of the run spent inside event handlers. If you scrape snapshots by hand rather than with Prometheus, envio_process_metric_time_seconds tells you when each was collected.

For a step-by-step walkthrough of capturing snapshots and working from a slow indexer to the metric that explains it, see Benchmarking Your Indexer.

Health checks

The indexer exposes a /healthz endpoint that returns HTTP 200 once the service is up. It's designed as a machine-readable liveness probe - for example, a Kubernetes livenessProbe:

GET http://localhost:9898/healthz  →  200 OK
livenessProbe:
httpGet:
path: /healthz
port: 9898
initialDelaySeconds: 10
periodSeconds: 10

Indexing status

To track per-chain indexing progress from your application, query the official _meta field on the GraphQL API. This is the most reliable way to know whether a chain has caught up before you read indexed data - useful for health checks, custom dashboards, and waiting for a write to be indexed before refetching.

{
_meta {
chainId
progressBlock
eventsProcessed
bufferBlock
firstEventBlock
sourceBlock
readyAt
isReady
startBlock
endBlock
}
}

Result:

{
"data": {
"_meta": [
{
"chainId": 1,
"progressBlock": 22817138,
"eventsProcessed": 2380000,
"bufferBlock": 22820499,
"firstEventBlock": 21688545,
"sourceBlock": 23368264,
"readyAt": null,
"isReady": false,
"startBlock": 0,
"endBlock": null
},
{
"chainId": 10,
"progressBlock": 137848820,
"eventsProcessed": 2455000,
"bufferBlock": 137873621,
"firstEventBlock": 130990676,
"sourceBlock": 141168975,
"readyAt": null,
"isReady": false,
"startBlock": 0,
"endBlock": null
}
]
}
}

Metadata fields

Configuration fields

These fields are populated on indexer startup and don't change during the indexer process.

  • chainId - Chain ID the metadata belongs to. Results are sorted by chainId in ascending order. Use _meta(where: { chainId: { _eq: 1 } }) to get the metadata for a specific chain.
  • startBlock - Start block number from config.yaml.
  • endBlock - End block number from config.yaml.

Transactional fields

These fields are updated in the batch write transaction, and are guaranteed to be written to the database at the same time. This means progressBlock and eventsProcessed increase at the same moment the data for the processed events is written to the database and becomes available for querying.

  • progressBlock - Block number fully processed and written to the DB.
  • eventsProcessed - Number of processed events written to the DB (reorg resistant).

Throttled fields

These fields are updated outside of the batch transaction and throttled to avoid performance overhead. There might be a small delay between the event processing and the metadata update.

  • bufferBlock - Block number of the latest event ready for processing.
  • firstEventBlock - Block number of the first processed event for the chain.
  • sourceBlock - The latest known block number of the actively used data source (the chain head).
  • readyAt - Timestamp when the chain finished historical sync or reached its end block.
  • isReady - Whether the chain finished historical sync or reached its end block.

Dev Console

When you run envio dev, HyperIndex enables the Dev Console - a web UI for debugging your indexer during local development, available at envio.dev/console. The TUI also prints the link on startup.

The Dev Console connects to your locally running indexer, so your data never leaves your machine. It's available in development mode only - envio start (production) does not expose console state.

Use it to inspect indexing state and to manage the Effect API cache while iterating.

Local GraphQL (Hasura)

envio dev also starts a local Hasura console at http://localhost:8080 for exploring your indexed data. The default admin secret is testing. Disable Hasura with ENVIO_HASURA=false.

Envio Cloud

If you deploy to Envio Cloud, observability is fully managed for you - no need to run Prometheus or ship logs yourself:

  • Real-time dashboard - sync status, events processed, and per-chain progress bars.
  • Logs - live and historical logs with level filtering, integrated and configured by Envio.
  • Built-in alerts - get notified via Discord, Slack, Telegram, Email, or a generic webhook when your endpoint goes down or your indexer stops processing.

Envio Cloud also exposes the same Prometheus metrics described above, so you can scrape them into your own Grafana or Datadog. On Cloud the endpoint is served under your deployment's URL:

<your-endpoint-url>/hyperindex/metrics
Version requirement

Prometheus metrics on Envio Cloud require indexers deployed with version 3.0.0 or higher.

See Monitoring Your Indexer and the Envio Cloud features page for details, and Envio Cloud CLI for monitoring deployments from the command line.


HyperIndex Terminology & Key Concepts

File: Advanced/terminology.md

This comprehensive glossary explains the key terms and concepts used throughout the Envio documentation and ecosystem. Terms are organized by category for easier reference.

Table of Contents

Blockchain Fundamentals

Address

A unique identifier representing an account or entity within a blockchain network. Addresses are typically represented as hexadecimal strings (e.g., 0x1234...abcd) and used to send, receive, or interact with blockchain resources.

Block

A collection of data containing a set of transactions that are bundled together and added to the blockchain. Blocks are linked together chronologically to form the blockchain.

EVM

Ethereum Virtual Machine (EVM) is a runtime environment that executes smart contracts on the Ethereum blockchain. It provides a sandboxed and deterministic execution environment for smart contract code.

EVM Compatible

The ability for a blockchain to run the EVM and execute Ethereum smart contracts. In the context of Envio, it's the ability to deploy a unified API to retrieve data from multiple EVM-compatible blockchains (e.g., Ethereum, BSC, Arbitrum, Polygon, Avalanche, Optimism, Fantom, Cronos, etc.).

Node

A device or computer that participates in a blockchain network, maintaining a copy of the blockchain and validating transactions.

Transaction

An action or set of actions recorded on the blockchain, typically involving the transfer of assets, execution of smart contracts, or other network interactions. Once confirmed, transactions become a permanent part of the blockchain.

Smart Contract Concepts

Event

A specific occurrence or action within a blockchain system that is specified in smart contracts and used to emit data from the blockchain. Smart contracts can emit events to essentially communicate that something has happened on the blockchain.

Web applications or any kind of application (e.g., mobile app, backend job, etc.) can listen to events and take actions when they occur. Events are typically data that are not stored on-chain as it would be considerably more expensive to store.

Example:

Declaring an event:

event Deposit(address indexed _from, bytes32 indexed _id, uint _value);

Emitting an event:

emit Deposit(msg.sender, _id, msg.value);

Event Handler

A function that listens for a specific event from a smart contract and either updates or inserts new data into your Envio API. Event handlers define the business logic for processing blockchain events.

Smart Contract

A self-executing program with the terms of an agreement directly written into code that runs on the blockchain. Smart contracts are not controlled by a user but are deployed to the network and run as programmed. User accounts can interact with smart contracts by submitting transactions that execute defined functions.

Tokens

Digital representations of assets or utilities within a blockchain system that follow a specific standard. Common token standards include:

  • ERC-20: Standard for fungible tokens (identical and interchangeable)
  • ERC-721: Standard for non-fungible tokens (unique and non-interchangeable)
  • ERC-1155: Multi-token standard supporting both fungible and non-fungible tokens

Indexing & Data

API

Application Programming Interface is a set of protocols and tools for building software applications. APIs define how different software components should interact with each other.

Endpoint

A URL that can be used to query an Envio custom API. Endpoints provide a structured way to request specific data from the indexer.

GraphQL

A query language for interacting with APIs, commonly used in blockchain systems for retrieving specific data from blockchain platforms. As an alternative to REST, GraphQL lets developers construct requests that pull data from multiple data sources in a single API call.

GraphQL API

The data presentation part of an Envio indexer. Typically, it's a GraphQL API auto-generated from the schema file, allowing flexible and efficient data queries.

Blockchain Indexer

A specialized database management system (DBMS) that indexes and organizes blockchain data, making it easier for developers to efficiently query, retrieve, and utilize on-chain data.

Web2 apps usually rely on indexers like Google to pre-sort information into indices for data retrieval and filtering. In blockchain and Web3, applications need blockchain indexers to achieve similar data retrieval capabilities.

Query

A request for data. In the context of Envio, a query is a request for data from an Envio API that will be answered by an Envio Indexer.

Schema File

A file that defines entities based on events emitted from smart contracts and specifies the data types for these entities. The schema serves as the blueprint for your indexed data structure.

Development Tools

Codegen

The process of automatically generating code based on a given input. In blockchain development, codegen is often used for generating client libraries, interfaces, or type-safe data access layers from schemas or specifications.

Envio CLI

A command line interface tool for building and deploying Envio indexers. The CLI provides commands for initializing, developing, and managing your indexer projects.

SDK

Software Development Kit is a collection of tools, libraries, and documentation that facilitates the development of applications for a specific platform or system.

Programming Languages

JavaScript

A high-level, interpreted programming language primarily used for client-side scripting in web browsers. It is the de facto language for web development, enabling developers to create interactive and dynamic web applications.

ReScript

A robustly typed language that compiles to efficient and human-readable JavaScript. ReScript aims to bring the power and expressiveness of functional programming to JavaScript development. It offers seamless integration with JavaScript and provides features like static typing, pattern matching, and immutable data structures.

TypeScript

A superset of JavaScript that adds static typing and other advanced features to the language. It compiles down to plain JavaScript, making it compatible with existing JavaScript codebases. TypeScript helps developers catch errors during development by providing type-checking and improved tooling support. It enhances JavaScript by adding features like interfaces, classes, modules, and generics.

Envio Platform

Envio Cloud

A managed service platform for building, hosting, and querying Envio's Indexers with guaranteed uptime and performance service level agreements. Envio Cloud removes the operational burden of running blockchain indexers.

Ploffen

Ploffen (meaning "Pop" in Dutch) is a fun game based on an ERC20 token contract, where users can deposit a game token (i.e., make a contribution) into a savings pool.

The last user to add a contribution to the savings pool has a chance of winning the entire pool if no other user deposits a contribution within 1 hour of the previous contribution. For example, if 30 persons play the game, and each person contributes a small amount, the last person can win the total contributions made by all 30 persons in the savings pool.

The Ploffen project demonstrates a Hardhat framework example. It includes a sample contract, a test for that contract, a deployment script, and the Envio integration to index emitted events from the Ploffen smart contract.

Mathematical Concepts

Commutative Property

A fundamental property of certain binary operations in mathematics. An operation is said to be commutative if the order in which you apply the operation to two operands does not affect the result. In other words, for a commutative operation:

a + b = b + a

Examples of commutative operations:

  1. Addition: 2 + 3 = 3 + 2
  2. Multiplication: 2 _ 3 = 3 _ 2

Examples of non-commutative operations:

  1. Subtraction: 5 - 3 ≠ 3 - 5
  2. Division: 8 / 4 ≠ 4 / 8
  3. String Concatenation: "Hello" + "World" ≠ "World" + "Hello"

The commutative property is a property of the operation itself, not necessarily the numbers involved. If an operation is commutative, you can switch the order of the operands without changing the result.


Optimizing Database Performance in HyperIndex

File: Advanced/performance/database-performance-optimization.md

Introduction

As your indexed data grows, database performance becomes critical to maintaining responsive queries and efficient operations. This guide explains how to optimize your HyperIndex database through strategic indexing and schema design to ensure your applications remain fast even as data volume increases.

Understanding Database Indices

Database indices are special data structures that improve the speed of data retrieval operations. Think of them like the index at the back of a book - rather than scanning every page (row) to find what you're looking for, indices allow the database to quickly locate the relevant data.

Why Indices Matter

Without proper indices, your database must perform "full table scans" when searching for data, examining every row to find matches. As your data grows, this becomes increasingly inefficient:

Data SizeWithout IndicesWith Proper Indices
1,000 records~10ms~1ms
100,000 records~500ms~2ms
1,000,000+ records5+ seconds~5ms

Note: Actual performance varies based on hardware, query complexity, and database load.

Creating Custom Indices in Your Schema

HyperIndex provides several ways to define indices in your GraphQL schema, giving you control over database performance.

Single-Column Indices

The simplest form of indexing is on individual fields using the @index directive:

type Transaction {
id: ID!
userAddress: String! @index
tokenAddress: String! @index
amount: BigInt!
timestamp: BigInt! @index
}

In this example:

  • Queries filtering on userAddress (e.g., finding all transactions for a user)
  • Queries filtering on tokenAddress (e.g., finding all transactions for a token)
  • Queries filtering on timestamp (e.g., finding transactions in a date range)

All become significantly faster because the database can use the indices to quickly locate matching records.

Composite Indices for Multi-Field Queries

When you frequently query using multiple fields together, composite indices provide better performance:

type Transfer @index(fields: ["from", "to", "tokenId"]) {
id: ID!
from: String! @index
to: String! @index
tokenId: BigInt!
value: BigInt!
timestamp: BigInt!
}

This creates:

  1. Individual indices on from and to fields
  2. A composite index on the combination of from, to, and tokenId

Composite indices are particularly valuable for complex queries that filter on multiple columns simultaneously, such as "find all transfers from address X to address Y for token Z."

Automatic Indices

HyperIndex automatically creates indices for:

  • All ID fields
  • All fields marked with @derivedFrom

There's no need to manually add indices for these fields.

Deferred Index Creation

Indices make writes slower, and a backfill is nothing but writes. Since v3.5, HyperIndex builds the indices your schema declares in one pass after the backfill completes, rather than up front. Some users see a 2.5x backfill speedup from this alone.

Nothing to configure. Two things change in what you'll observe:

  • Querying the database directly mid-backfill is slower, because the declared indices don't exist yet.
  • Near the end of the backfill, the indexer pauses to create them and logs that it's doing so. On a large database this takes a while - it's progress, not a hang.

Indices created on demand

A getWhere call on a field with no index creates one the first time a handler asks for it. That means getWhere works on any entity field, and you don't need to declare @index for the fields your handlers filter on - HyperIndex works out which indices your handlers need.

@index is for the queries you serve. Declare it on the fields your GraphQL consumers filter and sort by, since HyperIndex can't know those from your handler code:

type Transfer {
id: ID!
userAddress: String! @index
timestamp: BigInt!
}

Strategic Indexing: When to Use Each Type

When to Use Single-Column Indices

Use single-column indices when:

  • You frequently filter by a specific field
  • You sort results by a specific field
  • The field has high "cardinality" (many different values)

Example use case: Indexing userAddress in a transaction table when users frequently look up their transaction history.

When to Use Composite Indices

Use composite indices when:

  • You frequently query using multiple fields together
  • Your queries consistently filter on the same combination of fields
  • You need to optimize complex queries with multiple conditions

Example use case: Indexing (tokenAddress, timestamp) together when users frequently view token transaction history within specific time ranges.

Performance Tradeoffs

While indices improve query performance, they come with tradeoffs:

Write Performance Impact

Each index requires additional updates when data is inserted or modified:

  • No indices: Fastest write performance, but slow reads
  • Few targeted indices: Slight write slowdown (5-10%), much faster reads
  • Many indices: Noticeable write slowdown (15%+), fastest possible reads

For most applications, the read performance benefits outweigh the write performance costs, especially since blockchain data is primarily read-intensive.

Storage Considerations

Indices increase database storage requirements:

  • Each index typically requires 2-10 bytes per row
  • For large datasets (millions of records), this can add up
  • Consider storage requirements when designing indices for very large tables

Practical Examples

Optimizing Token Transfer Queries

Consider a token transfer entity:

type TokenTransfer {
id: ID!
token: Token! @index
from: String! @index
to: String! @index
amount: BigInt!
blockNumber: BigInt! @index
timestamp: BigInt! @index
}

With this schema, the following queries will be optimized:

  • Find all transfers for a specific token
  • Find all transfers from a specific address
  • Find all transfers to a specific address
  • Find transfers within a specific block range
  • Find transfers within a specific time range

Optimizing Complex NFT Marketplace Queries

For an NFT marketplace with listings and sales:

type NFTListing
@index(fields: ["collection", "status", "price"])
@index(fields: ["seller", "status"]) {
id: ID!
collection: String! @index
tokenId: BigInt!
seller: String! @index
price: BigInt!
status: String! @index # "active", "sold", "cancelled"
createdAt: BigInt! @index
}

This schema efficiently supports:

  • Finding all active listings for a collection, sorted by price
  • Finding all listings by a specific seller with a specific status
  • Finding recently created listings across all collections

Optimizing GraphQL Queries

Beyond schema design, how you write your GraphQL queries affects performance:

Fetch Only What You Need

Request only the fields you actually need:

# Good
query {
tokenTransfers(where: { token: { _eq: "0x123" } }, limit: 10) {
id
amount
}
}

# Bad - fetches unnecessary fields
query {
tokenTransfers(where: { token: { _eq: "0x123" } }, limit: 10) {
id
amount
from
to
timestamp
blockNumber
transactionHash
# other fields you don't need
}
}

Use Pagination for Large Result Sets

Always paginate large result sets:

query {
tokenTransfers(
where: { token: { _eq: "0x123" } }
limit: 20
offset: 40 # Skip first 40 results (page 3 with 20 items per page)
) {
id
amount
}
}

Use Timestamps for Efficient Polling

When building applications that poll for updates, use timestamps to fetch only new data:

query getUpdatedTransfers($lastFetched: BigInt!) {
tokenTransfers(where: { timestamp: { _gt: $lastFetched } }) {
id
from
to
amount
}
}

Diagnosing Slow Queries

If your GraphQL queries are returning slowly, follow this workflow:

1. Check if you have indices on filtered fields

The most common cause of slow queries is missing indices. If you're filtering or sorting by a field that doesn't have @index, add it:

# Before - slow queries on userAddress
type Transaction {
id: ID!
userAddress: String!
}

# After - fast queries on userAddress
type Transaction {
id: ID!
userAddress: String! @index
}

After adding indices, you'll need to redeploy (on Envio Cloud) or restart locally for the schema changes to take effect.

2. Reduce result set size

Large unbounded queries are a common cause of slow responses. Always use limit and check that you're not requesting more data than needed.

Summary

Proper database indexing is essential for maintaining performance as your indexed data grows. By strategically placing indices on frequently queried fields and field combinations, you can ensure fast query responses even with large datasets.

Key takeaways:

  • Use @index for frequently filtered or sorted individual fields
  • Use composite indices for multi-field query patterns
  • Consider performance tradeoffs for write-heavy applications
  • Design your schema and queries with performance in mind from the start
  • Always use pagination for large result sets

Understanding Chain Head Latency

File: Advanced/performance/latency-at-head.md

Maintaining low latency at the chain head is crucial for ensuring timely data updates in your indexed data. This page explains how HyperSync handles this important aspect of blockchain indexing.

HyperSync Block Retrieval

  • Efficient Processing: We pull new blocks from HyperSync using a highly efficient process, ensuring your indexer stays up-to-date with minimal delay.
  • Reliable Operation: This process typically runs smoothly without significant issues.
  • Redundancy Plans: We're developing a system to sync new blocks from both RPC and HyperSync simultaneously, improving robustness if one source experiences issues.

Chain-Specific Performance

Optimized Major Chains

  • Priority Chains: We've dedicated significant resources to maintaining extremely low latency on popular chains including Ethereum, Optimism, and Arbitrum.
  • User Experience: Users should experience seamless, near real-time data updates on these chains.

Smaller Chains

  • Standard Performance: On smaller chains, latency might be slightly higher as they have received less optimization.
  • Improvement Process: Your feedback helps us prioritize which chains to optimize next. Please let us know in Discord if low latency on specific smaller chains is important for your use case.

Special Configuration Options

Multi-Chain Indexing

  • Unordered Event Ordering: For applications indexing multiple chains, HyperIndex always processes events in unordered mode, so each chain keeps syncing independently - there is nothing to configure.
  • Resilient Design: With this configuration, even if one chain experiences latency, your other chains will continue syncing normally.

Chain Reorganization Support

  • Reorg Handling: Our reorg support system ensures data consistency even when chains reorganize.
  • Documentation: Contact our team on Discord if you have concerns about reorg support while we finalize documentation.

Envio Cloud Performance

Envio Cloud offers reliable performance with ongoing improvements:

  • Continuous Enhancement: We're actively improving sync and build times on Envio Cloud.
  • Relative Performance: Currently, indexers may run slightly slower on Envio Cloud compared to high-performance local machines.
  • Enterprise Solutions: For applications requiring exceptional performance, contact us on Discord to discuss our enterprise hosting plans.

By leveraging these features and providing feedback on your specific needs, you can help us continually improve the HyperIndex head latency performance.


Benchmarking Your Indexer

File: Advanced/performance/benchmarking.md

Every HyperIndex indexer reports what it's doing over an HTTP metrics endpoint while it runs. There's no benchmarking mode to enable and no profiler to attach

  • if the indexer is running, the numbers are already there.

This page turns those numbers into an answer to one question: what is my indexer waiting on, and what do I do about it?

Work through it in order:

  1. Measure throughput - how fast is it, in events per second?
  2. Find the bottleneck - which of five things is it waiting on?
  3. Fix it - the section for your bottleneck says what to change.
  4. Prove the fix worked - re-measure and compare.

Step 1: Measure throughput

Start the indexer in one terminal - locally with envio dev, or in production with envio start:

pnpm envio dev

Read its metrics from a second terminal, in the same project:

pnpm envio metrics          # indexing metrics
pnpm envio metrics runtime # Node.js process metrics (CPU, memory, GC, event-loop lag)

The output is plain Prometheus text - a # HELP line describing each metric, a # TYPE line, then one sample per line with its labels in braces:

# HELP envio_progress_events The number of events processed and reflected in the database.
# TYPE envio_progress_events gauge
envio_progress_events{chainId="1"} 158205

# HELP envio_process_elapsed_seconds How long the indexer has been running.
# TYPE envio_process_elapsed_seconds gauge
envio_process_elapsed_seconds 45.801

Because every metric documents itself, grepping for the metric you care about - pnpm envio metrics | grep envio_progress - is usually the fastest way to answer a specific question.

Take two snapshots a minute or so apart, and divide the change in events by the change in elapsed time:

pnpm envio metrics > t1.txt
# wait ~60s
pnpm envio metrics > t2.txt

grep -E '^envio_(progress_events|process_elapsed_seconds)' t1.txt t2.txt
# t1: 158205 events at 45.8s
# t2: 262000 events at 105.8s
# (262000 - 158205) / (105.8 - 45.8) = ~1730 events/second

envio_progress_events is reported per chainId, so a multichain indexer prints one sample per chain: compare the same chainId across both snapshots for a per-chain figure, or sum every chain within each snapshot for the indexer as a whole. Both snapshots also have to come from one uninterrupted run - restarting the indexer resets envio_process_elapsed_seconds, so start a fresh baseline after any restart. (Prometheus rate() handles that for you when you scrape instead.)

Interpret the result against what your workload plausibly allows:

Events per secondReading
Over 10,000Excellent - most likely bounded by the data source, not by you
1,000–5,000Good. Worth tuning only if your sync time is still too long
Under 500Something is probably wrong. Continue to step 2
Measure over historical sync, not at the head

Once a chain is caught up (envio_progress_ready is 1), throughput reflects how fast new blocks arrive, not how fast your indexer is. Benchmark while the chain is still syncing history, or against a fixed block range with an end_block in config.yaml, so two runs are comparable.

Step 2: Find the bottleneck

A slow indexer is waiting on something. Two counters attribute that wait directly - read them first, because they point at the answer instead of hinting at it:

pnpm envio metrics | grep -E 'stalled_on|process_elapsed'
MetricMeaningGo to
envio_processing_stalled_on_fetch_secondsNothing to process - events hadn't been fetched yetFetching
envio_processing_stalled_on_storage_write_secondsProcessing paused - too many changes queued for writingStorage writes

Both are cumulative seconds, so read them as a share of the run:

envio_processing_stalled_on_fetch_seconds / envio_process_elapsed_seconds

Anything above roughly 0.3 (30% of the run) is worth acting on. The same division works for every _seconds counter - that's how you compare parts of the pipeline against each other:

MetricShare of the run spent…
envio_processing_secondsinside your event handlers
envio_preload_secondsloading entities for a batch
envio_storage_write_secondswriting batches to storage
envio_fetching_block_range_secondsfetching block ranges (per chainId)
envio_effect_call_seconds_totalinside Effect API calls (per effect)
Counters, counts, and gauges

Only _seconds counters are durations you can divide by elapsed time. Counts (envio_progress_events, envio_processing_handler_total) and gauges (envio_progress_block, envio_indexing_concurrency) are read as values or as deltas between snapshots. envio_process_metric_time_seconds records when a snapshot was taken, so you can compute rates between two of them by hand.

If you scrape with Prometheus instead of reading snapshots, every counter above works as a rate - rate(envio_processing_stalled_on_fetch_seconds[5m]) - see Scraping with Prometheus.

Step 3: Fix the bottleneck

Each section below covers one bottleneck: how you recognise it, what it means, and what to change. Work only the one that step 2 pointed you at.

Fetching

How you know

pnpm envio metrics | grep -E 'stalled_on_fetch|indexing_idle|fetching_block_range_seconds'

envio_processing_stalled_on_fetch_seconds is a large share of the run, and envio_fetching_block_range_seconds dominates the other _seconds counters. envio_indexing_idle_seconds climbing on a specific chainId tells you which chain is holding you up.

What it means

Your handlers are idle waiting for data. Time spent waiting at the chain head for new blocks is deliberately excluded from the stall counter, so a fully synced indexer never looks stalled here.

What to do

  • Use HyperSync if it supports your chain - it's the single biggest change available, and RPC is orders of magnitude slower.
  • On RPC, a faster provider or a higher rate limit is the fix; check envio_source_request_seconds_total per source to see what each one costs you.
  • Compare envio_indexing_concurrency with envio_indexing_partitions for the chain - if concurrency sits at its ceiling while events still arrive slowly, the source is saturated, not the indexer.
  • Watch envio_fetching_block_range_parse_seconds: when parsing rivals fetching, you're decoding far more events than your handlers use, so narrow the events in config.yaml.

Entity loading

How you know

pnpm envio metrics | grep -E 'preload_seconds|storage_load'

envio_preload_seconds or envio_storage_load_seconds_total is a large share of the run. envio_storage_load_size and envio_storage_load_where_size (labeled by operation and storage) show which loads pull the most data.

What it means

Preload optimization batches the entity reads of a whole event batch into a few queries - but only for reads it can see during the preload phase. A read hidden behind a condition that only some events reach, or one that depends on the result of an earlier read, falls back to a single-event round trip.

What to do

  • Read entities unconditionally near the top of the handler so the preload phase can batch them, rather than inside a branch.
  • Avoid chaining reads (get → use the id you just read → get again); load both entities up front where possible.
  • Add database indices for the fields you filter on with getWhere.
  • Reduce how much each load returns: a getWhere that matches thousands of rows costs more than the handler usually needs.

Handlers

How you know

pnpm envio metrics | grep -E 'processing_handler_(seconds|total)'

envio_processing_seconds is a large share of the run. To find the specific handler, divide cumulative time by call count - both are labeled by contract and event:

envio_processing_handler_seconds / envio_processing_handler_total

What it means

Your own code is the cost. A mean above ~1ms per call is worth a look; compare handlers against each other rather than against an absolute number.

What to do

  • Move external calls (RPC, HTTP, IPFS) out of the handler and into the Effect API, which batches, parallelizes, and caches them.
  • Keep heavy derived computation out of the hot path - store what you need and compute the rest at query time.
  • Look for accidental work per event: parsing the same static data repeatedly, formatting, or building large intermediate objects.

Storage writes

How you know

pnpm envio metrics | grep -E 'stalled_on_storage_write|storage_write'

envio_processing_stalled_on_storage_write_seconds is a large share of the run, and envio_storage_write_seconds is high next to envio_storage_write_total (labeled by storage).

What it means

The indexer produced entity changes faster than storage accepted them, so processing paused until the write queue drained.

What to do

  • Write fewer entities per event. Updating a running-total entity on every event makes each event a write; aggregating in memory and writing on a coarser boundary avoids most of them.
  • Don't update the same entity several times within one handler - the last write wins anyway.
  • Check your database: indices speed up reads but every extra index slows writes down, and an under-resourced Postgres shows up here first.
  • If reorg support is enabled, entity history is written alongside your entities - envio_rollback_history_prune_seconds per entity shows what that costs.

External calls

How you know

pnpm envio metrics | grep -E 'envio_effect_'

envio_effect_call_seconds_total is a large share of the run, or envio_effect_queue_wait_seconds shows calls waiting on a rate limit. Both are labeled per effect, so the slow one names itself.

What it means

Handlers are blocked on Effect API calls to something outside the indexer - an RPC node, an HTTP API, IPFS.

What to do

  • Set cache: true on effects whose result is stable for a given input, so a rerun doesn't repeat the call; envio_effect_cache tracks cache size and envio_effect_cache_invalidations how often it's discarded.
  • Raise the effect's rateLimit if the provider allows it - envio_effect_queue sitting above zero means calls are queuing rather than running.
  • If effects are slow and can't be batched, lower full_batch_size so a batch doesn't wait on thousands of pending calls.
  • Compare envio_effect_call_seconds_total with envio_effect_call_total to separate "each call is slow" from "there are too many calls".

Step 4: Prove the fix worked

Benchmarking is only useful as a loop:

  1. Capture a baseline before changing anything: pnpm envio metrics > before.txt.
  2. Change one thing. Two changes at once make it impossible to attribute the difference.
  3. Re-run over the same block range, ideally from a fresh database so the run does the same work.
  4. Compare the same numbers: events per second from step 1, and the share of the run for the counter you targeted.
pnpm envio metrics > after.txt
diff <(grep -E '^envio_(progress_events|.*_seconds)' before.txt) \
<(grep -E '^envio_(progress_events|.*_seconds)' after.txt)

Two things to watch while you iterate:

  • Memory. pnpm envio metrics runtime reports heap usage, GC, and event-loop lag. An indexer that speeds up while memory climbs is often deferring work rather than avoiding it.
  • Realistic conditions. Benchmark against a block range with the event mix your indexer actually sees - a quiet range flatters every change you make.

Next Steps

  • Observability - the full metric reference, logs, health checks, and _meta indexing status
  • Preload Optimization - how batched entity reads work
  • Effect API - batching, parallelizing, and caching external calls
  • Database Performance - indices and schema design
  • Latency at the Head - what to expect once the indexer is caught up
  • Still stuck? Share your metrics snapshot in Discord - we're happy to read it with you.

Loaders Optimization (Removed in V3)

File: Advanced/loaders.md

warning

The handlerWithLoader API and the loaders flag in config.yaml were removed in HyperIndex V3. Preload Optimization is now always on - there is no flag to enable or disable it. This page is kept for historical context and to help V2 projects migrate.

What Were Loaders?

Loaders were a feature in early V2 versions of HyperIndex that significantly improved database access performance for event handlers.

They worked by implementing the Preload Optimization - loading required data upfront before processing events.

The preloaded data would then be available to event handlers through a loaderReturn object, eliminating the need for individual database queries during event processing.

In V2, handlers with loaders didn't have a Preload Phase and always ran once. In V3, every handler runs through the Preload Phase automatically, so the dedicated handlerWithLoader API and the loaders: config flag have both been removed.

The V2 shape looked like this (no longer accepted in V3):

// V2 only — removed in V3
ContractName.EventName.handlerWithLoader({
// The loader function runs before event processing starts
loader: async ({ event, context }) => {
// Load all required data from the database
// Return the data needed for event processing
return {}; // This will be available in the handler as loaderReturn
},

// The handler function processes each event with pre-loaded data
handler: async ({ event, context, loaderReturn }) => {
// Process the event using the data returned by the loader
},
});

How It Works in V3

In V3 the optimization is built in. See Preload Optimization - How It Works? for the full mechanics. The two-phase execution is identical to what loaders provided, just without a separate API.

For example, this is how a V2 loader is rewritten as a regular V3 handler - the only thing that changed is that the loader code now lives inline at the top of the handler:

// V2 — removed
ERC20.Transfer.handlerWithLoader({
loader: async ({ event, context }) => {
// Load sender and receiver accounts efficiently
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);

// Return the loaded data to the handler
return {
sender,
receiver,
};
},

handler: async ({ event, context, loaderReturn }) => {
const { sender, receiver } = loaderReturn;

// Process the transfer with the pre-loaded data
// No database lookups needed here!
},
});

// V3 — Preload Optimization is always on

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
// Load sender and receiver accounts efficiently
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);

// To imitate the behavior of the loader,
// we can use `context.isPreload` to make next code run only once.
// Note: This is not required, but might be useful for CPU-intensive operations.
if (context.isPreload) {
return;
}

// Process the transfer with the pre-loaded data
},
);

If your project still has loaders: true or preload_handlers: true in config.yaml, remove both fields - V3 will reject them.


Using WebSockets with GraphQL

File: Advanced/websockets.md

⚠️ Important: WebSocket support is available but should be used at your own risk on plans other than dedicated.

Overview

By default, HyperIndex provides GraphQL endpoints over HTTP/HTTPS. However, you can also connect to your GraphQL endpoint using WebSocket connections (WSS/WS) for real-time subscriptions and persistent connections.

WebSocket Support Status

WebSocket connections are available with different levels of support depending on your hosting plan:

  • Dedicated: WebSockets are fully supported. The dedicated package includes additional infrastructure such as connection pooling which enables proper WebSocket support.
  • Other Plans: WebSocket support is available but should be used at your own risk. These plans do not have the same infrastructure support for WebSockets as dedicated. We don't recommend relying on WebSockets for more than 10 concurrent connections.

How to Use WebSockets

To use WebSockets with your HyperIndex GraphQL endpoint, simply swap the protocol in your endpoint URL:

Protocol Mapping

  • HTTPS → WSS: Change https:// to wss:// in your GraphQL endpoint URL
  • HTTP → WS: Change http:// to ws:// in your GraphQL endpoint URL

Example

If your GraphQL endpoint is:

https://indexer.hyperindex.xyz/123abcd/graphql

You can connect via WebSocket using:

wss://indexer.hyperindex.xyz/123abcd/graphql

Similarly, for HTTP endpoints:

http://localhost:8080/v1/graphql

Becomes:

ws://localhost:8080/v1/graphql

Usage Example

Here's a TypeScript example using the graphql-ws library to subscribe to real-time updates from your indexer:


// Define the entity you are subscribing to (must match your schema definition)
interface Swap {
id: string;
}

interface SubscriptionData {
data?: {
Swap?: Swap[];
};
}

const client = createClient({
url: 'ws://localhost:8080/v1/graphql',
});

console.log('Connecting to WebSocket...');

client.subscribe<SubscriptionData>(
{
query: `
subscription {
Swap(order_by: { id: desc }, limit: 10) {
id
}
}
`,
},
{
next: (data) => {
console.log('\n📥 New event received:');
console.log(JSON.stringify(data, null, 2));
},
error: (err) => {
console.error('❌ Error:', err);
},
complete: () => {
console.log('✅ Subscription completed');
},
}
);

Installation

To use this example, install the required dependencies:

npm install graphql-ws ws
# or
yarn add graphql-ws ws

Getting Support

For any questions about WebSocket usage, contact the Envio team via Telegram or Discord.


Query Conversion

File: Advanced/query-conversion.md

Envio uses standard GraphQL query language, while TheGraph uses a custom GraphQL syntax. While the queries are very similar, there are some important differences to be aware of when migrating.

This guide covers all the differences between TheGraph's query syntax and Envio's query syntax, with examples for each conversion rule.

Converter Tool

We've built a query converter tool that automatically converts TheGraph queries to Envio's GraphQL syntax. You can:

  • Convert and execute: Provide your Envio GraphQL endpoint and a query written in TheGraph syntax. The tool will convert it, execute it against your endpoint, and return the results
  • Convert only: Use the tool to convert queries and view the converted output without executing them. To convert queries without executing them, add /debug to the converter endpoint and send your query. The response will contain only the converted query without actually running it.

Repository: subgraph-to-hyperindex-query-converter

Availability

The query converter tool is only available to users on paid tiers.

Setup Assistance

If you'd like to use the query converter tool for your indexer, please reach out to our team and we can add an instance of the converter tool to be deployed with your indexer.

Best Practice

We strongly recommend converting your queries to use Envio's standard GraphQL syntax rather than relying on the query converter tool. This ensures better performance, maintainability, and avoids potential conversion limitations. The converter tool is primarily intended for backwards compatibility in cases where you might have external-facing APIs which depend on TheGraph's query syntax.

Beta Status

This converter tool is still very much in beta. We're actively working on it and discovering new query conversions that need to be handled.
If you encounter any conversion failures or incorrect conversions, please file a GitHub issue in the repository so we can address it.

Converter Tool Limitations

The query converter tool has several limitations to be aware of:

  • Rate limits: The rate limits of your associated tier will still apply when using the converter tool.
  • Beta status and incomplete coverage: Since the tool is in beta there is a possibility we have missed some conversion patterns. Some queries may still fail to convert correctly or may not be handled at all. If you encounter any conversion failures or incorrect conversions, please file a GitHub issue in the repository so we can address it.
  • WebSocket subscriptions: WebSocket subscriptions are not supported. The converter only works with standard HTTP GraphQL queries.
  • Directives not supported: GraphQL directives (such as @skip, @include, etc.) are not supported because the converter uses simple string parsing rather than a full GraphQL parser. Directives are ignored or may break parsing.
  • Variables in orderBy/orderDirection: Variables used in orderBy and orderDirection parameters are ignored because HyperIndex requires literal field names in order_by clauses, and variable values are unknown at conversion time. Queries using variables for ordering will return unordered results.
  • Array filter operators: The _containsAny and _containsAll filters are TheGraph-specific array operators that don't have direct Hasura equivalents. The converter explicitly rejects these and returns an UnsupportedFilter error. Use _in for array matching instead.
  • Meta queries: Meta queries only support _meta { block { number } } because HyperIndex exposes block information differently. Other _meta fields (hash, timestamp, deployment, hasIndexingErrors) are not available in HyperIndex's schema and will return a ComplexMetaQuery error.
  • Introspection queries: Introspection queries only work if they use the operation name "IntrospectionQuery". Other introspection queries (like querying __schema directly) will fail because they go through normal conversion which doesn't understand introspection syntax.

For production applications, we recommend migrating your queries to Envio's standard GraphQL syntax to avoid these limitations.


1. Entity Name Conversion

Rule: TheGraph uses pluralized entity names (e.g., pools, factories, tokens), while Envio uses the entity name as-is from the schema (singular, PascalCase). When converting, plural entity names are automatically singularized and capitalized to match Envio's schema.

Example:

# TheGraph
query {
pools { id }
factories { id }
tokens { id }
}

# Envio
query {
Pool { id }
Factory { id }
Token { id }
}

Single entity queries use EntityName_by_pk (by primary key) in Envio. Alternatively, you could use a where clause with the primary key field:

# TheGraph
query {
pool(id: "0x123") { value }
}

# Envio
query {
Pool_by_pk(id: "0x123") { value }
}

Or using a where clause:

# Envio
query {
Pool(where: {id: {_eq: "0x123"}}) { value }
}

2. Pagination Parameters

First → Limit

Rule: The first parameter is converted to limit.

Example:

# TheGraph
query {
pools(first: 10) { id }
}

# Envio
query {
Pool(limit: 10) { id }
}

Skip → Offset

Rule: The skip parameter is converted to offset.

Example:

# TheGraph
query {
pools(skip: 20) { id }
}

# Envio
query {
Pool(offset: 20) { id }
}

3. Ordering Parameters

OrderBy and OrderDirection → Order_by

Rule: The orderBy and orderDirection parameters are combined into a single order_by: {field: direction} clause.

Example:

# TheGraph
query {
pools(orderBy: name, orderDirection: desc) { id name }
# Use orderDirection: asc for ascending order
}

# Envio
query {
Pool(order_by: {name: desc}) { id name }
# Use asc for ascending order, e.g., order_by: {name: asc}
}

4. Filter Operators

Equality Filter

Rule: Simple equality filters are converted to where: {field: {_eq: value}} format.

Example:

# TheGraph
query {
pools(name: "test") { id name }
}

# Envio
query {
Pool(where: {name: {_eq: "test"}}) { id name }
}

Comparison Filters

Rule: Comparison filters (_not, _gt, _gte, _lt, _lte, _in, _not_in) are converted to their Hasura equivalents.

Examples:

# TheGraph
query {
pools(id_not: "0x123") { id }
pools(amount_gt: 100) { id amount }
pools(amount_gte: 100) { id amount }
pools(timestamp_lt: 1650000000) { id timestamp }
pools(timestamp_lte: 1650000000) { id timestamp }
pools(id_in: ["1", "2", "3"]) { id name }
pools(id_not_in: ["1", "2", "3"]) { id name }
}

# Envio
query {
Pool(where: {id: {_neq: "0x123"}}) { id }
Pool(where: {amount: {_gt: 100}}) { id amount }
Pool(where: {amount: {_gte: 100}}) { id amount }
Pool(where: {timestamp: {_lt: 1650000000}}) { id timestamp }
Pool(where: {timestamp: {_lte: 1650000000}}) { id timestamp }
Pool(where: {id: {_in: ["1", "2", "3"]}}) { id name }
Pool(where: {id: {_nin: ["1", "2", "3"]}}) { id name }
}

String Filters

Rule: String filters (_contains, _starts_with, _ends_with, and their _not and _nocase variants) are converted to _ilike with appropriate wildcards. The % symbol represents any text at that position in the pattern.

Examples:

# TheGraph
query {
pools(name_contains: "test") { id name }
pools(name_not_contains: "test") { id name }
pools(symbol_starts_with: "ABC") { id symbol }
pools(symbol_ends_with: "XYZ") { id symbol }
pools(name_not_starts_with: "A") { id name }
pools(name_not_ends_with: "x") { id name }
pools(name_contains_nocase: "test") { id name }
pools(name_starts_with_nocase: "test") { id name }
pools(name_ends_with_nocase: "test") { id name }
}

# Envio
query {
Pool(where: {name: {_ilike: "%test%"}}) { id name }
Pool(where: {_not: {name: {_ilike: "%test%"}}}) { id name }
Pool(where: {symbol: {_ilike: "ABC%"}}) { id symbol }
Pool(where: {symbol: {_ilike: "%XYZ"}}) { id symbol }
Pool(where: {_not: {name: {_ilike: "A%"}}}) { id name }
Pool(where: {_not: {name: {_ilike: "%x"}}}) { id name }
Pool(where: {name: {_ilike: "%test%"}}) { id name }
Pool(where: {name: {_ilike: "test%"}}) { id name }
Pool(where: {name: {_ilike: "%test"}}) { id name }
}

5. Variable Type Conversions

ID → String

Rule: Variable types ID and ID! are converted to String and String! respectively.

Example:

# TheGraph
query getPoolValue($id: ID!) {
pool(id: $id) { value }
}

# Envio
query getPoolValue($id: String!) {
Pool_by_pk(id: $id) { value }
}

Bytes → String

Rule: Variable types Bytes and Bytes! are converted to String and String! respectively.

Example:

# TheGraph
query getTokens($id: Bytes) {
tokens(where: { id: $id }) { id timestamp }
}

# Envio
query getTokens($id: String) {
Token(where: {id: {_eq: $id}}) { id timestamp }
}

BigInt → numeric

Rule: Variable types BigInt and BigInt! are converted to numeric and numeric! respectively.

Example:

# TheGraph
query GetTokens($amount: BigInt) {
tokens(where: { amount: $amount }) { id amount }
}

# Envio
query GetTokens($amount: numeric) {
Token(where: {amount: {_eq: $amount}}) { id amount }
}

BigDecimal → numeric

Rule: Variable types BigDecimal and BigDecimal! are converted to numeric and numeric! respectively.

Example:

# TheGraph
query GetValue($value: BigDecimal!) {
pools(where: { value: $value }) { id }
}

# Envio
query GetValue($value: numeric!) {
Pool(where: {value: {_eq: $value}}) { id }
}

Summary Table

CategoryTheGraphEnvioExample
Entity NamesPlural camelCaseSingular PascalCase (as-is from schema)poolsPool
Paginationfirst, skiplimit, offsetfirst: 10, skip: 20limit: 10, offset: 20
OrderingorderBy, orderDirectionorder_by: {field: direction}orderBy: name, orderDirection: descorder_by: {name: desc}
Equality Filterfield: valuefield: {_eq: value}name: "test"name: {_eq: "test"}
Comparison Filtersfield_gt, field_gte, etc.field: {_gt: value}, etc.amount_gt: 100amount: {_gt: 100}
String Filters_contains, _starts_with, etc._ilike with % wildcardsname_contains: "test"name: {_ilike: "%test%"}
Variable TypesID, Bytes, BigInt, BigDecimalString, numeric$id: ID!$id: String!

Getting Help

If you encounter any issues with query conversion or have questions:


MCP Server

File: Advanced/mcp-server.md

Envio provides a Model Context Protocol (MCP) server that lets AI coding assistants search and retrieve documentation directly. This means tools like Claude Code, Cursor, and other MCP-compatible clients can access Envio docs without you needing to copy-paste context manually.

Endpoint

https://docs.envio.dev/mcp

Available Tools

The MCP server exposes two tools:

ToolDescription
docs_searchFull-text search across all documentation. Returns matching pages with titles, URLs, and content snippets.
docs_fetchRetrieves the full content of a documentation page as markdown.

Envio CLI Tools

Starting from Envio 3.1, the same documentation search and retrieval is also available directly through the Envio CLI - no MCP server setup required. AI agents can call these tools out of the box:

ToolDescription
envio tools search-docs <query>Full-text search across all documentation, equivalent to the docs_search MCP tool.
envio tools fetch-docs <url>Retrieves the full content of a documentation page as markdown, equivalent to the docs_fetch MCP tool.

Setup

Claude Code

claude mcp add --transport http envio-docs https://docs.envio.dev/mcp

Cursor / VS Code

Add the following to your MCP configuration (.cursor/mcp.json or VS Code MCP settings):

{
"mcpServers": {
"envio-docs": {
"url": "https://docs.envio.dev/mcp"
}
}
}

Other MCP Clients

Point any MCP-compatible client to the endpoint URL above using the Streamable HTTP transport.


Common Issues and Troubleshooting

File: Troubleshoot/common-issues.md

This guide helps you identify and resolve common issues you might encounter when working with Envio HyperIndex. If you don't find a solution to your problem here, please join our Discord community for additional support.

Table of Contents

Setup and Configuration Issues

Cannot find module errors on pnpm start

Problem: Errors like Cannot find module when starting your blockchain indexer indicate missing generated files.

Cause: The indexer cannot find necessary files, typically because the code generation step was skipped after cloning the repository.

Solution:

  1. Delete the generated folder if it exists
  2. Run the code generation command:
pnpm codegen

Important: Always run pnpm codegen immediately after cloning an indexer repository using Envio.

Using Envio inside a monorepo

Problem: Your indexer lives inside a larger monorepo and you see Cannot find module or missing-generated-code errors even after running pnpm codegen.

Cause: pnpm-workspace.yaml doesn't include both your indexer root and its generated output directory.

Solution: Add both <envio-indexer> and <envio-indexer>/generated to the packages list in pnpm-workspace.yaml, for example:

packages:
- "apps/*"
- "packages/*"
- "envio-indexer"
- "envio-indexer/generated"

Smart contract updated after the initial codegen

Problem: Changes to smart contracts aren't reflected in your blockchain indexer.

Cause: When smart contracts are modified after initial setup, the ABIs need to be regenerated and the indexer needs to be updated.

Solution:

  1. Re-export smart contract ABIs (example using Hardhat):
cd contracts/
pnpm hardhat export-abi
  1. Verify that the ABI directory in config.yaml points to the correct location where ABIs were freshly generated
  2. Run codegen again:
pnpm codegen

Using the correct version of Node.js

Problem: Compatibility issues or unexpected errors when running the indexer.

Solution: Envio requires Node.js v22 or newer. If you're using Node.js v16 or older, please update:

# Using nvm (recommended)
nvm install 22
nvm use 22

# Or download directly from https://nodejs.org/

Runtime Issues

Indexer not starting at the specified start block

Problem: The indexer runs but doesn't start from the start_block defined in your configuration.

Cause: This typically happens when the indexer's state is persisted from a previous run.

Solution: Stop the indexer completely before restarting:

# First stop the indexer
pnpm envio stop

# Then restart it
pnpm dev

Tables for entities are not registered on Hasura

Problem: Entity tables defined in your schema don't appear in Hasura.

Cause: Database schema might be out of sync with your entity definitions.

Solution: Reset the indexer environment to recreate the necessary tables:

# Stop the indexer
pnpm envio stop

# Restart it (this will recreate tables)
pnpm dev

Problem: The indexer shows warnings such as:

  • Error getting events, will retry after backoff time
  • Failed Combined Query Filter from block
  • Issue while running fetching batch of events from the RPC. Will wait ()ms and try again.

Cause: Issues connecting to or retrieving data from the blockchain RPC endpoint.

Solutions:

  1. Recommended: Use HyperSync if your chain is supported, as it provides better performance and reliability

  2. If HyperSync isn't an option, try:

    • Using a different RPC endpoint in your config.yaml
    • Verifying your RPC endpoint is stable and has archive data if needed
    • Checking if your RPC provider has rate limits you're exceeding
# Example of updating RPC in config.yaml
network:
# Replace with a more reliable RPC
rpc_url: "https://mainnet.infura.io/v3/YOUR-API-KEY"

Debugging a Stuck Indexer

Indexer not making progress

Problem: Your indexer appears frozen - the block counter stops advancing, or sync has been running far longer than expected with no visible progress.

First step - check the logs:

The most important thing to do is check your indexer logs for errors or warnings. Logs will almost always point you to the root cause.

  • Locally: Check the terminal output from pnpm dev for error messages or stack traces.
  • Envio Cloud: Go to your deployment in the Envio Cloud dashboard and open the Logs tab.

If the logs don't reveal an obvious error, work through the common causes below:

  1. RPC rate limiting or connectivity issues

    • If using RPC sync, your provider may be throttling requests. Check your RPC provider's dashboard for 429 errors or usage spikes.
    • Try switching to a different RPC endpoint or using HyperSync if your chain is supported.
  2. Large blocks or high event density

    • Some blocks contain an unusually large number of events (e.g., airdrop blocks, protocol launches). The indexer may appear stuck while processing them.
    • Check the logs for the current block number - if it's advancing slowly rather than frozen, the indexer is likely processing a dense block range.
  3. Handler errors causing silent failures

    • An unhandled error in your event handler can cause the indexer to stall. Look for error messages or stack traces in the logs that point to a specific handler or event.
  4. Memory pressure

    • Processing very large datasets or having expensive handler logic (e.g., many eth_call requests) can cause memory issues. See the performance optimization guide for tuning options.

If running on Envio Cloud:

  • Check the deployment logs in the Envio Cloud dashboard for error details.
  • If the deployment is unrecoverable, you can delete it and redeploy from the dashboard.
  • Consider running the same configuration locally first to reproduce and debug the issue before redeploying.

Rate Limiting on Hosted Service

HTTP 429 errors when querying your endpoint

Problem: You receive 429 Too Many Requests responses when querying your hosted GraphQL endpoint, or your queries are being throttled.

Cause: Envio Cloud applies rate limits to GraphQL query endpoints based on your plan tier. This protects shared infrastructure and ensures fair usage across all deployments.

What to check:

  1. Confirm it's a query rate limit (not an RPC issue)

    • Rate limiting applies to your GraphQL query endpoint, not to the indexer's data ingestion. If your indexer is slow to sync, that's a different issue - see Debugging a Stuck Indexer.
  2. Check your plan's limits

    • Review your current plan in the Envio Cloud dashboard under your deployment settings. Higher-tier plans include higher query rate limits. See Billing & Plans for details.
  3. Reduce query frequency from your application

    • If your frontend or backend polls the endpoint frequently, consider adding caching, reducing poll intervals, or using WebSocket subscriptions for real-time updates instead of polling.
  4. Check for unexpected traffic

    • Ensure your endpoint URL hasn't been shared publicly or isn't being hit by an unintended client. You can restrict access - see the Hosted Service features for endpoint security options.
  5. Upgrade your plan

    • If you consistently hit rate limits, consider upgrading to a higher tier for increased query throughput.

Hasura Authentication

Cannot log in to the Hasura console

Problem: You're prompted for an admin secret or password when accessing the Hasura console, and don't know what it is.

Local development:

When running locally with pnpm dev, the default Hasura admin secret is testing. Access the console at http://localhost:8080 and enter testing when prompted.

You can customize this by setting the HASURA_GRAPHQL_ADMIN_SECRET environment variable before starting your indexer.

Envio Cloud (hosted):

On Envio Cloud, you do not need the Hasura admin secret to query your data. Your deployed indexer exposes a public GraphQL endpoint that you can query directly without authentication:

https://indexer.dev.hyperindex.xyz/<your-deployment-id>/v1/graphql

The Hasura console UI is not exposed on hosted deployments. To explore your data, use the GraphQL playground available in the Envio Cloud dashboard, or query the endpoint directly from your application or tools like Postman.

If you need to restrict access to your endpoint, see the Hosted Service features for security options.

Infrastructure Conflicts

Postgres running locally

Problem: Conflicts when Postgres is already running on port 5432.

Cause: The default Postgres port (5432) is already in use by another instance.

Solution: Configure Envio to use a different port by setting environment variables:

# Option 1: Set variables inline
ENVIO_PG_PORT=5433 pnpm codegen
ENVIO_PG_PORT=5433 pnpm dev

# Option 2: Export variables for the session
export ENVIO_PG_PORT=5433
pnpm codegen
pnpm dev

You can further customize your Postgres connection with these additional environment variables:

  • ENVIO_PG_PASSWORD: Set a custom password
  • ENVIO_PG_USER: Set a custom username
  • ENVIO_PG_DATABASE: Set a custom database name

Missing Events

Events not appearing in indexed data

Problem: Some expected events are missing from your indexed data, or event counts don't match what you see on-chain.

Common causes:

  1. Incorrect start_block

    • If your start_block is set after the block where the event was emitted, it will be missed. Verify that the start block in your config.yaml is at or before the contract's deployment block.
  2. ABI mismatch

    • If the ABI in your config doesn't match the contract's actual event signature, events won't be decoded. Double-check that your ABI file is up to date and matches the deployed contract.
  3. Missing contract address

    • For multi-address or dynamic contract setups, ensure all relevant addresses are registered. If using dynamic contracts, verify that the contractRegister handler is correctly adding addresses.
  4. RPC provider issues

    • Some RPC providers may return incomplete log data, especially for older blocks. Try switching to a different RPC endpoint or use HyperSync for more reliable data retrieval.
  5. Reorg handling

    • During chain reorganizations, events from orphaned blocks may temporarily appear and then be removed. If rollback_on_reorg is enabled (default), the indexer will handle this automatically. See Reorg Support.

How to verify:

  • Check the indexer logs for any skipped blocks or error messages
  • Test with a small block range locally to isolate the issue


Reserved Words in Envio

File: Troubleshoot/reserved-words.md

Overview

When creating your Envio indexer, certain words cannot be used in entity names, field names, contract names, or event names because they are reserved by the underlying programming languages or by Envio itself. Using these reserved words will trigger validation errors (such as EE102 for configuration files or EE210 for schema files).

Reserved words in Envio are taken from JavaScript, TypeScript, and ReScript because Envio generates code in these languages to power your blockchain indexer. Using these reserved words would create syntax conflicts in the generated code.

Why This Matters

When you define names in your:

  • config.yaml file (for contracts and events)
  • schema.graphql file (for entities and fields)

Envio automatically generates code based on these names. If you use reserved words, the generated code will contain syntax errors and will not compile, causing your indexer to fail.

Common Error Scenarios

If you use reserved words, you'll encounter these errors:

  • Error EE102: Reserved words in the configuration file
  • Error EE210: Reserved words in the schema file
  • Error EE212: Reserved enum names that conflict with Envio internal types

How to Fix Reserved Word Errors

When you encounter these errors, you need to rename the offending identifiers:

  1. Identify which names in your configuration or schema are using reserved words
  2. Choose alternative names that aren't reserved
  3. Update all references to these names in your code
  4. Run codegen again to regenerate the code

Example Problem

# In config.yaml
contracts:
- name: class # Error: 'class' is a reserved word in JavaScript
abi_file_path: ./abis/MyContract.json
# In schema.graphql
type interface { # Error: 'interface' is a reserved word in JavaScript and TypeScript
id: ID!
name: String!
}

Example Solution

# Fixed config.yaml
contracts:
- name: ClassContract # Good: Not a reserved word
abi_file_path: ./abis/MyContract.json
# Fixed schema.graphql
type UserInterface { # Good: Not a reserved word
id: ID!
name: String!
}

Tips for Avoiding Reserved Word Conflicts

  • Use camelCase or PascalCase for naming (e.g., userAccount instead of class)
  • Add a prefix or suffix to potentially conflicting names (e.g., userInterface instead of interface)
  • Use domain-specific terms that are less likely to be programming keywords
  • When in doubt, check against the lists below before finalizing your schema or configuration

Complete List of Reserved Words

JavaScript Reserved Words

These keywords cannot be used as identifiers in your Envio configuration or schema:

abstract, arguments, await, boolean, break, byte, case, catch, char,
class, const, continue, debugger, default, delete, do, double, else,
enum, eval, export, extends, false, final, finally, float, for, function,
goto, if, implements, import, in, instanceof, int, interface, let, long,
native, new, null, package, private, protected, public, return, short,
static, super, switch, synchronized, this, throw, throws, transient, true,
try, typeof, var, void, volatile, while, with, yield

TypeScript Reserved Words

In addition to JavaScript keywords, these TypeScript-specific keywords are also reserved:

any, as, boolean, break, case, catch, class, const, constructor, continue,
declare, default, delete, do, else, enum, export, extends, false, finally,
for, from, function, get, if, implements, import, in, instanceof, interface,
let, module, new, null, number, of, package, private, protected, public,
require, return, set, static, string, super, switch, symbol, this, throw,
true, try, type, typeof, var, void, while, with, yield

ReScript Reserved Words

These ReScript-specific keywords are also reserved:

and, as, assert, constraint, else, exception, external, false, for, if, in,
include, lazy, let, module, mutable, of, open, rec, switch, true, try, type,
when, while, with

Envio Internal Reserved Types

These types are used internally by Envio and cannot be used as enum or entity names:

EVENT_TYPE
CONTRACT_TYPE

Best Practices

  1. Use descriptive names that are unlikely to be programming keywords
  2. Check these lists before finalizing your schema design
  3. Run validation early with pnpm codegen to catch issues before spending time on implementation
  4. Use prefixes for domain entities (e.g., TokenTransfer instead of Transfer)

If you encounter persistent issues with reserved words or need help refactoring your schema to avoid them, please reach out for support on our Discord community.



Any EVM with RPC 🐌

File: supported-networks/any-evm-with-rpc.md


Any EVM-compatible chain can be indexed using an RPC as a source. This means that you can use any EVM-compatible chain as a data source for your indexer. This is particularly useful for chains that do not have a native HyperSync or HyperRPC solution.

Defining Chain Configurations

name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 1234567890
rpc: https://custom-chain-rpc.com # RPC URL for that custom chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event

Support

Can’t find what you’re looking for or need support? Reach out to us on Discord; we’re always happy to help!

info

The backbone of HyperIndex’s blazing-fast indexing speed lies in using HyperSync as a more performant and cost-effective data source to RPC for data retrieval. While RPCs are functional, and can be used in HyperIndex as a data source, they are far from efficient when it comes to querying large amounts of data (a time-consuming and resource-intensive endeavour).

HyperSync is significantly faster and more cost-effective than traditional RPC methods, allowing the retrieval of multiple blocks at once, and enabling sync speeds up to 1000x faster than RPC.



Local chain - Anvil

File: supported-networks/local-anvil.md


A local chain can be used as a data source for your indexer. You simply need to specify it in config.yaml.

Defining Chain Configurations

name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Anvil default chain id
rpc: http://localhost:8545 # RPC URL for your local Anvil chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event


Local chain - Hardhat

File: supported-networks/local-hardhat.md


A local chain can be used as a data source for your indexer. You simply need to specify it in config.yaml.

Defining Chain Configurations

name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Hardhat default chain id
rpc: http://localhost:8545 # RPC URL for your local Hardhat chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event

--


Indexing on Solana

File: solana/solana.md

HyperIndex indexes Solana programs at the instruction level. You select the programs and instructions you care about, HyperIndex decodes their arguments and accounts from an IDL (Anchor, Shank or Codama) or a layout you declare, and writes the results to Postgres with an auto-generated GraphQL API. Inner instructions (CPIs), account balance activity, transaction metadata and program logs are all available.

It is powered by HyperSync for Solana, the same high-performance data engine behind EVM indexing, so historical backfills are fast and you never touch an RPC node for the bulk of indexing.

Stable since v3.11

The SVM API - configuration, handlers and payload types - is final and follows semver like the rest of HyperIndex. Building on Solana? Say hello on Discord; we'd love your input on what to prioritize next.

Two ways to index Solana

ApproachAPIData sourceUse it for
Instruction handlersindexer.onInstructionHyperSyncThe main path: decode and index program instructions (swaps, deposits, mints, transfers…), including inner/CPI instructions, with per-account balance activity.
Slot handlersindexer.onSlotRPC (via the Effect API)Per-slot orchestration, time-series snapshots, or pulling extra data from RPC on a schedule.

Most indexers use instruction handlers. Slot handlers are for cases where you need to run logic on a slot cadence rather than react to a specific instruction. For raw, low-level data you can also query HyperSync for Solana directly.

Quickstart

pnpx envio init

Choose Solana when prompted, then pick the USDC Transfers template - a working SPL Token instruction indexer with tests. See Getting Started for the full walkthrough.

The HyperSync endpoint comes from the chain id, so there's nothing to configure beyond picking a start_slot - see choosing a start slot.

Mental model: coming from EVM?

If you've used HyperIndex on EVM, the shift is mostly vocabulary:

EVMSolana
Contract + ABIProgram + IDL
Event (onEvent)Instruction (onInstruction)
Block (onBlock)Slot (onSlot)
event.paramsinstruction.args

EVM vs Solana has the full mapping.

What's supported today

  • Instruction indexing via indexer.onInstruction: match by program + discriminator.
  • IDL-aware decoding: point at an Anchor (legacy or 0.30+), Shank or Codama IDL and every instruction it declares becomes indexable, with its arguments and account names. No IDL? Declare the layout yourself.
  • Inner instructions (CPIs): decoded the same way as top-level ones, with a full instruction path so you can reconstruct the call tree.
  • Account activity: pre/post lamport and SPL Token (and Token-2022) balances per account, so you get net value movement without indexing every transfer. See account activity.
  • Transaction metadata & logs: fee payer, fee, compute units, success, the transaction signature, and per-instruction program logs (opt-in via the handler's fields option).
  • Slot handlers via indexer.onSlot + the Effect API for RPC enrichment.
  • Local dev + GraphQL + Envio Cloud: the same workflow and hosting as EVM.

What is not supported yet

These are gaps in the built-in instruction-handler surface, not hard limits: for most of them you can still pull the data yourself by calling out to RPC from a handler with the Effect API, you just don't get it as a struct field for free.

  • Account-change subscriptions. There is no onAccount/program-account handler. instruction.accounts gives you the accounts an instruction touched and, with fields.accountActivity, their pre/post lamport and token balances for that transaction - but not arbitrary account state. For that, read the account over RPC in a handler with the Effect API.
  • A separate log handler. Logs are a field on the instruction, not their own handler.
  • Dynamic registration. No Solana equivalent of dynamic contracts: programs are declared in config.yaml, not registered at runtime.
  • Wildcard indexing across programs. A registration always names one program.
  • No-code contract import. Solana has no contract-import flow, so you configure programs by hand. (IDLs are wired up in config.yaml, not auto-imported.)
  • ReScript. Solana indexers are TypeScript only. Codegen emits no ReScript for ecosystem: svm, and envio init silently picks TypeScript if you ask for ReScript.

If the piece you need is on this list, tell us on Discord: there's a good chance we can sequence the work to unblock you, or point you at a HyperSync-direct path that gets the data today.

In this section

  • Getting Started: scaffold and run your first Solana indexer.
  • Instruction Handlers: onInstruction, the instruction object, account activity, CPIs, testing.
  • Decoding Instructions: IDLs, discriminators, inline layouts, supported types.
  • Slot Handlers: onSlot and RPC enrichment.
  • Configuration: the config.yaml reference for ecosystem: svm.
  • EVM vs Solana: every difference in one place.

Getting Started on Solana

File: solana/getting-started.md

This guide takes you from nothing to a running Solana indexer with a live GraphQL API. If you've used HyperIndex on EVM the workflow is identical: only the config and handlers differ.

Prerequisites

  • Node.js v20+ and pnpm. The commands below use pnpm/pnpx; npm, Yarn, and Bun work too if you swap the equivalents
  • Docker Desktop (for the local Postgres + GraphQL stack)
  • A HyperSync API token: the CLI's login flow sets this up for you, or generate one in the Envio Cloud portal. See API tokens.

1. Scaffold a project

pnpx envio init

Choose Solana at the ecosystem prompt, then pick the USDC Transfers (SPL Token instructions) template: a working indexer of every USDC transfer through the SPL Token program, with tests.

Non-interactive equivalent:

pnpx envio init svm template --template usdc-transfers --name my-indexer

The template scaffolds:

my-indexer/
├── config.yaml # chains + programs/instructions
├── schema.graphql # the entities you index into
├── src/
│ ├── handlers/…ts # your onInstruction / onSlot handlers
│ └── indexer.test.ts # tests, with simulated instructions
├── .env # ENVIO_API_TOKEN
└── package.json

envio init also runs codegen, installs dependencies, and initializes git.

2. Pick a start slot

start_slot in config.yaml is a slot number, not a block number. Three common choices:

start_slotIndexes from
latestThe head when the indexer is first deployed. Fastest way to see live data.
A few tens of thousands of slots below the headA short backfill, good for trying things out.
The slot your program was deployed atIts full history.

Check the current head to pick a number:

curl -s https://solana.hypersync.xyz/height
# => 440067639
History doesn't reach genesis yet

Mainnet goes back to around slot 403,000,000 (September 2026). A start_slot before that doesn't error - the indexer starts from the earliest indexed slot instead. See choosing a start slot.

3. Run it

pnpm install            # if you didn't let init do it
pnpm envio codegen # regenerate types from config.yaml + schema.graphql
pnpm envio dev # start Postgres + the indexer + GraphQL (Docker)

envio dev brings up the local stack and runs the indexer with hot reload. The GraphQL playground (Hasura) is at http://localhost:8080 (default admin secret testing). See Navigating Hasura.

To run the pieces separately:

pnpm envio local docker up   # start Postgres + Hasura
pnpm envio codegen
pnpm envio start # run the indexer against the running stack
Re-run codegen after config/schema changes

Editing config.yaml or schema.graphql (including adding a program, instruction, or IDL) requires pnpm envio codegen to regenerate the typed envio module and the entity types in .envio/.

4. Add your own program

Add an entry to the top-level programs list, then write a handler for it:

config.yaml
programs:
- name: MyProgram
program_id: MyPr0gram11111111111111111111111111111111111
idl: ./idls/my-program.json

With an idl (Anchor, Shank or Codama) every instruction it declares is indexable and you select them by name. Without one, declare the discriminator, accounts and args yourself - see Decoding Instructions.

src/handlers/MyProgram.ts

indexer.onInstruction(
{
program: "MyProgram",
instruction: "swap",
fields: { instruction: ["args", "accounts"], block: ["time"] },
},
async ({ instruction, context }) => {
// instruction.args and instruction.accounts are typed after codegen
},
);

A registration reads only the fields it lists in fields, and can narrow what it indexes with where - both are covered in Instruction Handlers. Re-run pnpm envio codegen after editing config.yaml.

Next steps

  • Configuration: every config.yaml field for Solana.
  • Instruction Handlers: the full instruction object, account activity, CPIs, and testing.
  • Decoding Instructions: IDLs, discriminators, inline layouts, supported types.
  • Deploy to Envio Cloud: host your Solana indexer the same way as EVM.

Instruction Handlers

File: solana/instruction-handlers.md

On Solana you react to instructions instead of EVM events. Register a handler with indexer.onInstruction; it fires once for every matched instruction (top-level or inner) of the configured program.


indexer.onInstruction(
{ program: "<PROGRAM_NAME>", instruction: "<INSTRUCTION_NAME>" },
async ({ instruction, context }) => {
// your logic here
},
);

program is the name you gave it under the top-level programs in config.yaml. instruction is a name from that program's instructions list, or - when the program points at an idl - any instruction the IDL declares.

Run codegen after config/schema changes

The envio module exposes a unified indexer value plus types derived from your config.yaml and schema.graphql. Run pnpm codegen whenever you change either file. After codegen, program/instruction autocomplete and instruction.args / instruction.accounts are typed per instruction.

A complete handler


/** Only the listed fields are fetched, so keep it to what the handlers read. */
const fields = {
instruction: ["accounts", "args", "path"],
transaction: ["signature", "transactionIndex"],
block: ["time"],
} as const;

indexer.onInstruction(
{ program: "SplToken", instruction: "transferChecked", fields },
async ({ instruction, context }) => {
context.Transfer.set({
id: `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`,
amount: instruction.args.amount, // bigint, decoded from the u64
source: instruction.accounts.source.address, // base58
destination: instruction.accounts.destination.address,
signer: instruction.accounts.authority.address,
txSignature: instruction.transaction.signature,
slot: instruction.block.slot,
timestamp: instruction.block.time,
});
},
);

An instruction whose data the configured layout rejects is skipped rather than delivered undecoded, so args and accounts need no null checks.

Selecting fields

A registration carries only the data it asks for. List it in fields:

const fields = {
instruction: ["args", "accounts", "path", "isInner", "programId", "data", "accountArguments"],
transaction: ["signature", "feePayer", "success", "fee", "computeUnitsConsumed"],
accountActivity: ["token.mint", "token.owner", "token.decimals", "lamports.pre", "lamports.post"],
block: ["time", "hash", "height", "parentSlot", "parentHash"],
log: ["kind", "message"],
} as const;
KnobAdds
instructionargs, accounts, accountArguments, programId, data, path, isInner.
transactiontransactionIndex, signature, feePayer, success, err, fee, computeUnitsConsumed, accountKeys, recentBlockhash, version, allSignatures.
accountActivityPer-account balances and token info on instruction.accounts.<name>.activity and instruction.transaction.accountActivities - see account activity.
blocktime, hash, height, parentSlot, parentHash on top of the always-present slot.
loginstruction.logs, the program logs scoped to this instruction.

Reading a field you didn't select is a compile error naming the knob to add, not a runtime undefined. Write the selection inline in the registration, or declare it as const (as above) so its element types still name the fields.

instruction.programName, instruction.instructionName, instruction.discriminator and instruction.block.slot are always available.

signature, not allSignatures[0]

The identifying transaction id is the scalar instruction.transaction.signature. allSignatures is the array of every signer's signature and is selected separately - selecting signature doesn't give you allSignatures. Nearly every handler wants the scalar.

The instruction object

type SvmInstruction = {
programName: string; // the program name from config
instructionName: string; // the instruction name from config or IDL
discriminator: string; // the matched hex prefix, e.g. "0x0c"
programId: string; // base58
data: Uint8Array; // raw instruction data
path: readonly number[]; // CPI path, e.g. [0] or [0, 1]
isInner: boolean; // true => inner (CPI) instruction
args: { ... }; // decoded Borsh arguments, typed per instruction
accounts: { [name]: SvmInstructionAccount }; // named account slots
accountArguments: readonly string[]; // every account address, positionally
logs: readonly { kind: string; message: string }[];
transaction: SvmTransaction;
block: { slot: number /* + selected block fields */ };
};
  • args is keyed by the argument names from the IDL or the inline args layout, typed after codegen. See supported types.
  • accounts is keyed by the slot names you declared. Each entry is { address, accountName, instructionAccountIndex, activity }.
  • accountArguments is the positional list of addresses, including slots you never named.
  • path locates the instruction in the transaction's call tree - see inner instructions.

instruction.transaction

type SvmTransaction = {
transactionIndex: number;
signature: string; // the transaction id, a scalar
feePayer: string;
success: boolean;
err: string | undefined;
fee: bigint; // lamports
computeUnitsConsumed: bigint | undefined;
accountKeys: readonly string[];
recentBlockhash: string;
version: string | undefined;
allSignatures: readonly string[];
accountActivities: readonly SvmAccountActivity[]; // with fields.accountActivity
};

The object is always there; each field is readable only when the registration selected it, so a missing selection is a compile error on the property rather than a crash on undefined.

Account activity

Selecting fields.accountActivity attaches per-account, per-transaction activity: pre/post lamport balances and pre/post SPL Token (and Token-2022) balances. postAmount − preAmount is the balance change, which is the cleanest way to capture net value flow without indexing every transfer.

type SvmAccountActivity = {
address: string;
transactionAccountIndex: number;
isSigner: boolean;
isWritable: boolean;
lamports: { pre: bigint; post: bigint } | undefined;
token:
| {
mint: string;
owner: string;
decimals: number;
preAmount: bigint | undefined; // absent if the account was created in the tx
postAmount: bigint | undefined; // absent if it was closed in the tx
}
| undefined;
};

It arrives in two places: on a named account as instruction.accounts.<name>.activity (the account's own row, or undefined when the transaction reports none for it), and on instruction.transaction.accountActivities for every account the transaction touched.

const fields = {
instruction: ["accounts"],
transaction: ["signature"],
accountActivity: ["token.mint", "token.decimals", "token.preAmount", "token.postAmount"],
} as const;

indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute", fields },
async ({ instruction, context }) => {
for (const { address, token } of instruction.transaction.accountActivities) {
if (!token) continue;
context.TokenDelta.set({
id: `${instruction.transaction.signature}:${address}`,
mint: token.mint,
decimals: token.decimals,
delta: (token.postAmount ?? 0n) - (token.preAmount ?? 0n), // signed
});
}
},
);
Amounts are bigint

Balances are raw base units typed as bigint, so use ?? 0n rather than ?? "0" for the absent case. Both amounts absent means the entry carries no movement at all, which is worth distinguishing from a genuine zero.

Filtering with where

where narrows a registration server-side, so filtered-out instructions are never fetched or decoded:

indexer.onInstruction(
{
program: "SplToken",
instruction: "transferChecked",
fields,
where: {
accounts: { mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
isInner: false,
block: { slot: { _gte: 445_000_000 } },
},
},
handler,
);
KeyEffect
accountsMatch named account slots against a pubkey or a list of them. Keys within one group are AND-ed; pass an array of groups to OR them.
isInnertrue matches only inner (CPI) instructions, false only top-level ones. Omit it to match both.
block.slot._gteA per-registration start slot. It overrides the chain's start_slot for this handler. Only _gte is supported here - use indexer.onSlot for _lte / _every.

Only slots you named in accounts are filterable (_ placeholders and unnamed trailing slots are not).

Inner instructions (CPIs)

HyperIndex decodes inner instructions (those invoked by other programs via cross-program invocation) exactly like top-level ones. Two fields let you reconstruct the call tree:

  • isInner: false for a top-level instruction, true for a CPI.
  • path: the position in the tree. [0] is the first top-level instruction, [0, 1] the second inner instruction it invoked, [0, 1, 2] one level deeper.
const path = instruction.path; // e.g. [0, 1]
const id = path.join("."); // "0.1"
const depth = path.length - 1; // 1
const parent = path.length > 1 ? path.slice(0, -1).join(".") : undefined;

A registration matches both inner and outer occurrences by default; narrow it with where: { isInner: true } or false.

EVM difference

EVM "internal calls" aren't surfaced as first-class events. On Solana, CPIs are real indexable instructions: a Jupiter route's underlying Raydium/Orca swaps are all visible if you index those programs.

The context object

context is the same as in EVM handlers - the per-entity operations (set, get, getOrThrow, getOrCreate, getWhere, deleteUnsafe), context.log, context.effect for Effects, and context.isPreload. See the Event Handlers context.

Two things carry over from EVM that matter here:

  • context.chain is { id, isRealtime }, and id is 7565164 on Solana mainnet.
  • Preload optimization runs every handler twice, so keep writes idempotent and guard non-idempotent side effects with if (context.isPreload) return;.

A deterministic Solana entity id combines the slot, the transaction index and the instruction path:

const id = `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`;

Testing

Solana indexers use the same test framework as EVM ones. Two things are SVM-specific: chain overrides are keyed by the numeric chain id (7565164), and simulate items describe instructions rather than events.

const SOLANA = 7565164;

await indexer.process({
chains: {
[SOLANA]: {
simulate: [
{
program: "SplToken",
instruction: "transferChecked",
slot: 445_000_000,
path: [1, 0],
args: { amount: 250_000n, decimals: 6 },
accounts: {
source: { address: SOURCE },
mint: { address: USDC },
destination: { address: DESTINATION },
authority: { address: AUTHORITY },
},
block: { time: 1_800_000_000 },
transaction: { signature: SIGNATURE, transactionIndex: 4 },
},
],
},
},
});

A simulated instruction defaults its data to the configured discriminator bytes and its path to [0], and only runs when its slot is inside the configured range. transaction.accountActivities entries are joined onto the named accounts at process time, so a handler reading accounts.destination.activity.token.mint is testable without a live endpoint.

To run against real data, pass a pinned slot window instead and let the indexer fetch it from HyperSync:

await indexer.process({
chains: { [SOLANA]: { startBlock: 445_000_000, endBlock: 445_000_060 } },
});

That needs ENVIO_API_TOKEN - without one, POST /query 401s are retried rather than failing fast, so the run hangs until the test timeout instead of erroring. Since it hits the real endpoint, assert on shape and invariants ("produced rows", "delta equals post minus pre") rather than exact counts.

  • Decoding Instructions - what args and accounts contain.
  • Configuration: programs, instructions, discriminators, account slots.
  • Slot Handlers: the other Solana handler type.

Decoding Instructions

File: solana/decoding.md

Solana instruction data is a packed Borsh byte string with no self-describing structure: unlike an EVM log, there's no ABI travelling with it. HyperIndex needs to know which bytes identify an instruction and how to read the rest, and there are two ways to tell it:

  • Point at an IDL. The usual path, and nothing else on this page applies.
  • Declare the layout yourself. For programs with no IDL - most native ones.

Point at an IDL

Give the program an IDL JSON file (relative to config.yaml). Anchor 0.30+, legacy Anchor, Shank and Codama IDLs all work through the same path:

programs:
- name: Jupiter
program_id: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
idl: ./idls/jupiter.json

That's the whole configuration. Every usable instruction the IDL declares is indexable - names, discriminators, argument layouts, ordered account names (including nested account groups) and the IDL's types registry all come from the file - so you select what you index by name in onInstruction and read instruction.args and instruction.accounts typed after codegen.

Add an instructions row only to override or extend the catalog:

  • A row whose name the IDL declares replaces that instruction, and must spell out both accounts and args.
  • A row with a new name adds an instruction the IDL didn't declare.

The argument type table below describes what the IDL's types become in TypeScript. Everything else on this page is about programs without an IDL.

Programs without an IDL

For a program with no IDL - most native programs - you declare the pieces an IDL would have carried: which bytes select the instruction, what its arguments look like, and what to call its accounts.

Discriminators

The discriminator is a 0x-prefixed hex prefix of the instruction data, of any whole number of bytes. Every instruction whose data carries the prefix is dispatched to it.

  • Format: hex only, with the 0x prefix. Base58 and decimal are not accepted (base58 is only for program_id).
  • Native / non-Anchor programs: whatever leading byte(s) the program uses (SPL Token transfer is 0x03; Raydium AMM v4 swap is 0x09).
  • Anchor programs: the 8-byte Anchor sighash, which an idl carries for you.
  • The whole program: "0x" - the empty prefix, carried by every call.
instructions:
- name: swap
discriminator: "0x09" # 1-byte native
- name: sharedAccountsRoute
discriminator: "0xc1209b3341d69c81" # 8-byte Anchor sighash

instruction.discriminator in a handler reads back the same string you wrote in config.yaml, so a config value and a handler comparison always match.

Overlapping prefixes all fire

Dispatch is by prefix, not by exclusive match, so an entry whose prefix a call carries always receives it. A program-wide "0x" entry fires alongside a keyed one, and two entries may deliberately share a prefix - for example the layouts before and after a program upgrade. Each decodes with its own args, and one whose layout rejects the data is skipped for that call.

Declaring the layout

args is the Borsh argument list in order (after the discriminator); accounts names the positional account slots.

programs:
- name: Raydium
program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8
instructions:
- name: swap
discriminator: "0x09"
accounts: # positional: slot 0 is tokenProgram, etc.
- tokenProgram
- amm
- userSourceTokenAccount
- userDestTokenAccount
args:
- { name: amountIn, type: u64 }
- { name: minAmountOut, type: u64 }

Account slots also accept ?optional (the key is absent when the call omits the slot or fills it with the program id) and _ (holds a position without naming it). See account slots.

Verify positional layouts

For native programs the account order is the program's canonical layout, not something HyperIndex can verify. Check it against a real transaction. Accounts beyond your named list still arrive, positionally, in instruction.accountArguments.

args is also a filter

Setting args attaches a decoder, and a call whose data the layout rejects is skipped rather than delivered undecoded. That makes the three states meaningfully different:

argsBehavior
omittedNo decoder at all. Every matched call is indexed and the payload stays raw, reachable as instruction.data.
[]The instruction takes no arguments, so only calls carrying nothing past the discriminator are indexed.
a listOnly calls whose data decodes cleanly against the layout are indexed.

An idl always declares the layout of the instructions it names, empty included.

Supported argument types

These are the types you can write in args, and also what HyperIndex understands from an IDL. The right column is the TypeScript type each value has in instruction.args after codegen.

typeType in instruction.args
boolboolean
u8 u16 u32, i8 i16 i32, f32 f64number
u64 u128 i64 i128bigint
stringstring
pubkey (alias publicKey)string (base58)
bytesUint8Array
{ option: <type> }the value, or null
{ vec: <type> }readonly T[] - but vec<u8> is a Uint8Array
{ array: [<type>, <len>] }a readonly N-tuple - but [u8, N] is a Uint8Array
{ struct: [ {name,type}, … ] }object
{ enum: [ {name, fields?}, … ] }a variant name as a string literal, or { VariantName: { …fields } } for a variant with fields
args:
- { name: amount, type: u64 } # bigint
- { name: authority, type: pubkey } # base58 string
- { name: maybeOwner, type: { option: pubkey } } # string | null
- { name: seedHash, type: { array: [u8, 32] } } # Uint8Array
- { name: side, type: { enum: [{ name: Bid }, { name: Ask }] } } # "Bid" | "Ask"

Nominal types are declared inline at the field that uses them. There's no way to name one in YAML and refer to it elsewhere - attach an idl to the program when its types are shared between instructions.

Two things worth knowing about the decoded output:

  • Byte arrays are Uint8Array. bytes, vec<u8> and [u8; N] all decode to Uint8Array, so a 32-byte hash doesn't come back as a base58 string.
  • Address lookup tables need no configuration. ALT-resolved addresses arrive in the instruction's account list and are mapped positionally like any other.
  • Configuration: where idl, discriminator, args and accounts live.
  • Instruction Handlers: using args and accounts in handlers.

Slot Handlers

File: solana/slot-handlers.md

indexer.onSlot runs logic on every slot or at an interval, the Solana equivalent of EVM block handlers. Use it for time-series snapshots, periodic aggregations, or pulling extra data from RPC on a schedule with the Effect API. For indexing program activity, reach for instruction handlers instead.


indexer.onSlot({ name: "MySlotHandler" }, async ({ slot, context }) => {
context.log.info(`Processing slot ${slot}`);
});

Slot handlers self-register: they need no entry in config.yaml beyond the chain itself. With no where, the handler runs on every slot.

Options

indexer.onSlot(options, handler):

  • name (required): unique name, used for logging, metrics, and progress tracking.
  • where (optional): ({ chain }) => false | true | { slot: { _gte?, _lte?, _every? } }. Evaluated once per chain at registration to decide which chains the handler runs on and over which slot range/interval.
indexer.onSlot(
{
name: "SlotSampler",
where: ({ chain }) =>
chain.id === 7565164
? {
slot: {
_gte: 385_453_000, // start slot (inclusive)
_lte: 385_500_000, // end slot (inclusive)
_every: 100, // every 100th slot
},
}
: false,
},
async ({ slot, context }) => {
context.SlotPing.set({ id: slot.toString(), slot });
},
);
Differences from EVM onBlock
  • The handler argument is { slot: number, context }: a plain slot number, not a block object.
  • The filter key is slot (with _gte / _lte / _every), not block.number.
  • There's no interval option; express intervals with _every. _every aligns to _gte (or the chain start): it fires when (slot − _gte) % _every === 0.

The handler

The handler receives { slot, context }:

  • slot: the slot number being processed (a plain number).
  • context: entity operations (one object per schema.graphql entity, with get / getOrThrow / getWhere / getOrCreate / set / deleteUnsafe), plus context.log, context.effect, context.chain (id is 7565164 on Solana mainnet), and context.isPreload. This is the same context as EVM handlers; see the Event Handlers context.

Enriching with RPC data via Effects

A slot number alone is rarely enough, so pair onSlot with an Effect to fetch block/transaction/account data from RPC. Effects are deduplicated and cached, and can be rate-limited so you don't exhaust your RPC provider. S (from envio) builds the input/output schemas (it's the Sury library).


const blockSchema = S.schema({
blockhash: S.string,
blockHeight: S.nullable(S.number),
blockTime: S.nullable(S.number),
});

const getBlock = createEffect(
{
name: "getBlock",
input: { slot: S.number },
output: S.nullable(blockSchema),
rateLimit: { calls: 3, per: "second" },
},
async ({ input }) => {
const res = await fetch(process.env.ENVIO_MAINNET_RPC_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getBlock",
params: [input.slot, { maxSupportedTransactionVersion: 1, transactionDetails: "none" }],
}),
});
const { result } = await res.json();
return result ?? null;
},
);

indexer.onSlot({ name: "BlockTracker" }, async ({ slot, context }) => {
const block = await context.effect(getBlock, { slot });
if (!block) {
context.log.info(`Slot ${slot} has no block (skipped leader)`);
return; // some slots produce no block
}
context.BlockInfo.set({
id: slot.toString(),
hash: block.blockhash,
height: block.blockHeight ?? undefined,
time: block.blockTime ? new Date(block.blockTime * 1000) : undefined,
});
});
Not every slot has a block

On Solana a slot may be skipped (the leader produced no block). Handle the empty result rather than assuming getBlock always returns data.

Preload double-run

Like all V3 handlers, slot handlers run twice (a parallel preload pass to warm the cache, then the ordered pass). Effects are cached across both runs, so reads are cheap, but guard non-idempotent side effects with if (context.isPreload) return;. See Preload Optimization.

Slot handlers vs instruction handlers

Reach for instruction handlers to index what programs did, and slot handlers to do something on a cadence. They compose: an instruction handler records activity, and a slot handler rolls it into periodic snapshots. The overview compares the two side by side. For raw, low-level data over large ranges, query HyperSync for Solana directly.

  • Instruction Handlers: the main Solana handler type.
  • Effect API: external/RPC calls, caching, rate limiting.
  • Configuration: chains, RPC, programs, and start slot.

Solana Configuration File

File: solana/configuration.md

A Solana indexer is defined by a config.yaml with ecosystem: svm. It tells HyperIndex which chain to read, which programs and instructions to match, and how to decode them. This page is the field-by-field reference; for the meaning of discriminators, IDLs and argument types see Decoding Instructions.

Add this line at the top of the file for editor autocompletion and validation:

# yaml-language-server: $schema=./node_modules/envio/svm.schema.json

A complete example

config.yaml
# yaml-language-server: $schema=./node_modules/envio/svm.schema.json
name: my-solana-indexer
description: Index Jupiter swaps and SPL Token transfers
ecosystem: svm
chains:
- id: solana
start_slot: 437000000 # a SLOT number, not a block number
programs:
# --- decoded from an IDL ---
- name: Jupiter
program_id: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
idl: ./idls/jupiter.json # every usable instruction of the IDL is indexable
# --- decoded from an inline layout (no IDL) ---
- name: SplToken
program_id: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
instructions:
- name: transfer
discriminator: "0x03"
accounts: [source, destination, authority]
args:
- name: amount
type: u64

Handlers then select what they index and which fields they read - see Instruction Handlers.

Top-level fields

FieldRequiredDefaultNotes
name-Project name.
ecosystem-Must be svm.
chains-One or more Solana clusters to index (see below).
programs--Programs to index. Declared once for the whole project, not per chain.
description--Free-text description.
schema-schema.graphqlPath to your GraphQL schema.
handlers-src/handlersDirectory that handler files are auto-loaded from.
full_batch_size-5000Target number of instructions processed per batch.
storage-postgres: trueStorage backends (postgres, clickhouse).
disable_default_cross_chain-falseMake entities and effect caches per-chain instead of shared.
No EVM-style global fields

For Solana, several EVM top-level fields don't apply: contracts, rollback_on_reorg, save_full_history, raw_events, field_selection, address_format and bytes_type. Reorgs are handled automatically on the HyperSync source (it rolls back on reorg), field selection is per handler (the fields option), addresses are base58, and Bytes is always a Uint8Array.

chains

Each entry is one Solana cluster.

FieldRequiredDefaultNotes
id-The cluster: the label solana (7565164), solana-devnet (7565165), or an explicit number for another cluster. SVM has no native numeric chain id, so Envio assigns the label ids.
start_slot-The slot to start indexing from, or latest to start from the current head (see below).
end_slot--Stop at this slot. Useful for finite backfills.
block_lag--Stay this many slots behind the head.
hypersync_config-the chain id's public endpointBlock containing url. Optional for solana and solana-devnet; required for any other chain id.
rpc--Accepted but unused: instruction sync is served by HyperSync.
skip-falseExclude the chain from indexing and migrations.
chains:
- id: solana
start_slot: 437000000
- id: solana-devnet
start_slot: latest
EVM difference: the id is a label

EVM chains use the public numeric chain ID. Solana clusters have no such id, so you write solana or solana-devnet and HyperIndex maps them to 7565164 / 7565165 - the values you see as context.chain.id in handlers and as the key in test indexer chain overrides. Before v3.8 the Solana chain id was 0.

Starting from the head

start_slot: latest resolves the current head once, when the indexer is first deployed, and persists the concrete slot. A normal resume (a crash or a process restart) picks up from the stored slot, so downtime is backfilled rather than skipped; envio dev/envio start with -r re-resolves it against the head at that time.

Choosing an endpoint and a start slot

The endpoint follows the chain id, so the only real decision is the start slot:

ChainChain idEndpoint
Mainnetsolana (7565164)https://solana.hypersync.xyz
Devnetsolana-devnet (7565165)https://solana-devnet.hypersync.xyz

Each endpoint serves history back to its earliest indexed slot, not to genesis - mainnet is around slot 403,000,000 as of September 2026, and we keep extending it backwards. GET <endpoint>/height returns the current head.

A start_slot before the earliest indexed slot doesn't error

The indexer starts from the earliest indexed slot instead, so a backfill can look healthy while silently skipping every slot before that. To test a candidate slot, send a one-slot bounded query and compare next_slot to from_slot: equal means the slot isn't indexed yet, from_slot + 1 means served (see the HyperSync curl examples). Need history further back? Tell us on Discord.

programs

Programs are defined once for the whole project; program_id says where each one lives on every chain.

FieldRequiredDefaultNotes
name-A unique name you choose. Used in handlers (onInstruction({ program: "<name>" })) and in generated types.
program_id-Base58 program address. A single value is allowed only when the config defines one chain; with several, give a mapping keyed by chain id, naming every one of them and writing _ for a chain the program isn't deployed on.
idl--Path to an IDL JSON (Anchor 0.30+, legacy Anchor, Shank or Codama), relative to config.yaml. Every usable instruction becomes indexable, and onInstruction selects by name.
instructions--Instructions to index. Required when there's no idl; with an idl, use it only to override or add instructions (see below).
programs:
- name: SplToken
program_id:
solana: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
solana-devnet: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
- name: MyProgram
program_id:
solana: MyPr0gram11111111111111111111111111111111111
solana-devnet: _ # not deployed there

instructions

Each entry declares one instruction of the program.

FieldRequiredDefaultNotes
name-The instruction name, unique per program. It's the key in onInstruction({ instruction: "<name>" }) and in the generated types.
discriminator-0x-prefixed hex prefix of the instruction data to dispatch on, of any whole number of bytes; an 8-byte value is the standard Anchor discriminator. The empty prefix "0x" matches every instruction of the program. See discriminators.
accounts--Positional account slots, in the order the program expects them. The Nth entry names slot N and surfaces it as instruction.accounts.<name>.
args--Borsh argument layout. Setting it attaches a decoder that also filters - see args is also a filter.

With an idl, both accounts and args are required on a row whose name the IDL declares (that row replaces the IDL's version of it); a row with a new name adds an instruction the IDL didn't declare.

instructions:
- name: transferChecked
discriminator: "0x0c"
accounts: [source, mint, destination, authority]
args:
- name: amount
type: u64
- name: decimals
type: u8

Account slots

Entries in accounts are positional, and three forms are accepted:

FormMeaning
payerA slot the call always carries. Surfaces as instruction.accounts.payer.
?authorityAn optional slot. The key is absent from instruction.accounts when the call omits the slot or fills it with the program id.
_Holds a position without naming it, so the slots after it keep theirs. Never surfaced, never filterable, and the list may not end with one.

Naming a slot is what makes it filterable from a handler's where.accounts; the raw positional addresses are available either way through instruction.accountArguments.

Choosing what to index

Solana's highest-frequency programs (SPL Token, System) produce enormous volumes of instructions, and matching them unfiltered can swamp a backfill. Narrow a registration with a where filter on account keys, or read value flow from account activity on a protocol instruction instead of indexing every transfer.

  • Decoding Instructions: IDLs, discriminators, inline layouts, argument types.
  • Instruction Handlers: what arrives in the handler, field selection, and filters.
  • Schema file: defining the entities you write to (same as EVM).

EVM vs Solana

File: solana/evm-vs-solana.md

The HyperIndex workflow is the same on both: define a config.yaml and schema.graphql, write handlers, run codegen, then dev/start. What changes is the unit of work: EVM indexes events emitted by contracts, Solana indexes instructions executed by programs. This page collects every difference in one place.

Concept mapping

EVMSolana
Smart contractProgram
ABIIDL (Anchor, Shank, Codama) or a declared layout
Event / logInstruction
indexer.onEventindexer.onInstruction
event.params.<name>instruction.args.<name>
Event signature / topic0Instruction discriminator
Indexed event argsNamed account slots, filtered with the handler's where.accounts
indexer.onBlockindexer.onSlot
Block numberSlot number
Address 0x… (20 bytes, hex)Pubkey (32 bytes, base58)
Internal calls (not surfaced)Inner instructions / CPIs (first-class)
bigint paramsu64+ args as bigint, smaller ints as number

Config differences

EVMSolana
ecosystemevm (default)svm
What you listcontracts (with abi_file_path, address, events)top-level programs (with program_id, optional idl, instructions)
Chain identitychains[].id (public chain ID)chains[].id is a cluster label: solana (7565164) or solana-devnet (7565165)
Start of indexingstart_block (a block number, or latest)start_slot (a slot number, or latest)
Matching keyevent signature (from ABI)discriminator (a hex data prefix of any byte length)
Decoding sourceABIan IDL (Anchor, Shank, Codama) or an inline args + accounts layout
Field selectionthe handler's fields option, or field_selection in config.yamlthe handler's fields option only, with the extra instruction, accountActivity and log knobs
Data source configHyperSync endpoint inferred from chains[].idinferred from chains[].id too; set hypersync_config.url to pin a specific one
Reorg optionsrollback_on_reorg, save_full_historyhandled automatically on the HyperSync source (rolls back on reorg); the RPC source is finalized-only
chains, not networks

Current HyperIndex uses chains for both EVM and Solana. (V2 used networks for EVM; if you see that in an old guide, it's the same idea.)

Handler differences

EVM:

indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
const from = event.params.from; // decoded by ABI
const amount = event.params.value; // bigint
const chainId = event.chainId;
const contract = event.srcAddress; // 0x… hex
},
);

Solana:

const fields = {
instruction: ["args", "accounts", "isInner", "programId"],
transaction: ["signature"],
} as const;

indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute", fields },
async ({ instruction, context }) => {
const inAmount = instruction.args.inAmount; // bigint, decoded from the u64
const sourceMint = instruction.accounts.sourceMint.address; // base58
const programId = instruction.programId; // base58
const slot = instruction.block.slot;
const txSig = instruction.transaction.signature; // needs fields.transaction
const isInner = instruction.isInner; // CPI?
},
);

Key handler-level differences:

  • A failed decode never reaches the handler. EVM event.params is always populated (the ABI is known); on Solana an instruction whose data the configured layout rejects is skipped, so args and accounts need no null checks.
  • Accounts are objects, not strings. instruction.accounts.<name> is { address, accountName, instructionAccountIndex, activity } - read .address for the pubkey.
  • Addresses are base58. No 0x lowercase/checksum concerns; pubkeys are base58 strings.
  • Every field is opt-in. Instruction, transaction, account-activity, block and log fields all come from the registration's fields; on EVM the event always carries some block context.
  • The chain id is a big number. context.chain.id === 7565164 on mainnet (7565165 on devnet).

CPIs vs internal calls

On EVM, a contract calling another contract doesn't emit a separate indexable event for the internal call. On Solana, cross-program invocations are real instructions: if you index the inner program, you receive them, with isInner: true and a path that reconstructs the call tree. This makes Solana's composability directly indexable (e.g. the Raydium/Orca swaps underneath a Jupiter route). See Inner instructions.

Account activity

Solana instructions can carry pre/post lamport and SPL Token balances for the accounts a transaction touched (fields.accountActivity). This gives you net value movement (the balance change) without indexing every transfer instruction; there's no direct EVM equivalent built into the event. The amounts are bigint raw base units, and each token entry carries the mint's decimals. See Account activity.

Slots vs blocks

  • start_slot is a slot, and history doesn't reach genesis yet - see choosing a start slot.
  • Some slots have no block (skipped leader). Slot handlers must handle the empty case.
  • The handler arg for onSlot is { slot: number }, not a block object.

Not supported on Solana (yet)

Contract import, dynamic registration, wildcard indexing across programs, account-change and log handlers, and ReScript codegen have no Solana equivalent today. The overview lists each one and what to reach for instead.

What's the same: the schema file, the entity context API, the Effect API, local Docker dev, the GraphQL/Hasura layer, preload optimization, and Envio Cloud deployment.

  • Solana Overview
  • Configuration
  • Instruction Handlers
  • Decoding Instructions

Indexing on Fuel Network

File: fuel/fuel.md

Introduction

Envio supports the Fuel Network on mainnet and testnet. This page shows how to use HyperIndex with Fuel’s architecture and features.

Fuel offers several advantages as a modular execution layer including:

  • Parallel transaction execution
  • State-minimized design
  • UTXO-based architecture
  • Advanced FuelVM capabilities

HyperIndex for Fuel

HyperIndex enables developers to easily index and query real-time and historical data on Fuel Network with the same powerful features available for EVM chains.

Getting Started with Fuel Indexing

You can start indexing Fuel contracts in two ways:

  1. Quick Start (5-minute tutorial): Follow our step-by-step tutorial to create your first Fuel indexer quickly.

  2. No-Code Contract Import: Use our Contract Import tool to automatically generate configuration and schema files for your Fuel contracts.

Example Fuel Indexers

Looking for inspiration? Check out these indexers built by projects in the Fuel ecosystem:

ProjectTypeGitHub Repository
SparkOrderbook DEXgithub
MiraAMM DEXgithub
ThunderNFT Marketplacegithub
SwaylendLending Protocolgithub
GreeterTutorialgithub

Features Supported on Fuel

HyperIndex for Fuel supports all the core features available in the EVM version:

  • ✅ No-code Contract Import
  • ✅ Dynamic Contracts / Factory Tracking
  • ✅ Testing Framework
  • ✅ Envio Cloud
  • ✅ Wildcard Indexing

Fuel-Specific Event Types

Understanding Fuel's Event Model

Fuel's event model differs significantly from EVM. Instead of predefined events, Fuel uses a more flexible approach with various receipt types that can be indexed.

LOG_DATA Receipts (Primary Event Type)

The most common event type in Fuel is the LOG_DATA receipt, created by the log instruction in Sway contracts.

Unlike Solidity's emit which requires predefined event structures, Sway's log function allows passing any data, providing greater flexibility.

Configuration Example:

ecosystem: fuel
chains:
- id: 0 # Fuel testnet
start_block: 1
contracts:
- name: SwayContract
abi_file_path: "./abis/SwayContract.json"
address: "0x123..."
events:
- name: NewGreeting
logId: "8500535089865083573"

The logId is a unique identifier for the logged struct, which you can find in your contract's ABI file.

Auto-detection of logId:

If your event name matches the logged struct name in Sway, you can omit the logId:

events:
- name: NewGreeting # Will automatically detect logId if it matches the struct name

Tip: Instead of manually configuring events, use the Contract Import tool which automatically detects events and generates the proper configuration.

Additional Fuel Event Types

Fuel allows indexing several additional receipt types not available in EVM:

Event TypeDescriptionExample Configuration
MintTriggered when a contract mints tokens- name: Mint
BurnTriggered when a contract burns tokens- name: Burn
TransferCombines TRANSFER and TRANSFER_OUT receipts- name: Transfer
CallTriggered when a contract calls another contract- name: Call

Using Custom Names:

You can rename these events while maintaining their type:

events:
- name: MintMyNft # Custom name
type: mint # Actual event type

Note: All event types can be used with Wildcard Indexing.

Transfer Event Specifics

The Transfer event type combines two Fuel receipt types:

  • TRANSFER: Emitted when a contract transfers tokens to another contract
  • TRANSFER_OUT: Emitted when a contract transfers tokens to a wallet

Important: Transfers between wallets are not included in the Transfer event type.

Event Object Structure in Handlers

When handling Fuel events, the event object structure differs from EVM:

// Example Fuel event handler

indexer.onEvent(
{ contract: "SwayContract", event: "NewGreeting" },
async ({ event, context }) => {
// Access event parameters
const message = event.params.message;

// Access block information
const blockHeight = event.block.height;
const blockTime = event.block.time;
const blockId = event.block.id;

// Access transaction information
const txId = event.transaction.id;

// Access source contract address
const sourceContract = event.srcAddress;

// Access log position
const logIndex = event.logIndex;

// Store data
context.Greeting.set({
id: event.transaction.id,
message: message,
timestamp: blockTime,
});
},
);

HyperFuel

HyperFuel is Envio's low-level data API for the Fuel Network (equivalent to HyperSync for EVM chains).

HyperFuel provides:

  • High-performance data access
  • Flexible query capabilities
  • Multiple data formats (Parquet, Arrow, typed data)
  • Complete historical data

Available Clients

Access HyperFuel data using any of these clients:

HyperFuel Endpoints

For detailed information, see the HyperFuel documentation.

About Fuel Network

Fuel is an operating system purpose-built for Ethereum rollups with unique architecture focused on:

  • Parallelization: Execute transactions concurrently for higher throughput
  • State-minimized execution: Efficient storage and computation model
  • Interoperability: Seamless integration with other blockchain systems

Powered by the FuelVM, Fuel expands Ethereum's capabilities without compromising security or decentralization.

Resources

Need Help?

If you encounter any issues with Fuel indexing, please:

  1. Check our Troubleshooting guides
  2. Join our Discord for community support
  3. Create an issue in our GitHub repository

Licensing

File: licensing.md

TL;DR

  • Envio's licensing reflects open source ethos but is not OSI recognized.
  • Developers can use Envio's services without vendor lock-in, either by self-hosting or specifying an RPC URL.
  • The generated code is open and public
  • Our license allows self-hosting but restricts third-party competition with Envio Cloud.
  • Envio may consider open-sourcing in the future but prioritizes stakeholder interests and market traction.

Our position

We're devs and we value OS ethos too, that's why our licensing mirrors a lot of the benefits from open source licensing however Envio and its products do not use a recognized open source license by the OSI, we are however public and open and our licensing reflects this.

Our future business model lies in Envio Cloud and HyperSync requests and so we are protecting this, but to ensure continuity and no vendor lock-in, developers are able to run and develop on their indexer without either. Either by self-hosting, which our license permits, or by specifying an RPC URL in their indexer configuration and thus bypassing HyperSync.

Envio is in its formative stages and though we may look to open-source the software in the future we are dedicated to ensuring the best interests of all stakeholders. Going open source is somewhat of a one-way function and it is easier to go open source than to proverbially go "closed source". Once we have gained more market traction we will review our position on going open source.

HyperIndex End-User License Agreement (EULA)

This agreement describes the users' rights and the conditions upon which the Software and Generated Code may be used. The user should review the entire agreement, including any supplemental license terms that accompany the Software since all of the terms are important and together create this agreement that applies to them.

1. Definitions

Software: HyperIndex, a copyrightable work created by Envio and licensed under this End User License Agreement (“EULA”).

Generated Code: In the context of this license agreement, the term "generated code" refers to computer programming code that is produced automatically by the Software based on input provided by the user.

Licensed Material: The Software and Generated Code defined here will be collectively referred to as “Licensed Material”.

2. Installation and User Rights

License: The Software is provided under this EULA. By agreeing to the EULA terms, you are granted the right to install and operate one instance of the Software on your device (referred to as the licensed device), for the use of one individual at a time, on the condition that you adhere to all terms outlined in this agreement. The licensor provides you with a non-exclusive, royalty-free, worldwide license that is non-sublicensable and non-transferable. This license allows you to use the Software subject to the limitations and conditions outlined in this EULA. With one license, the user can only use the Software on a single device.

Device: In this agreement, "device" refers to a hardware system, whether physical or virtual, equipped with an internal storage device capable of executing the Software. This includes hardware partitions, which are considered individual devices for the purposes of this agreement. Updates may be provided to the Software, and these updates may alter the minimum hardware requirements necessary for the Software. It is the responsibility of users to comply with any changing hardware requirements.

Updates: The Software may be updated automatically. With each update, the EULA may be amended, and it is the users' responsibility to comply with the amendments.

Limitations: Envio reserves all rights, including those under intellectual property laws, not expressly granted in this agreement. For instance, this license does not confer upon you the right to, and you are prohibited from:

(i) Publishing, copying (other than the permitted backup copy), renting, leasing, or lending the Software;

(ii) Transferring the Software (except as permitted by this agreement);

(iii) Circumventing any technical restrictions or limitations in the Software;

(iv) Using the Software as server Software, for commercial hosting, making the Software available for simultaneous use by multiple users over a network, installing the Software on a server and allowing users to access it remotely, or installing the Software on a device solely for remote user use;

(v) Reverse engineering, decompiling, or disassembling the Software, or attempting to do so, except and only to the extent that the foregoing restriction is (a) permitted by applicable law; (b) permitted by licensing terms governing the use of open-source components that may be included with the Software and

(vi) When using the Software, you may not use any features in any manner that could interfere with anyone else's use of them, or attempt to gain unauthorized access to or use of any service, data, account, or network.

These limitations apply specifically to the Software and do not extend to the Generated Code. Details regarding the use of the Generated Code, including associated limitations, are provided below.

3. Use of the Generated Code

Limitations: Users can use, copy, distribute, make available, and create derivative works of the Generated Code freely, subject to the limitations and conditions specified below.

(i) The user is prohibited from offering the Generated Code or any software that includes the Generated Code to third parties as a hosted or managed service, where the service grants users access to a significant portion of the Software's features or functionality.

(ii) The user is not permitted to tamper with, alter, disable, or bypass the functionality of the license key in the Software. Additionally, the user may not eliminate or conceal any functionality within the Software that is safeguarded by the license key.

(iii) Any modification, removal, or concealment of licensing, copyright, or other notices belonging to the licensor in the Software is strictly forbidden. The use of the licensor's trademarks is subject to relevant laws.

Credit: If the user utilizes the Generated Code to develop and release new software, product, or service, the license agreement for said software, product, or service must include proper credit to HyperIndex.

Liability: Envio does not provide any assurance that the Generated Code functions correctly, nor does it assume any responsibility in this regard.

Additionally, it will be the responsibility of the user to assess whether the Generated Code is suitable for the products and services provided by the user. Envio will not bear any responsibility if the Generated Code is found unsuitable for the products and services provided by the user.

4. Additional Terms

Disclaimer of Warranties and Limitation of Liability:

(i) Unless expressly undertaken by the Licensor separately, the Licensed Material is provided on an as-is, as-available basis, and the Licensor makes no representations or warranties of any kind regarding the Licensed Material, whether express, implied, statutory, or otherwise. This encompasses, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether known or discoverable. If disclaimers of warranties are not permitted in whole or in part, this disclaimer may not apply to You.

(ii) To the fullest extent permitted by law, under no circumstances shall the Licensor be liable to You under any legal theory (including, but not limited to, negligence) for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising from the use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. If limitations of liability are not permitted in whole or in part, this limitation may not apply to You.

(iii) The disclaimers of warranties and limitations of liability outlined above shall be construed in a manner that most closely approximates an absolute disclaimer and waiver of all liability, to the fullest extent permitted by law.

Applicable Law and Competent Courts: This EULA shall be governed by and construed in accordance with the laws of England. The courts of England shall have exclusive jurisdiction to settle any dispute arising out of or in connection with this EULA.

Additional Agreements: If the user chooses to use the Software, it may be required to agree to additional terms or agreements outside of this EULA.


Terms of Service

File: terms-of-service.md

Last updated: Feb 06, 2025

The fine print: Please note these terms are intended to protect us, where adverse outcomes arise we will do our best to use generally accepted reason and logic as our first port of call.

  1. Introduction and Acceptance of Terms

    Welcome to Envio! By accessing or using our website and services, you agree to abide by these Terms of Service. If you do not agree with any part of these terms, you may not use our services.

  2. Description of Services

    Envio provides the following services:

    • A development framework named HyperIndex.
    • Envio Cloud, a hosted service for deploying and hosting HyperIndex indexers.
    • Low-level read-only API accessible as Hypersync and HyperRPC.
  3. User Accounts

    Users may create accounts to access and utilize our services. By creating an account, you agree to provide accurate and up-to-date information. You are responsible for maintaining the security of your account credentials.

  4. Payments and Refunds

    Our services are provided on a paid basis. By subscribing or making a payment, you agree to the applicable fees and billing terms.

    Billing & Payment: Fees are charged upfront based on the selected plan. Any additional unit fees exceeding the included base usage will be billed separately at the end of the monthly billing cycle. Should the additional unit fees exceed a significant threshold, we reserve the right to charge for accrued units at a point in time before the end of the billing cycle. Payments must be made via the payment methods we support.

    No Refunds: All payments are non-refundable. We do not provide refunds or credits for any unused service, partial subscription periods, downgrades, or cancellations.

    Pricing Changes: We reserve the right to modify our pricing at any time. Any changes will be communicated in advance and will take effect at the start of the next billing cycle.

    Cancellation: Subscriptions can be canceled at any time, but cancellations take effect at the end of the current monthly billing cycle for monthly subscriptions. For annual subscriptions, cancellations will take effect at the end of the current annual billing cycle. Previously paid fees will not be refunded.

    Failed Payments & Account Suspension: If a payment fails, we may retry the charge or suspend access to our services until the outstanding amount is settled. Continued failure to make payment may result in account termination.

    By using our services, you acknowledge and agree to these payment terms.

  5. Termination

    Envio reserves the right to terminate user accounts or suspend access to our services at our discretion. Users will be notified in advance of any such actions unless deemed necessary for security or legal reasons.

  6. Governing Law and Dispute Resolution

    These terms and any disputes arising from or related to them shall be governed by and construed in accordance with the laws of the United Kingdom. Any disputes shall be resolved through arbitration in the jurisdiction of the United Kingdom.

  7. Changes to Terms

    Envio reserves the right to update or modify these terms at any time. Changes will be effective upon posting on our website. It is your responsibility to review these terms periodically for any updates. Your continued use of our services after the posting of changes constitutes your acceptance of such changes.

  8. Prohibited Conduct

    Users are prohibited from engaging in the following conduct while using Envio's website and services:

    • Violating any applicable laws or regulations.
    • Transmitting any content that is unlawful, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable.
    • Attempting to gain unauthorized access to other users' accounts or to any part of Envio's systems.
    • Interfering with or disrupting the operation of Envio's website or services.
    • Engaging in any activity that could harm, disable, overburden, or impair Envio's servers or networks.
    • Uploading or transmitting any viruses, worms, or other malicious code.
    • Violating the intellectual property rights of Envio or any third party.
  9. Privacy Policy

    Envio is committed to protecting the privacy and security of our users' personal information. Our Privacy Policy outlines how we collect, use, and safeguard user data. By using our website and services, you agree to the terms of our Privacy Policy. Please review our Privacy Policy carefully to understand how we handle your information.

  10. Disclaimer of Warranties

    Envio's products are provided "as is" and without warranties of any kind. We make no guarantees regarding the reliability, availability, or performance of our services. Users utilize our services at their own risk.

  11. Limitation of Liability

    Envio shall not be liable for any damages arising from the use or inability to use our services, including but not limited to direct, indirect, incidental, consequential, or punitive damages.

  12. Indemnification

    Users agree to indemnify and hold harmless Envio, its affiliates, and their respective officers, directors, employees, and agents from any claims, damages, losses, or liabilities arising out of their use of our services or violation of these terms.


Privacy Policy

File: privacy-policy.md

Last updated: February 06, 2024

The fine print: Please note this privacy policy is intended to protect us, we have no intention of using your data for any malicious purposes

This Privacy Policy describes Our policies and procedures on the collection, use, and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.

We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.

Interpretation and Definitions

Interpretation

The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

Definitions

For the purposes of this Privacy Policy:

  • Account means a unique account created for You to access our Service or parts of our Service.

  • Affiliate means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest, or other securities entitled to vote for the election of directors or other managing authority.

  • Company (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Envio.

  • Cookies are small files that are placed on Your computer, mobile device, or any other device by a website, containing the details of Your browsing history on that website among its many uses.

  • Country refers to: Cayman Islands

  • Device means any device that can access the Service such as a computer, a cellphone, or a digital tablet.

  • Personal Data is any information that relates to an identified or identifiable individual.

  • Service refers to the Website.

  • Service Provider means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service, or to assist the Company in analyzing how the Service is used.

  • Third-party Social Media Service refers to any website or any social network website through which a User can log in or create an account to use the Service.

  • Usage Data refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).

  • Website refers to Envio, accessible from https://envio.dev

  • You means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.

Collecting and Using Your Personal Data

Types of Data Collected

Personal Data

While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:

  • Email address

  • First name and last name

  • Usage Data

Usage Data

Usage Data is collected automatically when using the Service.

Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.

When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device's unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.

We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.

Information from Third-Party Social Media Services

The Company allows You to create an account and log in to use the Service through the following Third-party Social Media Services:

Github

If You decide to register through or otherwise grant us access to a Third-Party Social Media Service, We may collect Personal data that is already associated with Your Third-Party Social Media Service's account, such as Your name and Your email address.

You may also have the option of sharing additional information with the Company through Your Third-Party Social Media Service's account. If You choose to provide such information and Personal Data, during registration or otherwise, You are giving the Company permission to use, share, and store it in a manner consistent with this Privacy Policy.

Tracking Technologies and Cookies

We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:

Cookies or Browser Cookies. A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse cookies, our Service may use Cookies. Web Beacons. Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity). Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser.

We use both Session and Persistent Cookies for the purposes set out below:

  • Necessary / Essential Cookies

    Type: Session Cookies

    Administered by: Us

    Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.

  • Cookies Policy / Notice Acceptance Cookies

    Type: Persistent Cookies

    Administered by: Us

    Purpose: These Cookies identify if users have accepted the use of cookies on the Website.

  • Functionality Cookies

    Type: Persistent Cookies

    Administered by: Us

    Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these cookies is to provide You with a more personal experience and to avoid having to re-enter your preferences every time You use the Website.

    For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.

By visiting this site you consent to the use of cookies.

Use of Your Personal Data

The Company may use Personal Data for the following purposes:

To provide and maintain our Service, including monitoring the usage of our Service.

To manage Your Account: to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.

For the performance of a contract: the development, compliance, and undertaking of the purchase contract for the products, items, or services You have purchased or of any other contract with Us through the Service.

To contact You: To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application's push notifications regarding updates or informative communications related to the functionalities, products, or contracted services, including the security updates, when necessary or reasonable for their implementation.

To provide You with news, special offers, and general information about other goods, services, and events that we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.

To manage Your requests: To attend and manage Your requests to Us.

For business transfers: We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.

For other purposes: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns, and evaluating and improving our Service, products, services, marketing, and your experience.

We may share Your personal information in the following situations:

With Service Providers: We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You. For business transfers: We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company. With Affiliates: We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners, or other companies that We control or that are under common control with Us. With business partners: We may share Your information with Our business partners to offer You certain products, services, or promotions. With other users: When you share personal information or otherwise interact in public areas with other users, such information may be viewed by all users and may be publicly distributed outside. If You interact with other users or register through a Third-Party Social Media Service, Your contacts on the Third-Party Social Media Service may see Your name, profile, pictures and description of Your activity. Similarly, other users will be able to view descriptions of Your activity, communicate with You, and view Your profile. With Your consent: We may disclose Your personal information for any other purpose with Your consent. Retention of Your Personal Data The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.

The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.

Transfer of Your Personal Data Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to - and maintained on - computers located outside of Your state, province, country, or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.

Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.

The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.

Delete Your Personal Data You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You.

Our Service may give You the ability to delete certain information about You from within the Service.

You may also contact Us to request access to, correct, or delete any personal information that You have provided to Us.

Please note, however, that We may need to retain certain information when we have a legal obligation or lawful basis to do so.

Disclosure of Your Personal Data

Business Transactions

If the Company is involved in a merger, acquisition, or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.

Law enforcement

Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).

The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:

Protect and defend the rights or property of the Company Prevent or investigate possible wrongdoing in connection with the Service Protect the personal safety of Users of the Service or the public Protect against legal liability Security of Your Personal Data The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.

Children's Privacy

Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.

Our Service may contain links to other websites that are not operated by Us. If You click on a third-party link, You will be directed to that third-party's site. We strongly advise You to review the Privacy Policy of every site You visit.

We have no control over and assume no responsibility for the content, privacy policies, or practices of any third party sites or services.

Changes to this Privacy Policy

We may update Our Privacy Policy from time to time. We will update the Privacy Policy by posting the new Privacy Policy on this page.

You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.

Contact Us

If you have any questions about this Privacy Policy, You can contact us by email: hello@envio.dev or on our discord


Frequently Asked Questions (FAQ)

What is HyperIndex?

HyperIndex is Envio's multichain blockchain indexing framework. It lets developers define which smart contract events to track, automatically transforms those on-chain events into structured data, and exposes them via a GraphQL API - giving you a complete, queryable backend for any blockchain application. It is powered by HyperSync, Envio's high-performance data engine.

How fast is HyperIndex compared to other indexers?

HyperIndex is the fastest blockchain indexer available. In independent benchmarks conducted by Sentio in April 2025, HyperIndex was up to 6x faster than the nearest competitor and over 63x faster than TheGraph in real-world scenarios. For example, indexing LBTC token transfers took 3 minutes with HyperIndex versus 3 hours and 9 minutes with TheGraph.

What blockchains does HyperIndex support?

HyperIndex supports all EVM-compatible chains. Over 70 networks have native HyperSync support for maximum performance. For chains without HyperSync, indexing is available via RPC endpoints.

What programming languages can I use?

HyperIndex supports TypeScript, JavaScript, and ReScript for writing event handlers.

What are the prerequisites to run HyperIndex locally?

You need Node.js v22 or newer, pnpm v8 or newer, and Docker Desktop. Docker is only required for local development - if you use Envio's hosted service, you can skip it. Windows users also need WSL (Windows Subsystem for Linux).

Do I need an API token?

API tokens are required for local development and self-hosted deployments as of 3 November 2025. You can set your token via the ENVIO_API_TOKEN environment variable in your project's .env file. Indexers deployed to Envio's hosted service have special access and do not require a custom API token.

How do I get started?

Run the following command and follow the prompts - you can have a working indexer in under 5 minutes:

pnpx envio init

You can import a contract directly from a block explorer (Etherscan, Blockscout, etc.) or from a local ABI file. HyperIndex will auto-generate your config.yaml, schema.graphql, and event handler files.

What is HyperSync and how does it relate to HyperIndex?

HyperSync is the high-performance data engine that powers HyperIndex. It provides the raw blockchain data access layer and delivers up to 2000x faster performance than traditional RPC endpoints. HyperIndex uses HyperSync under the hood to give you a complete indexing solution. HyperSync can also be used directly for custom data pipelines and specialised applications.

Can HyperIndex handle blockchain reorganisations (reorgs)?

Yes. HyperIndex automatically handles blockchain reorganisations by default. This behaviour can be configured via the rollback_on_reorg flag in your config.yaml.

Does HyperIndex support multichain indexing?

Yes. HyperIndex natively supports indexing across multiple chains in a single indexer. For the best multichain performance, you can enable unordered_multichain_mode in your configuration - this is the most common setup for multichain indexing, though it comes with tradeoffs worth understanding (see the docs).

Can I do wildcard indexing (without a specific contract address)?

Yes. HyperIndex supports wildcard indexing, which lets you index all events matching a given event signature across any contract on the chain - no contract address required.

How do I migrate from TheGraph and other indexing solutions?

HyperIndex has a dedicated migration guide for TheGraph subgraphs. The three main steps are: (1) convert your subgraph.yaml to config.yaml, (2) migrate your schema (near copy-paste), and (3) migrate your event handlers. Run pnpx envio init to generate a boilerplate, then follow the Migration Guide.

For developers migrating from other indexing solutions such as Ponder, Ormi, SQD (Subsquid), SubQuery, and others, the core concepts map similarly - define your contracts and events in config.yaml, write event handlers, and let HyperIndex generate the GraphQL API. Envio also offers full white glove migration support for teams moving from any indexing stack. Reach out via Discord to get personalised assistance.

What does the hosted service offer?

Envio's hosted service manages deployment and infrastructure for you. Indexers on the hosted service do not require a custom API token for HyperSync access. For pricing details and self-hosted tiered packages, reach out on Discord.

Where can I get help?