Add contracts

Add contracts

To add a new contract to your app, use the contracts field in ponder.config.ts. Ponder fetches raw blockchain data (event logs) for each contract you add, and passes 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.

ponder.config.ts
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.

Ponder uses ABIType to offer autocomplete and strict type checking. This means ABIs must be saved in .ts files and asserted as constant using as const. For more information, please reference the ABIType documentation (opens in a new tab).

abis/Blitmap.ts
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;
ponder.config.ts
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 function exported by @ponder/core safely removes duplicate ABI items and maintains strict types.

ponder.config.ts
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.

ponder.config.ts
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.

ponder.config.ts
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.

src/index.ts
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.

ponder.config.ts
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.

ponder.config.ts
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.

ponder.config.ts
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.

ponder.config.ts
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.

ponder.config.ts
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.

src/index.ts
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
});

Factory contract requirements & limitations

  1. Scaling: Ponder does not currently support factory contracts with more than 10,000 children. This includes Uniswap V2, V3, and many other permissionless pool-based DeFi protocols. Why not? The Ethereum JSON-RPC API does not scale well for factory contracts. There's only so much that Ponder's sync engine can do about this while remaining compatible with the JSON-RPC API. We're actively working to address scaling challenges via features like remote sync, shared caching, and concurrent indexing. Thanks for your patience.
  2. 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);
  1. Nested factory patterns: Ponder doesn't support factory patterns that are nested beyond a single layer.

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.

Etherscan contract proxy address

Event filter

Custom event filters allow you to index events emitted by any contract on the network that match some criteria.

By event name or signature

The filter.event field accepts an event name (or list of event names) present in the provided ABI.

ponder.config.ts
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.

src/index.ts
import { ponder } from "@/generated";
 
ponder.on("ERC20:Transfer", async ({ event }) => {
  // This is the address of the contract that emitted the event.
  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 parameter equals a specific address, and the to parameter equals one of two addresses.

ponder.config.ts
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.

src/index.ts
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;
});

Block range

The optional startBlock and endBlock options specify the block range to index. The default value for startBlock is 0. If you don't provide an endBlock, Ponder will index the contract in realtime and handle chain reorganizations.

Yes, it's annoying to keep track of deployment block numbers. We're working on ways to automatically detect them.

ponder.config.ts
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,
    },
  },
});