> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chainstack.com/llms.txt
> Use this file to discover all available pages before exploring further.

<AgentInstructions>

## Submitting Feedback

If you encounter incorrect, outdated, or confusing documentation on this page, submit feedback:

POST https://docs.chainstack.com/feedback

```json
{
  "path": "/docs/ponder-tutorial-building-blockchain-application-backends-with-chainstack",
  "feedback": "Description of the issue"
}
```

Only submit feedback when you have something specific and actionable to report.

</AgentInstructions>

# Ponder: Blockchain indexing backends with Chainstack

> Build a blockchain application backend using Ponder indexing framework with a Chainstack RPC endpoint for real-time smart contract event processing.

**TLDR:**

* You'll build a complete blockchain indexing backend using Ponder and Chainstack nodes.
* You'll create a Ponder project that indexes ERC-20 token transfers and balances across multiple chains.
* You'll configure Ponder to use Chainstack RPC endpoints for reliable data fetching.
* By the end, you'll have a production-ready GraphQL API for querying blockchain data with hot reloading and type safety.

## Main article

In this tutorial, you will:

* Create a Ponder project that indexes blockchain data from multiple networks.
* Configure Ponder to use Chainstack nodes for reliable, high-performance data access.
* Build a schema to track ERC-20 token transfers, balances, and holder statistics.
* Deploy indexing functions that process blockchain events in real-time.
* Query your data through Ponder's auto-generated GraphQL API.

Ponder is an open-source framework for building blockchain application backends. Unlike traditional indexing solutions, Ponder provides end-to-end type safety, hot reloading during development, and automatic GraphQL API generation based on your schema.

## Prerequisites

