Event Handlers
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.
import { indexer } from "envio";
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:
import { indexer, type User } from "envio";
// 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
},
);
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:
Advanced Use Cases
HyperIndex provides many features to help you build more powerful and efficient indexers. There's definitely the one for you:
- Handle Factory Contracts with Dynamic Contract Registration (with nested factories support)
- Perform external calls to decide which contract address to register using Async Contract Register
- Index all ERC20 token transfers with Wildcard Indexing
- Use Topic Filtering to ignore irrelevant events
- With multiple filters for single event
- With different filters per chain
- With filter by dynamicly registered contract addresses (eg Index all ERC20 transfers to/from your Contract)
- Access Contract State directly from handlers
- Perform external calls from handlers by following the IPFS Integration guide
Event Object
Each handler receives an event object containing details about the emitted event, including parameters and blockchain metadata.
Accessing Event Parameters
Event parameters are accessed via:
event.params.<PARAMETER_NAME>
Example usage:
const sender = event.params.sender;
const amount = event.params.amount;
Additional Event Information
The event object also contains additional metadata:
event.chainId– Chain ID of the network emitting the event.event.srcAddress– Contract address emitting the event.event.logIndex– Index of the log within the block.event.block– Block fields (By default:number,timestamp,hash).event.transaction– Transaction fields (eghash,gasUsed, etc. Empty by default).
By default, all addresses returned in the event object, such as event.transaction.from,
event.transaction.to, and event.srcAddress, are EIP-55 checksummed.
Use .toLowerCase() on a single value if you specifically need it in lowercase, or set
address_format: lowercase in config.yaml to switch every address that the indexer surfaces (events, chain.<Contract>.addresses, entity ids, etc.) to lowercase globally.
Configure block and transaction fields with field_selection in your config.yaml file.
Example event type definition:
type Event<Params, TransactionFields, BlockFields> = {
params: Params;
chainId: 1 | 137;
srcAddress: `0x${string}`;
logIndex: number;
transaction: TransactionFields;
block: BlockFields;
};
Context Object
The handler context provides methods to interact with entities stored in the database.
Retrieving Entities
Retrieve entities from the database using context.Entity.get where Entity is the name of the entity you want to retrieve, which is defined in your schema.graphql file.
await context.Entity.get(entityId);
It'll return Entity object or undefined if the entity doesn't exist.
Use context.Entity.getOrThrow to conveniently throw an error if the entity doesn't exist:
const pool = await context.Pool.getOrThrow(poolId);
// Will throw: Entity 'Pool' with ID '...' is expected to exist.
// Or you can pass a custom message as a second argument:
const pool = await context.Pool.getOrThrow(
poolId,
`Pool with ID ${poolId} is expected.`
);
Or use context.Entity.getOrCreate to automatically create an entity with default values if it doesn't exist:
const pool = await context.Pool.getOrCreate({
id: poolId,
totalValueLockedETH: 0n,
});
// Which is equivalent to:
let pool = await context.Pool.get(poolId);
if (!pool) {
pool = {
id: poolId,
totalValueLockedETH: 0n,
};
context.Pool.set(pool);
}
Retrieving Entities by Field
indexer.onEvent(
{ contract: "ERC20", event: "Approval" },
async ({ event, context }) => {
// Find all approvals for this specific owner
const currentOwnerApprovals = await context.Approval.getWhere({
owner_id: { _eq: event.params.owner },
});
// Process all the owner's approvals efficiently
for (const approval of currentOwnerApprovals) {
// Process each approval
}
},
);
You can also use comparison operators like _gt, _gte, _lt, _lte, and _in to filter entities by field value.
Important:
-
Preload Optimization is always enabled in V3 and powers
getWhere. See How Preload Optimization Works. -
Works with any field that:
- Is used in a relationship with the
@derivedFromdirective - Has an
@indexdirective
- Is used in a relationship with the
-
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 Logging 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:
import { indexer, createEffect, S } from "envio";
// 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:
import { indexer } from "envio";
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:
These guides offer detailed recommendations on optimizing entity loading and indexing performance.