Ponder Documentation

repository·main·Indexed 22 days ago

https://github.com/ponder-sh/ponder

An open-source TypeScript framework for high-performance EVM data indexing. Ponder transforms raw blockchain event logs into structured relational data stored in Postgres, accessible via GraphQL or SQL. The ecosystem includes @ponder/client for HTTP SQL queries, @ponder/react for React hook integration, and @ponder/utils for application development. It supports integration with Foundry and Next.js, and provides reference implementations for ERC20, ERC721, ERC1155, and ERC4626 token APIs.

Tokens
198.8K
Snippets
600
Records
767
Agent score
76%

What's inside Ponder

  1. What is Ponder?

    main

    Ponder is an open-source (MIT-licensed) backend framework designed for crypto applications. It allows developers to rapidly build and deploy APIs that serve custom data indexed from smart contracts on any EVM-compatible blockchain.

    Key Features:

    • EVM Support: Natively supports multiple chains; simply add an RPC URL to start processing data from a new chain.
    • TypeScript-First: Uses a JavaScript runtime (Node.js), allowing you to use standard NPM packages, make HTTP requests, and connect to databases.
    • Local Development: Features a powerful local development server with hot reloading and descriptive error logs.
    • High Performance: Optimized for speed and resource efficiency, outperforming traditional indexing solutions like The Graph in several benchmarks (e.g., indexing ERC20 contracts faster from both cold starts and cached states).
    • Developer Experience: Provides thorough type safety and editor autocomplete without requiring a separate codegen or build step.
  2. What is a transaction receipt?

    main

    A transaction receipt is an object containing the post-execution results of a transaction. This includes:

    • Price and amount of gas consumed
    • Revert status
    • Logs emitted
    • Other post-execution metadata

    Note on Transaction Inputs: Ponder automatically includes pre-execution data (like from, to, input, and native transfer amount) at event.transaction. This is distinct from the receipt.

  3. What is a transaction receipt in Ponder

    main

    A transaction receipt is an object containing the post-execution results of a transaction. This includes:

    • Price and amount of gas consumed
    • Revert status
    • Logs emitted
    • Other post-execution data

    In Ponder, transaction inputs (pre-execution data like from, to, input, and native transfer amount) are included automatically at event.transaction and do not require special configuration.

  4. Index contracts across multiple chains

    main

    To index a contract that exists on multiple chains, pass an object to the chain field in the contract configuration. Each entry in the object provides chain-specific overrides for address and startBlock.

    Note: All chain-specific configurations for a single contract must use the same ABI.

    In your indexing functions, you can use context.chain to differentiate logic based on the chain where the event occurred.

    // ponder.config.ts
    export default createConfig({
      chains: {
        mainnet: { id: 1, rpc: process.env.PONDER_RPC_URL_1 },
        base: { id: 8453, rpc: process.env.PONDER_RPC_URL_8453 },
      },
      contracts: {
        UniswapV3Factory: {
          abi: UniswapV3FactoryAbi,
          chain: {
            mainnet: {
              address: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
              startBlock: 12369621,
            },
            base: {
              address: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
              startBlock: 1371680,
            },
          },
        },
      },
    });
    
    // src/index.ts
    ponder.on("UniswapV3Factory:Ownership", async ({ event, context }) => {
      if (context.chain.name === "mainnet") {
        // Do mainnet-specific stuff!
      }
    });
  5. Database schema rules and concurrency

    main

    Ponder enforces strict rules regarding database schema usage to prevent data corruption and loss:

    • Concurrency: No two instances can use the same database schema simultaneously.
    • Production Safety (ponder start): Once a schema has been used by an instance running ponder start, no other instance can use that schema, even after the original instance stops. This is a safety mechanism to prevent accidental data loss in production environments.
    • Development Mode (ponder dev): If a schema was previously used by ponder dev, a new instance will drop the existing tables and start fresh.
    • Build ID Matching: If a schema was used by ponder start and the new build_id (the hash of your app code) does not match the previous one, the instance will error to prevent inconsistent state.
  6. How the in-memory database buffer works

    main

    To prevent database latency from blocking the indexing process, Ponder uses an in-memory buffer for database writes.

    When you call store API methods like insert(), update(), or delete(), Ponder typically adds the row to an in-memory cache instead of performing an immediate, blocking database query. Periodically, Ponder flushes this buffer to Postgres in large batches using the COPY command.

    Key details for developers:

    • Performance: This allows Ponder to handle database latencies of up to ~100ms efficiently.
    • Reads: The buffer can also serve reads (e.g., find() or insert().onConflictDoUpdate()), reducing blocking queries.
    • Trade-offs: This design increases memory usage. Large applications must manage memory carefully to avoid out-of-memory (OOM) errors.
    • Limitations: The in-memory buffer uses a key-value storage model, which imposes restrictions on table definitions. Raw SQL queries do not benefit from these optimizations.
    import { ponder } from "ponder:registry";
    import { allowance, approvalEvent } from "ponder:schema";
    
    ponder.on("ERC20:Approval", async ({ event, context }) => {
      // This write is buffered in-memory and flushed in batches
      await context.db
        .insert(allowance)
        .values({
          spender: event.args.spender,
          owner: event.args.owner,
          amount: event.args.amount,
        })
        .onConflictDoUpdate({ amount: event.args.amount });
    
      await context.db.insert(approvalEvent).values({
        id: event.id,
        amount: event.args.amount,
        timestamp: Number(event.block.timestamp),
        owner: event.args.owner,
        spender: event.args.spender,
      });
    });
  7. Configure network names and chain IDs

    main

    Each network in the networks object must have a unique name. This name is used to reference the network in the network option of your contracts configuration.

    Inside indexing functions, you can access the network name via context.network.name and the chain ID via context.network.chainId.

    import { createConfig } from "ponder";
    import { http } from "viem";
    
    export default createConfig({
      networks: {
        mainnet: {
          chainId: 1,
          transport: http(process.env.PONDER_RPC_URL_1),
        },
      },
      contracts: {
        Blitmap: {
          abi: BlitmapAbi,
          network: "mainnet", // References the key in networks
          address: "0x8d04a8c79cEB0889Bdd12acdF3Fa9D207eD3Ff63",
        },
      },
    });
  8. Use the "setup" event for contract initialization

    main

    The "setup" event allows you to define a function for each contract that runs once before indexing begins. This is useful for creating singleton records (e.g., a World state) to avoid expensive upsert logic in every subsequent indexing function.

    Key characteristics:

    • The function receives context but no event argument.
    • If you read from contracts within a "setup" function, the blockNumber for the request is automatically set to the contract's startBlock.

    Example: Initializing a singleton record

    import { ponder } from "ponder:registry";
    import { world } from "ponder:schema";
    
    // Run once at the start
    ponder.on("FunGame:setup", async ({ context }) => {
      await context.db.insert(world).values({
        id: 1,
        playerCount: 0,
      });
    });
    
    // Use the record in other functions
    ponder.on("FunGame:NewPlayer", async ({ context }) => {
      await context.db
        .update(world, { id: 1 })
        .set((row) => ({ playerCount: row.playerCount + 1 }));
    });
    import { ponder } from "ponder:registry";
    import { world } from "ponder:schema";
    
    ponder.on("FunGame:setup", async ({ context }) => {
      await context.db.insert(world).values({
        id: 1,
        playerCount: 0,
      });
    });
    
    ponder.on("FunGame:NewPlayer", async ({ context }) => {
      await context.db
        .update(world, { id: 1 })
        .set((row) => ({ playerCount: row.playerCount + 1 }));
    });