HyperIndex Complete Documentation
This document contains all HyperIndex documentation consolidated into a single file for LLM consumption.
| What it is | A blazing-fast, developer-friendly multichain blockchain indexer that transforms on-chain events into structured, queryable databases with GraphQL APIs |
| Data engine | Powered by HyperSync - up to 2000x faster than traditional RPC endpoints |
| Performance | Ranked #1 fastest indexer in independent Sentio benchmarks (April 2025) - up to 6x faster than the nearest competitor, 63x faster than TheGraph |
| Supported chains | 70+ EVM chains and Fuel, with new networks added regularly; all EVM-compatible chains supported via RPC |
| Languages | TypeScript, JavaScript, ReScript |
| Key files | config.yaml (indexer settings), schema.graphql (data schema), src/EventHandlers.* (event logic) |
| Prerequisites | Node.js v22+, pnpm v8+, Docker Desktop (local dev only) |
| Deployment | Hosted service (managed, no API token needed) or self-hosted |
| API token | Required for local dev and self-hosted deployments from 3 November 2025 via ENVIO_API_TOKEN env variable |
| Query interface | GraphQL API auto-generated from your schema |
| Multichain | Native multichain indexing with unordered_multichain_mode support |
| Wildcard indexing | Index by event signature rather than contract address |
| Migration | Straightforward migration path from TheGraph subgraphs |
| Get started | pnpx envio init |
| Support | Discord · 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.
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:
- Isolated Multichain Mode
- Polished Solana Support
- Indexing 1,000,000+ events per second
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_TOKENenvironment variable in your indexer configuration. This can be read from the.envfile 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.
🔗 Quick Links
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.
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]
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]
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
ERC20or Greeter projects, selectable from thepnpx envio initinteractive prompt. - Examples — copy and adapt an existing indexer from the Envio Explorer, 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.
If you'd rather drive the CLI yourself, see the Quickstart.
Prerequisites
- Node.js (v22 or newer)
- pnpm (recommended but not required)
- Docker Desktop (only needed to run the indexer locally)
- An AI coding assistant (we recommend Claude Code)
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.
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_TOKENin 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 devlocally, generate a token from the link above and setENVIO_API_TOKENin.envbefore 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:
- Describe the behavior you want in plain English.
- Let the assistant edit
config.yaml,schema.graphql, andsrc/handlers. - Have it follow a test-driven loop: write a failing test with
createTestIndexer(), implement the handler, then runpnpm testto capture and lock in snapshots. See the Testing guide for the full TDD workflow. - Iterate on failures together.
The three files your agent will spend most of its time in:
config.yaml: networks, contracts, eventsschema.graphql: entities and relationshipssrc/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.
Related Resources
- 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.
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 totrueto use replicated table engines.ENVIO_CLICKHOUSE_DATABASE_ENGINE— override the database engine (for example,Replicated).
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_LEVELenvironment 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 (Experimental)
HyperIndex now supports Solana with RPC as a source. This feature is experimental and may undergo minor breaking changes. Solana exposes its block-stream handler as indexer.onSlot (rather than onBlock) to match Solana's slot-based model.
To initialize a Solana project:
pnpx envio init svm
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.changescaptures every entity set/delete per block. Pair withtoMatchInlineSnapshotfor 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
--benchsupport — use the/metricsendpoint 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 inittemplates) - TypeScript upgraded from v5 to v6 (internally and in
envio inittemplates)
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)
wherefilters supportORconditions on RPC sources — pass an array toparamsto match any of several conditions. See Multiple Filters.- An RPC-backed indexer can register multiple
wildcardevents. 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 fetchedenvio_processing_stalled_on_storage_write_seconds— waiting for pending writes to drainenvio_process_metric_time_secondsandenvio_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.
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
tofield 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.nullableschema type to beT | nullinstead ofT | undefined
Release Notes
For detailed release notes, see:
- v3.5.0
- v3.4.0
- v3.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- v3.0.0-rc.1
- v3.0.0-rc.0
- v3.0.0-alpha.24
- v3.0.0-alpha.23
- v3.0.0-alpha.22
- v3.0.0-alpha.21
- v3.0.0-alpha.20
- v3.0.0-alpha.19
- v3.0.0-alpha.18
- v3.0.0-alpha.17
- v3.0.0-alpha.16
- v3.0.0-alpha.15
- v3.0.0-alpha.14
- v3.0.0-alpha.13
- v3.0.0-alpha.12
- v3.0.0-alpha.11
- v3.0.0-alpha.10
- v3.0.0-alpha.9
- v3.0.0-alpha.8
- v3.0.0-alpha.7
- v3.0.0-alpha.6
- v3.0.0-alpha.5
- v3.0.0-alpha.4
- v3.0.0-alpha.3
- v3.0.0-alpha.2
- v3.0.0-alpha.1
- v3.0.0-alpha.0
Benchmarks
File: benchmarks.md
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
| Case | Description | Envio | Nearest Competitor | The Graph | Ponder |
|---|---|---|---|---|---|
| LBTC Token Transfers | Event handling, No RPC calls, Write-only | 3m | 8m - 2.6x slower (Sentio) | 3h9m - 3780x slower | 1h40m - 2000x slower |
| LBTC Token with RPC calls | Event handling, RPC calls, Read-after-write | 1m | 6m - 6x slower (Sentio) | 1h3m - 63x slower | 45m - 45x slower |
| Ethereum Block Processing | 100K blocks with Metadata extraction | 7.9s | 1m - 7.5x slower (Subsquid) | 10m - 75x slower | 33m - 250x slower |
| Ethereum Transaction Gas Usage | Transaction handling, Gas calculations | 1m 26s | 7m - 4.8x slower (Subsquid) | N/A | 33m - 23x slower |
| Uniswap V2 Swap Trace Analysis | Transaction trace handling, Swap decoding | 41s | 2m - 3x slower (Subsquid) | 8m - 11x slower | N/A |
| Uniswap V2 Factory | Event handling, Pair and swap analysis | 8s | 2m - 15x slower (Subsquid) | 19m - 142x slower | 21m - 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.
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>
- After migration, run
pnpm devto 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
Please reach out to our team on Discord for personalized migration assistance.
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.
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:
- Subgraph.yaml migration
- Schema migration - near copy paste
- 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.yaml → config.yaml
pnpx envio init will generate this for you. It's a simple configuration file conversion. Effectively specifying which contracts to index, which networks to index (multiple networks 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
@entitydirective - Enums
- BigDecimals
3. Event handler migration
This consists of two parts
- Converting assemblyscript to typescript
- 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
awaitfor loading entitiesconst 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_selectionto 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 V2multichain: orderedmode 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.
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:
ponder.config.ts→config.yamlponder.schema.ts→schema.graphql- 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.ts → config.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
networksinstead ofchains. See the v2→v3 migration guide.
Key differences:
| Concept | Ponder | HyperIndex |
|---|---|---|
| Config format | ponder.config.ts (TypeScript) | config.yaml (YAML) |
| Chain reference | Named + viem object | Numeric chain ID |
| RPC URL | In config | ENVIO_RPC_URL_<chainId> env var |
| ABI source | TypeScript import | JSON file (abi_file_path) |
| Events to index | Inferred from handlers | Explicit events: list |
| Handler file | Inferred | Explicit 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.ts → schema.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:
| Ponder | HyperIndex 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_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
| Data | Ponder | HyperIndex |
|---|---|---|
| Event parameters | event.args.name | event.params.name |
| Contract address | event.log.address | event.srcAddress |
| Chain ID | context.chain.id | event.chainId |
| Block number | event.block.number | event.block.number |
| Block timestamp | event.block.timestamp (bigint) | event.block.timestamp (number) |
| Tx hash | event.transaction.hash | event.transaction.hash ⚠️ needs field_selection |
Entity operations
| Intent | Ponder | HyperIndex |
|---|---|---|
| Insert | context.db.insert(t).values({...}) | context.Entity.set({ id, ...fields }) |
| Update | context.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 check | context.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
Note: Alchemy subgraphs sunset on Dec 8th, 2025. Envio is offering affected Alchemy users 2 months of free hosting on Envio, along with full white-glove migration support to help projects move over smoothly.
For more info on how you can start your free trial or book migration support, visit this page to learn more.
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:
- Create a HyperIndex Project
- subgraph.yaml Migration to config.yaml
- schema.graphql Migration
- 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 networks 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()withcontext.ENTITY.set(VALUES) - Handlers need to be async
- Use
awaitwhen 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
Step 0: Prepare on V2 (Recommended)
While still on V2:
- Upgrade to
envio@^2.32.6. - Set
preload_handlers: trueinconfig.yaml. - If using loaders, migrate them per Migrating from Loaders.
- 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.nodeto>=22.0.0. - Update
envioto the latest v3 release. - Remove
optionalDependencies.generated— the localgeneratedpackage 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.ts → src/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"]
}
}
verbatimModuleSyntax and noUncheckedIndexedAccess are optional extra strictness — disable them to simplify migration.
Step 4: Update config.yaml
Renames:
networks→chainsconfirmed_block_threshold→max_reorg_depthrpc_config→rpc(now supports multiple URLs,for: sync | realtime | fallback, and WebSocket config)
Remove if present:
unordered_multichain_modeand anymultichain: 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_MODEUNORDERED_MULTICHAIN_MODEMAX_BATCH_SIZE(usefull_batch_sizeinconfig.yaml)ENVIO_INDEXING_BLOCK_LAG(use per-chainblock_lag)
Rename:
TUI_OFF=true→ENVIO_TUI=false(TUI also auto-disabled in CI and under AI agents)ENVIO_PG_PUBLIC_SCHEMA→ENVIO_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 }) => {},
);
eventFilters→where. Callback receives{ chain }(not{ chainId }) and returnsfalse,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.chainId → context.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 option | where callback returning { params: [...] } |
experimental_createEffect | createEffect |
block.chainId (in block handlers) | context.chain.id |
transaction.kind | transaction.type |
transaction.chainId | context.chain.id or event.chainId |
chain type | ChainId (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_eventLog | EvmEvent<"ERC20", "Transfer"> |
ERC20_Transfer_block | EvmEvent<"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 chaining | Automatic — pass multiple events in simulate |
Step 9: Update CLI Usage
envio devno longer auto-resets the DB — useenvio dev -r(--restart) if you relied on that.envio startis now production-only; useenvio devfor 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: NUMERIC→BIGINT, raw_events.serial: SERIAL→BIGSERIAL, envio_chains.events_processed: INTEGER→BIGINT, envio_checkpoints.id: INTEGER→BIGINT) 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.
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.
To access fields that are not provided by default, specify them using the field_selection option for your event:
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.
Chains
Everything under the top-level chains field configures the networks 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.
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
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"
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"
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.
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"
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)
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
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.
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
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.
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 ofENVIO_VAR${ENVIO_VAR:-default}– UseENVIO_VARif set, otherwise usedefault
For more detailed information about environment variables, see our Environment Variables Guide.
Advanced
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
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
Added in HyperIndex v3.1.
Configuration Schema Reference
Explore detailed configuration schema parameters here:
- See the full, deep-linkable reference: Config Schema Reference
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
idfield, using one of these scalar types:ID!,String!,Int!, orBigInt!
- The
idfield must be non-nullable, must not be a list, and cannot be a@derivedFromfield.
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, so Bid.auction above is typed bigint in your handlers 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 Scalar | Description | JavaScript/TypeScript | ReScript |
|---|---|---|---|
ID | Unique identifier | string | string |
String | UTF-8 character sequence | string | string |
Int | Signed 32-bit integer | number | int |
Float | Signed floating-point number | number | float |
Boolean | true or false | boolean | bool |
Bytes | UTF-8 character sequence (hex prefixed 0x) | string | string |
BigInt | Signed integer (int256 in Solidity) | bigint | bigint |
BigDecimal | Arbitrary-size floating-point | BigDecimal (imported) | BigDecimal.t |
Timestamp | Timestamp with timezone | Date | Js.Date.t |
Json | JSON object | Json | Js.Json.t |
Learn more about GraphQL scalars here.
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
-
Always use strings for initialization when precision matters:
// Preferred
const value = new BigDecimal("123.456789");
// May lose precision
const value = new BigDecimal(123.456789); -
Set precision explicitly when doing division:
// Set to 8 decimal places for crypto prices
const price = totalValue.div(tokenAmount).dp(8); -
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 -
Compare with equals method instead of
==or===:// Correct
if (value.eq(new BigDecimal(0))) {
/* ... */
}
// Incorrect - compares object references
if (value === new BigDecimal(0)) {
/* ... */
} -
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
tokensfield inNftCollectionis a virtual field, populated automatically when querying the API. - Set relationships in your handlers by assigning
<field>_idwith the related entity'sid. For example, create or update aTokenentity withcollection_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
idfields and fields referenced via@derivedFromare indexed automatically. - Declare
@indexfor the fields your GraphQL consumers filter and sort by. You don't need it for fields your handlers query withgetWhere— 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"
}
) {
id: ID!
timestamp: Timestamp!
amount: BigInt!
}
| Option | Type | Description |
|---|---|---|
partitionBy | ClickHouse expression | Emitted as PARTITION BY <expr>. Keeps queries and TTL deletes inside a partition instead of scanning the whole table. |
orderBy | list of entity field names | Fields that lead the table's sorting key, ahead of the default id. |
ttl | ClickHouse expression | Emitted as TTL <expr>. Ages rows out automatically. |
A few constraints, all caught at envio codegen rather than at runtime:
orderBytakes entity field names, not expressions — unlikepartitionByandttl, which are ClickHouse expressions passed through as written.orderBycan't listid(already the default sorting key), nor nullable, list or@derivedFromfields, which ClickHouse doesn't allow in a sorting key.- An entity can carry only one
@storagedirective, and it must enable at least one backend.
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.
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
},
);
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 network 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 (eghash,gasUsed, etc. Empty by default).
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.
Configure block and transaction fields with field_selection in your config.yaml file.
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 },
});
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
@indexfor it. -
Potential Memory Issues: Very large
getWherequeries might cause memory overflows. -
Tip: Try to put the
getWherequery 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,
});
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);
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
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. Usecontext.chain.idto 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 blockchain networks within a single indexer instance. This capability is essential for applications that:
- Track the same contract deployed across multiple networks
- 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 blockchain indexer will process events from all configured networks, maintaining proper synchronization across chains.
Configuration Requirements
To implement multichain indexing, you need to:
- Populate the
chainssection in yourconfig.yamlfile for each chain - Specify contracts to index from each chain
- 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 networks while maintaining performance and reliability.
Config File Structure for Multichain Indexing
The config.yaml file for multichain indexing contains three key sections:
- Global contract definitions - Define contracts, ABIs, and events once
- Chain-specific configurations - Specify chain IDs and starting blocks
- 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
contractssection defines the contract interface, ABI, handlers, and events once - The
chainssection 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
📢 Best Practice: When developing multichain indexers, append the chain ID to entity IDs to avoid collisions. For example:
user-1for Ethereum anduser-137for Polygon.
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. Entity ID Namespacing
Always namespace your entity IDs with the chain ID to prevent collisions between chains. This ensures that entities from different chains remain distinct.
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:
- Add the new chain entry to your
config.yamlwith the appropriatestart_blockand contract addresses - 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
-
Entity Conflicts: If you see unexpected entity updates, verify that your entity IDs are properly namespaced with chain IDs.
-
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.
Auto-exit (recommended for getting started)
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
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
- Write a failing test with expected entity output
- Implement the handler until the test passes
- Capture the snapshot — run
pnpm testto filltoMatchInlineSnapshot - Review and commit the snapshot for regression testing
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
Navigating Hasura
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.
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.graphqlfile - 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.graphqlfile dynamic_contracts(for dynamically added contracts)raw_eventstable (Note: This table is no longer populated by default to improve performance. To enable storage of raw events, addraw_events: trueto yourconfig.yamlfile 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
- Select any table from the "public" schema to view its contents
- Use the "Browse Rows" tab to see all data in that table
- Check the "Insert Row" tab to manually add data (useful for testing)
- View the "Modify" tab to see the table structure
Verifying Indexed Data
To confirm your blockchain indexer is working correctly:
- Check entity tables to ensure they contain the expected data
- Run the
_metaindexing 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:
- Run the
_metaindexing status query to see each chain's latest processed block - 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:
- Check if you've enabled raw events storage (
raw_events: trueinconfig.yaml) and then examine theraw_eventstable to confirm events were captured - Verify your event handlers are correctly processing these events
- Examine your GraphQL queries to ensure they match your schema structure
- Check console logs for any processing errors
Resetting Indexed Data
When testing, you may need to reset your database:
- Stop your indexer
- Reset your database (refer to the development guide for commands)
- 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_TOKENto 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 tofalseto disable Hasura integration for self-hosted blockchain indexers -
ENVIO_TUI: Set tofalseto disable the terminal UI (replaces the V2TUI_OFF=trueflag; the TUI is also auto-disabled in CI and under AI agents) -
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 (replacesENVIO_PG_PUBLIC_SCHEMA; the old name is still accepted until v4)
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:
- Using a
.envfile in your project root:
# .env
ENVIO_API_TOKEN=your-secret-token
ENVIO_RPC_URL=https://arbitrum.direct.dev/your-api-key
- 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
- Never commit sensitive values: Always use environment variables for sensitive information like API keys and database credentials
- Never commit or use private keys: Never commit or use private keys in your codebase
- Use descriptive names: Make your environment variable names clear and descriptive
- Document your variables: Keep a list of required environment variables in your project's README
- Use different values: Use different environment variables for development, staging, and production environments
- Validate required variables: Check that all required environment variables are set before starting your blockchain indexer
Troubleshooting
If you encounter issues with environment variables:
- Verify that all required variables are set
- Check that variables are prefixed with
ENVIO_ - Ensure there are no typos in variable names
- 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 networks. The architecture is designed to handle high throughput and maintain consistency across different blockchain networks.
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:
- Clone the repository
- Follow the installation instructions in the README
- Run the indexer locally or deploy it to a production environment
- 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.
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 networks 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:
- Clone the indexer that matches your needs:
- Review the file structure and implementation patterns
- Examine the event handlers for efficient data processing techniques
- Study the schema design for effective entity modeling
For complete API documentation and usage examples, see:
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.
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 networks.
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
- Open your terminal in an empty directory and run:
pnpx envio init
-
Name your indexer (we'll use "optimism-bridge-indexer" in this example):
-
Choose your preferred language (TypeScript, JavaScript, or ReScript):
Step 2: Import the Optimism Bridge Contract
-
Select Contract Import → Block Explorer → Optimism
-
Enter the Optimism bridge contract address:
0x4200000000000000000000000000000000000010 -
Select the
DepositFinalizedevent:- 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
-
When prompted, select Add a new contract
-
Choose Block Explorer → Ethereum Mainnet
-
Enter the Ethereum Mainnet gateway contract address:
0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1 -
Select the
ETHDepositInitiatedevent -
When finished adding contracts, select I'm finished
Step 4: Start Your Indexer
- If you have any running indexers, stop them first:
pnpm envio stop
- 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
- Open Hasura at http://localhost:8080
- When prompted, enter the admin password:
testing
Monitoring Indexing Progress
- Click the Data tab in the top navigation
- Find the
_events_sync_statetable to check indexing progress - 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
- Click the API tab
- 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
}
}
- 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 networks.
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 network 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
- Open your terminal in an empty directory and run:
pnpx envio init
-
Name your indexer (we'll use "usdc-base-transfer-indexer" in this example):
-
Choose your preferred language (TypeScript, JavaScript, or ReScript):
Step 2: Import the USDC Token Contract
-
Select Contract Import → Block Explorer → Base
-
Enter the USDC token contract address on Base:
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -
Select the
Transferevent:- Navigate using arrow keys (↑↓)
- Press spacebar to select the event
Tip: You can select multiple events to index simultaneously if needed.
- When finished adding contracts, select I'm finished
Step 3: Start Your Indexer
- 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.
- 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:
- Network 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
- Open Hasura at http://localhost:8080
- When prompted, enter the admin password:
testing
Monitoring Indexing Progress
- Click the Data tab in the top navigation
- Find the
_events_sync_statetable to check indexing progress - 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
- Click the API tab
- 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
}
}
- 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 network
- 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.
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
Greetertemplate.
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_DATAreceipts, but you can also indexMint,Burn,TransferandCallreceipts. 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 network 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.graphqlfile.
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 networks.
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:
- Open your terminal and run:
pnpx envio init
- When prompted for a directory, you can press Enter to use the current directory or specify another path:
? Set the directory: (.) .
- Choose your preferred programming language for event handlers:
? Which language would you like to use?
> JavaScript
TypeScript
ReScript
- Select the Template initialization option:
? Choose an initialization option
> Template
Contract Import
- 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 networks 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.
- 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:
- Visit the contract on Polygonscan
- Connect your wallet
- Use the
setGreetingfunction to write a new greeting - Submit the transaction
For Linea:
- Visit the contract on Lineascan
- Connect your wallet
- Use the
setGreetingfunction to write a new greeting - 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:
- Open Hasura at http://localhost:8080
- When prompted for authentication, use the password:
testing - Navigate to the Data tab to browse the database tables
- 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:
- Visit Envio Cloud
- Follow the steps to deploy your indexer
- 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:
| Method | Description | Speed | Accuracy | Decentralization |
|---|---|---|---|---|
| Oracles | On-chain price feeds (e.g., API3, Chainlink) | Fast | Medium | Medium |
| DEX Pools | Swap events from decentralized exchanges | Fast | Medium-High | High |
| Off-chain APIs | External services (e.g., CoinGecko) | Slow | High | Low |
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:
-
Identify the API3 contract address:
0x709944a48cAf83535e43471680fDA4905FB3920a -
Find the data feed ID for ETH/USD:
- The dAPI name "ETH/USD" as bytes32:
0x4554482f55534400000000000000000000000000000000000000000000000000 - Using the
dapiNameToDataFeedIdfunction, this maps to0x3efb3990846102448c3ee2e47d22f1e5433cd45fa56901abe7ab3ffa054f70b5
- The dAPI name "ETH/USD" as bytes32:
-
Monitor the
UpdatedBeaconSetWithBeaconsevents 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
getPoolfunction 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_selectionsection 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 networks
- Cache API results to avoid redundant calls
Next Steps
To further enhance your price data indexing:
- Implement caching for off-chain API calls
- Cross-reference multiple DEX pools for better accuracy
- Consider time-weighted average prices (TWAP) instead of spot prices
- Use multichain indexing to access higher-liquidity pools on major networks
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:
- Node.js v20 (v20 or newer required)
- pnpm (for Envio indexer)
- Docker Desktop (required to run the Envio indexer locally)
- Yarn (for Scaffold-ETH)
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
- Go to the Envio page at
http://localhost:3000/envio - 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 devfor 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
NftFactorycontract creates newSimpleNftcontracts- We want to index events from all NFTs created by this factory
- Each time a new NFT is created, the factory emits a
SimpleNftCreatedevent
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
NftFactorycontract has a known address specified in the config - The
SimpleNftcontract 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.
| TheGraph | Envio (HyperIndex) |
|---|---|
Define a template in subgraph.yaml | Define the contract in config.yaml without an address |
Call MyTemplate.create(address) in a mapping | Call context.chain.MyContract.add(address) in a contractRegister handler |
| Templates are triggered from other mappings | contractRegister 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
contractRegisterfunction 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).
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
},
);
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);
},
);
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:
- 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.
- 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.logcalls 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
fetchor other external calls directly in the handler.- Use the Effect API instead.
- Or use
context.isPreloadto 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.allto 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.isPreloadto 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 logginginput(required) - the input type of the effectoutput(required) - the output type of the effectrateLimit(required) - the maximum calls allowed per timeframe, orfalseto disablecache(optional) - save effect results in the database to prevent duplicate callscrossChain(optional) - whether the cache and rate limit are shared across all chains (default:true). 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
Sfrom theenviopackage, 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:
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 gainscontext.chain.id, the chain the effect was called on. Accessingcontext.chainon a cross-chain effect throws.
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(),
}),
});
},
);
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:
- Make RPC calls to external contracts within your event handlers
- Batch multiple calls using multicall for efficiency
- Learn about Preload Optimisation and how it makes your indexer thousands of times faster
- Use Effect API with built-in caching and Viem transport level batching
- 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:
- Extract the token addresses from the event
- Make RPC calls to each token's contract
- 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:
- Create a Pool entity from the event data
- Make RPC calls to fetch token information for both token0 and token1
- 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:
- Make RPC calls to token contracts
- Use multicall to batch multiple calls for efficiency
- Handle edge cases like non-standard ERC20 implementations
- 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
hexToStringmethod from Viem adds byte padding to the string. We remove this padding withreplace(/\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:
- Use multicall (as shown in our example) to batch multiple contract calls into a single RPC request
- Learn about Preload Optimization to make your indexer thousands of times faster
- Enable caching to avoid redundant requests
- Use a paid, unthrottled RPC provider for production indexers
- Implement request throttling to space out requests when needed
- 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:
- Create a basic indexer for Bored Ape Yacht Club NFT transfers
- Extend the indexer to fetch and store metadata from IPFS
- 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/cachedirectory 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:
- Splitting files into chunks
- Creating content-addressed identifiers (CIDs) based on the content itself
- Distributing these chunks across a network of nodes
- 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:
- Slow Retrieval Times: IPFS data can be slow to retrieve, especially for less widely replicated content
- Gateway Reliability: Public gateways can be inconsistent in their availability
- 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 networks
- 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
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:
- Detect the first block where your contract was deployed
- Begin indexing from that block
- 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 networks. 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
| Metric | Traditional RPC | HyperSync |
|---|---|---|
| Indexing 1M Events | Hours to days | Minutes |
| Resource Usage | High | Optimized |
| Network Calls | Many individual calls | Batched for efficiency |
| Rate Limiting | Common issue | Not applicable |
| Cost | Pay per API call | Included 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:
- Unsupported Chains: When indexing a blockchain that isn't yet supported by HyperSync
- Custom Requirements: When you need specific RPC functionality not available in HyperSync
- 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
| Parameter | Description | Recommended Value |
|---|---|---|
url | Your RPC endpoint URL | Depends on provider |
initial_block_interval | Starting block batch size | 1,000 - 10,000 |
backoff_multiplicative | How much to reduce batch size after errors | 0.5 - 0.9 |
acceleration_additive | How much to increase batch size on success | 500 - 2,000 |
interval_ceiling | Maximum blocks per request | 5,000 - 10,000 |
backoff_millis | Wait time after errors (ms) | 1,000 - 10,000 |
query_timeout_millis | Request 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:
ORconditions — passing an array toparamsmatches 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:
- Start from a recent block if possible, rather than indexing from genesis
- Tune batch parameters based on your provider's capabilities
- Use a paid service for better reliability and higher rate limits
- 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
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
- 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
- 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
- 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
| Feature | HyperSync | RPC |
|---|---|---|
| Speed | 10-100x faster | Baseline |
| Configuration | Minimal | Requires tuning |
| Rate Limits | None | Depends on provider |
| Cost | Included with Envio Cloud | Pay per request/subscription |
| Chain Support | Supported chains | Any EVM chain |
| Maintenance | Managed by Envio | Self-managed |
Summary
While RPC provides the flexibility to index any EVM blockchain, it comes with performance limitations and configuration complexity. For supported networks, 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 (required)
- description
- schema
- handlers
- full_batch_size
- storage
- ecosystem
- contracts
- chains (required)
- rollback_on_reorg
- save_full_history
- field_selection
- raw_events
- address_format
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:
1: StorageConfig2:null
Example (config.yaml):
storage:
postgres:
default: true
column_name_format: snake_case
clickhouse: true
ecosystem
Ecosystem of the project.
- type:
anyOf(enum (1 values) | null)
Variants:
1: EcosystemTag2:null
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:
1: FieldSelection2:null
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:
1: AddressFormat2:null
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 namehandler: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 ABIname:string | null– Name of the event in the HyperIndex generated code. When ommitted, the event field will be used. Should be unique per contractfield_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
- Available values:
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
- Available values:
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-tuningmax_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:integer– The block at which the indexer should start ingesting dataend_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:string2: Rpc3: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 querybackoff_multiplicative:number | null– After an RPC error, how much to scale back the number of blocks requested at onceacceleration_additive:integer | null– Without RPC errors or timeouts, how much to increase the number of blocks requested by for the next batchinterval_ceiling:integer | null– Do not further increase the block interval past this limitbackoff_millis:integer | null– After an error, how long to wait before retryingfallback_stall_timeout:integer | null– If a fallback RPC is provided, the amount of time in ms to wait before kicking off the next providerquery_timeout_millis:integer | null– How long to wait before cancelling an RPC requestpolling_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 sync2:const fallback3: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
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 leveladdress: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 namehandler: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
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 V2multichain: orderedopt-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 byrpc(see above).networks— renamed tochains.confirmed_block_threshold— renamed tomax_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.
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↴envio init↴envio init contract-import↴envio init contract-import explorer↴envio init contract-import local↴envio init template↴envio init svm↴envio init svm template↴envio init fuel↴envio init fuel contract-import↴envio init fuel contract-import local↴envio init fuel template↴envio dev↴envio stop↴envio codegen↴envio local↴envio local docker↴envio local docker up↴envio local docker down↴envio local db-migrate↴envio local db-migrate up↴envio local db-migrate down↴envio local db-migrate setup↴envio start↴envio metrics↴envio metrics runtime↴envio skills↴envio skills update↴envio tools↴envio tools search-docs↴envio tools fetch-docs↴envio config↴envio config view↴
envio
Usage: envio [OPTIONS] <COMMAND>
Subcommands:
init— Initialize an indexer with one of the initialization optionsdev— Development commands for starting, stopping, and restarting the indexer. Runs codegen automatically before launchingstop— Stop the local environment - delete the database and stop all processes (including Docker) for the current directorycodegen— Generate indexing code from user-defined configuration & schema fileslocal— Prepare local environment for envio testingstart— Start the indexer. Runs codegen automatically before launching so the on-disk types stay in sync withconfig.yamlandschema.graphqlmetrics— Fetch raw Prometheus metrics from the running indexer's /metrics endpointskills— Manage Envio-provided Claude Code skills under.claude/skills/tools— Tools for people and AI agents (search-docs, fetch-docs). Runenvio tools helpfor detailsconfig— Inspect the indexer config
Options:
-
-d,--directory <DIRECTORY>— The directory of the project. Defaults to current dir ("./") -
--config <CONFIG>— The file in the project containing the configuration. It can also be set via theENVIO_CONFIGenvironment variableDefault value:
config.yaml
envio init
Initialize an indexer with one of the initialization options
Usage: envio init [OPTIONS] [COMMAND]
Subcommands:
contract-import— Initialize Evm indexer by importing config from a contract for a given chaintemplate— Initialize Evm indexer from an example templatesvm— Initialization option for creating Svm indexerfuel— Initialization option for creating Fuel indexer
Options:
-
-n,--name <NAME>— The name of your project -
-l,--language <LANGUAGE>— The language used to write handlersPossible values:
typescript,rescript -
--package-manager <PACKAGE_MANAGER>— The package manager used forinstalland post-init build steps (default: pnpm)Possible values:
pnpm,npm,yarn,bun -
--api-token <API_TOKEN>— The hypersync API key to be initialized in your templates .env file. Falls back to theENVIO_API_TOKENenvironment variable
envio init contract-import
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 explorerlocal— 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 fromPossible 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,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,rsk,saakuru,scroll,scroll-sepolia,sei,sei-testnet,sepolia,shimmer-evm,sonic,sonic-testnet,sophon,sophon-testnet,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
Initialize Evm indexer from an example template
Usage: envio init template [OPTIONS]
Options:
-
-t,--template <TEMPLATE>— Name of the template to be used in initializationPossible 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 initializationPossible values:
feature-block-handler
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 chaintemplate— 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 usePossible 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 initializationPossible 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 commandsdb-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 environmentdown— 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 databasedown— Drop database schemasetup— 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 withfetch-docsto read a hit in fullfetch-docs— Print the full markdown of a docs page by URL. Use a URL returned bysearch-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 Type | Default Threshold | Notes |
|---|---|---|
| All Chains | 200 blocks | Will 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
- Keep reorg support enabled for production indexers
- Use HyperSync when possible for guaranteed reorg detection
- Avoid external side effects in your handlers that cannot be rolled back
- 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.
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 / location | Purpose |
|---|---|
.envio/types.d.ts | Ambient 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:
- Type-Safe Data Access - They provide strongly-typed interfaces to interact with your defined entities through
envio. - Event Processing - They describe each contract's events so
indexer.onEvent({ contract, event }, ...)is fully type-checked. - Database Interactions - They generate the entity types and helper signatures used by
context.<Entity>andindexer.<Entity>. - Runtime Orchestration - They feed into the
indexervalue (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:
- Modifying your
config.yamlfile - Changing your GraphQL schema
- Adding or updating event handlers
- Switching to a new contract or ABI
- 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
- Never modify generated files directly - Always change the source files
- Run codegen before starting your indexer - Ensure all declarations are up to date
- Check error messages carefully - They often pinpoint issues in your setup files
- Commit
envio-env.d.tsbut 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
/metricsendpoint. - Health checks — a
/healthzendpoint for liveness probes. - Indexing status — per-chain progress via the
_metaGraphQL query. - Dev Console — a web UI for debugging local development.
- Envio Cloud — managed dashboards, alerts, and metrics for hosted indexers.
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
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.
| Level | Description |
|---|---|
trace | Most verbose; detailed tracing information |
debug | Debugging information for developers |
info | General information about system operation (default) |
udebug | User-level debug logs |
uinfo | User-level info logs |
uwarn | User-level warning logs |
uerror | User-level error logs |
warn | System warnings |
error | System errors |
fatal | Critical 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.
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.).
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 resolves the port automatically from ENVIO_INDEXER_PORT in your
shell session or the .env file at your project root, 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
| Metric | Type | Description |
|---|---|---|
envio_progress_block | gauge | Latest block number processed and stored in the database. Labeled by chainId. |
envio_progress_events | gauge | Number of events processed and reflected in the database. Labeled by chainId. |
envio_progress_ready | gauge | Whether the chain is fully synced to the head (1 = synced). Labeled by chainId. |
envio_progress_latency | gauge | Milliseconds between the latest processed event being created on chain and being written to storage. Labeled by chainId. |
Event processing
| Metric | Type | Description |
|---|---|---|
envio_processing_seconds | counter | Cumulative time spent executing event handlers during batch processing. |
envio_processing_handler_seconds | counter | Cumulative time spent inside individual event handler executions. Labeled by contract and event. |
envio_processing_handler_total | counter | Total number of individual event handler executions. Labeled by contract and event. |
envio_processing_max_batch_size | gauge | Maximum number of items to process in a single batch. |
envio_processing_stalled_on_fetch_seconds | counter | Time 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_seconds | counter | Time the indexer paused processing because too many changes were still waiting to be written. |
Entity preloading
| Metric | Type | Description |
|---|---|---|
envio_preload_seconds | counter | Cumulative time spent preloading entities during batch processing. |
envio_preload_handler_seconds | counter | Wall-clock time spent inside individual preload handler executions. Labeled by contract and event. |
envio_preload_handler_seconds_total | counter | Cumulative time spent in preload handlers (can exceed wall-clock time due to parallel execution). Labeled by contract and event. |
envio_preload_handler_total | counter | Total number of individual preload handler executions. Labeled by contract and event. |
Storage
| Metric | Type | Description |
|---|---|---|
envio_storage_write_seconds | counter | Cumulative time spent writing batch data to storage. |
envio_storage_write_total | counter | Total number of batch writes to storage. |
envio_storage_load_seconds | counter | Time spent loading data from storage. Labeled by operation. |
envio_storage_load_seconds_total | counter | Cumulative time spent loading data from storage during indexing. Labeled by operation. |
envio_storage_load_total | counter | Number of successful storage load operations. Labeled by operation. |
envio_storage_load_size | counter | Cumulative number of records loaded from storage. Labeled by operation. |
envio_storage_load_where_size | counter | Cumulative number of filter conditions (where items) used in storage load operations. Labeled by operation. |
Data source & fetching
| Metric | Type | Description |
|---|---|---|
envio_fetching_block_range_seconds | counter | Cumulative time spent fetching block ranges. Labeled by chainId. |
envio_fetching_block_range_total | counter | Total number of block range fetch operations. Labeled by chainId. |
envio_fetching_block_range_events_total | counter | Cumulative number of events fetched across all block range operations. Labeled by chainId. |
envio_fetching_block_range_size | counter | Cumulative number of blocks covered across all fetch operations. Labeled by chainId. |
envio_fetching_block_range_parse_seconds | counter | Cumulative time spent parsing block range fetch responses. Labeled by chainId. |
envio_source_request_total | counter | Number of requests made to data sources. Labeled by source, chainId, and method. |
envio_source_request_seconds_total | counter | Cumulative time spent on data source requests. Labeled by source, chainId, and method. |
envio_source_known_height | gauge | Latest known block number reported by the data source. Labeled by source and chainId. |
Indexing pipeline
| Metric | Type | Description |
|---|---|---|
envio_indexing_known_height | gauge | Latest known block number reported by the active indexing source. Labeled by chainId. |
envio_indexing_concurrency | gauge | Number of executing concurrent queries to the chain data source. Labeled by chainId. |
envio_indexing_buffer_size | gauge | Current number of items in the indexing buffer. Labeled by chainId. |
envio_indexing_buffer_block | gauge | Highest block number fully fetched by the indexer. Labeled by chainId. |
envio_indexing_idle_seconds | counter | Time the indexer source syncing has been idle. A high value may indicate a bottleneck. Labeled by chainId. |
envio_indexing_partitions | gauge | Number of partitions used to split fetching logic. Labeled by chainId. |
envio_indexing_addresses | gauge | Number of addresses indexed on chain (static and dynamic). Labeled by chainId. |
envio_indexing_target_buffer_size | gauge | Indexer-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_block | gauge | The block number to stop indexing at (inclusive). Labeled by chainId. |
envio_indexing_source_querying_seconds | counter | Time spent performing queries to the chain data source. Labeled by chainId. |
envio_indexing_source_waiting_seconds | counter | Time the indexer has been waiting for new blocks. Labeled by chainId. |
Reorgs & rollbacks
| Metric | Type | Description |
|---|---|---|
envio_reorg_detected_total | counter | Total number of reorgs detected. |
envio_reorg_detected_block | gauge | Block number where a reorg was last detected. |
envio_reorg_threshold | gauge | Whether indexing is currently within the reorg threshold. |
envio_rollback_enabled | gauge | Whether rollback on reorg is enabled. |
envio_rollback_total | counter | Number of successful rollbacks on reorg. |
envio_rollback_seconds | counter | Total time spent on rollbacks. |
envio_rollback_events | counter | Number of events rolled back on reorg. |
envio_rollback_target_block | gauge | Block number the last reorg was rolled back to. Labeled by chainId. |
envio_rollback_history_prune_total | counter | Number of successful entity history prunes. Labeled by entity. |
envio_rollback_history_prune_seconds | counter | Total time spent pruning entity history outside the reorg threshold. Labeled by entity. |
Effect API
Metrics for the Effect API.
| Metric | Type | Description |
|---|---|---|
envio_effect_call_seconds | counter | Processing time taken to call the Effect function. Labeled by effect. |
envio_effect_call_seconds_total | counter | Cumulative time spent calling the Effect function during indexing. Labeled by effect. |
envio_effect_call_total | counter | Cumulative number of resolved Effect function calls. Labeled by effect. |
envio_effect_active_calls | gauge | Number of Effect function calls currently running. Labeled by effect. |
envio_effect_cache | gauge | Number of items in the effect cache. Labeled by effect. |
envio_effect_cache_invalidations | counter | Number of effect cache invalidations. Labeled by effect. |
envio_effect_queue | gauge | Number of effect calls waiting in the rate-limit queue. |
envio_effect_queue_wait_seconds | counter | Time spent waiting in the rate-limit queue. Labeled by effect. |
System info
| Metric | Type | Description |
|---|---|---|
envio_info | gauge | Information about the indexer. Labeled by version. |
envio_process_start_time_seconds | gauge | Start time of the process since the Unix epoch, in seconds. |
envio_process_metric_time_seconds | gauge | The time these metrics were collected. Use it to tell how fresh a snapshot is, or to measure rates between two snapshots. |
envio_process_elapsed_seconds | gauge | How 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"]
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-checkenvio_storage_write_secondsand 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.
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 bychainIdin ascending order. Use_meta(where: { chainId: { _eq: 1 } })to get the metadata for a specific chain.startBlock— Start block number fromconfig.yaml.endBlock— End block number fromconfig.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.
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-network 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
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
- Smart Contract Concepts
- Indexing & Data
- Development Tools
- Programming Languages
- Envio Platform
- Mathematical Concepts
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:
- Addition: 2 + 3 = 3 + 2
- Multiplication: 2 _ 3 = 3 _ 2
Examples of non-commutative operations:
- Subtraction: 5 - 3 ≠ 3 - 5
- Division: 8 / 4 ≠ 4 / 8
- 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 Size | Without Indices | With Proper Indices |
|---|---|---|
| 1,000 records | ~10ms | ~1ms |
| 100,000 records | ~500ms | ~2ms |
| 1,000,000+ records | 5+ 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:
- Individual indices on
fromandtofields - A composite index on the combination of
from,to, andtokenId
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
IDfields - 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
@indexfor 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.
Network-Specific Performance
Optimized Major Networks
- Priority Networks: We've dedicated significant resources to maintaining extremely low latency on popular networks including Ethereum, Optimism, and Arbitrum.
- User Experience: Users should experience seamless, near real-time data updates on these networks.
Smaller Chain Networks
- Standard Performance: On smaller chains, latency might be slightly higher as these networks 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 Multi-Chain Mode: For applications indexing multiple chains, our unordered multi-chain mode allows each chain to continue syncing independently.
- 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 Performance
File: Advanced/performance/benchmarking.md
Why Benchmark Your Indexer?
Benchmarking is a critical tool for understanding and optimizing your indexer's performance. By collecting and analyzing performance metrics, you can:
- Identify bottlenecks in your indexing pipeline
- Determine if performance issues are due to data fetching, processing, or database operations
- Measure the impact of code optimizations
- Set realistic expectations for indexing speed
- Plan infrastructure requirements for production deployments
Running Benchmarks
Capturing Benchmark Data
To collect performance metrics while your indexer is running:
pnpm envio start --bench
Note: Benchmarking adds some memory and processing overhead. It should not be enabled in production environments, as it holds benchmark data points in memory and periodically writes them to disk.
Viewing Benchmark Results
After running your indexer with benchmarking enabled, you can generate a performance summary:
pnpm envio benchmark-summary
This command processes the collected benchmark data and displays a comprehensive performance report.
Understanding Benchmark Output
The benchmark output is divided into several sections, each providing insights into different aspects of your indexer's performance:
Time Breakdown
Time breakdown
┌─────────────────────────────────────────┬─────────┐
│ (index) │ seconds │
├─────────────────────────────────────────┼─────────┤
│ Total Runtime │ 45 │
│ Total Time Fetching Chain 1 Partition 0 │ 44 │
│ Total Time Processing │ 9 │
└─────────────────────────────────────────┴─────────┘
What This Tells You:
- Total Runtime: Overall time the indexer has been running
- Total Time Fetching: Time spent retrieving data from the blockchain
- Total Time Processing: Time spent in event handlers and database operations
How to Interpret:
- If fetching time dominates (as in this example), your bottleneck is data retrieval, not processing
- If processing time is high relative to fetching, your handlers may need optimization
- Note that fetching and processing can overlap, so the sum may exceed total runtime
General Performance Metrics
General
┌─────────────────────┬─────────┐
│ (index) │ Values │
├─────────────────────┼─────────┤
│ batch sizes sum │ 158205 │
│ total runtime (sec) │ 45.801 │
│ events per second │ 3454.18 │
└─────────────────────┴─────────┘
What This Tells You:
- Batch Sizes Sum: Total number of events processed
- Total Runtime: Precise runtime in seconds
- Events Per Second: Overall processing throughput
How to Interpret:
- Events per second is your key performance indicator
- Over 10,000 events/second represents excellent performance
- 1,000-5,000 events/second indicates good performance
- Under 500 events/second may indicate optimization opportunities
Block Fetching Performance
BlockRangeFetched Summary for Chain 1 Root Register
┌───────────────────────────┬────┬───────────┬────────────┬──────┬──────────┬──────────┐
│ (index) │ n │ mean │ std-dev │ min │ max │ sum │
├───────────────────────────┼────┼───────────┼────────────┼──────┼──────────┼──────────┤
│ Total Time Elapsed (ms) │ 12 │ 3675.17 │ 1147.69 │ 2329 │ 5972 │ 44102 │
│ Parsing Time Elapsed (ms) │ 12 │ 142.17 │ 40.15 │ 80 │ 235 │ 1706 │
│ Page Fetch Time (ms) │ 12 │ 3481.58 │ 1042.93 │ 2249 │ 5737 │ 41779 │
│ Num Events │ 12 │ 13183.75 │ 3858.92 │ 7579 │ 22426 │ 158205 │
│ Block Range Size │ 12 │ 906593.17 │ 3006127.15 │ 149 │ 10876789 │ 10879118 │
└───────────────────────────┴────┴───────────┴────────────┴──────┴──────────┴──────────┘
What This Tells You:
- Total Time Elapsed: Time spent fetching and parsing each batch of blocks
- Parsing Time: Time spent decoding and preparing event data
- Page Fetch Time: Time spent retrieving data from the blockchain
- Num Events: Number of events in each batch
- Block Range Size: Number of blocks in each fetch operation
How to Interpret:
- Compare Page Fetch Time to Total Time to see if data retrieval is your bottleneck
- Large standard deviations (std-dev) indicate inconsistent performance
- If Block Range Size varies significantly, your indexer may be adjusting batch sizes dynamically
Event Processing Performance
EventProcessing Summary
┌─────────────────────────────────┬────┬─────────┬─────────┬─────┬──────┬────────┐
│ (index) │ n │ mean │ std-dev │ min │ max │ sum │
├─────────────────────────────────┼────┼─────────┼─────────┼─────┼──────┼────────┤
│ Batch Size │ 38 │ 4163.29 │ 1424.85 │ 89 │ 5000 │ 158205 │
│ Contract Register Duration (ms) │ 38 │ 0.11 │ 0.38 │ 0 │ 2 │ 4 │
│ Load Duration (ms) │ 38 │ 80.79 │ 32.58 │ 5 │ 149 │ 3070 │
│ Handler Duration (ms) │ 38 │ 22.18 │ 9.07 │ 0 │ 47 │ 843 │
│ DB Write Duration (ms) │ 38 │ 135.92 │ 52.09 │ 8 │ 220 │ 5165 │
│ Total Time Elapsed (ms) │ 38 │ 239 │ 83.24 │ 13 │ 370 │ 9082 │
└─────────────────────────────────┴────┴─────────┴─────────┴─────┴──────┴────────┘
What This Tells You:
- Batch Size: Number of events in each processing batch
- Contract Register Duration: Time spent preparing contract data
- Load Duration: Time spent loading entities from the database
- Handler Duration: Time spent executing your event handler logic
- DB Write Duration: Time spent writing updated entities to the database
- Total Time Elapsed: Overall time for the processing phase
How to Interpret:
- Compare Load, Handler, and DB Write durations to identify bottlenecks
- In this example, DB Write (135ms) and Load (80ms) operations dominate processing time
- If Load Duration is high, consider implementing entity loaders
- If DB Write Duration is high, check if you're updating too many entities per event
Per-Handler Performance
Handlers Per Event
┌─────────────────────────────┬────────┬────────┬─────────┬────────┬────────┬──────────┐
│ (index) │ n │ mean │ std-dev │ min │ max │ sum │
├─────────────────────────────┼────────┼────────┼─────────┼────────┼────────┼──────────┤
│ ERC20 Transfer Handler (ms) │ 158205 │ 0.0021 │ 0.0364 │ 0.0007 │ 4.6752 │ 329.7264 │
└─────────────────────────────┴────────┴────────┴─────────┴────────┴────────┴──────────┘
What This Tells You:
- Detailed timing for each specific event handler
- Shows average and total execution time across all events
How to Interpret:
- Compare different handlers to identify which ones are most expensive
- Look for handlers with high maximum values (max column), which may indicate inconsistent performance
- Handlers averaging above 1ms per event may benefit from optimization
Interpreting Results and Taking Action
Identifying Your Bottleneck
Based on the benchmark data, determine your primary performance bottleneck:
-
Data Fetching Bottleneck
- Symptoms: Most time spent in "Total Time Fetching"
- Solutions:
- Use HyperSync if available for your network
- If using RPC, consider a more performant provider
- Adjust block batch sizes in your configuration
-
Data Loading Bottleneck
- Symptoms: High "Load Duration" in Event Processing
- Solutions:
- Implement entity loaders to batch database operations
- Add appropriate database indices for frequently queried fields
- Optimize your entity relationships to reduce join complexity
-
Handler Logic Bottleneck
- Symptoms: High "Handler Duration" relative to other metrics
- Solutions:
- Simplify complex calculations in your handlers
- Move complex operations to a post-processing step
- Consider caching frequently accessed values
-
Database Write Bottleneck
- Symptoms: High "DB Write Duration"
- Solutions:
- Reduce the number of entities updated per event
- Batch related updates where possible
- Check if you're updating the same entity multiple times unnecessarily
Benchmarking Best Practices
-
Benchmark Before and After Optimizations
- Run benchmarks before making changes to establish a baseline
- Run again after each optimization to measure impact
-
Focus on the Largest Bottleneck First
- Prioritize optimizations based on where time is being spent
- Small improvements to the critical path yield the greatest results
-
Watch for Memory Usage
- Monitor memory consumption alongside performance metrics
- High memory usage can lead to degraded performance over time
-
Consider Real-World Conditions
- Test with realistic data volumes and event patterns
- Include periods of high activity in your benchmark tests
Advanced Performance Tuning
For cases where standard optimizations aren't sufficient:
-
Custom Database Indices
- Create indices tailored to your specific query patterns
- Add composite indices for multi-field filters
-
Handler Specialization
- Create specialized handlers for high-volume events
- Simplify logic for the most common paths
-
Speak to the Envio Team
- We can help!
By regularly benchmarking your indexer and methodically addressing performance bottlenecks, you can achieve significant improvements in indexing speed and efficiency.
Loaders Optimization (Removed in V3)
File: Advanced/loaders.md
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://towss://in your GraphQL endpoint URL - HTTP → WS: Change
http://tows://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
/debugto 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
The query converter tool is only available to users on paid tiers.
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.
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.
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
orderByandorderDirectionparameters are ignored because HyperIndex requires literal field names inorder_byclauses, and variable values are unknown at conversion time. Queries using variables for ordering will return unordered results. - Array filter operators: The
_containsAnyand_containsAllfilters are TheGraph-specific array operators that don't have direct Hasura equivalents. The converter explicitly rejects these and returns anUnsupportedFiltererror. Use_infor array matching instead. - Meta queries: Meta queries only support
_meta { block { number } }because HyperIndex exposes block information differently. Other_metafields (hash, timestamp, deployment, hasIndexingErrors) are not available in HyperIndex's schema and will return aComplexMetaQueryerror. - Introspection queries: Introspection queries only work if they use the operation name
"IntrospectionQuery". Other introspection queries (like querying__schemadirectly) 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
| Category | TheGraph | Envio | Example |
|---|---|---|---|
| Entity Names | Plural camelCase | Singular PascalCase (as-is from schema) | pools → Pool |
| Pagination | first, skip | limit, offset | first: 10, skip: 20 → limit: 10, offset: 20 |
| Ordering | orderBy, orderDirection | order_by: {field: direction} | orderBy: name, orderDirection: desc → order_by: {name: desc} |
| Equality Filter | field: value | field: {_eq: value} | name: "test" → name: {_eq: "test"} |
| Comparison Filters | field_gt, field_gte, etc. | field: {_gt: value}, etc. | amount_gt: 100 → amount: {_gt: 100} |
| String Filters | _contains, _starts_with, etc. | _ilike with % wildcards | name_contains: "test" → name: {_ilike: "%test%"} |
| Variable Types | ID, Bytes, BigInt, BigDecimal | String, numeric | $id: ID! → $id: String! |
Getting Help
If you encounter any issues with query conversion or have questions:
- Converter Issues: File a GitHub issue for the converter tool
- General Questions: Join our Discord community for support
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:
| Tool | Description |
|---|---|
docs_search | Full-text search across all documentation. Returns matching pages with titles, URLs, and content snippets. |
docs_fetch | Retrieves 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:
| Tool | Description |
|---|---|
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
- Runtime Issues
- Debugging a Stuck Indexer
- Rate Limiting on Hosted Service
- Hasura Authentication
- Infrastructure Conflicts
- Missing Events
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:
- Delete the
generatedfolder if it exists - Run the code generation command:
pnpm codegen
Important: Always run
pnpm codegenimmediately 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:
- Re-export smart contract ABIs (example using Hardhat):
cd contracts/
pnpm hardhat export-abi
- Verify that the ABI directory in
config.yamlpoints to the correct location where ABIs were freshly generated - 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
RPC-Related issues
Problem: The indexer shows warnings such as:
Error getting events, will retry after backoff timeFailed Combined Query Filter from blockIssue 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:
-
Recommended: Use HyperSync if your network is supported, as it provides better performance and reliability
-
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
- Using a different RPC endpoint in your
# 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 devfor 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:
-
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 network is supported.
-
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.
-
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.
-
Memory pressure
- Processing very large datasets or having expensive handler logic (e.g., many
eth_callrequests) can cause memory issues. See the performance optimization guide for tuning options.
- Processing very large datasets or having expensive handler logic (e.g., many
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:
-
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.
-
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.
-
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.
-
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.
-
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 passwordENVIO_PG_USER: Set a custom usernameENVIO_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:
-
Incorrect
start_block- If your
start_blockis set after the block where the event was emitted, it will be missed. Verify that the start block in yourconfig.yamlis at or before the contract's deployment block.
- If your
-
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.
-
Missing contract address
- For multi-address or dynamic contract setups, ensure all relevant addresses are registered. If using dynamic contracts, verify that the
contractRegisterhandler is correctly adding addresses.
- For multi-address or dynamic contract setups, ensure all relevant addresses are registered. If using dynamic contracts, verify that the
-
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.
-
Reorg handling
- During chain reorganizations, events from orphaned blocks may temporarily appear and then be removed. If
rollback_on_reorgis enabled (default), the indexer will handle this automatically. See Reorg Support.
- During chain reorganizations, events from orphaned blocks may temporarily appear and then be removed. If
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
Envio Error Codes
File: Troubleshoot/error-codes.md
This guide provides a comprehensive list of error codes you may encounter when using Envio HyperIndex. Each error includes an explanation and recommended actions to resolve the issue.
How to Use This Guide
When encountering an error in Envio, you'll receive an error code (like EE101). Use this guide to:
- Locate the error code by category or by searching for the specific code
- Read the explanation to understand what caused the error
- Follow the recommended steps to resolve the issue
If you can't resolve an error after following the suggestions, please reach out for support on our Discord community.
Error Categories
Envio error codes are categorized by their first digits:
| Error Code Range | Category | Description |
|---|---|---|
EE100 - EE199 | Configuration File | Issues with the configuration file format, parameters, or values |
EE200 - EE299 | Schema File | Problems with GraphQL schema definition |
EE300 - EE399 | ABI File | Issues with smart contract ABI files or event definitions |
EE400 - EE499 | Initialization Arguments | Problems with initialization parameters or directories |
EE500 - EE599 | Event Handling | Issues with event handler files or functions |
EE600 - EE699 | Event Syncing | Problems with event synchronization process |
EE700 - EE799 | Database Functions | Issues with database operations |
EE800 - EE899 | Database Migrations | Problems with database schema migrations or tracking |
EE900 - EE999 | Contract Interface | Issues related to smart contract interfaces |
EE1000 - EE1099 | Chain Manager | Problems with blockchain network connections |
EE1100 - EE1199 | Lazy Loader | General errors related to the loading process |
Initialization-Related Errors
Configuration File Errors (EE100-EE111)
EE100: Invalid Addresses
Issue: The configuration file contains invalid smart contract addresses.
Solution: Verify all contract addresses in your configuration file. Ensure they:
- Match the correct format for the blockchain (0x-prefixed for EVM chains)
- Are valid addresses for the specified network
- Have the correct length (42 characters including '0x' for EVM)
EE101: Non-Unique Contract Names
Issue: The configuration file contains duplicate contract names.
Solution: Each contract in your configuration must have a unique name. Review your config.yaml and ensure all contract names are unique.
EE102: Reserved Words in Configuration File
Issue: Your configuration uses reserved programming words that conflict with Envio's code generation.
Solution:
- Review the reserved words list for JavaScript, TypeScript, and ReScript
- Rename any contract or event names that use reserved words
- Choose descriptive names that don't conflict with programming languages
EE103: Parse Event Error
Issue: Envio couldn't parse event signatures in your configuration.
Solution:
- Check your event signatures in the configuration file
- Ensure they match the format in your ABI
- Refer to the configuration guide for correct event definition syntax
EE104: Resolve Config Path
Issue: Envio couldn't find your configuration file at the specified path.
Solution:
- Verify that your configuration file exists in the correct directory
- Ensure the file is named correctly (usually
config.yaml) - Check for file permission issues
EE105: Deserialize Config
Issue: Your configuration file contains invalid YAML syntax.
Solution:
- Check your YAML file for syntax errors
- Ensure proper indentation and structure
- Validate your YAML using a linter or validator
EE106: Undefined Network Config
Issue: No hypersync_config or rpc defined for the chain specified in your configuration.
Solution:
- Add either a HyperSync or RPC configuration for your chain
- See the HyperSync Data Source or RPC Data Source documentation
- Example:
chains:
- id: 1
rpc: https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY
EE108: Invalid Postgres Database Name
Issue: The Postgres database name provided doesn't meet requirements.
Solution: Provide a database name that:
- Begins with a letter or underscore
- Contains only letters, numbers, and underscores (no spaces)
- Has a maximum length of 63 characters
EE109: Incorrect RPC URL Format
Issue: The RPC URL in your configuration has an invalid format.
Solution:
- Ensure all RPC URLs start with either
http://orhttps:// - Verify the URL is correctly formatted and accessible
- Example:
https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY
EE110: End Block Not Greater Than Start Block
Issue: Your configuration specifies an end block that is less than or equal to the start block.
Solution: If providing an end block, ensure it's greater than the start block:
start_block: 10000000
end_block: 20000000 # Must be greater than start_block
EE111: Invalid Characters in Contract or Event Names
Issue: Contract or event names contain invalid characters.
Solution: Use only alphanumeric characters and underscores in contract and event names.
Schema File Errors (EE200-EE217)
EE200: Schema File Read Error
Issue: Envio couldn't read the schema file.
Solution:
- Ensure the schema file exists at the expected location
- Check file permissions
- Verify the file isn't corrupted
EE201: Schema Parse Error
Issue: The schema file contains syntax errors.
Solution:
- Check for GraphQL syntax errors in your schema.graphql file
- Ensure all entities and fields are properly defined
- Validate your GraphQL schema with a schema validator
EE202: Multiple @derivedFrom Directives
Issue: An entity field has more than one @derivedFrom directive.
Solution: Use only one @derivedFrom directive per entity. Review your schema and remove duplicate directives.
EE203: Missing Field Argument for @derivedFrom
Issue: A @derivedFrom directive is missing the required field argument.
Solution: Add the field argument to your @derivedFrom directive:
type User {
id: ID!
orders: [Order!]! @derivedFrom(field: "user")
}
EE204: Invalid @derivedFrom Argument
Issue: The field argument in @derivedFrom has an invalid value.
Solution: Ensure the field argument contains a valid string value that matches a field name in the referenced entity.
EE207: Undefined Type
Issue: The schema contains an undefined type.
Solution: Use only supported scalar types or defined entity types:
IDStringIntFloatBooleanBytesBigInt
EE208: Unsupported Nullable Scalars
Issue: The schema contains nullable scalar types inside lists.
Solution: Use non-nullable scalars in lists by adding ! after the type:
# Incorrect
items: [String]
# Correct
items: [String!]!
EE209: Unsupported Multidimensional Lists
Issue: The schema contains nullable multidimensional list types.
Solution: Ensure inner list types are non-nullable:
# Incorrect
matrix: [[Int]]
# Correct
matrix: [[Int!]!]!
EE210: Reserved Words in Schema File
Issue: The schema uses reserved programming words.
Solution:
- Check the reserved words list
- Rename any entities or fields using reserved words
- Choose alternative descriptive names
EE211: Unsupported Arrays of Entities
Issue: The schema uses unsupported array syntax for entity relationships.
Solution: Use one of the supported methods for entity references as outlined in the schema documentation.
EE212: Reserved Enum Names
Issue: The schema uses enum names that conflict with Envio's internal enums.
Solution: Check the internal reserved types list and rename conflicting enums.
EE213: Duplicate Enum Values
Issue: An enum in the schema contains duplicate values.
Solution: Ensure all values within each enum type are unique.
EE214: Naming Conflicts Between Enums and Entities
Issue: The schema has enums and entities with the same names.
Solution: Ensure all enum and entity names are unique within the schema.
EE215: Incorrectly Placed Directive
Issue: A directive is used in an incorrect location in the schema.
Solution: Ensure directives are placed on appropriate schema elements according to GraphQL specifications.
EE216: Incorrect Directive Parameters
Issue: A directive has incorrect parameter labels or count.
Solution: Verify that all directive parameters match the expected format and count.
EE217: Incorrect Directive Parameter Type
Issue: A directive parameter has an invalid type.
Solution: Ensure parameter values match the expected types for each directive.
ABI File Errors (EE300-EE305)
EE300: Event ABI Parse Error
Issue: Cannot parse the ABI for specified contract events.
Solution:
- Verify the ABI file contains valid JSON
- Ensure the ABI includes all events referenced in your configuration
- Check for syntax errors in your ABI file
EE301: Missing ABI File Path
Issue: No ABI file path specified for a contract.
Solution: Add the abi_file_path property in your configuration for each contract:
contracts:
- name: MyContract
abi_file_path: ./abis/MyContract.json
EE302: Invalid ABI File Path
Issue: The specified ABI file path is invalid or inaccessible.
Solution:
- Verify the ABI file exists at the specified path
- Ensure the path is relative to your project directory
- Check file permissions
EE303: Missing Event in ABI
Issue: An event referenced in your configuration doesn't exist in the ABI.
Solution:
- Ensure the event name matches exactly what's in the ABI
- Verify the ABI includes all events you want to track
- If using a human-readable ABI, check event signature formatting
EE304: Mismatched Event Signature
Issue: Event signature in configuration doesn't match the ABI.
Solution: Ensure event signatures in your configuration match exactly what's in the ABI file.
EE305: ABI Config Mismatch
Issue: Event parameters in configuration don't match ABI definition.
Solution: Verify that event parameters in your configuration match the types and order defined in the ABI.
Initialization Arguments Errors (EE400-EE402)
EE400: Invalid Directory Name
Issue: A specified directory name contains invalid characters.
Solution: Use directory names without special characters like /, \, :, *, ?, ", <, >, |.
EE401: Directory Already Exists
Issue: Trying to create a directory that already exists.
Solution: Use a different directory name or remove the existing directory if appropriate.
EE402: Invalid Subgraph ID
Issue: The subgraph ID for migration is invalid.
Solution: Provide a valid subgraph ID that starts with "Qm".
Event-Related Errors
Event Handling Errors (EE500)
EE500: Event Handler File Not Found
Issue: Envio couldn't find or import the event handler file.
Solution:
- Ensure the handler file exists in the correct directory
- Verify the file path in your configuration
- Make sure the handler file is compiled correctly
- Refer to the event handlers documentation for proper setup
Event Syncing Errors (EE600)
EE600: Top Level Error During Event Processing
Issue: An unexpected error occurred while processing events.
Solution:
- Check your event handler logic for errors
- Review recent changes to your blockchain indexer
- If unable to resolve, contact support through Discord with error details
Database-Related Errors
For database-related errors (EE700-EE808), you can often resolve issues by resetting the database migration:
pnpm envio local db-migrate setup
Database Function Errors (EE700)
EE700: Database Row Parse Error
Issue: Unable to parse rows from the database.
Solution:
- Check entity definitions in your schema
- Verify data types match between schema and database
- Reset database migrations using the command above
Database Migration Errors (EE800-EE808)
EE800: Raw Table Creation Error
Issue: Error creating raw events table in database.
Solution: Reset database migrations using the command above.
EE801: Dynamic Contracts Table Creation Error
Issue: Error creating dynamic contracts table.
Solution: Reset database migrations using the command above.
EE802: Entity Tables Creation Error
Issue: Error creating entity tables.
Solution:
- Check your schema for invalid entity definitions
- Reset database migrations
EE803: Tracking Tables Error
Issue: Error tracking tables in database.
Solution: Reset database migrations using the command above.
EE804: Drop Entity Tables Error
Issue: Error dropping entity tables.
Solution:
- Check if any other processes are using the database
- Reset database migrations
EE805: Drop Tables Except Raw Error
Issue: Error dropping all tables except raw events table.
Solution: Reset database migrations using the command above.
EE806: Clear Metadata Error
Issue: Error clearing metadata.
Solution:
- Reset database migrations
- Note: Indexing may still work, but you might have issues querying data in Hasura
EE807: Table Tracking Error
Issue: Error tracking a table in Hasura.
Solution:
- Reset database migrations
- Note: Indexing may still work, but you might have issues querying data in Hasura
EE808: View Permissions Error
Issue: Error setting up view permissions.
Solution:
- Reset database migrations
- Note: Indexing may still work, but you might have issues querying data in Hasura
Contract-Related Errors
EE900: Undefined Contract
Issue: Referencing a contract that isn't defined in configuration.
Solution:
- Verify all contract names in your handlers match those in the configuration file
- Check for typos in contract names
EE901: Interface Mapping Error
Issue: Contract name not found in interface mapping (unexpected internal error).
Solution: Contact support through Discord for assistance.
Network-Related Errors
EE1000: Undefined Chain
Issue: Using a chain ID that isn't defined or supported.
Solution:
- Use a valid chain ID in your configuration file
- Check if the network is supported by Envio
- Verify chain ID matches the intended network
General Errors
EE1100: Promise Timeout
Issue: A long-running operation timed out.
Solution:
- Check network connectivity
- Verify RPC endpoint performance
- Consider increasing timeouts if possible
- If persists, contact support through Discord
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.yamlfile (for contracts and events)schema.graphqlfile (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:
- Identify which names in your configuration or schema are using reserved words
- Choose alternative names that aren't reserved
- Update all references to these names in your code
- 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.,
userAccountinstead ofclass) - Add a prefix or suffix to potentially conflicting names (e.g.,
userInterfaceinstead ofinterface) - 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
- Use descriptive names that are unlikely to be programming keywords
- Check these lists before finalizing your schema design
- Run validation early with
pnpm codegento catch issues before spending time on implementation - Use prefixes for domain entities (e.g.,
TokenTransferinstead ofTransfer)
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 Network Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 1234567890
rpc: https://custom-network-rpc.com # RPC URL for that custom network
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!
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 network - Anvil
File: supported-networks/local-anvil.md
A local network can be used as a data source for your indexer. You simply need to specify the local network.
Defining Network Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Anvil network default chainId
rpc: http://localhost:8545 # RPC URL for your local Anvil network
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event
Local network - Hardhat
File: supported-networks/local-hardhat.md
A local network can be used as a data source for your indexer. You simply need to specify the local network.
Defining Network Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Hardhat network default chainId
rpc: http://localhost:8545 # RPC URL for your local Hardhat network
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event
--
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:
-
Quick Start (5-minute tutorial): Follow our step-by-step tutorial to create your first Fuel indexer quickly.
-
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:
| Project | Type | GitHub Repository |
|---|---|---|
| Spark | Orderbook DEX | github |
| Mira | AMM DEX | github |
| Thunder | NFT Marketplace | github |
| Swaylend | Lending Protocol | github |
| Greeter | Tutorial | github |
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
network:
name: "fuel_testnet"
contracts:
- name: SwayContract
abi_file_path: "./abis/SwayContract.json"
start_block: 1
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 Type | Description | Example Configuration |
|---|---|---|
Mint | Triggered when a contract mints tokens | - name: Mint |
Burn | Triggered when a contract burns tokens | - name: Burn |
Transfer | Combines TRANSFER and TRANSFER_OUT receipts | - name: Transfer |
Call | Triggered 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 contractTRANSFER_OUT: Emitted when a contract transfers tokens to a wallet
Important: Transfers between wallets are not included in the
Transferevent 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:
- Rust: hyperfuel-client-rust
- Python: hyperfuel-client-python
- Node.js: hyperfuel-client-node
- JSON API: hyperfuel-json-api
HyperFuel Endpoints
- Mainnet: https://fuel.hypersync.xyz
- Testnet: https://fuel-testnet.hypersync.xyz
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:
- Check our Troubleshooting guides
- Join our Discord for community support
- 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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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).
Other legal requirements
The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:
Comply with a legal obligation
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.
Links to Other Websites
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?
- Discord: discord.gg/envio - the fastest way to get help from the team and community
- Telegram: Envio Telegram - the offcial Envio Telegram to get support from the team and community
- GitHub: github.com/enviodev
- Email: hello@envio.dev