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. If you are new to indexing, see what a blockchain indexer is for the wider context.
HyperIndex is Envio's full-featured blockchain indexing framework that transforms on-chain events into structured, queryable databases with GraphQL APIs.
HyperSync is the high-performance data engine that powers HyperIndex. It provides the raw blockchain data access layer, delivering up to 2000x faster performance than traditional RPC endpoints.
While HyperIndex gives you a complete indexing solution with schema management and event handling, HyperSync can be used directly for custom data pipelines and specialized applications.
Key Features
- Quickstart templates – Rapidly bootstrap your indexer.
- Real-time indexing – Instantly track blockchain events.
- Multichain indexing – Support multiple blockchains simultaneously.
- Local development – A full-featured local environment with Docker.
- Reorg support – Gracefully handle blockchain reorganizations without sacrificing latency.
- GraphQL API – Easily query indexed data.
- Cross-platform support – Index any EVM-, SVM-, or Fuel-compatible blockchain.
- High performance – Perform historical backfills at 30,000+ events per second.
- Indexer auto-generation – Generate indexers directly from smart contract addresses.
- Flexible language support – TypeScript, JavaScript, and ReScript.
- Factory contract support – Index data from over 1M dynamically registered contracts seamlessly.
- On-chain and off-chain data integration – Easily combine multiple data sources.
- Self-hosted and managed options – Run your own setup or use Envio Cloud.
- Detailed logging and observability – Debug and optimize with clarity.
- External API actions – Trigger external services based on blockchain events.
- Wildcard topic indexing – Flexibly index based on event topics.
- Fallback RPC data sources – Enhance reliability with RPC connections.
Feature Roadmap
Upcoming features on our development roadmap:
- Indexing 1,000,000+ events per second
- Configurable Query/REST API layer
- No-Code Indexers
- Durable & Non-Blocking Effect API
Recently shipped: isolated multichain mode (v3.6, with per-chain rollbacks in v3.10) and stable Solana support (v3.11).
HyperSync API Token Requirements
HyperSync (the data engine powering HyperIndex) requires an API token for all requests. You can generate one in the Envio Cloud portal. Here's what you need to know:
- Local Development: An API token is required. The CLI supports an automatic login flow to make this smoother.
- Self-Hosted Deployments: API tokens are required for HyperSync access in self-hosted deployments. Set the token via the
ENVIO_API_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. If you are new to indexing, see what a blockchain indexer is for the wider context.
Example: Autogenerate an indexer for the Eigenlayer contract and index its entire history in less than 5 minutes by simply running pnpx envio init and providing the contract address from Etherscan.
Prerequisites
- Node.js (v22 or newer recommended)
- pnpm (recommended but not required)
- Docker Desktop (required to run the Envio indexer locally)
Note: Docker is only required if you plan to run your indexer locally. You can skip installing Docker if you'll only be using Envio Cloud.
Additionally for Windows Users:
- WSL Windows Subsystem for Linux
Getting Started
Run the following command to initialize your blockchain indexer:
pnpx envio init
You'll then follow interactive prompts to customize your indexer.
Video Tutorials
Indexer Initialization Options
During initialization, you'll be presented with two options:
- Contract Import (recommended for existing smart contracts)
- Template
Choose the Contract Import option to auto-generate indexers directly from smart contracts.
? Choose an initialization option
Template
> Contract Import
[↑↓ to move, enter to select]
Contract Import Methods
There are two convenient methods to import your contract:
- Block Explorer (verified contracts on supported explorers like Etherscan and Blockscout)
- Local ABI (custom or unverified contracts)
1. Block Explorer Import
This method uses a verified contract's address from a supported blockchain explorer (Etherscan, Routescan, etc.) to automatically fetch the ABI.
Steps:
a. Select the blockchain
? Which blockchain would you like to import a contract from?
> ethereum-mainnet
goerli
optimism
base
bsc
gnosis
polygon
[↑↓ to move, enter to select]
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 our Examples, our Tutorials, or the GitHub repositories.
Quickstart With Ai
File: quickstart-with-ai.md
Build an Envio HyperIndex indexer end-to-end with an AI coding assistant.
Most developers now reach for an AI coding assistant before they open a file. This guide walks through an AI-centric flow for creating, developing, and deploying a HyperIndex indexer. It is semi-generic, so any capable AI coding assistant (Cursor, Windsurf, Copilot Agent, Continue, etc.) will work. That said, we've seen the best results with Claude Code and recommend starting there.
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: chains, 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
HyperIndex now supports Solana. Solana exposes its block-stream handler as indexer.onSlot (rather than onBlock) to match Solana's slot-based model, and program instructions are indexed with indexer.onInstruction over a HyperSync source.
To initialize a Solana project:
pnpx envio init svm
Solana support shipped experimental in v3.0 and became stable in v3.11. See the Solana documentation for more details.
pnpx envio init Improvements
- Removed language selection to prefer TypeScript by default
- Cleaned up templates to follow the latest good practices
- Added new templates to highlight HyperIndex features:
Feature: Factory Contract,Feature: External Calls - Pre-configured GitHub Actions workflow for running tests and initialized git repository
- Generated projects include Cursor/Claude skills to support agent-driven development
Block Handler Only Indexers
Now it's possible to create indexers with only block handlers. Previously, it was required to have at least one event handler for it to work. The contracts field became optional in config.yaml.
Flexible Entity Fields
We no longer have restrictions on entity field names, such as type and others. Shape your entities any way you want. There are also improvements in generating database columns in the same order as they are defined in the schema.graphql.
Unordered Multichain Mode Only
Unordered multichain mode is now the only mode in V3 - events from different chains are processed in parallel without strict cross-chain ordering, which provides better performance for most use cases. The V2 unordered_multichain_mode option and the multichain: ordered opt-in have been removed.
Preload Optimization by Default
Preload optimization is now enabled by default, replacing the previous loaders and preload_handlers options. This improves historical sync performance automatically.
TUI Improvements
We gave our TUI some love, making it look more beautiful and compact. It also consumes fewer resources, shares a link to the Hasura playground, and dynamically adjusts to the terminal width.
The TUI now shows an events-per-second indicator during backfill so you can see indexing throughput at a glance.
The TUI is also auto-disabled in CI environments and when running under AI agents, so logs stay clean without manual configuration. The legacy TUI_OFF=true environment variable was renamed to ENVIO_TUI=false.
New Testing Framework
HyperIndex ships a purpose-built testing framework powered by createTestIndexer(). Write tests against the same indexer that runs in production - no database, no Docker, no manual mock wiring.
The framework integrates with Vitest, replacing the previous mocha/chai setup with a single package that doesn't require configuration by default and includes snapshot testing out-of-the-box. It also provides typed test assertions and utilities to read/write entities in-between processing runs.
Three ways to feed events
1. Auto-exit - processes the first block with matching events, then exits. Each subsequent call continues where the last one stopped. Zero config needed.
describe("ERC20 indexer", () => {
it("processes the first block with events", async (t) => {
const indexer = createTestIndexer();
const result = await indexer.process({ chains: { 1: {} } });
// Auto-filled by Vitest on first run — just review and commit
t.expect(result).toMatchInlineSnapshot(`
{
"changes": [
{
"Transfer": {
"sets": [
{
"blockNumber": 10861674,
"from": "0x0000000000000000000000000000000000000000",
"id": "1-10861674-23",
"to": "0x41653c7d61609D856f29355E404F310Ec4142Cfb",
"transactionHash": "0x4b37d2f343608457ca...",
"value": 1000000000000000000000000000n,
},
],
},
"block": 10861674,
"chainId": 1,
"eventsProcessed": 1,
},
],
}
`);
});
});
2. Explicit block range - pin to specific blocks for deterministic CI snapshots.
const result = await indexer.process({
chains: {
1: {
startBlock: 10_861_674,
endBlock: 10_861_674,
},
},
});
3. Simulate - feed typed synthetic events for pure unit tests. No network, no block ranges.
await indexer.process({
chains: {
137: {
simulate: [
{
contract: "Greeter",
event: "NewGreeting",
params: { greeting: "Hello", user: "0x123..." },
},
],
},
},
});
Key capabilities
- Snapshot-driven assertions -
result.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.
Per-Chain Entities and Effects (v3.6)
By default one entity id means one row shared by every chain, which is why multichain indexers have always had to prefix ids with the chain id. Set disable_default_cross_chain: true - recommended for multichain indexers - and entities and effect caches become per-chain instead:
name: my-indexer
disable_default_cross_chain: true
Entity tables then get a composite (id, chainId) primary key, so the same id on two chains is two independent rows in memory, in Postgres and in ClickHouse, with entity history and reorg rollback scoped per chain too. Handler code doesn't change - a handler always runs on one chain, so context.Token.get(id) reads that chain's row and the id prefixing goes away. Sharing becomes explicit with the @crossChain directive on an entity, or crossChain: true on an effect.
Read more in Per-Chain Data Mode.
Per-Handler Field Selection (v3.7)
A registration now names the block and transaction fields its handler reads, instead of every handler paying for one global field_selection:
indexer.onEvent(
{
contract: "MyContract",
event: "Transfer",
fields: { transaction: ["hash", "from"], block: ["timestamp"] },
},
async ({ event, context }) => {
event.transaction.hash; // string
event.block.timestamp; // number
event.transaction.gasUsed; // Type error - not listed in fields
},
);
Reading a field the registration didn't list is a type error, and two handlers on the same event can select different fields. field_selection in config.yaml still works, and a handler's fields overrides it.
Read more in Selecting Block and Transaction Fields.
More Fields on the RPC Source (v3.7)
Indexers running on an RPC data source gained 20 transaction fields (gas, nonce, v, r, s, yParity, type, maxFeePerBlobGas, blobVersionedHashes, cumulativeGasUsed, effectiveGasPrice, gasUsed, logsBloom, root, status, and the l1* L2 fields) and 15 block fields (sha3Uncles, logsBloom, transactionsRoot, receiptsRoot, totalDifficulty, size, uncles, blobGasUsed, excessBlobGas, parentBeaconBlockRoot, withdrawalsRoot, mixHash, and the Arbitrum l1BlockNumber / sendCount / sendRoot).
Hidden Entities with @internal (v3.8)
Mark an entity @internal to keep it out of the GraphQL API while still using it normally in handlers - cursors, checkpoints, dedup markers, or anything you don't want on the public API surface. Read more in Hiding Entities from the API.
ClickHouse Skipping Indexes (v3.8)
The @storage(clickhouse: {...}) options gained skippingIndexes, so an entity's history table can carry minmax, bloom_filter or set data skipping indexes for the columns your analytics queries filter on. Read more in Per-Entity ClickHouse Tuning.
Per-Chain Entities in Postgres and ClickHouse (v3.9)
With disable_default_cross_chain: true, per-chain entities are split into Postgres partitions by chain, which speeds up both reads and writes for chain-scoped queries. The generated chainId column can also be used in an entity's ClickHouse orderBy.
Clearer schema.graphql Validation Errors (v3.9)
Schema validation errors now point at the exact location, describe the problem in one line, and suggest the fix - for example Invalid `@index` on `Second`: `missing` is not a column of the entity.
Any Nullability with @derivedFrom (v3.9)
Derived fields accept every nullability combination ([Child!]!, [Child!], [Child]!, [Child]), matching subgraph behavior, so a schema ported from The Graph doesn't need editing here.
One Address, Multiple Contracts (v3.9)
The same address can now be registered for more than one contract. This matters for factory patterns and proxies where you don't control the deployed address and need to decode it against several ABIs.
Isolated Multichain Rollbacks (v3.10)
When disable_default_cross_chain: true is set and no entity is marked @crossChain, a reorg rolls back only the chain that triggered it rather than every chain - better isolation, and less work on every rollback.
ClickHouse Storage Rewritten in Rust (v3.10)
The ClickHouse backend was rewritten in Rust: faster writes, fewer stability issues, and a simpler codebase to build on.
Bytes as Uint8Array (v3.10)
bytes_type chooses how the Bytes scalar reaches handlers and storage:
bytes_type: uint8array # default: hex
hex keeps 0x-prefixed strings stored as text; uint8array gives handlers Uint8Array values and stores raw bytes (BYTEA in Postgres), roughly halving the storage taken by addresses and hashes. Available on EVM and Fuel; Solana is always Uint8Array. Read more in Bytes Representation.
Height Stream Reliability (v3.10)
Better stream-keep-alive logic and fallback-to-polling for tracking new blocks, which cuts latency at the head. Two Prometheus metrics make the stream observable: envio_source_height_stream_connects_total and envio_source_height_stream_disconnects_total{reason}. Read more in Available metrics.
Start From Latest Block (v3.11)
start_block (and Solana's start_slot) accepts the literal "latest", so an indexer can start at the chain head without looking up a block number:
chains:
- id: 1
start_block: latest
The head is resolved once, when the indexer is first deployed, and the concrete block is persisted - so a normal resume backfills the downtime instead of skipping it. Read more in Start Block.
Solana Support Is Stable (v3.11)
The SVM API - configuration, handlers, and payload types - is final and follows semver like the rest of HyperIndex. Solana indexers are configured with ecosystem: svm, a chain id of solana or solana-devnet, and a top-level programs list; instructions are decoded from an Anchor or Codama IDL (or an inline layout) and handled with indexer.onInstruction, with indexer.onSlot for slot handlers. Postgres, ClickHouse, the GraphQL API and Envio Cloud hosting all work the same as on EVM.
pnpx envio init svm
Read more in the Solana documentation.
Fixes
- Fixed an issue where the indexer stops progressing without any error (PostgreSQL client update)
- Fixed checksum for addresses returned by RPC in lowercase
- Fixed incorrect validation of transactions
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.11.0
- v3.10.0
- v3.9.0
- v3.8.0
- v3.7.0
- v3.6.0
- 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 chains to index (multiple chains can be specified with envio) and which events from those contracts to index.
Take the following conversion as an example, where the subgraph.yaml file is converted to config.yaml the below comparisons is for the Uniswap v4 pool manager subgraph.
The Graph - subgraph.yaml
specVersion: 0.0.4
description: Uniswap is a decentralized protocol for automated token exchange on Ethereum.
repository: https://github.com/Uniswap/v4-subgraph
schema:
file: ./schema.graphql
features:
- nonFatalErrors
- grafting
- kind: ethereum/contract
name: PositionManager
network: mainnet
source:
abi: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
startBlock: 21689089
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/mappings/index.ts
entities:
- Position
abis:
- name: PositionManager
file: ./abis/PositionManager.json
eventHandlers:
- event: Subscription(indexed uint256,indexed address)
handler: handleSubscription
- event: Unsubscription(indexed uint256,indexed address)
handler: handleUnsubscription
- event: Transfer(indexed address,indexed address,indexed uint256)
handler: handleTransfer
HyperIndex - config.yaml
# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: uni-v4-indexer
chains:
- id: 1
start_block: 21689089
contracts:
- name: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
events:
- event: Subscription(uint256 indexed tokenId, address indexed subscriber)
- event: Unsubscription(uint256 indexed tokenId, address indexed subscriber)
- event: Transfer(address indexed from, address indexed to, uint256 indexed id)
For any potential hurdles, please refer to the Configuration File documentation.
2. Schema migration
copy & paste the schema from the subgraph to the HyperIndex config file.
Small nuance differences:
- You can remove the
@entitydirective - Enums
- BigDecimals
- GraphQL
interfacetypes are not supported
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")
}
type TransferEvent {
id: ID!
token: Token!
}
@derivedFrom(field:) names the relationship field on the other entity, without
the _id suffix. Codegen then exposes that field to your handlers as token_id.
See the full Schema docs.
Step 3: Event Handlers
Handler registration
Ponder
ponder.on("MyToken:Transfer", async ({ event, context }) => {
// ...
});
HyperIndex
indexer.onEvent(
{ contract: "MyToken", event: "Transfer" },
async ({ event, context }) => {
// ...
},
);
Event data access
| 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
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 chains you want to index, and the specific events you want to track from those contracts.
Below is an example showing how a Uniswap V4 subgraph.yaml maps to a HyperIndex config.yaml in a real migration.
The Graph - subgraph.yaml
specVersion: 0.0.4
description: Uniswap is a decentralized protocol for automated token exchange on Ethereum.
repository: https://github.com/Uniswap/v4-subgraph
schema:
file: ./schema.graphql
features:
- nonFatalErrors
- grafting
- kind: ethereum/contract
name: PositionManager
network: mainnet
source:
abi: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
startBlock: 21689089
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/mappings/index.ts
entities:
- Position
abis:
- name: PositionManager
file: ./abis/PositionManager.json
eventHandlers:
- event: Subscription(indexed uint256,indexed address)
handler: handleSubscription
- event: Unsubscription(indexed uint256,indexed address)
handler: handleUnsubscription
- event: Transfer(indexed address,indexed address,indexed uint256)
handler: handleTransfer
HyperIndex - config.yaml
# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: uni-v4-indexer
chains:
- id: 1
start_block: 21689089
contracts:
- name: PositionManager
address: "0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e"
events:
- event: Subscription(uint256 indexed tokenId, address indexed subscriber)
- event: Unsubscription(uint256 indexed tokenId, address indexed subscriber)
- event: Transfer(address indexed from, address indexed to, uint256 indexed id)
If you hit any issues, check the Configuration File docs or reach out to our team in Discord.
schema.graphql Migration
This step is simple. You keep the entire file as is, with one small change: remove all @entity directives from your entities. Everything else stays the same.
Event Handler Migration
This is the final step of the migration which consists of two parts:
- Moving from AssemblyScript to TypeScript
- Updating Subgraph syntax to HyperIndex syntax
AssemblyScript to TypeScript
HyperIndex uses TypeScript instead of AssemblyScript. Since AssemblyScript is a subset of TypeScript, you can simply copy most of your code over without worrying about major syntax changes.
Subgraph to HyperIndex
The HyperIndex workflow is very similar to Subgraphs, but there are a few important differences to keep in mind:
- Replace
ENTITY.save()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.
Since v3.7 the recommended way to ask for more is the handler's own fields option, which lists the fields next to the code that reads them and applies to that registration only.
field_selection in config.yaml is the older, coarser alternative: it applies to every handler of an event. Specify it under the event entry:
events:
- event: "Assigned(address indexed user, uint256 amount)"
field_selection:
transaction_fields:
- transactionIndex
block_fields:
- timestamp
See all possible options in the Config File Reference or use IDE autocomplete for your help.
Global Field Selection
You can also specify fields globally for all events in the root of the config file:
field_selection:
transaction_fields:
- hash
- gasUsed
block_fields:
- parentHash
Try to use this option sparingly as it can cause redundant Data Source calls and increased credits usage. A handler's fields option overrides field_selection for that registration.
Chains
Everything under the top-level chains field configures the chains your indexer connects to - data sources, start/end blocks, reorg behavior, and multichain indexing semantics.
RPC
The rpc option configures an RPC data source per chain. It supports multiple URLs with explicit roles via the for field:
sync– use this RPC for historical sync.realtime– use this RPC for low-latency head tracking once the indexer enters realtime mode. WebSocket endpoints (wss://...) are supported here.fallback– use as a fallback when the primary source is unavailable.
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"
Since v3.11 start_block also accepts the literal "latest", which starts the indexer from the chain's current head instead of a block you have to look up:
chains:
- id: 1
start_block: latest # resolved once, when the indexer is first deployed
A chain on latest can't also carry per-contract start_block overrides.
The head is resolved once, when the indexer is first deployed, and the block is stored. Resuming reuses it, so downtime is backfilled rather than skipped. Restarting with -r resolves latest again.
For finer-grained control, see Per-Contract Start Block Override and the per-event start block tip below it.
End Block
Set end_block on a chain to stop indexing once that block is reached. Useful for backfills with a known cutoff or one-off snapshots.
chains:
- id: 1
start_block: 18000000
end_block: 19000000
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
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"
The same address for several contracts:
Since v3.9, one address can be registered for more than one contract, so a single deployment can be decoded against several ABIs. This matters for proxies and factory patterns where you don't control the deployed address:
chains:
- id: 1
start_block: 0
contracts:
- name: ERC20
address: "0xAddress1"
- name: ERC4626Vault
address: "0xAddress1"
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.
Bytes Representation
bytes_type controls how the Bytes scalar from schema.graphql reaches handlers and storage.
bytes_type: uint8array # default: hex
hex(default) –0x-prefixed hex strings, stored as text.uint8array–Uint8Arrayvalues in handlers, stored as raw bytes (BYTEAin Postgres,Stringin ClickHouse). Roughly halves the storage taken by addresses and hashes.
Available on EVM and Fuel. Solana always uses Uint8Array and has no option. Changing the setting changes the underlying column types, so switch it with a fresh sync.
Added in HyperIndex v3.10.
Per-Chain Entities
By default one entity id means one row shared by every chain. Set disable_default_cross_chain: true (recommended for multichain indexers) to make entities and effect caches per-chain instead:
name: my-indexer
disable_default_cross_chain: true
Entity tables then get a composite (id, chainId) primary key, so the same id on two chains is two independent rows, and sharing becomes explicit through the @crossChain directive. See Per-Chain Data Mode for the full behavior.
Added in HyperIndex v3.6.
Ecosystem
ecosystem selects which chain family your indexer targets. EVM is the default and what every example above assumes, but the same config.yaml schema is shared with Fuel and Solana (SVM).
ecosystem: evm # default — also: fuel, svm
Most projects don't set this field explicitly. Use pnpx envio init fuel or pnpx envio init svm to scaffold non-EVM indexers - the generated config.yaml will already have ecosystem set correctly. See the Fuel and Solana guides for the ecosystem-specific options.
Environment Variables
Environment variable interpolation is supported anywhere in config.yaml, which is useful for keeping RPC URLs, addresses, or chain IDs out of source control.
chains:
- id: ${ENVIO_CHAIN_ID:-ethereum-mainnet}
contracts:
- name: Greeter
address: "${ENVIO_GREETER_ADDRESS}"
Run your indexer with custom environment variables:
ENVIO_CHAIN_ID=optimism ENVIO_GREETER_ADDRESS=0xYourContractAddress pnpm dev
Interpolation syntax:
${ENVIO_VAR}– Use the value 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. Codegen exposes Bid.auction above to your handlers as auction_id, typed bigint rather than string. You don't declare the foreign key type. Keep the two sides in sync by changing the referenced entity's id.
Scalar Types
Scalar types represent basic data types and map directly to JavaScript, TypeScript, or ReScript types.
| GraphQL 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 | Byte string (see bytes_type) | string or Uint8Array | 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.
Bytes Representation
Since v3.10, the bytes_type option in config.yaml decides how Bytes reaches handlers and storage: hex (the EVM and Fuel default) gives a 0x-prefixed string stored as text, uint8array gives a Uint8Array stored as raw bytes (BYTEA in Postgres), which roughly halves the storage taken by addresses and hashes. Solana is always Uint8Array.
Working with BigDecimal
The BigDecimal scalar type in HyperIndex is based on the bignumber.js library, which provides arbitrary-precision decimal arithmetic. This is essential for financial calculations and handling numeric values that exceed JavaScript's native number precision.
Importing BigDecimal
// JavaScript/TypeScript
// ReScript
open BigDecimal;
Creating BigDecimal Instances
// From string (recommended for precision)
const price = new BigDecimal("123.456789");
// From number (may lose precision for very large values)
const amount = new BigDecimal(123.45);
// From other BigDecimal
const copy = new BigDecimal(price);
Arithmetic Operations
BigDecimal instances are immutable. Operations return new BigDecimal instances:
// Basic arithmetic
const a = new BigDecimal("123.45");
const b = new BigDecimal("67.89");
const sum = a.plus(b); // 191.34
const difference = a.minus(b); // 55.56
const product = a.times(b); // 8,381.03
const quotient = a.div(b); // 1.81839...
// Power
const squared = a.pow(2); // 15,239.9025
// Square root
const root = a.sqrt(); // 11.11...
// Absolute value
const abs = new BigDecimal("-123.45").abs(); // 123.45
Comparison Methods
const x = new BigDecimal("10.5");
const y = new BigDecimal("10.5");
const z = new BigDecimal("9.9");
x.eq(y); // true (equal)
x.gt(z); // true (greater than)
x.gte(y); // true (greater than or equal)
x.lt(z); // false (less than)
x.lte(y); // true (less than or equal)
// Check for special values
x.isZero(); // false
x.isPositive(); // true
x.isNegative(); // false
x.isFinite(); // true
Rounding and Formatting
const value = new BigDecimal("123.456789");
// Get with specific decimal places
value.dp(2); // 123.46 (rounded)
value.dp(2, 1); // 123.45 (rounded down)
// Format as string
value.toString(); // "123.456789"
value.toFixed(2); // "123.46"
value.toExponential(2); // "1.23e+2"
value.toPrecision(5); // "123.46"
Working with Schema-Defined BigDecimal Fields
When you've defined a BigDecimal field in your schema:
type TokenPair {
id: ID!
name: String!
price: BigDecimal!
volume: BigDecimal!
}
You can use it in your handlers:
// In your event handler
context.TokenPair.set({
id: event.params.pairId,
name: event.params.name,
price: new BigDecimal(event.params.price),
volume: new BigDecimal("0"), // Start with zero volume
});
// Updating a field
const tokenPair = await context.TokenPair.get(pairId);
if (tokenPair) {
const newVolume = tokenPair.volume.plus(new BigDecimal(tradeAmount));
context.TokenPair.set({
...tokenPair,
volume: newVolume,
});
}
Example: Financial Calculation
function calculateFee(amount: BigDecimal, feeRate: BigDecimal): BigDecimal {
// Calculate fee with proper rounding
return amount.times(feeRate).dp(2);
}
const tradeAmount = new BigDecimal("1250.75");
const feeRate = new BigDecimal("0.0025"); // 0.25%
const fee = calculateFee(tradeAmount, feeRate); // 3.13
Best Practices for BigDecimal
-
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"
skippingIndexes: [
{ name: "idx_amount", expr: "amount", type: "minmax", granularity: 4 }
]
}
) {
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. |
skippingIndexes | list of index objects | Since v3.8. Data skipping indexes emitted into the history table DDL as INDEX <name> <expr> TYPE <type> GRANULARITY <granularity>. Each entry takes name, expr, type (e.g. minmax, bloom_filter, set) and an optional granularity, which defaults to ClickHouse's own default. |
A few constraints, all caught at envio codegen rather than at runtime:
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. - With
disable_default_cross_chain: true,orderBycan also list the generatedchainIdcolumn (v3.9).
Sharing Entities Across Chains (@crossChain)
Entities are shared by every chain by default: one id means one row, whichever chain wrote it. Setting disable_default_cross_chain: true in config.yaml (v3.6, recommended for multichain indexers) flips that - entities become per-chain, keyed on (id, chainId) - and @crossChain marks the individual entities that should stay shared:
type Counter {
# per-chain: one row per (id, chain)
id: ID!
count: BigInt!
}
type GlobalCounter @crossChain {
# one row shared by every chain
id: ID!
count: BigInt!
}
The directive is only valid when disable_default_cross_chain: true is set - without it entities are already cross-chain, and envio codegen rejects the directive. Per-chain entities also reserve the chainId column name (chain_id under column_name_format: snake_case), so a schema field can't claim it.
Hiding Entities from the API (@internal)
Since v3.8.0, mark an entity with @internal to keep it out of the GraphQL API. The entity is stored and usable in handlers exactly as normal - set, get, getWhere and getOrThrow all work - but it is never exposed through GraphQL: no queries, no relationships, no introspection entry.
# Bookkeeping state the indexer needs, but API consumers shouldn't see
type SwapCheckpoint @internal {
id: ID!
lastProcessedLogIndex: Int!
pendingAmount: BigInt!
}
Use it for internal indexing state (cursors, checkpoints, deduplication markers, intermediate aggregates) or for data you don't want on the public API surface.
A few rules, enforced at envio codegen:
- An exposed entity can't reference an
@internalone - neither through an object reference nor a@derivedFromfield - because the relationship could never be served over GraphQL. Either mark the referencing entity@internaltoo, or store a plain id field (e.g.checkpointId: String!) instead of a relationship. - The reverse is fine: an
@internalentity may freely reference exposed entities.
Advanced: Precision and Scale (@config Directive)
Customize the precision and scale for BigInt and BigDecimal fields using @config.
Syntax:
BigInt(precision only):
amount: BigInt @config(precision: 76)
BigDecimal(precision and scale):
price: BigDecimal @config(precision: 10, scale: 2)
Example:
type Payment {
id: ID!
amount: BigInt @config(precision: 76)
price: BigDecimal @config(precision: 10, scale: 2)
}
This controls PostgreSQL storage allocation and numerical accuracy.
Detailed Example with Arrays
type AdvancedEntity {
exampleBigInt: BigInt @config(precision: 76)
exampleBigIntRequired: BigInt! @config(precision: 77)
exampleBigIntArray: [BigInt!] @config(precision: 78)
exampleBigIntArrayRequired: [BigInt!]! @config(precision: 79)
exampleBigDecimal: BigDecimal @config(precision: 10, scale: 5)
exampleBigDecimalRequired: BigDecimal! @config(precision: 12, scale: 4)
}
Documenting Entities, Fields, and Relationships
You can document your entities, fields, and relationships directly in schema.graphql using GraphQL string descriptions. These descriptions are exposed through the generated GraphQL API and appear in introspection, making your API self-documenting.
"""
A token transfer between two accounts
"""
type Transfer {
id: ID!
"The address the tokens were sent from"
from: String!
"The address the tokens were sent to"
to: String!
"The amount transferred, in wei"
value: BigInt!
}
Both single-line ("...") and multi-line ("""...""") descriptions are supported.
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 chain emitting the event.event.srcAddress– Contract address emitting the event.event.logIndex– Index of the log within the block.event.block– Block fields (By default:number,timestamp,hash).event.transaction– Transaction fields (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.
Which fields event.block and event.transaction carry is up to you - see Selecting Block and Transaction Fields.
Selecting Block and Transaction Fields
Since v3.7, a registration names the block and transaction fields its handler reads with the fields option:
indexer.onEvent(
{
contract: "MyContract",
event: "Transfer",
fields: { transaction: ["hash", "from"], block: ["timestamp"] },
},
async ({ event, context }) => {
event.transaction.hash; // string
event.block.timestamp; // number
event.transaction.gasUsed; // Type error - not listed in fields
},
);
Reading a field you didn't list is a type error, so the selection stays honest as handlers change. event.block.number is always available without listing it.
fields works on both indexer.onEvent and indexer.contractRegister, and two handlers on the same event can select different fields - the indexer only fetches and decodes what's actually asked for.
Write the selection inline, as above. To share one across registrations, declare it as const so its element types still name the fields:
const txFields = { transaction: ["hash", "from"] } as const;
indexer.onEvent(
{ contract: "MyContract", event: "Transfer", fields: txFields },
handler,
);
The config.yaml field_selection option still works and applies to every handler of an event (or of every event, at the root level). A handler's fields overrides it. Prefer fields: the list sits next to the code that reads it, and it doesn't make every other handler pay for the fetch.
See FieldSelection in the Config Schema Reference for the full list of available transaction and block fields.
Example event type definition:
type Event<Params, TransactionFields, BlockFields> = {
params: Params;
chainId: 1 | 137;
srcAddress: `0x${string}`;
logIndex: number;
transaction: TransactionFields;
block: BlockFields;
};
Context Object
The handler context provides methods to interact with entities stored in the database.
Retrieving Entities
Retrieve entities from the database using context.Entity.get where Entity is the name of the entity you want to retrieve, which is defined in your schema.graphql file.
await context.Entity.get(entityId);
It'll return Entity object or undefined if the entity doesn't exist.
Use context.Entity.getOrThrow to conveniently throw an error if the entity doesn't exist:
const pool = await context.Pool.getOrThrow(poolId);
// Will throw: Entity 'Pool' with ID '...' is expected to exist.
// Or you can pass a custom message as a second argument:
const pool = await context.Pool.getOrThrow(
poolId,
`Pool with ID ${poolId} is expected.`
);
Or use context.Entity.getOrCreate to automatically create an entity with default values if it doesn't exist:
const pool = await context.Pool.getOrCreate({
id: poolId,
totalValueLockedETH: 0n,
});
// Which is equivalent to:
let pool = await context.Pool.get(poolId);
if (!pool) {
pool = {
id: poolId,
totalValueLockedETH: 0n,
};
context.Pool.set(pool);
}
Retrieving Entities by Field
indexer.onEvent(
{ contract: "ERC20", event: "Approval" },
async ({ event, context }) => {
// Find all approvals for this specific owner
const currentOwnerApprovals = await context.Approval.getWhere({
owner_id: { _eq: event.params.owner },
});
// Process all the owner's approvals efficiently
for (const approval of currentOwnerApprovals) {
// Process each approval
}
},
);
Beyond _eq, you can filter by value using comparison operators like _gt, _gte, _lt, _lte, and _in.
To narrow results further, combine several conditions in a single call - they all have to match (AND):
// Find accounts matching a specific id AND a balance within a range
const accounts = await context.Account.getWhere({
id: { _eq: event.params.account },
balance: { _gte: 1_000_000n, _lte: 10_000_000n },
});
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 chains within a single indexer instance. This capability is essential for applications that:
- Track the same contract deployed across multiple chains
- Need to aggregate data from different chains into a unified view
- Monitor cross-chain interactions or state
How It Works
With multichain indexing, events from contracts deployed on multiple chains can be used to create and update entities defined in your schema file. Your indexer will process events from all configured chains, maintaining proper synchronization across them.
Configuration Requirements
To implement multichain indexing, you need to:
- 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 chains 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
Per-Chain Data Mode
By default every entity row and effect cache is shared by all chains, so the same entity id on two chains resolves to a single row. That's why multichain indexers traditionally namespace ids with the chain id (user-1, user-137).
Since v3.6 you can instead make entities per-chain, which is the recommended setup for a multichain indexer:
name: my-indexer
disable_default_cross_chain: true
With it on:
- Entity tables get a composite
(id, chainId)primary key. The same id on two chains is two independent rows - in memory, in Postgres and in ClickHouse - and entity history and reorg rollback are scoped per chain too. - Effects that don't state a
crossChainoption get one cache per chain. See Per-Chain Effects. - Handler code is unchanged. A handler always runs on one chain, so
context.Token.get(id)reads that chain's row and you can drop the id prefixing.
Sharing becomes explicit. Mark an entity @crossChain when its rows should stay shared by every chain:
type Counter {
# per-chain: one row per (id, chain)
id: ID!
count: BigInt!
}
type GlobalCounter @crossChain {
# one row shared by every chain
id: ID!
count: BigInt!
}
@crossChain is only valid together with disable_default_cross_chain: true - without the flag entities are already cross-chain and codegen rejects the directive.
Per-chain entities reserve the chainId column name (chain_id under column_name_format: snake_case), so a schema field can't claim it.
Outside a handler there is no chain in context, so the test framework's chain-agnostic operations take one explicitly:
indexer.Counter.set({ id: "1", count: 0n, chainId: 1 });
// Throws if the id exists on more than one chain
indexer.Counter.get("1");
// Narrow it instead
indexer.Counter.getWhere({ chainId: { _eq: 1 } });
Since v3.10, rollbacks are isolated too: when disable_default_cross_chain: true is set and no entity is marked @crossChain, a reorg rolls back only the chain that triggered it instead of every chain.
Multichain Event Ordering
In V3 the indexer always processes multichain events in unordered mode. Events from different chains are processed as soon as they're available, without waiting for the other chains, which keeps latency low.
- Events are still processed in order within each individual chain.
- Events across different chains may be processed out of order.
- Processing happens as soon as events are emitted, so you don't wait for the slowest chain's block time.
This is ideal when:
- Operations on your entities are commutative (order doesn't matter).
- Entities from different chains never interact with each other.
- Processing speed matters more than guaranteed cross-chain ordering.
The V2 unordered_multichain_mode option, the multichain: ordered opt-in, and the UNORDERED_MULTICHAIN_MODE / UNSTABLE__TEMP_UNORDERED_HEAD_MODE environment variables have all been removed in V3 - there is nothing to configure.
Ordered Multichain Mode
HyperIndex V3 doesn't offer an ordered multichain mode, and it's a deliberate choice rather than a feature gap. Ordered mode worked by pausing every chain until events from the slowest chain had caught up, so the moment one chain hiccuped (RPC rate limits, a slow block, a brief reorg) every other chain stalled with it. In practice that meant a multichain indexer was only ever as reliable and as fast as its worst-performing chain, and even healthy chains paid for that coupling with significantly higher latency at the head.
You almost always get better reliability and lower latency by keeping every chain unordered and modeling the cross-chain relationship in your schema instead. Each chain writes a small temporary entity when its side of a cross-chain interaction happens, and the second chain (whichever arrives last) reads those temporary entities and finalizes the unified entity. Because each chain progresses independently, none of them are blocked on each other.
A typical pattern for an A → B cross-chain message:
type CrossChainMessage {
id: ID! # The shared cross-chain message id (e.g. nonce + originChainId)
sourceChainId: Int
sourceTxHash: String
destinationChainId: Int
destinationTxHash: String
status: String! # "sent" | "delivered"
}
// Chain A: the message was emitted. Create or update the entity with the
// "sent" side of the data. The destination handler may run before or after.
indexer.onEvent(
{ contract: "Bridge", event: "MessageSent" },
async ({ event, context }) => {
const id = `${event.params.originChainId}-${event.params.nonce}`;
const existing = await context.CrossChainMessage.get(id);
context.CrossChainMessage.set({
id,
sourceChainId: event.chainId,
sourceTxHash: event.transaction.hash,
destinationChainId: existing?.destinationChainId,
destinationTxHash: existing?.destinationTxHash,
status: existing?.destinationTxHash ? "delivered" : "sent",
});
},
);
// Chain B: the message was delivered. Read the (maybe-already-existing)
// entity and fill in the destination side, regardless of which side arrived
// first.
indexer.onEvent(
{ contract: "Bridge", event: "MessageDelivered" },
async ({ event, context }) => {
const id = `${event.params.originChainId}-${event.params.nonce}`;
const existing = await context.CrossChainMessage.get(id);
context.CrossChainMessage.set({
id,
sourceChainId: existing?.sourceChainId,
sourceTxHash: existing?.sourceTxHash,
destinationChainId: event.chainId,
destinationTxHash: event.transaction.hash,
status: existing?.sourceTxHash ? "delivered" : "sent",
});
},
);
The same shape works for any "rendezvous" where two chains contribute parts of one logical record (bridges, cross-chain governance, multichain user profiles, etc.). The "temporary" entity is just a regular entity that gets progressively completed as each chain's events arrive - there's no special API to learn, and the indexer stays fast and resilient because no chain ever waits on another.
If you have a use case that genuinely cannot be expressed this way, reach out on Discord - we'd like to hear it.
Best Practices for Multichain Indexing
1. Keep Chains' Data Distinct
Set disable_default_cross_chain: true so entities are per-chain, and mark the few entities that should be shared with @crossChain. Prefer that over prefixing entity IDs with ${event.chainId}-: the prefix has to be repeated at every read and write, it leaks the chain into ids your API consumers see, and one handler that forgets it silently merges two chains' data. Per-chain mode keeps the ids clean and makes the separation the database's job.
2. Error Handling
Implement robust error handling for chain-specific issues. A failure on one chain shouldn't prevent indexing from continuing on other chains.
3. Testing
- Test your indexer with realistic scenarios across all chains
- Use testnet deployments for initial validation
- Verify entity updates work correctly across chains
4. Performance Considerations
- Consider your indexing frequency based on the block times of each chain.
- Monitor resource usage, as indexing multiple chains increases load.
- Adding more chains does not linearly degrade performance - chains are indexed in parallel.
5. Adding a New Chain to an Existing Indexer
To add a new chain to a running indexer:
- 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 one chain's updates overwriting another's, either turn on per-chain data mode or verify that your entity IDs are properly namespaced with chain IDs.
-
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
chainIdWith disable_default_cross_chain: true there is no chain in context outside a handler, so these chain-agnostic operations take one:
indexer.Counter.set({ id: "1", count: 0n, chainId: 1 });
// Throws if the id exists on more than one chain
await indexer.Counter.get("1");
// Narrow it instead
await indexer.Counter.getWhere({ chainId: { _eq: 1 } });
Assertions
The testing library works with any JavaScript assertion library. The examples below use Vitest's built-in expect.
// Snapshot (recommended — captures full output, auto-filled on first run)
t.expect(result.changes).toMatchInlineSnapshot(`...`);
// Entity assertions
const pool = await indexer.Pool.getOrThrow(poolId);
t.expect(pool).toEqual({ id: poolId, token0_id: "0xabc..." });
// Count
t.expect(result.changes[0]?.Pair?.sets).toHaveLength(1);
// Contract addresses (after dynamic registration)
t.expect(indexer.chains[1].MyContract.addresses).toContain("0x1234...");
TDD Workflow
- 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_INDEXER_PORT: Port for the indexer's HTTP server, which serves/metrics,/metrics/runtime, and/healthz(default:9898) - see Observability -
ENVIO_PG_HOST: Postgres host (self-hosted; defaults tolocalhostin dev) -
ENVIO_PG_PORT: Port for the Postgres service used by HyperIndex during local development -
ENVIO_PG_PASSWORD: Postgres password (self-hosted) -
ENVIO_PG_USER: Postgres username (self-hosted) -
ENVIO_PG_DATABASE: Postgres database name (self-hosted) -
ENVIO_PG_SCHEMA: Postgres schema name override for the generated/public schema (replacesENVIO_PG_PUBLIC_SCHEMA; the old name is still accepted until v4) -
ENVIO_PG_SSL_MODE: Postgres SSL mode -require,allow,preferorverify-full(disabled by default in local dev)
When the indexer also writes to ClickHouse (storage.clickhouse in config.yaml), envio start reads the connection from ENVIO_CLICKHOUSE_HOST, ENVIO_CLICKHOUSE_DATABASE, ENVIO_CLICKHOUSE_USERNAME and ENVIO_CLICKHOUSE_PASSWORD, plus the optional ENVIO_CLICKHOUSE_REPLICATED and ENVIO_CLICKHOUSE_DATABASE_ENGINE for high-availability setups. envio dev spins up its own container and injects these for you.
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 chains. The architecture is designed to handle high throughput and maintain consistency across different chains.
Performance Advantages
The Envio-powered Uniswap V4 indexer offers extraordinary performance benefits:
- 10-100x Faster Sync Times: Leveraging Envio's HyperSync technology, this indexer can process historical blockchain data orders of magnitude faster than traditional solutions
- Real-time Updates: Maintains low latency for new blocks while efficiently managing historical data
Use Cases
- Power analytics dashboards and trading interfaces
- Monitor DeFi positions and protocol health
- Track historical performance of Uniswap V4 pools
- Build custom notifications and alerts
- Analyze hook interactions and their impact
Getting Started
To use this indexer, you can:
- 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 chains share the same indexer endpoint
Best Practices Showcase
These blockchain indexers demonstrate several development best practices:
- Modular Code Structure: Well-organized code with clear separation of concerns
- Consistent Naming Conventions: Professional and consistent naming throughout
- Efficient Event Handling: Optimized processing of blockchain events
- Comprehensive Entity Relationships: Well-designed data model with proper relationships
- Thorough Input Validation: Robust error handling and input validation
- Detailed Changelogs: Documentation of breaking changes and migrations
- Preload Optimization: Envio indexers benefit from always-on Preload Optimization, which batches entity reads and runs external calls in parallel through the Effect API
Getting Started
To use these indexers as a reference for your own development:
- 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.
Velodrome & Aerodrome DEX Indexer
File: Examples/example-aerodrome-velodrome.md
This blockchain indexer provides a comprehensive implementation for tracking data from the popular Velodrome and Aerodrome decentralized exchanges across multiple chains.
Overview
The Velodrome & Aerodrome Indexer demonstrates how to:
- Create a multichain indexer that works across Superchain chains
- Index core DEX functionality including pools, swaps, and liquidity positions
- Unify data access through a single GraphQL API
- Build efficient handlers using TypeScript
Key Features
- Complete tracking of liquidity pools and trading activity
- Real-time synchronization across multiple chains
- Well-documented codebase suitable as a starting point for similar DEX indexers
- Making ad-hoc RPC requests for additional data (use with caution)
For implementation details, usage examples, and setup instructions, see the project's README.
This indexer was built by Envio partners and community builders and serves as a valuable reference implementation. As with any indexer, perform appropriate testing and data validation before using in production environments.
Tutorial Op Bridge Deposits
File: Tutorials/tutorial-op-bridge-deposits.md
Introduction
This tutorial will guide you through indexing Optimism Standard Bridge deposits in under 5 minutes using Envio HyperIndex's no-code contract import feature.
The Optimism Standard Bridge enables the movement of ETH and ERC-20 tokens between Ethereum and Optimism. We'll index bridge deposit events by extracting the DepositFinalized logs emitted by the bridge contracts on both chains.
Prerequisites
Before starting, ensure you have the following installed:
- Node.js (v22 or newer recommended)
- pnpm (recommended but not required)
- Docker Desktop (required to run the Envio indexer locally)
Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.
Step 1: Initialize Your Indexer
- 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.
What You've Learned
- How to initialize a multi-network indexer using Envio
- How to import contracts from different blockchains
- How to query and explore indexed blockchain data
Next Steps
- Try customizing the event handlers to add additional logic
- Create relationships between events on different networks
- Deploy your indexer to Envio Cloud
For more tutorials and advanced features, check out our documentation or watch our video walkthroughs on YouTube.
Tutorial Erc20 Token Transfers
File: Tutorials/tutorial-erc20-token-transfers.md
Introduction
In this tutorial, you'll learn how to index ERC20 token transfers on the Base chain using Envio HyperIndex. By leveraging the no-code contract import feature, you'll be able to quickly analyze USDC transfer activity, including identifying the largest transfers.
We'll create an indexer that tracks all USDC token transfers on Base by extracting the Transfer events emitted by the USDC contract. The entire process takes less than 5 minutes to set up and start querying data.
Prerequisites
Before starting, ensure you have the following installed:
- Node.js (v22 or newer recommended)
- pnpm (recommended but not required)
- Docker Desktop (required to run the Envio indexer locally)
Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.
Step 1: Initialize Your Indexer
- 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:
- Chain to index (Base)
- Starting block for indexing
- Contract address and ABI details
- Events to track (Transfer)
2. schema.graphql
This schema defines the data structures for the Transfer event:
- Entity types based on event data
- Field types for sender, receiver, and amount
- Any relationships between entities
3. src/handlers
This directory contains the business logic for processing events:
- Functions that execute when Transfer events are detected
- Data transformation and storage logic
- Entity creation and relationship management
Step 5: Exploring Your Indexed Data
Now you can interact with your indexed USDC transfer data:
Accessing Hasura
- 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 chain
- How to query and analyze token transfer data using GraphQL
Next Steps
- Try customizing the event handlers to add additional logic
- Create aggregated statistics about token transfers
- Add more tokens or events to your indexer
- Deploy your indexer to Envio Cloud
For more tutorials and advanced features, check out our documentation or watch our video walkthrough on YouTube.
Tutorial Indexing Fuel
File: Tutorials/tutorial-indexing-fuel.md
HyperIndex supports any EVM-compatible blockchain and the Fuel Network.
Blockchain indexers are vital to the success of any dApp. In this tutorial, we will create an Envio indexer for the Fuel dApp Sway Farm step by step.
Sway Farm is a simple farming game and for the sake of a real-world example, let's create the indexer for a leaderboard of all farmers 🧑🌾
About Fuel
Fuel is an operating system purpose-built for Ethereum rollups. Fuel's unique architecture allows rollups to solve for PSI (parallelization, state minimized execution, interoperability). Powered by the FuelVM, Fuel aims to expand Ethereum's capability set without compromising security or decentralization.
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 chain and contract specifications and the event information to be used in the indexing process.
name: sway-farm-indexer
ecosystem: fuel
chains:
- id: 0
start_block: 0
contracts:
- name: SwayFarm
address:
- "0xf5b08689ada97df7fd2fbd67bee7dea6d219f117c1dc9345245da16fe4e99111"
abi_file_path: abis/swayfarm-abi.json
events:
- name: SellItem
logId: "11192939610819626128"
- name: LevelUp
logId: "9956391856148830557"
- name: NewPlayer
logId: "169340015036328252"
In the tutorial, we don't need to adjust it in any way. But later you can modify the file and add more events for indexing.
As a nice to have, you can use a Sway struct name without specifying a logId, like this:
- name: SellItem
- name: LevelUp
- name: NewPlayer
schema.graphql
The schema.graphql file serves as a representation of your application's data model. It defines entity types that directly correspond to database tables, and the event handlers you create are responsible for creating and updating records within those tables. Additionally, the GraphQL API is automatically generated based on the entity types specified in the schema.graphql file, to allow access to the indexed data.
🧠 A separate Guide page provides more details about the
schema.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.
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 chains and contracts to index:
# Partial example
chains:
- id: 137 # Polygon
# ... Polygon chain settings
contracts:
- name: Greeter
address: "0x9D02A17dE4E68545d3a58D3a20BbBE0399E05c9c"
# ... contract settings
- id: 59144 # Linea
# ... Linea chain settings
contracts:
- name: Greeter
address: "0xdEe21B97AB77a16B4b236F952e586cf8408CF32A"
# ... contract settings
schema.graphql
This schema defines the data structures for the indexed events:
type Greeting {
id: ID!
user: String!
greeting: String!
blockNumber: Int!
blockTimestamp: Int!
transactionHash: String!
}
type User {
id: ID!
latestGreeting: String!
numberOfGreetings: Int!
greetings: [String!]!
}
src/handlers (or .ts/.res)
This file contains the logic to process events emitted by the Greeter contract.
Step 3: Start Your Indexer
Important: Make sure Docker Desktop is running before proceeding.
- 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 chains
- 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 chains
By carefully choosing and implementing the right price data strategy, you can build robust indexers that provide accurate financial data for your blockchain applications.
Scaffold-Eth-2 Envio Extension
File: Tutorials/tutorial-scaffold-eth-2.md
Introduction
The Scaffold-ETH 2 Envio extension makes indexing your deployed smart contracts as simple as possible. Generate a boilerplate indexer for your deployed contracts with a single click and start indexing their events immediately.
With this extension, you get:
- 🔍 Automatic indexer generation from your deployed contracts
- 📊 Status dashboard with links to Envio metrics and database
- 🔄 One-click regeneration to update the indexer when you deploy new contracts
- 📈 GraphQL API for querying your indexed blockchain data
Prerequisites
Before starting, ensure you have the following installed:
- 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, orfalsewhen the indexer setsdisable_default_cross_chain). See Per-Chain Effects
The second argument is a function that will be called with the effect's input.
Note: For type definitions, you should use
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:
(With disable_default_cross_chain: true the default flips: an effect that doesn't state crossChain gets one cache per chain, and crossChain: true opts it back into a shared one.)
export const getTokenName = createEffect(
{
name: "getTokenName",
input: S.string,
output: S.string,
// Each chain gets its own 5 calls/second budget
rateLimit: {
calls: 5,
per: "second",
},
cache: true,
crossChain: false,
},
async ({ input, context }) => {
// context.chain is only available on `crossChain: false` effects
const client = clientsByChainId[context.chain.id];
return await client.readContract({
address: input,
abi: erc20Abi,
functionName: "name",
});
},
);
Setting crossChain: false changes three things:
- Cache - entries are keyed per chain, so two chains calling with the same input each get their own result.
- Rate limit - each chain gets its own budget rather than competing for a shared one. An effect limited to 5 calls/second across 3 chains now allows 15 calls/second in total.
context.chain- the handler 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 chains
- Working with thousands of contracts
HyperSync addresses these limitations by providing a streamlined data access layer that dramatically reduces sync times from days to minutes.
Default Enablement
HyperSync is used by default as the data source for all HyperIndex chains. This means:
- No additional configuration is required to benefit from its speed
- No need to worry about RPC rate limiting
- No management of multiple RPC providers
- No costs for external RPC services
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 chains. We handle the infrastructure, allowing you to focus on building your indexer logic.
Supported Chains
HyperSync supports numerous EVM chains including Ethereum, Unichain, Arbitrum, Optimism, and more. For a complete and up-to-date list of supported chains, see the HyperSync Supported Networks documentation.
Alternative Data Sources
HyperSync data source is vendorlock-free. While HyperSync is recommended for optimal performance, you can always switch to RPCs without the need to change your indexer code. For information on configuring RPC-based indexing, visit the RPC Data Source documentation.
Improving Resilience with RPC fallback
For production deployments, it’s recommended to use HyperSync as the primary data source and have RPCs as fallback to improve reliability.You can read more about it in the RPC Fallback section.
Performance Comparison
| 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 chains, we recommend using HyperSync as your data source for optimal performance.
If you must use RPC:
- Choose a reliable provider
- Configure your indexer for optimal performance
- Consider implementing eRPC for enhanced reliability and performance
- Start from recent blocks when possible to reduce indexing time
For any questions about using RPC with HyperIndex, please contact the Envio team.
Config Schema Reference
File: Advanced/config-schema-reference.md
Static, deep-linkable reference for the V3 config.yaml schema.
Tip: Use the Table of Contents to jump to a field or definition.
Top-level Properties
- name (required)
- description
- schema
- handlers
- full_batch_size
- storage
- disable_default_cross_chain
- ecosystem
- contracts
- chains (required)
- rollback_on_reorg
- save_full_history
- field_selection
- raw_events
- address_format
- bytes_type
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
disable_default_cross_chain
Make entities and effect caches per-chain instead of shared across every chain (recommended). Sharing then becomes explicit - add @crossChain to an entity in schema.graphql or crossChain: true to an effect. (default: false)
- type:
boolean | null
ecosystem
Ecosystem of the project.
- type:
anyOf(enum (1 values) | null)
Variants:
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
bytes_type
How the Bytes scalar in schema.graphql is represented. hex keeps 0x-prefixed hex strings stored as text, uint8array exposes Uint8Array values in handlers and stores raw bytes (BYTEA in Postgres, String in ClickHouse). (default: hex)
- type:
anyOf(oneOf(const hex | const uint8array) | null)
Variants:
1: BytesType2: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:anyOf(integer | enum (1 values))– The block at which the indexer should start ingesting data, or "latest" to start from the chain's current head block when the indexer is first deployed. Once resolved, the concrete block is persisted and reused every time the indexer resumes normally (for example recovering from a crash), so downtime is backfilled instead of skipped. Runningenvio start/devwith -r (--restart) resets this like any other config change: "latest" resolves again, against the head at that time.end_block:integer | null– The block at which the indexer should terminate.contracts:array | null– All the contracts that should be indexed on the given chain
Example (config.yaml):
chains:
- id: 1
start_block: 0
end_block: 19000000
contracts:
- name: Greeter
address: "0x1111111111111111111111111111111111111111"
# Excluded from indexing and migrations, but still code-generated
- id: 137
skip: true
start_block: 0
RpcSelection
- type:
anyOf(string | object<Rpc> | array<object<Rpc>>)
Variants:
1: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
StartBlock
A chain's configured start block: either a concrete block number or the
literal "latest". Config parsing never touches the network, so Latest
stays unresolved here - it's resolved once at runtime, right before the
indexer's first-ever persisted state is written, and never re-resolved
on a normal resume.
Note: the -r/--restart CLI flag wipes the DB and re-deploys
from scratch, so it re-resolves "latest" too - "resume" here means the
opposite: recovering from a crash or process restart without -r.
- type:
anyOf(integer | enum (1 values))
Variants:
1:integer2: StartBlockTag
StartBlockTag
- type:
enum (1 values) - allowed:
latest
ChainContract
- type:
object - required:
name
Properties:
name:string– A unique project-wide name for this contract if events and handler are defined OR a reference to the name of contract defined globally at the top 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
BytesType
How the schema.graphql Bytes scalar reaches handlers and storage.
- type:
oneOf(const hex | const uint8array)
Variants:
1:const hex2:const uint8array
Removed in V3
The following V2 options have been removed and are no longer accepted in config.yaml:
output- generated types are always emitted to.envio/.unordered_multichain_mode- unordered is now the only mode. The 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- Create a new indexerdev- 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 config file path, resolved relative to the project directory. It can also be set via theENVIO_CONFIGenvironment variableDefault value:
config.yaml
envio init
Create a new indexer
Quick start - run with no arguments:
pnpx envio init
Guided step-by-step for humans and AI agents. Reach for the subcommands below only when you already know exactly what you want.
Usage: envio init [OPTIONS] [COMMAND]
Subcommands:
contract-import- [Advanced] Initialize Evm indexer by importing config from a contract for a given chaintemplate- [Advanced] 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 Envio API token to be initialized in your templates .env file. Falls back to theENVIO_API_TOKENenvironment variable. Create one at https://envio.dev/app/api-tokens
envio init contract-import
[Advanced] Initialize Evm indexer by importing config from a contract for a given chain
Usage: envio init contract-import [OPTIONS] [COMMAND]
Subcommands:
explorer- Initialize by pulling the contract ABI from a block 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,etherlink,evmos,fantom,fantom-testnet,fhenix-helium,flare,fraxtal,fuji,galadriel-devnet,gnosis,gnosis-chiado,goerli,harmony,holesky,hoodi,hyperliquid,kroma,linea,linea-sepolia,lisk,lukso,lukso-testnet,manta,mantle,mantle-testnet,megaeth-testnet2,metis,mode,mode-sepolia,monad,monad-testnet,moonbase-alpha,moonbeam,moonriver,morph,morph-testnet,neon-evm,opbnb,optimism,optimism-sepolia,plasma,poa-core,poa-sokol,polygon,polygon-zkevm,polygon-zkevm-testnet,robinhood,rsk,saakuru,scroll,scroll-sepolia,sei,sei-testnet,sepolia,shimmer-evm,sonic,sonic-testnet,sophon,swell,taiko,tangle,unichain,unichain-sepolia,worldchain,xdc,xdc-testnet,zeta,zksync-era,zora,zora-sepolia -
--api-token <API_TOKEN>- API token for the block explorer -
--single-contract- If selected, prompt will not ask for additional contracts/addresses/chains -
--all-events- If selected, prompt will not ask to confirm selection of events on a contract
envio init contract-import local
Initialize from a local json ABI file
Usage: envio init contract-import local [OPTIONS]
Options:
-a,--abi-file <ABI_FILE>- The path to a json abi file--contract-name <CONTRACT_NAME>- The name of the contract-b,--blockchain <BLOCKCHAIN>- Name or ID of the contract network-r,--rpc-url <RPC_URL>- The rpc url to use if the network id used is unsupported by our hypersync-s,--start-block <START_BLOCK>- The start block to use on this network--single-contract- If selected, prompt will not ask for additional contracts/addresses/chains--all-events- If selected, prompt will not ask to confirm selection of events on a contract
envio init template
[Advanced] Initialize Evm indexer from an example template
Usage: envio init template [OPTIONS]
Options:
-
-t,--template <TEMPLATE>- Name of the template to be used in 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:
usdc-transfers
envio init fuel
Initialization option for creating Fuel indexer
Usage: envio init fuel [COMMAND]
Subcommands:
contract-import- Initialize Fuel indexer by importing config from a contract for a given 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 talks to the indexer on the port set by ENVIO_INDEXER_PORT,
falling back to 9898.
Available metrics
Every metric is prefixed with envio_. Counters and gauges are labeled where
noted (e.g. by chainId, contract, event).
Progress & sync status
| 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. Labeled by storage. |
envio_storage_write_total | counter | Total number of batch writes to storage. Labeled by storage. |
envio_storage_load_seconds | counter | Time spent loading data from storage. Labeled by operation and storage. |
envio_storage_load_seconds_total | counter | Cumulative time spent loading data from storage during indexing. Labeled by operation and storage. |
envio_storage_load_total | counter | Number of successful storage load operations. Labeled by operation and storage. |
envio_storage_load_size | counter | Cumulative number of records loaded from storage. Labeled by operation and storage. |
envio_storage_load_where_size | counter | Cumulative number of filter conditions (where items) used in storage load operations. Labeled by operation and storage. |
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_height_stream_connects_total | counter | Number of times a source's height subscription connected. Labeled by source and chainId. One more connect than disconnects means the stream is up; equal counts mean it is down and the indexer is polling instead. Zero connects is normal while a chain is still backfilling - subscriptions only open once a chain reaches the head. |
envio_source_height_stream_disconnects_total | counter | Number of times a source's height subscription lost a connection. Labeled by source, chainId and reason, and absent until the first disconnect. rotated is a connection that served its time, unsubscribed is the source being benched; any other reason ended a connection early. Failed retries aren't counted, so this measures outages rather than their length. |
envio_source_known_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 address registrations on chain (static and dynamic). Labeled by chainId. An address shared by N contracts counts N times. |
envio_indexing_contract_addresses | gauge | Number of address registrations per contract on chain (static and dynamic). Labeled by chainId and contract. |
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 and scope. |
envio_effect_call_seconds_total | counter | Cumulative time spent calling the Effect function during indexing. Labeled by effect and scope. |
envio_effect_call_total | counter | Cumulative number of resolved Effect function calls. Labeled by effect and scope. |
envio_effect_active_calls | gauge | Number of Effect function calls currently running. Labeled by effect and scope. |
envio_effect_cache | gauge | Number of items in the effect cache. Labeled by effect and scope. |
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. Labeled by effect and scope. |
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"] # use your ENVIO_INDEXER_PORT if you set one
Key metrics to watch
envio_progress_ready- confirm your indexer is caught up to the chain head.envio_progress_block- track indexing progress over time.envio_processing_handler_seconds- identify slow event handlers by contract and event.envio_indexing_idle_seconds- a high value may indicate the data source sync is a bottleneck.envio_reorg_detected_total- track how frequently chain reorganizations occur.
Finding the bottleneck
A slow indexer is usually waiting on one of two things: fetching events, or writing them. Since v3.5, two counters attribute that wait directly, so you no longer have to infer it from handler timings:
envio_processing_stalled_on_fetch_seconds- the indexer had nothing to process because events hadn't arrived yet. A high rate means fetching is the bottleneck: check data-source latency and whether it can take more concurrency. Time spent waiting at the chain head for new blocks is deliberately excluded, so a fully synced indexer doesn't look stalled.envio_processing_stalled_on_storage_write_seconds- the indexer paused because too many changes were still queued for writing. A high rate means storage writes are the bottleneck: cross-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.
For a step-by-step walkthrough of capturing snapshots and working from a slow indexer to the metric that explains it, see Benchmarking Your Indexer.
Health checks
The indexer exposes a /healthz endpoint that returns HTTP 200 once the
service is up. It's designed as a machine-readable liveness probe - for example,
a Kubernetes livenessProbe:
GET http://localhost:9898/healthz → 200 OK
livenessProbe:
httpGet:
path: /healthz
port: 9898
initialDelaySeconds: 10
periodSeconds: 10
Indexing status
To track per-chain indexing progress from your application, query the official
_meta field on the GraphQL API. This is the most reliable way to know whether a
chain has caught up before you read indexed data - useful for health checks,
custom dashboards, and waiting for a write to be indexed before refetching.
{
_meta {
chainId
progressBlock
eventsProcessed
bufferBlock
firstEventBlock
sourceBlock
readyAt
isReady
startBlock
endBlock
}
}
Result:
{
"data": {
"_meta": [
{
"chainId": 1,
"progressBlock": 22817138,
"eventsProcessed": 2380000,
"bufferBlock": 22820499,
"firstEventBlock": 21688545,
"sourceBlock": 23368264,
"readyAt": null,
"isReady": false,
"startBlock": 0,
"endBlock": null
},
{
"chainId": 10,
"progressBlock": 137848820,
"eventsProcessed": 2455000,
"bufferBlock": 137873621,
"firstEventBlock": 130990676,
"sourceBlock": 141168975,
"readyAt": null,
"isReady": false,
"startBlock": 0,
"endBlock": null
}
]
}
}
Metadata fields
Configuration fields
These fields are populated on indexer startup and don't change during the indexer process.
chainId- Chain ID the metadata belongs to. Results are sorted 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-chain progress bars.
- Logs - live and historical logs with level filtering, integrated and configured by Envio.
- Built-in alerts - get notified via Discord, Slack, Telegram, Email, or a generic webhook when your endpoint goes down or your indexer stops processing.
Envio Cloud also exposes the same Prometheus metrics described above, so you can scrape them into your own Grafana or Datadog. On Cloud the endpoint is served under your deployment's URL:
<your-endpoint-url>/hyperindex/metrics
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.
Chain-Specific Performance
Optimized Major Chains
- Priority Chains: We've dedicated significant resources to maintaining extremely low latency on popular chains including Ethereum, Optimism, and Arbitrum.
- User Experience: Users should experience seamless, near real-time data updates on these chains.
Smaller Chains
- Standard Performance: On smaller chains, latency might be slightly higher as they have received less optimization.
- Improvement Process: Your feedback helps us prioritize which chains to optimize next. Please let us know in Discord if low latency on specific smaller chains is important for your use case.
Special Configuration Options
Multi-Chain Indexing
- Unordered Event Ordering: For applications indexing multiple chains, HyperIndex always processes events in unordered mode, so each chain keeps syncing independently - there is nothing to configure.
- Resilient Design: With this configuration, even if one chain experiences latency, your other chains will continue syncing normally.
Chain Reorganization Support
- Reorg Handling: Our reorg support system ensures data consistency even when chains reorganize.
- Documentation: Contact our team on Discord if you have concerns about reorg support while we finalize documentation.
Envio Cloud Performance
Envio Cloud offers reliable performance with ongoing improvements:
- Continuous Enhancement: We're actively improving sync and build times on Envio Cloud.
- Relative Performance: Currently, indexers may run slightly slower on Envio Cloud compared to high-performance local machines.
- Enterprise Solutions: For applications requiring exceptional performance, contact us on Discord to discuss our enterprise hosting plans.
By leveraging these features and providing feedback on your specific needs, you can help us continually improve the HyperIndex head latency performance.
Benchmarking Your Indexer
File: Advanced/performance/benchmarking.md
Every HyperIndex indexer reports what it's doing over an HTTP metrics endpoint while it runs. There's no benchmarking mode to enable and no profiler to attach
- if the indexer is running, the numbers are already there.
This page turns those numbers into an answer to one question: what is my indexer waiting on, and what do I do about it?
Work through it in order:
- Measure throughput - how fast is it, in events per second?
- Find the bottleneck - which of five things is it waiting on?
- Fix it - the section for your bottleneck says what to change.
- Prove the fix worked - re-measure and compare.
Step 1: Measure throughput
Start the indexer in one terminal - locally with
envio dev, or in production with
envio start:
pnpm envio dev
Read its metrics from a second terminal, in the same project:
pnpm envio metrics # indexing metrics
pnpm envio metrics runtime # Node.js process metrics (CPU, memory, GC, event-loop lag)
The output is plain Prometheus text - a # HELP line describing each metric, a
# TYPE line, then one sample per line with its labels in braces:
# HELP envio_progress_events The number of events processed and reflected in the database.
# TYPE envio_progress_events gauge
envio_progress_events{chainId="1"} 158205
# HELP envio_process_elapsed_seconds How long the indexer has been running.
# TYPE envio_process_elapsed_seconds gauge
envio_process_elapsed_seconds 45.801
Because every metric documents itself, grepping for the metric you care about -
pnpm envio metrics | grep envio_progress - is usually the fastest way to
answer a specific question.
Take two snapshots a minute or so apart, and divide the change in events by the change in elapsed time:
pnpm envio metrics > t1.txt
# wait ~60s
pnpm envio metrics > t2.txt
grep -E '^envio_(progress_events|process_elapsed_seconds)' t1.txt t2.txt
# t1: 158205 events at 45.8s
# t2: 262000 events at 105.8s
# (262000 - 158205) / (105.8 - 45.8) = ~1730 events/second
envio_progress_events is reported per chainId, so a multichain indexer prints
one sample per chain: compare the same chainId across both snapshots for a
per-chain figure, or sum every chain within each snapshot for the indexer as a
whole. Both snapshots also have to come from one uninterrupted run - restarting
the indexer resets envio_process_elapsed_seconds, so start a fresh baseline
after any restart. (Prometheus rate() handles that for you when you scrape
instead.)
Interpret the result against what your workload plausibly allows:
| Events per second | Reading |
|---|---|
| Over 10,000 | Excellent - most likely bounded by the data source, not by you |
| 1,000–5,000 | Good. Worth tuning only if your sync time is still too long |
| Under 500 | Something is probably wrong. Continue to step 2 |
Once a chain is caught up (envio_progress_ready is 1), throughput reflects
how fast new blocks arrive, not how fast your indexer is. Benchmark while the
chain is still syncing history, or against a fixed block range with an
end_block in config.yaml, so two runs
are comparable.
Step 2: Find the bottleneck
A slow indexer is waiting on something. Two counters attribute that wait directly - read them first, because they point at the answer instead of hinting at it:
pnpm envio metrics | grep -E 'stalled_on|process_elapsed'
| Metric | Meaning | Go to |
|---|---|---|
envio_processing_stalled_on_fetch_seconds | Nothing to process - events hadn't been fetched yet | Fetching |
envio_processing_stalled_on_storage_write_seconds | Processing paused - too many changes queued for writing | Storage writes |
Both are cumulative seconds, so read them as a share of the run:
envio_processing_stalled_on_fetch_seconds / envio_process_elapsed_seconds
Anything above roughly 0.3 (30% of the run) is worth acting on. The same
division works for every _seconds counter - that's how you compare parts of
the pipeline against each other:
| Metric | Share of the run spent… |
|---|---|
envio_processing_seconds | inside your event handlers |
envio_preload_seconds | loading entities for a batch |
envio_storage_write_seconds | writing batches to storage |
envio_fetching_block_range_seconds | fetching block ranges (per chainId) |
envio_effect_call_seconds_total | inside Effect API calls (per effect) |
Only _seconds counters are durations you can divide by elapsed time. Counts
(envio_progress_events, envio_processing_handler_total) and gauges
(envio_progress_block, envio_indexing_concurrency) are read as values or as
deltas between snapshots. envio_process_metric_time_seconds records when a
snapshot was taken, so you can compute rates between two of them by hand.
If you scrape with Prometheus instead of reading snapshots, every counter above
works as a rate - rate(envio_processing_stalled_on_fetch_seconds[5m]) - see
Scraping with Prometheus.
Step 3: Fix the bottleneck
Each section below covers one bottleneck: how you recognise it, what it means, and what to change. Work only the one that step 2 pointed you at.
Fetching
How you know
pnpm envio metrics | grep -E 'stalled_on_fetch|indexing_idle|fetching_block_range_seconds'
envio_processing_stalled_on_fetch_seconds is a large share of the run, and
envio_fetching_block_range_seconds dominates the other _seconds counters.
envio_indexing_idle_seconds climbing on a specific chainId tells you which
chain is holding you up.
What it means
Your handlers are idle waiting for data. Time spent waiting at the chain head for new blocks is deliberately excluded from the stall counter, so a fully synced indexer never looks stalled here.
What to do
- Use HyperSync if it supports your chain - it's the single biggest change available, and RPC is orders of magnitude slower.
- On RPC, a faster provider or a higher rate limit is the fix; check
envio_source_request_seconds_totalpersourceto see what each one costs you. - Compare
envio_indexing_concurrencywithenvio_indexing_partitionsfor the chain - if concurrency sits at its ceiling while events still arrive slowly, the source is saturated, not the indexer. - Watch
envio_fetching_block_range_parse_seconds: when parsing rivals fetching, you're decoding far more events than your handlers use, so narrow the events inconfig.yaml.
Entity loading
How you know
pnpm envio metrics | grep -E 'preload_seconds|storage_load'
envio_preload_seconds or envio_storage_load_seconds_total is a large share of
the run. envio_storage_load_size and envio_storage_load_where_size (labeled
by operation and storage) show which loads pull the most data.
What it means
Preload optimization batches the entity reads of a whole event batch into a few queries - but only for reads it can see during the preload phase. A read hidden behind a condition that only some events reach, or one that depends on the result of an earlier read, falls back to a single-event round trip.
What to do
- Read entities unconditionally near the top of the handler so the preload phase can batch them, rather than inside a branch.
- Avoid chaining reads (
get→ use the id you just read →getagain); load both entities up front where possible. - Add database indices for the fields you filter on with
getWhere. - Reduce how much each load returns: a
getWherethat matches thousands of rows costs more than the handler usually needs.
Handlers
How you know
pnpm envio metrics | grep -E 'processing_handler_(seconds|total)'
envio_processing_seconds is a large share of the run. To find the specific
handler, divide cumulative time by call count - both are labeled by contract
and event:
envio_processing_handler_seconds / envio_processing_handler_total
What it means
Your own code is the cost. A mean above ~1ms per call is worth a look; compare handlers against each other rather than against an absolute number.
What to do
- Move external calls (RPC, HTTP, IPFS) out of the handler and into the Effect API, which batches, parallelizes, and caches them.
- Keep heavy derived computation out of the hot path - store what you need and compute the rest at query time.
- Look for accidental work per event: parsing the same static data repeatedly, formatting, or building large intermediate objects.
Storage writes
How you know
pnpm envio metrics | grep -E 'stalled_on_storage_write|storage_write'
envio_processing_stalled_on_storage_write_seconds is a large share of the run,
and envio_storage_write_seconds is high next to envio_storage_write_total
(labeled by storage).
What it means
The indexer produced entity changes faster than storage accepted them, so processing paused until the write queue drained.
What to do
- Write fewer entities per event. Updating a running-total entity on every event makes each event a write; aggregating in memory and writing on a coarser boundary avoids most of them.
- Don't update the same entity several times within one handler - the last write wins anyway.
- Check your database: indices speed up reads but every extra index slows writes down, and an under-resourced Postgres shows up here first.
- If reorg support is enabled, entity history is written alongside your entities -
envio_rollback_history_prune_secondsperentityshows what that costs.
External calls
How you know
pnpm envio metrics | grep -E 'envio_effect_'
envio_effect_call_seconds_total is a large share of the run, or
envio_effect_queue_wait_seconds shows calls waiting on a rate limit. Both are
labeled per effect, so the slow one names itself.
What it means
Handlers are blocked on Effect API calls to something outside the indexer - an RPC node, an HTTP API, IPFS.
What to do
- Set
cache: trueon effects whose result is stable for a given input, so a rerun doesn't repeat the call;envio_effect_cachetracks cache size andenvio_effect_cache_invalidationshow often it's discarded. - Raise the effect's
rateLimitif the provider allows it -envio_effect_queuesitting above zero means calls are queuing rather than running. - If effects are slow and can't be batched, lower
full_batch_sizeso a batch doesn't wait on thousands of pending calls. - Compare
envio_effect_call_seconds_totalwithenvio_effect_call_totalto separate "each call is slow" from "there are too many calls".
Step 4: Prove the fix worked
Benchmarking is only useful as a loop:
- Capture a baseline before changing anything:
pnpm envio metrics > before.txt. - Change one thing. Two changes at once make it impossible to attribute the difference.
- Re-run over the same block range, ideally from a fresh database so the run does the same work.
- Compare the same numbers: events per second from step 1, and the share of the run for the counter you targeted.
pnpm envio metrics > after.txt
diff <(grep -E '^envio_(progress_events|.*_seconds)' before.txt) \
<(grep -E '^envio_(progress_events|.*_seconds)' after.txt)
Two things to watch while you iterate:
- Memory.
pnpm envio metrics runtimereports heap usage, GC, and event-loop lag. An indexer that speeds up while memory climbs is often deferring work rather than avoiding it. - Realistic conditions. Benchmark against a block range with the event mix your indexer actually sees - a quiet range flatters every change you make.
Next Steps
- Observability - the full metric reference, logs, health checks, and
_metaindexing status - Preload Optimization - how batched entity reads work
- Effect API - batching, parallelizing, and caching external calls
- Database Performance - indices and schema design
- Latency at the Head - what to expect once the indexer is caught up
- Still stuck? Share your metrics snapshot in Discord - we're happy to read it with you.
Loaders Optimization (Removed in V3)
File: Advanced/loaders.md
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 chain 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 chain 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
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 Chain Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 1234567890
rpc: https://custom-chain-rpc.com # RPC URL for that custom chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event
Support
Can’t find what you’re looking for or need support? Reach out to us on Discord; we’re always happy to help!
The backbone of HyperIndex’s blazing-fast indexing speed lies in using HyperSync as a more performant and cost-effective data source to RPC for data retrieval. While RPCs are functional, and can be used in HyperIndex as a data source, they are far from efficient when it comes to querying large amounts of data (a time-consuming and resource-intensive endeavour).
HyperSync is significantly faster and more cost-effective than traditional RPC methods, allowing the retrieval of multiple blocks at once, and enabling sync speeds up to 1000x faster than RPC.
Local chain - Anvil
File: supported-networks/local-anvil.md
A local chain can be used as a data source for your indexer. You simply need to specify it in config.yaml.
Defining Chain Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Anvil default chain id
rpc: http://localhost:8545 # RPC URL for your local Anvil chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event
Local chain - Hardhat
File: supported-networks/local-hardhat.md
A local chain can be used as a data source for your indexer. You simply need to specify it in config.yaml.
Defining Chain Configurations
name: IndexerName # Specify indexer name
description: Indexer Description # Include indexer description
chains:
- id: 31337 # Local Hardhat default chain id
rpc: http://localhost:8545 # RPC URL for your local Hardhat chain
start_block: START_BLOCK_NUMBER # Specify the starting block
contracts:
- name: ContractName
address:
- "0xYourContractAddress1"
- "0xYourContractAddress2"
events:
- event: Event # Specify event
- event: Event
--
Indexing on Solana
File: solana/solana.md
HyperIndex indexes Solana programs at the instruction level. You select the programs and instructions you care about, HyperIndex decodes their arguments and accounts from an IDL (Anchor, Shank or Codama) or a layout you declare, and writes the results to Postgres with an auto-generated GraphQL API. Inner instructions (CPIs), account balance activity, transaction metadata and program logs are all available.
It is powered by HyperSync for Solana, the same high-performance data engine behind EVM indexing, so historical backfills are fast and you never touch an RPC node for the bulk of indexing.
The SVM API - configuration, handlers and payload types - is final and follows semver like the rest of HyperIndex. Building on Solana? Say hello on Discord; we'd love your input on what to prioritize next.
Two ways to index Solana
| Approach | API | Data source | Use it for |
|---|---|---|---|
| Instruction handlers | indexer.onInstruction | HyperSync | The main path: decode and index program instructions (swaps, deposits, mints, transfers…), including inner/CPI instructions, with per-account balance activity. |
| Slot handlers | indexer.onSlot | RPC (via the Effect API) | Per-slot orchestration, time-series snapshots, or pulling extra data from RPC on a schedule. |
Most indexers use instruction handlers. Slot handlers are for cases where you need to run logic on a slot cadence rather than react to a specific instruction. For raw, low-level data you can also query HyperSync for Solana directly.
Quickstart
pnpx envio init
Choose Solana when prompted, then pick the USDC Transfers template - a working SPL Token instruction indexer with tests. See Getting Started for the full walkthrough.
The HyperSync endpoint comes from the chain id, so there's nothing to configure
beyond picking a start_slot - see
choosing a start slot.
Mental model: coming from EVM?
If you've used HyperIndex on EVM, the shift is mostly vocabulary:
| EVM | Solana |
|---|---|
| Contract + ABI | Program + IDL |
Event (onEvent) | Instruction (onInstruction) |
Block (onBlock) | Slot (onSlot) |
event.params | instruction.args |
EVM vs Solana has the full mapping.
What's supported today
- Instruction indexing via
indexer.onInstruction: match by program + discriminator. - IDL-aware decoding: point at an Anchor (legacy or 0.30+), Shank or Codama IDL and every instruction it declares becomes indexable, with its arguments and account names. No IDL? Declare the layout yourself.
- Inner instructions (CPIs): decoded the same way as top-level ones, with a full instruction path so you can reconstruct the call tree.
- Account activity: pre/post lamport and SPL Token (and Token-2022) balances per account, so you get net value movement without indexing every transfer. See account activity.
- Transaction metadata & logs: fee payer, fee, compute units, success, the transaction signature, and per-instruction program logs (opt-in via the handler's
fieldsoption). - Slot handlers via
indexer.onSlot+ the Effect API for RPC enrichment. - Local dev + GraphQL + Envio Cloud: the same workflow and hosting as EVM.
What is not supported yet
These are gaps in the built-in instruction-handler surface, not hard limits: for most of them you can still pull the data yourself by calling out to RPC from a handler with the Effect API, you just don't get it as a struct field for free.
- Account-change subscriptions. There is no
onAccount/program-account handler.instruction.accountsgives you the accounts an instruction touched and, withfields.accountActivity, their pre/post lamport and token balances for that transaction - but not arbitrary account state. For that, read the account over RPC in a handler with the Effect API. - A separate log handler. Logs are a field on the instruction, not their own handler.
- Dynamic registration. No Solana equivalent of dynamic contracts: programs are declared in
config.yaml, not registered at runtime. - Wildcard indexing across programs. A registration always names one program.
- No-code contract import. Solana has no
contract-importflow, so you configure programs by hand. (IDLs are wired up inconfig.yaml, not auto-imported.) - ReScript. Solana indexers are TypeScript only. Codegen emits no ReScript for
ecosystem: svm, andenvio initsilently picks TypeScript if you ask for ReScript.
If the piece you need is on this list, tell us on Discord: there's a good chance we can sequence the work to unblock you, or point you at a HyperSync-direct path that gets the data today.
In this section
- Getting Started: scaffold and run your first Solana indexer.
- Instruction Handlers:
onInstruction, the instruction object, account activity, CPIs, testing. - Decoding Instructions: IDLs, discriminators, inline layouts, supported types.
- Slot Handlers:
onSlotand RPC enrichment. - Configuration: the
config.yamlreference forecosystem: svm. - EVM vs Solana: every difference in one place.
Getting Started on Solana
File: solana/getting-started.md
This guide takes you from nothing to a running Solana indexer with a live GraphQL API. If you've used HyperIndex on EVM the workflow is identical: only the config and handlers differ.
Prerequisites
- Node.js v20+ and pnpm. The commands below use
pnpm/pnpx; npm, Yarn, and Bun work too if you swap the equivalents - Docker Desktop (for the local Postgres + GraphQL stack)
- A HyperSync API token: the CLI's login flow sets this up for you, or generate one in the Envio Cloud portal. See API tokens.
1. Scaffold a project
pnpx envio init
Choose Solana at the ecosystem prompt, then pick the USDC Transfers (SPL Token instructions) template: a working indexer of every USDC transfer through the SPL Token program, with tests.
Non-interactive equivalent:
pnpx envio init svm template --template usdc-transfers --name my-indexer
The template scaffolds:
my-indexer/
├── config.yaml # chains + programs/instructions
├── schema.graphql # the entities you index into
├── src/
│ ├── handlers/…ts # your onInstruction / onSlot handlers
│ └── indexer.test.ts # tests, with simulated instructions
├── .env # ENVIO_API_TOKEN
└── package.json
envio init also runs codegen, installs dependencies, and initializes git.
2. Pick a start slot
start_slot in config.yaml is a slot number, not a block number. Three
common choices:
start_slot | Indexes from |
|---|---|
latest | The head when the indexer is first deployed. Fastest way to see live data. |
| A few tens of thousands of slots below the head | A short backfill, good for trying things out. |
| The slot your program was deployed at | Its full history. |
Check the current head to pick a number:
curl -s https://solana.hypersync.xyz/height
# => 440067639
Mainnet goes back to around slot 403,000,000 (September 2026). A start_slot
before that doesn't error - the indexer starts from the earliest indexed slot
instead. See
choosing a start slot.
3. Run it
pnpm install # if you didn't let init do it
pnpm envio codegen # regenerate types from config.yaml + schema.graphql
pnpm envio dev # start Postgres + the indexer + GraphQL (Docker)
envio dev brings up the local stack and runs the indexer with hot reload. The
GraphQL playground (Hasura) is at http://localhost:8080 (default admin
secret testing). See Navigating Hasura.
To run the pieces separately:
pnpm envio local docker up # start Postgres + Hasura
pnpm envio codegen
pnpm envio start # run the indexer against the running stack
Editing config.yaml or schema.graphql (including adding a program,
instruction, or IDL) requires pnpm envio codegen to regenerate the typed
envio module and the entity types in .envio/.
4. Add your own program
Add an entry to the top-level programs list, then write a handler for it:
programs:
- name: MyProgram
program_id: MyPr0gram11111111111111111111111111111111111
idl: ./idls/my-program.json
With an idl (Anchor, Shank or Codama) every instruction it declares is
indexable and you select them by name. Without one, declare the discriminator,
accounts and args yourself - see
Decoding Instructions.
indexer.onInstruction(
{
program: "MyProgram",
instruction: "swap",
fields: { instruction: ["args", "accounts"], block: ["time"] },
},
async ({ instruction, context }) => {
// instruction.args and instruction.accounts are typed after codegen
},
);
A registration reads only the fields it lists in fields, and can narrow what
it indexes with where - both are covered in
Instruction Handlers. Re-run
pnpm envio codegen after editing config.yaml.
Next steps
- Configuration: every
config.yamlfield for Solana. - Instruction Handlers: the full instruction object, account activity, CPIs, and testing.
- Decoding Instructions: IDLs, discriminators, inline layouts, supported types.
- Deploy to Envio Cloud: host your Solana indexer the same way as EVM.
Instruction Handlers
File: solana/instruction-handlers.md
On Solana you react to instructions instead of EVM events. Register a handler
with indexer.onInstruction; it fires once for every matched instruction (top-level
or inner) of the configured program.
indexer.onInstruction(
{ program: "<PROGRAM_NAME>", instruction: "<INSTRUCTION_NAME>" },
async ({ instruction, context }) => {
// your logic here
},
);
program is the name you gave it under the top-level programs in
config.yaml. instruction is a name from that program's instructions list,
or - when the program points at an idl - any instruction the IDL declares.
The envio module exposes a unified indexer value plus types derived from your
config.yaml and schema.graphql. Run pnpm codegen whenever you change
either file. After codegen, program/instruction autocomplete and
instruction.args / instruction.accounts are typed per instruction.
A complete handler
/** Only the listed fields are fetched, so keep it to what the handlers read. */
const fields = {
instruction: ["accounts", "args", "path"],
transaction: ["signature", "transactionIndex"],
block: ["time"],
} as const;
indexer.onInstruction(
{ program: "SplToken", instruction: "transferChecked", fields },
async ({ instruction, context }) => {
context.Transfer.set({
id: `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`,
amount: instruction.args.amount, // bigint, decoded from the u64
source: instruction.accounts.source.address, // base58
destination: instruction.accounts.destination.address,
signer: instruction.accounts.authority.address,
txSignature: instruction.transaction.signature,
slot: instruction.block.slot,
timestamp: instruction.block.time,
});
},
);
An instruction whose data the configured layout rejects is skipped rather than
delivered undecoded, so args and accounts need no null checks.
Selecting fields
A registration carries only the data it asks for. List it in fields:
const fields = {
instruction: ["args", "accounts", "path", "isInner", "programId", "data", "accountArguments"],
transaction: ["signature", "feePayer", "success", "fee", "computeUnitsConsumed"],
accountActivity: ["token.mint", "token.owner", "token.decimals", "lamports.pre", "lamports.post"],
block: ["time", "hash", "height", "parentSlot", "parentHash"],
log: ["kind", "message"],
} as const;
| Knob | Adds |
|---|---|
instruction | args, accounts, accountArguments, programId, data, path, isInner. |
transaction | transactionIndex, signature, feePayer, success, err, fee, computeUnitsConsumed, accountKeys, recentBlockhash, version, allSignatures. |
accountActivity | Per-account balances and token info on instruction.accounts.<name>.activity and instruction.transaction.accountActivities - see account activity. |
block | time, hash, height, parentSlot, parentHash on top of the always-present slot. |
log | instruction.logs, the program logs scoped to this instruction. |
Reading a field you didn't select is a compile error naming the knob to add,
not a runtime undefined. Write the selection inline in the registration, or
declare it as const (as above) so its element types still name the fields.
instruction.programName, instruction.instructionName, instruction.discriminator
and instruction.block.slot are always available.
signature, not allSignatures[0]The identifying transaction id is the scalar instruction.transaction.signature.
allSignatures is the array of every signer's signature and is selected
separately - selecting signature doesn't give you allSignatures. Nearly
every handler wants the scalar.
The instruction object
type SvmInstruction = {
programName: string; // the program name from config
instructionName: string; // the instruction name from config or IDL
discriminator: string; // the matched hex prefix, e.g. "0x0c"
programId: string; // base58
data: Uint8Array; // raw instruction data
path: readonly number[]; // CPI path, e.g. [0] or [0, 1]
isInner: boolean; // true => inner (CPI) instruction
args: { ... }; // decoded Borsh arguments, typed per instruction
accounts: { [name]: SvmInstructionAccount }; // named account slots
accountArguments: readonly string[]; // every account address, positionally
logs: readonly { kind: string; message: string }[];
transaction: SvmTransaction;
block: { slot: number /* + selected block fields */ };
};
argsis keyed by the argument names from the IDL or the inlineargslayout, typed after codegen. See supported types.accountsis keyed by the slot names you declared. Each entry is{ address, accountName, instructionAccountIndex, activity }.accountArgumentsis the positional list of addresses, including slots you never named.pathlocates the instruction in the transaction's call tree - see inner instructions.
instruction.transaction
type SvmTransaction = {
transactionIndex: number;
signature: string; // the transaction id, a scalar
feePayer: string;
success: boolean;
err: string | undefined;
fee: bigint; // lamports
computeUnitsConsumed: bigint | undefined;
accountKeys: readonly string[];
recentBlockhash: string;
version: string | undefined;
allSignatures: readonly string[];
accountActivities: readonly SvmAccountActivity[]; // with fields.accountActivity
};
The object is always there; each field is readable only when the registration
selected it, so a missing selection is a compile error on the property rather
than a crash on undefined.
Account activity
Selecting fields.accountActivity attaches per-account, per-transaction activity:
pre/post lamport balances and pre/post SPL Token (and Token-2022) balances.
postAmount − preAmount is the balance change, which is the cleanest way to
capture net value flow without indexing every transfer.
type SvmAccountActivity = {
address: string;
transactionAccountIndex: number;
isSigner: boolean;
isWritable: boolean;
lamports: { pre: bigint; post: bigint } | undefined;
token:
| {
mint: string;
owner: string;
decimals: number;
preAmount: bigint | undefined; // absent if the account was created in the tx
postAmount: bigint | undefined; // absent if it was closed in the tx
}
| undefined;
};
It arrives in two places: on a named account as instruction.accounts.<name>.activity
(the account's own row, or undefined when the transaction reports none for it),
and on instruction.transaction.accountActivities for every account the
transaction touched.
const fields = {
instruction: ["accounts"],
transaction: ["signature"],
accountActivity: ["token.mint", "token.decimals", "token.preAmount", "token.postAmount"],
} as const;
indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute", fields },
async ({ instruction, context }) => {
for (const { address, token } of instruction.transaction.accountActivities) {
if (!token) continue;
context.TokenDelta.set({
id: `${instruction.transaction.signature}:${address}`,
mint: token.mint,
decimals: token.decimals,
delta: (token.postAmount ?? 0n) - (token.preAmount ?? 0n), // signed
});
}
},
);
bigintBalances are raw base units typed as bigint, so use ?? 0n rather than
?? "0" for the absent case. Both amounts absent means the entry carries no
movement at all, which is worth distinguishing from a genuine zero.
Filtering with where
where narrows a registration server-side, so filtered-out instructions are
never fetched or decoded:
indexer.onInstruction(
{
program: "SplToken",
instruction: "transferChecked",
fields,
where: {
accounts: { mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
isInner: false,
block: { slot: { _gte: 445_000_000 } },
},
},
handler,
);
| Key | Effect |
|---|---|
accounts | Match named account slots against a pubkey or a list of them. Keys within one group are AND-ed; pass an array of groups to OR them. |
isInner | true matches only inner (CPI) instructions, false only top-level ones. Omit it to match both. |
block.slot._gte | A per-registration start slot. It overrides the chain's start_slot for this handler. Only _gte is supported here - use indexer.onSlot for _lte / _every. |
Only slots you named in accounts are filterable (_ placeholders and unnamed
trailing slots are not).
Inner instructions (CPIs)
HyperIndex decodes inner instructions (those invoked by other programs via cross-program invocation) exactly like top-level ones. Two fields let you reconstruct the call tree:
isInner:falsefor a top-level instruction,truefor a CPI.path: the position in the tree.[0]is the first top-level instruction,[0, 1]the second inner instruction it invoked,[0, 1, 2]one level deeper.
const path = instruction.path; // e.g. [0, 1]
const id = path.join("."); // "0.1"
const depth = path.length - 1; // 1
const parent = path.length > 1 ? path.slice(0, -1).join(".") : undefined;
A registration matches both inner and outer occurrences by default; narrow it
with where: { isInner: true } or false.
EVM "internal calls" aren't surfaced as first-class events. On Solana, CPIs are real indexable instructions: a Jupiter route's underlying Raydium/Orca swaps are all visible if you index those programs.
The context object
context is the same as in EVM handlers - the per-entity operations (set,
get, getOrThrow, getOrCreate, getWhere, deleteUnsafe), context.log,
context.effect for Effects, and
context.isPreload. See the
Event Handlers context.
Two things carry over from EVM that matter here:
context.chainis{ id, isRealtime }, andidis7565164on Solana mainnet.- Preload optimization runs every handler twice, so keep writes idempotent and guard non-idempotent side effects with
if (context.isPreload) return;.
A deterministic Solana entity id combines the slot, the transaction index and the instruction path:
const id = `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`;
Testing
Solana indexers use the same test framework as EVM
ones. Two things are SVM-specific: chain overrides are keyed by the numeric
chain id (7565164), and simulate items describe instructions rather than
events.
const SOLANA = 7565164;
await indexer.process({
chains: {
[SOLANA]: {
simulate: [
{
program: "SplToken",
instruction: "transferChecked",
slot: 445_000_000,
path: [1, 0],
args: { amount: 250_000n, decimals: 6 },
accounts: {
source: { address: SOURCE },
mint: { address: USDC },
destination: { address: DESTINATION },
authority: { address: AUTHORITY },
},
block: { time: 1_800_000_000 },
transaction: { signature: SIGNATURE, transactionIndex: 4 },
},
],
},
},
});
A simulated instruction defaults its data to the configured discriminator
bytes and its path to [0], and only runs when its slot is inside the
configured range. transaction.accountActivities entries are joined onto the
named accounts at process time, so a handler reading
accounts.destination.activity.token.mint is testable without a live endpoint.
To run against real data, pass a pinned slot window instead and let the indexer fetch it from HyperSync:
await indexer.process({
chains: { [SOLANA]: { startBlock: 445_000_000, endBlock: 445_000_060 } },
});
That needs ENVIO_API_TOKEN - without one, POST /query 401s are retried
rather than failing fast, so the run hangs until the test timeout instead of
erroring. Since it hits the real endpoint, assert on shape and invariants
("produced rows", "delta equals post minus pre") rather than exact counts.
Related
- Decoding Instructions - what
argsandaccountscontain. - Configuration: programs, instructions, discriminators, account slots.
- Slot Handlers: the other Solana handler type.
Decoding Instructions
File: solana/decoding.md
Solana instruction data is a packed Borsh byte string with no self-describing structure: unlike an EVM log, there's no ABI travelling with it. HyperIndex needs to know which bytes identify an instruction and how to read the rest, and there are two ways to tell it:
- Point at an IDL. The usual path, and nothing else on this page applies.
- Declare the layout yourself. For programs with no IDL - most native ones.
Point at an IDL
Give the program an IDL JSON file (relative to config.yaml). Anchor 0.30+,
legacy Anchor, Shank and Codama IDLs all work through the same path:
programs:
- name: Jupiter
program_id: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
idl: ./idls/jupiter.json
That's the whole configuration. Every usable instruction the IDL declares is
indexable - names, discriminators, argument layouts, ordered account names
(including nested account groups) and the IDL's types registry all come from
the file - so you select what you index by name in
onInstruction and read
instruction.args and instruction.accounts typed after codegen.
Add an instructions row only to override or extend the catalog:
- A row whose
namethe IDL declares replaces that instruction, and must spell out bothaccountsandargs. - A row with a new name adds an instruction the IDL didn't declare.
The argument type table below describes what the IDL's types become in TypeScript. Everything else on this page is about programs without an IDL.
Programs without an IDL
For a program with no IDL - most native programs - you declare the pieces an IDL would have carried: which bytes select the instruction, what its arguments look like, and what to call its accounts.
Discriminators
The discriminator is a 0x-prefixed hex prefix of the instruction data, of any
whole number of bytes. Every instruction whose data carries the prefix is
dispatched to it.
- Format: hex only, with the
0xprefix. Base58 and decimal are not accepted (base58 is only forprogram_id). - Native / non-Anchor programs: whatever leading byte(s) the program uses (SPL Token
transferis0x03; Raydium AMM v4swapis0x09). - Anchor programs: the 8-byte Anchor sighash, which an
idlcarries for you. - The whole program:
"0x"- the empty prefix, carried by every call.
instructions:
- name: swap
discriminator: "0x09" # 1-byte native
- name: sharedAccountsRoute
discriminator: "0xc1209b3341d69c81" # 8-byte Anchor sighash
instruction.discriminator in a handler reads back the same string you wrote in
config.yaml, so a config value and a handler comparison always match.
Dispatch is by prefix, not by exclusive match, so an entry whose prefix a call
carries always receives it. A program-wide "0x" entry fires alongside a keyed
one, and two entries may deliberately share a prefix - for example the layouts
before and after a program upgrade. Each decodes with its own args, and one
whose layout rejects the data is skipped for that call.
Declaring the layout
args is the Borsh argument list in order (after the discriminator); accounts
names the positional account slots.
programs:
- name: Raydium
program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8
instructions:
- name: swap
discriminator: "0x09"
accounts: # positional: slot 0 is tokenProgram, etc.
- tokenProgram
- amm
- userSourceTokenAccount
- userDestTokenAccount
args:
- { name: amountIn, type: u64 }
- { name: minAmountOut, type: u64 }
Account slots also accept ?optional (the key is absent when the call omits the
slot or fills it with the program id) and _ (holds a position without naming
it). See account slots.
For native programs the account order is the program's canonical layout, not
something HyperIndex can verify. Check it against a real transaction. Accounts
beyond your named list still arrive, positionally, in
instruction.accountArguments.
args is also a filter
Setting args attaches a decoder, and a call whose data the layout rejects is
skipped rather than delivered undecoded. That makes the three states
meaningfully different:
args | Behavior |
|---|---|
| omitted | No decoder at all. Every matched call is indexed and the payload stays raw, reachable as instruction.data. |
[] | The instruction takes no arguments, so only calls carrying nothing past the discriminator are indexed. |
| a list | Only calls whose data decodes cleanly against the layout are indexed. |
An idl always declares the layout of the instructions it names, empty included.
Supported argument types
These are the types you can write in args, and also what HyperIndex
understands from an IDL. The right column is the TypeScript type each value has
in instruction.args after codegen.
type | Type in instruction.args |
|---|---|
bool | boolean |
u8 u16 u32, i8 i16 i32, f32 f64 | number |
u64 u128 i64 i128 | bigint |
string | string |
pubkey (alias publicKey) | string (base58) |
bytes | Uint8Array |
{ option: <type> } | the value, or null |
{ vec: <type> } | readonly T[] - but vec<u8> is a Uint8Array |
{ array: [<type>, <len>] } | a readonly N-tuple - but [u8, N] is a Uint8Array |
{ struct: [ {name,type}, … ] } | object |
{ enum: [ {name, fields?}, … ] } | a variant name as a string literal, or { VariantName: { …fields } } for a variant with fields |
args:
- { name: amount, type: u64 } # bigint
- { name: authority, type: pubkey } # base58 string
- { name: maybeOwner, type: { option: pubkey } } # string | null
- { name: seedHash, type: { array: [u8, 32] } } # Uint8Array
- { name: side, type: { enum: [{ name: Bid }, { name: Ask }] } } # "Bid" | "Ask"
Nominal types are declared inline at the field that uses them. There's no way to
name one in YAML and refer to it elsewhere - attach an idl to the program when
its types are shared between instructions.
Two things worth knowing about the decoded output:
- Byte arrays are
Uint8Array.bytes,vec<u8>and[u8; N]all decode toUint8Array, so a 32-byte hash doesn't come back as a base58 string. - Address lookup tables need no configuration. ALT-resolved addresses arrive in the instruction's account list and are mapped positionally like any other.
Related
- Configuration: where
idl,discriminator,argsandaccountslive. - Instruction Handlers: using
argsandaccountsin handlers.
Slot Handlers
File: solana/slot-handlers.md
indexer.onSlot runs logic on every slot or at an interval, the Solana
equivalent of EVM block handlers. Use it for
time-series snapshots, periodic aggregations, or pulling extra data from RPC on a
schedule with the Effect API. For indexing program
activity, reach for instruction handlers
instead.
indexer.onSlot({ name: "MySlotHandler" }, async ({ slot, context }) => {
context.log.info(`Processing slot ${slot}`);
});
Slot handlers self-register: they need no entry in config.yaml beyond the
chain itself. With no where, the handler runs on every slot.
Options
indexer.onSlot(options, handler):
name(required): unique name, used for logging, metrics, and progress tracking.where(optional):({ chain }) => false | true | { slot: { _gte?, _lte?, _every? } }. Evaluated once per chain at registration to decide which chains the handler runs on and over which slot range/interval.
indexer.onSlot(
{
name: "SlotSampler",
where: ({ chain }) =>
chain.id === 7565164
? {
slot: {
_gte: 385_453_000, // start slot (inclusive)
_lte: 385_500_000, // end slot (inclusive)
_every: 100, // every 100th slot
},
}
: false,
},
async ({ slot, context }) => {
context.SlotPing.set({ id: slot.toString(), slot });
},
);
onBlock- The handler argument is
{ slot: number, context }: a plain slot number, not ablockobject. - The filter key is
slot(with_gte/_lte/_every), notblock.number. - There's no
intervaloption; express intervals with_every._everyaligns to_gte(or the chain start): it fires when(slot − _gte) % _every === 0.
The handler
The handler receives { slot, context }:
slot: the slot number being processed (a plainnumber).context: entity operations (one object perschema.graphqlentity, withget/getOrThrow/getWhere/getOrCreate/set/deleteUnsafe), pluscontext.log,context.effect,context.chain(idis7565164on Solana mainnet), andcontext.isPreload. This is the same context as EVM handlers; see the Event Handlers context.
Enriching with RPC data via Effects
A slot number alone is rarely enough, so pair onSlot with an
Effect to fetch block/transaction/account data from
RPC. Effects are deduplicated and cached, and can be rate-limited so you don't
exhaust your RPC provider. S (from envio) builds the input/output schemas
(it's the Sury library).
const blockSchema = S.schema({
blockhash: S.string,
blockHeight: S.nullable(S.number),
blockTime: S.nullable(S.number),
});
const getBlock = createEffect(
{
name: "getBlock",
input: { slot: S.number },
output: S.nullable(blockSchema),
rateLimit: { calls: 3, per: "second" },
},
async ({ input }) => {
const res = await fetch(process.env.ENVIO_MAINNET_RPC_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getBlock",
params: [input.slot, { maxSupportedTransactionVersion: 1, transactionDetails: "none" }],
}),
});
const { result } = await res.json();
return result ?? null;
},
);
indexer.onSlot({ name: "BlockTracker" }, async ({ slot, context }) => {
const block = await context.effect(getBlock, { slot });
if (!block) {
context.log.info(`Slot ${slot} has no block (skipped leader)`);
return; // some slots produce no block
}
context.BlockInfo.set({
id: slot.toString(),
hash: block.blockhash,
height: block.blockHeight ?? undefined,
time: block.blockTime ? new Date(block.blockTime * 1000) : undefined,
});
});
On Solana a slot may be skipped (the leader produced no block). Handle the empty
result rather than assuming getBlock always returns data.
Preload double-run
Like all V3 handlers, slot handlers run twice (a parallel preload pass to warm the
cache, then the ordered pass). Effects are cached across both runs, so reads are
cheap, but guard non-idempotent side effects with if (context.isPreload) return;.
See Preload Optimization.
Slot handlers vs instruction handlers
Reach for instruction handlers to index what programs did, and slot handlers to do something on a cadence. They compose: an instruction handler records activity, and a slot handler rolls it into periodic snapshots. The overview compares the two side by side. For raw, low-level data over large ranges, query HyperSync for Solana directly.
Related
- Instruction Handlers: the main Solana handler type.
- Effect API: external/RPC calls, caching, rate limiting.
- Configuration: chains, RPC, programs, and start slot.
Solana Configuration File
File: solana/configuration.md
A Solana indexer is defined by a config.yaml with ecosystem: svm. It tells
HyperIndex which chain to read, which programs and instructions to match, and how
to decode them. This page is the field-by-field reference; for the meaning of
discriminators, IDLs and argument types see Decoding Instructions.
Add this line at the top of the file for editor autocompletion and validation:
# yaml-language-server: $schema=./node_modules/envio/svm.schema.json
A complete example
# yaml-language-server: $schema=./node_modules/envio/svm.schema.json
name: my-solana-indexer
description: Index Jupiter swaps and SPL Token transfers
ecosystem: svm
chains:
- id: solana
start_slot: 437000000 # a SLOT number, not a block number
programs:
# --- decoded from an IDL ---
- name: Jupiter
program_id: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
idl: ./idls/jupiter.json # every usable instruction of the IDL is indexable
# --- decoded from an inline layout (no IDL) ---
- name: SplToken
program_id: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
instructions:
- name: transfer
discriminator: "0x03"
accounts: [source, destination, authority]
args:
- name: amount
type: u64
Handlers then select what they index and which fields they read - see Instruction Handlers.
Top-level fields
| Field | Required | Default | Notes |
|---|---|---|---|
name | ✅ | - | Project name. |
ecosystem | ✅ | - | Must be svm. |
chains | ✅ | - | One or more Solana clusters to index (see below). |
programs | - | - | Programs to index. Declared once for the whole project, not per chain. |
description | - | - | Free-text description. |
schema | - | schema.graphql | Path to your GraphQL schema. |
handlers | - | src/handlers | Directory that handler files are auto-loaded from. |
full_batch_size | - | 5000 | Target number of instructions processed per batch. |
storage | - | postgres: true | Storage backends (postgres, clickhouse). |
disable_default_cross_chain | - | false | Make entities and effect caches per-chain instead of shared. |
For Solana, several EVM top-level fields don't apply: contracts,
rollback_on_reorg, save_full_history, raw_events, field_selection,
address_format and bytes_type. Reorgs are handled automatically on the
HyperSync source (it rolls back on reorg), field selection is per handler (the
fields option),
addresses are base58, and Bytes is always a Uint8Array.
chains
Each entry is one Solana cluster.
| Field | Required | Default | Notes |
|---|---|---|---|
id | ✅ | - | The cluster: the label solana (7565164), solana-devnet (7565165), or an explicit number for another cluster. SVM has no native numeric chain id, so Envio assigns the label ids. |
start_slot | ✅ | - | The slot to start indexing from, or latest to start from the current head (see below). |
end_slot | - | - | Stop at this slot. Useful for finite backfills. |
block_lag | - | - | Stay this many slots behind the head. |
hypersync_config | - | the chain id's public endpoint | Block containing url. Optional for solana and solana-devnet; required for any other chain id. |
rpc | - | - | Accepted but unused: instruction sync is served by HyperSync. |
skip | - | false | Exclude the chain from indexing and migrations. |
chains:
- id: solana
start_slot: 437000000
- id: solana-devnet
start_slot: latest
EVM chains use the public numeric chain ID. Solana clusters have no such id, so
you write solana or solana-devnet and HyperIndex maps them to 7565164 /
7565165 - the values you see as context.chain.id in handlers and as the key
in test indexer chain
overrides. Before v3.8 the Solana chain id was 0.
Starting from the head
start_slot: latest resolves the current head once, when the indexer is first
deployed, and persists the concrete slot. A normal resume (a crash or a process
restart) picks up from the stored slot, so downtime is backfilled rather than
skipped; envio dev/envio start with -r re-resolves it against the head at
that time.
Choosing an endpoint and a start slot
The endpoint follows the chain id, so the only real decision is the start slot:
| Chain | Chain id | Endpoint |
|---|---|---|
| Mainnet | solana (7565164) | https://solana.hypersync.xyz |
| Devnet | solana-devnet (7565165) | https://solana-devnet.hypersync.xyz |
Each endpoint serves history back to its earliest indexed slot, not to genesis -
mainnet is around slot 403,000,000 as of September 2026, and we keep
extending it backwards. GET <endpoint>/height returns the current head.
start_slot before the earliest indexed slot doesn't errorThe indexer starts from the earliest indexed slot instead, so a backfill can
look healthy while silently skipping every slot before that. To test a candidate
slot, send a one-slot bounded query and compare next_slot to from_slot:
equal means the slot isn't indexed yet, from_slot + 1 means served (see the
HyperSync curl examples). Need history
further back? Tell us on Discord.
programs
Programs are defined once for the whole project; program_id says where each one
lives on every chain.
| Field | Required | Default | Notes |
|---|---|---|---|
name | ✅ | - | A unique name you choose. Used in handlers (onInstruction({ program: "<name>" })) and in generated types. |
program_id | ✅ | - | Base58 program address. A single value is allowed only when the config defines one chain; with several, give a mapping keyed by chain id, naming every one of them and writing _ for a chain the program isn't deployed on. |
idl | - | - | Path to an IDL JSON (Anchor 0.30+, legacy Anchor, Shank or Codama), relative to config.yaml. Every usable instruction becomes indexable, and onInstruction selects by name. |
instructions | - | - | Instructions to index. Required when there's no idl; with an idl, use it only to override or add instructions (see below). |
programs:
- name: SplToken
program_id:
solana: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
solana-devnet: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
- name: MyProgram
program_id:
solana: MyPr0gram11111111111111111111111111111111111
solana-devnet: _ # not deployed there
instructions
Each entry declares one instruction of the program.
| Field | Required | Default | Notes |
|---|---|---|---|
name | ✅ | - | The instruction name, unique per program. It's the key in onInstruction({ instruction: "<name>" }) and in the generated types. |
discriminator | ✅ | - | 0x-prefixed hex prefix of the instruction data to dispatch on, of any whole number of bytes; an 8-byte value is the standard Anchor discriminator. The empty prefix "0x" matches every instruction of the program. See discriminators. |
accounts | - | - | Positional account slots, in the order the program expects them. The Nth entry names slot N and surfaces it as instruction.accounts.<name>. |
args | - | - | Borsh argument layout. Setting it attaches a decoder that also filters - see args is also a filter. |
With an idl, both accounts and args are required on a row whose name the
IDL declares (that row replaces the IDL's version of it); a row with a new name
adds an instruction the IDL didn't declare.
instructions:
- name: transferChecked
discriminator: "0x0c"
accounts: [source, mint, destination, authority]
args:
- name: amount
type: u64
- name: decimals
type: u8
Account slots
Entries in accounts are positional, and three forms are accepted:
| Form | Meaning |
|---|---|
payer | A slot the call always carries. Surfaces as instruction.accounts.payer. |
?authority | An optional slot. The key is absent from instruction.accounts when the call omits the slot or fills it with the program id. |
_ | Holds a position without naming it, so the slots after it keep theirs. Never surfaced, never filterable, and the list may not end with one. |
Naming a slot is what makes it filterable from a handler's
where.accounts;
the raw positional addresses are available either way through
instruction.accountArguments.
Choosing what to index
Solana's highest-frequency programs (SPL Token, System) produce enormous volumes
of instructions, and matching them unfiltered can swamp a backfill. Narrow a
registration with a
where
filter on account keys, or read value flow from
account activity
on a protocol instruction instead of indexing every transfer.
Related
- Decoding Instructions: IDLs, discriminators, inline layouts, argument types.
- Instruction Handlers: what arrives in the handler, field selection, and filters.
- Schema file: defining the entities you write to (same as EVM).
EVM vs Solana
File: solana/evm-vs-solana.md
The HyperIndex workflow is the same on both: define a config.yaml and
schema.graphql, write handlers, run codegen, then dev/start. What changes
is the unit of work: EVM indexes events emitted by contracts, Solana
indexes instructions executed by programs. This page collects every
difference in one place.
Concept mapping
| EVM | Solana |
|---|---|
| Smart contract | Program |
| ABI | IDL (Anchor, Shank, Codama) or a declared layout |
| Event / log | Instruction |
indexer.onEvent | indexer.onInstruction |
event.params.<name> | instruction.args.<name> |
| Event signature / topic0 | Instruction discriminator |
| Indexed event args | Named account slots, filtered with the handler's where.accounts |
indexer.onBlock | indexer.onSlot |
| Block number | Slot number |
Address 0x… (20 bytes, hex) | Pubkey (32 bytes, base58) |
| Internal calls (not surfaced) | Inner instructions / CPIs (first-class) |
bigint params | u64+ args as bigint, smaller ints as number |
Config differences
| EVM | Solana | |
|---|---|---|
ecosystem | evm (default) | svm |
| What you list | contracts (with abi_file_path, address, events) | top-level programs (with program_id, optional idl, instructions) |
| Chain identity | chains[].id (public chain ID) | chains[].id is a cluster label: solana (7565164) or solana-devnet (7565165) |
| Start of indexing | start_block (a block number, or latest) | start_slot (a slot number, or latest) |
| Matching key | event signature (from ABI) | discriminator (a hex data prefix of any byte length) |
| Decoding source | ABI | an IDL (Anchor, Shank, Codama) or an inline args + accounts layout |
| Field selection | the handler's fields option, or field_selection in config.yaml | the handler's fields option only, with the extra instruction, accountActivity and log knobs |
| Data source config | HyperSync endpoint inferred from chains[].id | inferred from chains[].id too; set hypersync_config.url to pin a specific one |
| Reorg options | rollback_on_reorg, save_full_history | handled automatically on the HyperSync source (rolls back on reorg); the RPC source is finalized-only |
chains, not networksCurrent HyperIndex uses chains for both EVM and Solana. (V2 used networks
for EVM; if you see that in an old guide, it's the same idea.)
Handler differences
EVM:
indexer.onEvent(
{ contract: "ERC20", event: "Transfer" },
async ({ event, context }) => {
const from = event.params.from; // decoded by ABI
const amount = event.params.value; // bigint
const chainId = event.chainId;
const contract = event.srcAddress; // 0x… hex
},
);
Solana:
const fields = {
instruction: ["args", "accounts", "isInner", "programId"],
transaction: ["signature"],
} as const;
indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute", fields },
async ({ instruction, context }) => {
const inAmount = instruction.args.inAmount; // bigint, decoded from the u64
const sourceMint = instruction.accounts.sourceMint.address; // base58
const programId = instruction.programId; // base58
const slot = instruction.block.slot;
const txSig = instruction.transaction.signature; // needs fields.transaction
const isInner = instruction.isInner; // CPI?
},
);
Key handler-level differences:
- A failed decode never reaches the handler. EVM
event.paramsis always populated (the ABI is known); on Solana an instruction whose data the configured layout rejects is skipped, soargsandaccountsneed no null checks. - Accounts are objects, not strings.
instruction.accounts.<name>is{ address, accountName, instructionAccountIndex, activity }- read.addressfor the pubkey. - Addresses are base58. No
0xlowercase/checksum concerns; pubkeys are base58 strings. - Every field is opt-in. Instruction, transaction, account-activity, block and log fields all come from the registration's
fields; on EVM the event always carries some block context. - The chain id is a big number.
context.chain.id === 7565164on mainnet (7565165on devnet).
CPIs vs internal calls
On EVM, a contract calling another contract doesn't emit a separate indexable
event for the internal call. On Solana, cross-program invocations are real
instructions: if you index the inner program, you receive them, with
isInner: true and a path that reconstructs the call tree.
This makes Solana's composability directly indexable (e.g. the Raydium/Orca swaps
underneath a Jupiter route). See
Inner instructions.
Account activity
Solana instructions can carry pre/post lamport and SPL Token balances for the
accounts a transaction touched (fields.accountActivity). This gives you net
value movement (the balance change) without indexing every transfer
instruction; there's no direct EVM equivalent built into the event. The amounts
are bigint raw base units, and each token entry carries the mint's decimals.
See Account activity.
Slots vs blocks
start_slotis a slot, and history doesn't reach genesis yet - see choosing a start slot.- Some slots have no block (skipped leader). Slot handlers must handle the empty case.
- The handler arg for
onSlotis{ slot: number }, not ablockobject.
Not supported on Solana (yet)
Contract import, dynamic registration, wildcard indexing across programs, account-change and log handlers, and ReScript codegen have no Solana equivalent today. The overview lists each one and what to reach for instead.
What's the same: the schema file, the entity
context API, the Effect API, local Docker dev,
the GraphQL/Hasura layer, preload optimization,
and Envio Cloud deployment.
Related
- Solana Overview
- Configuration
- Instruction Handlers
- Decoding Instructions
Indexing on Fuel Network
File: fuel/fuel.md
Introduction
Envio supports the Fuel Network on mainnet and testnet. This page shows how to use HyperIndex with Fuel’s architecture and features.
Fuel offers several advantages as a modular execution layer including:
- Parallel transaction execution
- State-minimized design
- UTXO-based architecture
- Advanced FuelVM capabilities
HyperIndex for Fuel
HyperIndex enables developers to easily index and query real-time and historical data on Fuel Network with the same powerful features available for EVM chains.
Getting Started with Fuel Indexing
You can start indexing Fuel contracts in two ways:
-
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
chains:
- id: 0 # Fuel testnet
start_block: 1
contracts:
- name: SwayContract
abi_file_path: "./abis/SwayContract.json"
address: "0x123..."
events:
- name: NewGreeting
logId: "8500535089865083573"
The logId is a unique identifier for the logged struct, which you can find in your contract's ABI file.
Auto-detection of logId:
If your event name matches the logged struct name in Sway, you can omit the logId:
events:
- name: NewGreeting # Will automatically detect logId if it matches the struct name
Tip: Instead of manually configuring events, use the Contract Import tool which automatically detects events and generates the proper configuration.
Additional Fuel Event Types
Fuel allows indexing several additional receipt types not available in EVM:
| Event 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