Contracts & networks
To add a new contract to your app, use the contracts
field in ponder.config.ts
. For each contract you add, the sync engine will fetch raw blockchain data and pass that data to the indexing functions you write.
This guide explains how each contract configuration field works, and suggests patterns for common use cases. See also the ponder.config.ts
API reference page.
Contract name
Every contract must have a unique name, provided as a key to the contracts
object.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
},
},
});
ABI
Each contract must have an ABI.
The config uses ABIType to offer autocomplete and type checking. All ABIs should be saved in .ts
files and include an as const
assertion. For more information, please reference the ABIType documentation.
export const BlitmapAbi = [
{ inputs: [], stateMutability: "nonpayable", type: "constructor" },
{
inputs: [{ internalType: "address", name: "owner", type: "address" }],
name: "balanceOf",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
// ...
] as const;
import { createConfig } from "@ponder/core";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
// ...
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
},
},
});
Multiple ABIs
It's occasionally useful to provide multiple ABIs for one contract, like when defining a proxy/upgradable contract that has gone through multiple implementation contracts. The mergeAbis
utility function safely removes duplicate ABI items and maintains strict types.
import { createConfig, mergeAbis } from "@ponder/core";
import { http } from "viem";
import { ERC1967ProxyAbi } from "./abis/ERC1967Proxy";
import { NameRegistryAbi } from "./abis/NameRegistry";
import { NameRegistry2Abi } from "./abis/NameRegistry2";
export default createConfig({
networks: {
goerli: { chainId: 5, transport: http(process.env.PONDER_RPC_URL_5) },
},
contracts: {
FarcasterNameRegistry: {
abi: mergeAbis([ERC1967ProxyAbi, NameRegistryAbi, NameRegistry2Abi]),
network: "goerli",
address: "0xe3Be01D99bAa8dB9905b33a3cA391238234B79D1",
startBlock: 7648795,
},
},
});
Network
Single network
If the contract is only deployed to one network, just pass the network name as a string to the network
field.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
networks: {
mainnet: {
chainId: 1,
transport: http(process.env.PONDER_RPC_URL_1),
},
},
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04...D3Ff63",
startBlock: 12439123,
},
},
});
Multiple networks
If you'd like to index the same contract (having the same ABI) across multiple networks, pass an object to the network
field containing network-specific options.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { UniswapV3FactoryAbi } from "./abis/UniswapV3Factory";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
base: { chainId: 8453, transport: http(process.env.PONDER_RPC_URL_8453) },
},
contracts: {
UniswapV3Factory: {
abi: UniswapV3FactoryAbi,
network: {
mainnet: {
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
startBlock: 12369621,
},
base: {
address: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
startBlock: 1371680,
},
},
},
},
});
Now, the indexing functions you write for UniswapV3Factory
will process events from both mainnet and Base.
The event
and context
objects are still strictly typed according to the configuration you provide. The context.network
object contains information about which network the current event is from.
import { ponder } from "@/generated";
ponder.on("UniswapV3Factory:Ownership", async ({ event, context }) => {
context.network;
// ^? { name: "mainnet", chainId 1 } | { name: "base", chainId 8453 }
event.log.address;
// ^? "0x1F98431c8aD98523631AE4a59f267346ea31F984" | "0x33128a8fC17869897dcE68Ed026d694621f6FDfD"
if (context.network.name === "mainnet") {
// Do mainnet-specific stuff!
}
});
Network override logic
Network-specific configuration uses an override pattern. Any options defined at the top level are the default, and the network-specific objects override those defaults. All fields other than abi
can be specified on a per-network basis, including address
, factory
, filter
, startBlock
, and endBlock
.
For example, the Uniswap V3 factory contract is deployed to the same address on most chains, but has a different address on Base.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { UniswapV3FactoryAbi } from "./abis/EntryPoint";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
optimism: { chainId: 10, transport: http(process.env.PONDER_RPC_URL_10) },
base: { chainId: 8453, transport: http(process.env.PONDER_RPC_URL_8453) },
},
contracts: {
UniswapV3Factory: {
abi: UniswapV3FactoryAbi,
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
network: {
// No network-specific address provided for mainnet and Optimism.
// The default address will be used ("0x1F98431c8aD98523631AE4a59f267346ea31F984").
mainnet: { startBlock: 12369621 },
optimism: { startBlock: 0 },
// The network-specific address will be used ("0x33128a8fC17869897dcE68Ed026d694621f6FDfD").
base: {
address: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
startBlock: 1371680,
},
},
},
},
});
On the other hand, the ERC-4337 Entry Point contract is deployed to the same address on all networks, so you could define the address
field at the top level.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { EntryPointAbi } from "./abis/EntryPoint";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
optimism: { chainId: 10, transport: http(process.env.PONDER_RPC_URL_10) },
},
contracts: {
EntryPoint: {
abi: EntryPointAbi,
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
network: {
mainnet: { startBlock: 12369621 },
optimism: { startBlock: 88234528 },
},
},
},
});
Address
Single address
The simplest (and most common) option is to pass a single static address.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
},
},
});
Multiple addresses
The address
field also accepts a list of contract addresses.
This option can be used to index multiple contracts with known addresses that have the same ABI (or share an interface, like ERC721) using the same indexing functions.
When using this option, startBlock
is shared across all addresses. It's
often best to use the earliest deployment block among them.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { ERC721Abi } from "./abis/ERC721";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
NiceJpegs: {
abi: ERC721Abi,
network: "mainnet",
address: [
"0x4E1f41613c9084FdB9E34E11fAE9412427480e56", // Terraforms
"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", // BAYC
"0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e", // Doodles
"0x0000000000664ceffed39244a8312bD895470803", // !fundrop
],
startBlock: 12439123,
},
},
});
Factory contracts
The factory
field specifies a set of contracts that are created by a factory. The factory
and address
fields are mutually exclusive.
import { createConfig } from "@ponder/core";
import { parseAbiItem } from "viem";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
SudoswapPool: {
abi: SudoswapPoolAbi,
network: "mainnet",
factory: {
// The address of the factory contract that creates instances of this child contract.
address: "0xb16c1342E617A5B6E4b631EB114483FDB289c0A4",
// The event emitted by the factory that announces a new instance of this child contract.
event: parseAbiItem("event NewPair(address poolAddress)"),
// The name of the parameter that contains the address of the new child contract.
parameter: "poolAddress",
},
startBlock: 14645816,
},
},
});
Now, the indexing functions you write for SudoswapPool
will process events emitted by all child contracts that are created by the specified factory. The event.log.address
field contains the address of the child contract that emitted the current event.
import { ponder } from "@/generated";
ponder.on("SudoswapPool:Transfer", async ({ event }) => {
// This is the address of the child contract that emitted the event.
event.log.address;
// ^? string
});
To run an indexing function whenever a child contract is created (but before any of its events are indexed), add a new contract to your config that uses the factory contract in the address
field.
import { createConfig } from "@ponder/core";
import { parseAbiItem } from "viem";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
SudoswapFactory: {
abi: SudoswapFactoryAbi,
network: "mainnet",
address: "0xb16c1342E617A5B6E4b631EB114483FDB289c0A4",
startBlock: 14645816,
},
SudoswapPool: {
abi: SudoswapPoolAbi,
network: "mainnet",
factory: {
address: "0xb16c1342E617A5B6E4b631EB114483FDB289c0A4",
event: parseAbiItem("event NewPair(address poolAddress)"),
parameter: "poolAddress",
},
startBlock: 14645816,
},
},
});
import { ponder } from "@/generated";
// This function will run whenever a new child contract is created.
ponder.on("SudoswapFactory:NewPair", async ({ event }) => {
// Address of the child contract that was created.
event.args.poolAddress;
// ^? string
});
ponder.on("SudoswapPool:Transfer", async ({ event }) => {
// Address of the child contract that emitted the event.
event.log.address;
// ^? string
});
Multiple factory contracts create the same child contract
The factory address
field also accepts a list of factory contract addresses. Use this option if there are multiple factory contracts on the same network that have the same ABI, factory event signature, and create the same kind of child contract.
import { createConfig } from "@ponder/core";
import { parseAbiItem } from "viem";
export default createConfig({
// ...
contracts: {
SudoswapPool: {
abi: SudoswapPoolAbi,
network: "mainnet",
factory: {
// A list of factory contract addresses that all create SudoswapPool contracts.
address: [
"0xb16c1342E617A5B6E4b631EB114483FDB289c0A4",
"0xb16c1342E617A5B6E4b631EB114483FDB289c0A4"
],
event: parseAbiItem("event NewPair(address poolAddress)"),
parameter: "poolAddress",
},
},
},
});
Factory contract requirements & limitations
- Event signature requirements: The factory contract must emit an event log announcing the creation of each new child contract that contains the new child contract address as a named parameter (with type
"address"
). The parameter can be either indexed or non-indexed. Here are a few factory event signatures with their eligibility explained:
// âś… Eligible. The parameter "child" has type "address" and is non-indexed.
event ChildContractCreated(address child);
// âś… Eligible. The parameter "pool" has type "address" and is indexed.
event PoolCreated(address indexed deployer, address indexed pool, uint256 fee);
// ❌ Ineligible. The parameter "contracts" is an array type, which is not supported.
// Always emit a separate event for each child contract, even if they are created in a batch.
event ContractsCreated(address[] contracts);
// ❌ Ineligible. The parameter "child" is a struct/tuple, which is not supported.
struct ChildContract {
address addr;
}
event ChildCreated(ChildContract child);
-
Nested factory patterns: The sync engine doesn't support factory patterns that are nested beyond a single layer.
-
Scaling: As of
0.5.9
, the sync engine supports any number of child contracts. If a factory contract has more than 1,000 children, the sync engine omits theaddress
argument when callingeth_getLogs
ortrace_filter
and filters the result client-side. This can cause slow sync performance for very large factories.
Proxy & upgradable contracts
To index a proxy/upgradable contract, use the proxy contract address in the address
field. Then, be sure to include the ABIs of all implementation contracts that the proxy has ever had. The implementation ABIs are required to properly identify and decode all historical event logs. To add multiple ABIs safely, use the mergeAbis
utility function.
Tip: On Etherscan, there is a link to the current implementation contract on the Contract → Read as Proxy tab. You can copy all the implementation ABIs as text and paste them into .ts
files.
Event filter
The filter
option filters for events by signature and indexed argument values. This makes it possible to index events emitted by any contract on the network that match the specified signature and arguments.
By event name or signature
The filter.event
option accepts an event name (or list of event names) present in the provided ABI.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { ERC20Abi } from "./abis/ERC20";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
ERC20: {
abi: ERC20Abi,
network: "mainnet",
filter: { event: "Transfer" },
// ^? "Transfer" | "Approval" | ("Transfer" | "Approval")[]
startBlock: 18500000,
endBlock: 18505000,
},
},
});
The indexing functions you write will run for all events matching the filter, regardless of which contract emitted them.
import { ponder } from "@/generated";
ponder.on("ERC20:Transfer", async ({ event }) => {
// This is the address of the contract that emitted the event.
// With this config, it could be any ERC20 contract on mainnet.
event.log.address;
// ^? string
});
By indexed argument value
You can use the filter.event
and filter.args
options together to filter for events that have specific indexed argument values. Each indexed argument field accepts a single value or a list of values to match.
This example filters for all ERC20 Transfer
events where the from
argument matches a specific address, and the to
argument matches one of two addresses.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { ERC20Abi } from "./abis/ERC20";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
ERC20: {
abi: ERC20Abi,
network: "mainnet",
filter: {
event: "Transfer",
args: {
from: "0xa0ee7a142d267c1f36714e4a8f75612f20a79720",
to: [
"0x06012c8cf97bead5deae237070f9587f8e7a266d",
"0x7c40c393dc0f283f318791d746d894ddd3693572",
],
},
},
startBlock: 18500000,
endBlock: 18505000,
},
},
});
The indexing function will run for all events matching the filter.
import { ponder } from "@/generated";
ponder.on("ERC20:Transfer", async ({ event }) => {
// This will always be "0xa0ee7a142d267c1f36714e4a8f75612f20a79720"
event.args.from;
// This will be one of the two addresses above
event.args.to;
});
Call traces
The includeCallTraces
option specifies whether to enable call trace indexing for a contract. By default, call traces are disabled.
After enabling this option, each function in the contract ABI will become available as an indexing function event name. Read more about call traces.
import { createConfig } from "@ponder/core";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
includeCallTraces: true,
},
},
// ...
});
import { ponder } from "@/generated";
ponder.on("Blitmap.mintOriginal()", async ({ event }) => {
event.args;
// ^? [tokenData: Hex, name: string]
event.trace.gasUsed;
// ^? bigint
});
Transaction receipts
The includeTransactionReceipts
option specifies whether to include the transaction receipt. By default, transaction receipts are disabled.
After enabling this option, the event.transactionReceipt
property will become available in all indexing functions for the contract. To see all fields available on the receipt object, read the API reference.
import { createConfig } from "@ponder/core";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
includeTransactionReceipts: true,
},
},
// ...
});
import { ponder } from "@/generated";
ponder.on("Blitmap.mintOriginal()", async ({ event }) => {
event.transactionReceipt.cumulativeGasUsed;
// ^? bigint
event.transactionReceipt.logs;
// ^? Log[]
});
Block range
The optional startBlock
and endBlock
options specify the block range to index.
Option | Default |
---|---|
startBlock | 0 |
endBlock | undefined |
If endBlock
is undefined
, the contract will be indexed in realtime. If endBlock
is defined, no events will be indexed after that block. This option can be useful if you're only interested in a slice of historical data, or to enable faster feedback loops during development where it's not necessary to index the entire history.
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { BlitmapAbi } from "./abis/Blitmap";
export default createConfig({
networks: {
mainnet: { chainId: 1, transport: http(process.env.PONDER_RPC_URL_1) },
},
contracts: {
Blitmap: {
abi: BlitmapAbi,
network: "mainnet",
address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
startBlock: 12439123,
endBlock: 16500000,
},
},
});