* [Chainstack account](https://console.chainstack.com/user/login) to deploy blockchain nodes
* [Node.js](https://nodejs.org/) version 18.14 or higher
* [pnpm](https://pnpm.io/installation) package manager (recommended)

## Dependencies

* ponder: ^0.11.0
* viem: ^2.30.0
* typescript: ^5.0.0

## Step-by-step

### Create a public chain project

See [Create a project](/docs/manage-your-project#create-a-project).

### Join blockchain networks

For this tutorial, we'll index data from multiple chains. Join the following networks:

* **Ethereum** - for established DeFi protocols
* **Polygon** - for high-volume, low-cost transactions
* **Base** - for modern L2 applications

See [Join a public network](/docs/manage-your-networks).

### Get your node access and credentials

See [View node access and credentials](/docs/manage-your-node#view-node-access-and-credentials).

You'll need the RPC endpoints for each network you joined.

### Create a new Ponder project

1. Create a new Ponder project using the CLI:

   ```bash theme={"system"}
   pnpm create ponder@latest my-ponder-indexer
   ```

   This will prompt you to choose a template. For this tutorial, select **"ERC-20"** as it provides a good starting point for token indexing.

2. Navigate to your project directory:

   ```bash theme={"system"}
   cd my-ponder-indexer
   ```

3. Install dependencies:

   ```bash theme={"system"}
   pnpm install
   ```

### Configure environment variables

1. Create a `.env.local` file in your project root:

   ```sh theme={"system"}
   # Chainstack RPC endpoints
   PONDER_RPC_URL_1="YOUR_ETHEREUM_CHAINSTACK_ENDPOINT"
   PONDER_RPC_URL_137="YOUR_POLYGON_CHAINSTACK_ENDPOINT"
   PONDER_RPC_URL_8453="YOUR_BASE_CHAINSTACK_ENDPOINT"

   # Optional: Database URL for production
   # DATABASE_URL="postgresql://user:password@localhost:5432/ponder"
   ```

   Replace the placeholder values with your actual Chainstack endpoints.

   <Info>
     ### Environment variable naming

     Ponder uses the pattern `PONDER_RPC_URL_{CHAIN_ID}` for RPC endpoints. The chain IDs are:

     * Ethereum: 1
     * Polygon: 137
     * Base: 8453
   </Info>

### Configure Ponder for multiple chains

1. Edit `ponder.config.ts` to configure your chains and contracts:

   ```ts theme={"system"}
   import { createConfig } from "ponder";
   import { http } from "viem";

   // Import ABIs for the contracts you want to index
   import { erc20Abi } from "viem";

   export default createConfig({
     chains: {
       ethereum: {
         id: 1,
         rpc: http(process.env.PONDER_RPC_URL_1!),
       },
       polygon: {
         id: 137, 
         rpc: http(process.env.PONDER_RPC_URL_137!),
       },
       base: {
         id: 8453,
         rpc: http(process.env.PONDER_RPC_URL_8453!),
       },
     },
     contracts: {
       // USDC on multiple chains
       UsdcMainnet: {
         chain: "ethereum",
         abi: erc20Abi,
         address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
         startBlock: 22842789,
       },
       UsdcPolygon: {
         chain: "polygon", 
         abi: erc20Abi,
         address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
         startBlock: 73549831,
       },
       UsdcBase: {
         chain: "base",
         abi: erc20Abi, 
         address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
         startBlock: 32402921,
       },
     },
   });
   ```

   This configuration sets up Ponder to index USDC transfers across three different chains using your Chainstack nodes.

   <Info>
     ### Start block optimization

     Setting appropriate start blocks is crucial for performance. Choose blocks close to when your contracts were deployed or when interesting activity began.
   </Info>

### Define your database schema

1. Edit `ponder.schema.ts` to define the data structure you want to track:

   ```ts theme={"system"}
   import { onchainTable } from "ponder";

   // Track individual token transfers
   export const transfer = onchainTable("transfer", (t) => ({
     id: t.text().primaryKey(), // tx_hash:log_index
     from: t.text().notNull(),
     to: t.text().notNull(), 
     amount: t.bigint().notNull(),
     contract: t.text().notNull(),
     chain: t.text().notNull(),
     blockNumber: t.bigint().notNull(),
     timestamp: t.integer().notNull(),
   }));

   // Track account balances per contract
   export const balance = onchainTable("balance", (t) => ({
     id: t.text().primaryKey(), // account:contract:chain
     account: t.text().notNull(),
     contract: t.text().notNull(), 
     chain: t.text().notNull(),
     balance: t.bigint().notNull(),
     lastUpdated: t.integer().notNull(),
   }));

   // Track holder statistics per contract
   export const tokenStats = onchainTable("token_stats", (t) => ({
     id: t.text().primaryKey(), // contract:chain
     contract: t.text().notNull(),
     chain: t.text().notNull(),
     totalHolders: t.integer().notNull().default(0),
     totalTransfers: t.bigint().notNull().default(0n),
     totalVolume: t.bigint().notNull().default(0n),
     lastUpdated: t.integer().notNull(),
   }));
   ```

   This schema defines three tables:

   * **transfers**: Individual transfer events
   * **balances**: Current token balances per account
   * **tokenStats**: Aggregate statistics per token contract

### Write indexing functions

1. Create indexing functions to process Transfer events. Replace the contents of `src/index.ts`:

   ```ts theme={"system"}
   import { ponder } from "ponder:registry";
   import schema from "ponder:schema";

   // Helper function to create unique IDs
   function createTransferId(event: any) {
     return `${event.transaction.hash}:${event.log.logIndex}`;
   }

   function createBalanceId(account: string, contract: string, chain: string) {
     return `${account}:${contract}:${chain}`;
   }

   function createStatsId(contract: string, chain: string) {
     return `${contract}:${chain}`;
   }

   // Index USDC transfers on Ethereum
   ponder.on("UsdcMainnet:Transfer", async ({ event, context }) => {
     const { from, to, value } = event.args;
     const transferId = createTransferId(event);
     
     // Insert transfer record
     await context.db.insert(schema.transfer).values({
       id: transferId,
       from,
       to,
       amount: value,
       contract: event.log.address,
       chain: "ethereum",
       blockNumber: event.block.number,
       timestamp: Number(event.block.timestamp),
     });

     // Update balances for both accounts (if not mint/burn)
     const zeroAddress = "0x0000000000000000000000000000000000000000";
     
     if (from !== zeroAddress) {
       await updateBalance(context, from, event.log.address, "ethereum", -value, event.block.timestamp);
     }
     
     if (to !== zeroAddress) {
       await updateBalance(context, to, event.log.address, "ethereum", value, event.block.timestamp);
     }

     // Update token statistics
     await updateTokenStats(context, event.log.address, "ethereum", value, event.block.timestamp);
   });

   // Index USDC transfers on Polygon
   ponder.on("UsdcPolygon:Transfer", async ({ event, context }) => {
     const { from, to, value } = event.args;
     const transferId = createTransferId(event);
     
     await context.db.insert(schema.transfer).values({
       id: transferId,
       from,
       to,
       amount: value,
       contract: event.log.address,
       chain: "polygon",
       blockNumber: event.block.number,
       timestamp: Number(event.block.timestamp),
     });

     const zeroAddress = "0x0000000000000000000000000000000000000000";
     
     if (from !== zeroAddress) {
       await updateBalance(context, from, event.log.address, "polygon", -value, event.block.timestamp);
     }
     
     if (to !== zeroAddress) {
       await updateBalance(context, to, event.log.address, "polygon", value, event.block.timestamp);
     }

     await updateTokenStats(context, event.log.address, "polygon", value, event.block.timestamp);
   });

   // Index USDC transfers on Base
   ponder.on("UsdcBase:Transfer", async ({ event, context }) => {
     const { from, to, value } = event.args;
     const transferId = createTransferId(event);
     
     await context.db.insert(schema.transfer).values({
       id: transferId,
       from,
       to,
       amount: value,
       contract: event.log.address,
       chain: "base", 
       blockNumber: event.block.number,
       timestamp: Number(event.block.timestamp),
     });

     const zeroAddress = "0x0000000000000000000000000000000000000000";
     
     if (from !== zeroAddress) {
       await updateBalance(context, from, event.log.address, "base", -value, event.block.timestamp);
     }
     
     if (to !== zeroAddress) {
       await updateBalance(context, to, event.log.address, "base", value, event.block.timestamp);
     }

     await updateTokenStats(context, event.log.address, "base", value, event.block.timestamp);
   });

   // Helper function to update account balances
   async function updateBalance(
     context: any,
     account: string, 
     contract: string,
     chain: string,
     change: bigint,
     blockTimestamp: bigint
   ) {
     const balanceId = createBalanceId(account, contract, chain);
     
     // Try to get existing balance
     const existingBalance = await context.db.find(schema.balance, {
       id: balanceId
     });

     if (existingBalance) {
       // Update existing balance
       const oldBalance = existingBalance.balance;
       const newBalance = oldBalance + change;
       
       // Handle cases where we're processing historical data from a start block
       // and don't have the complete balance history
       if (newBalance < 0n && change < 0n) {
         // This suggests the account had a balance before our start block
         // Initialize with the absolute value of the change (minimum needed balance)
         const initializedBalance = -change;
         await context.db.update(schema.balance, {
           id: balanceId
         }).set({
           balance: initializedBalance + change, // This will equal initializedBalance - abs(change) = 0
           lastUpdated: Number(blockTimestamp),
         });
         return; // Exit early after handling this case
       } else if (newBalance < 0n) {
         throw new Error(`Balance would become negative for account ${account}. Old: ${oldBalance}, Change: ${change}`);
       }
       
       await context.db.update(schema.balance, {
         id: balanceId
       }).set({
         balance: newBalance,
         lastUpdated: Number(blockTimestamp),
       });

       // Update holder count based on balance changes
       if (oldBalance === 0n && newBalance > 0n) {
         // New holder (balance went from 0 to positive)
         await updateHolderCount(context, contract, chain, 1, blockTimestamp);
       } else if (oldBalance > 0n && newBalance === 0n) {
         // Holder exit (balance went from positive to 0)
         await updateHolderCount(context, contract, chain, -1, blockTimestamp);
       }
     } else {
       // Create new balance record
       if (change < 0n) {
         // Account is sending tokens but has no balance record
         // This means they had tokens before our start block
         // Initialize with the absolute value of the change, then subtract it
         const initializedBalance = -change;
         await context.db.insert(schema.balance).values({
           id: balanceId,
           account,
           contract,
           chain,
           balance: 0n, // After the transfer, balance will be 0
           lastUpdated: Number(blockTimestamp),
         });

         // Account just exited its position – adjust holder count
         await updateHolderCount(context, contract, chain, -1, blockTimestamp);
       } else {
         // Normal case: receiving tokens to a new account
         await context.db.insert(schema.balance).values({
           id: balanceId,
           account,
           contract,
           chain,
           balance: change,
           lastUpdated: Number(blockTimestamp),
         });

         // If this is a new holder with positive balance, increment holder count
         if (change > 0n) {
           await updateHolderCount(context, contract, chain, 1, blockTimestamp);
         }
       }
     }
   }

   // Helper function to update holder count
   async function updateHolderCount(
     context: any,
     contract: string,
     chain: string,
     change: number,
     blockTimestamp: bigint
   ) {
     const statsId = createStatsId(contract, chain);
     
     const existingStats = await context.db.find(schema.tokenStats, {
       id: statsId
     });

     if (existingStats) {
       await context.db.update(schema.tokenStats, {
         id: statsId
       }).set({
         totalHolders: Math.max(0, existingStats.totalHolders + change),
         lastUpdated: Number(blockTimestamp),
       });
     } else {
       // Create new token stats record for the first holder event
       await context.db.insert(schema.tokenStats).values({
         id: statsId,
         contract,
         chain,
         totalHolders: Math.max(0, change),
         totalTransfers: 0n,
         totalVolume: 0n,
         lastUpdated: Number(blockTimestamp),
       });
     }
   }

   // Helper function to update token statistics
   async function updateTokenStats(
     context: any,
     contract: string,
     chain: string, 
     volume: bigint,
     blockTimestamp: bigint
   ) {
     const statsId = createStatsId(contract, chain);
     
     const existingStats = await context.db.find(schema.tokenStats, {
       id: statsId
     });

     if (existingStats) {
       await context.db.update(schema.tokenStats, {
         id: statsId
       }).set({
         totalTransfers: existingStats.totalTransfers + 1n,
         totalVolume: existingStats.totalVolume + volume,
         lastUpdated: Number(blockTimestamp),
       });
     } else {
       await context.db.insert(schema.tokenStats).values({
         id: statsId,
         contract,
         chain,
         totalHolders: 0,
         totalTransfers: 1n,
         totalVolume: volume,
         lastUpdated: Number(blockTimestamp),
       });
     }
   }
   ```

   This indexing logic:

   * Records every transfer event across all three chains
   * Maintains real-time balance tracking for all accounts
   * Handles historical data gaps when starting from an arbitrary block (accounts may have balances from before the start block)
   * Tracks holder counts by detecting when balances go from zero to positive (new holder) or positive to zero (holder exit)
   * Calculates aggregate statistics including transfer volume and holder statistics per token contract

### Start the development server

1. Start the Ponder development server:

   ```bash theme={"system"}
   pnpm dev
   ```

   You should see output similar to:

   ```
   09:14:25.156 INFO  server  Started listening on port 42069
   09:14:25.341 INFO  build   Hot reloaded config for update
   09:14:25.445 INFO  indexing Started indexing from blocks ethereum: 22842789, polygon: 73549831, base: 32402921
   09:14:26.123 INFO  indexing Processed 100 UsdcMainnet:Transfer events (mainnet)
   ```

   <Info>
     ### Development features

     Ponder's development server includes:

     * Hot reloading when you change config, schema, or indexing functions
     * Real-time progress monitoring and error reporting
     * Built-in GraphQL playground at [http://localhost:42069](http://localhost:42069)
   </Info>

2. Open your browser to [http://localhost:42069](http://localhost:42069) to access the GraphQL playground.

### Query your indexed data

1. In the GraphQL playground, try these example queries:

   **Get recent transfers across all chains:**

   ```graphql theme={"system"}
   {
     transfers(
       limit: 10
       orderBy: "timestamp"
       orderDirection: "desc"
     ) {
       items {
         id
         from
         to
         amount
         contract
         chain
         timestamp
       }
     }
   }
   ```

   **Get top holders for a specific token:**

   ```graphql theme={"system"}
   {
     balances(
       where: { chain: "ethereum" }
       limit: 10
       orderBy: "balance"
       orderDirection: "desc"
     ) {
       items {
         account
         balance
         contract
         chain
         lastUpdated
       }
     }
   }
   ```

### Deploy to production

1. For production deployment, you'll need a PostgreSQL database. Update your `.env.local`:

   ```sh theme={"system"}
   # Add your production database URL
   DATABASE_URL="postgresql://user:password@localhost:5432/ponder"

   # Keep your Chainstack endpoints
   PONDER_RPC_URL_1="YOUR_ETHEREUM_CHAINSTACK_ENDPOINT"
   PONDER_RPC_URL_137="YOUR_POLYGON_CHAINSTACK_ENDPOINT" 
   PONDER_RPC_URL_8453="YOUR_BASE_CHAINSTACK_ENDPOINT"
   ```

2. Build your Ponder application:

   ```bash theme={"system"}
   pnpm build
   ```

3. Start the production server:

   ```bash theme={"system"}
   pnpm start
   ```

   Your Ponder application will be available at the configured port with a production-ready GraphQL API.

## Conclusion

This tutorial guided you through building a comprehensive blockchain indexing backend using Ponder and Chainstack. You've created a system that:

* Indexes data from multiple blockchain networks simultaneously
* Provides type-safe, real-time data processing
* Offers a production-ready GraphQL API
* Scales efficiently with Chainstack's reliable infrastructure

The combination of Ponder's developer experience and Chainstack's enterprise-grade blockchain infrastructure provides a robust foundation for building modern Web3 applications.

### About the author

<CardGroup>
  <Card title="Ake">
    <img src="https://mintcdn.com/chainstack/UN3rP7zhB69idvnC/images/docs/profile_images/1719912994363326464/8_Bi4fdM_400x400.jpg?fit=max&auto=format&n=UN3rP7zhB69idvnC&q=85&s=792a24ab1b4682406fa589c0ecd88e5d" alt="Ake" style={{width: '80px', height: '80px', borderRadius: '50%', objectFit: 'cover', display: 'block', margin: '0 auto'}} noZoom width="400" height="400" data-path="images/docs/profile_images/1719912994363326464/8_Bi4fdM_400x400.jpg" />

    <Icon icon="code" iconType="solid" /> Director of Developer Experience @ Chainstack
    <br /><Icon icon="screwdriver-wrench" iconType="solid" /> Talk to me all things Web3
    <br />20 years in technology | 8+ years in Web3 full time years experience

    <div style={{display: "flex", justifyContent: "center", gap: "12px"}}>
      <a href="https://github.com/akegaviar/" style={{textDecoration: "none", borderBottom: "none"}}>
        <Icon icon="github" iconType="brands" />
      </a>

      <a href="https://twitter.com/akegaviar" style={{textDecoration: "none", borderBottom: "none"}}>
        <Icon icon="twitter" iconType="brands" />
      </a>

      <a href="https://www.linkedin.com/in/ake/" style={{textDecoration: "none", borderBottom: "none"}}>
        <Icon icon="linkedin" iconType="brands" />
      </a>
    </div>
  </Card>
</CardGroup>
