Skip to main content

Migrate from Ponder to HyperIndex

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 Getting Started guide first.

Why Migrate to HyperIndex?

  • Up to 1000x faster historical sync via HyperSync
  • Multichain by default — index any number of chains in one config
  • No infrastructure to manage — deploy with envio deploy
  • Same language — your TypeScript logic transfers directly

Migration Overview

Migration has three steps:

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

At any point, run:

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

Step 0: Bootstrap the Project

pnpx envio init

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


Step 1: ponder.config.tsconfig.yaml

Ponder

import { createConfig } from "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
handler: ./src/EventHandlers.ts
events:
- event: Transfer
- event: Approval

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

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

Key differences:

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

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

Field selection — accessing transaction and block fields

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

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

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

name: my-indexer

field_selection:
transaction_fields:
- hash

contracts:
# ...

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


Step 2: ponder.schema.tsschema.graphql

Ponder

import { onchainTable, primaryKey, index } from "ponder";

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

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

HyperIndex

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

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

Type mapping:

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

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

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

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

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

See the full Schema docs.

Step 3: Event Handlers

Handler registration

Ponder

import { ponder } from "ponder:registry";

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

HyperIndex

import { MyToken } from "generated";

MyToken.Transfer.handler(async ({ event, context }) => {
// ...
});

Event data access

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

Entity operations

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

Full handler example

Ponder

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

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

HyperIndex

import { MyToken } from "generated";

MyToken.Transfer.handler(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:

import { MyFactory } from "generated";

// Registers each newly deployed contract for indexing
MyFactory.ContractCreated.contractRegister(({ event, context }) => {
context.addMyContract(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:

import { createEffect, S } from "envio";

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