Skip to content

EmProps Ponder - Blockchain Indexer Documentation

External Package

This documentation covers the emprops-ponder package which currently lives in a separate repository. It will be integrated into the monorepo as part of the Arbitrum launch.

Current Location: External repo (emprops-ponder) Target Location: emerge-turbo/apps/nft-indexer

Related Documentation

Version: Private package Package Name: @webe3/ponderLast Updated: 2025-11-09


Table of Contents

  1. Overview
  2. Architecture
  3. Database Schema
  4. API Endpoints
  5. WebSocket Server
  6. Event Handlers
  7. Configuration
  8. Directory Structure
  9. Technology Stack
  10. Development Workflow
  11. Migration Notes

Overview

Purpose

emprops-ponder is the blockchain event indexing and API layer for the EmProps NFT infrastructure. It:

  • Indexes Contract Events - Monitors blockchain for NFT events (mints, transfers, upgrades)
  • PostgreSQL Storage - Stores indexed data in 8-table relational schema
  • HTTP API - Provides REST endpoints for querying indexed data
  • WebSocket Server - Real-time event streaming via Socket.io
  • Dynamic Configuration - Reads contract addresses from database (deployed by hardhat)
  • Cross-Chain Support - Can index multiple networks simultaneously

Key Features

  1. Real-Time Indexing

    • Ponder monitors blockchain for contract events
    • Events processed and stored in PostgreSQL
    • Sub-second latency for new events
  2. Comprehensive Data Model

    • Owner tokens and transfers
    • NFT collections (apps) with metadata
    • App tokens and minting events
    • Contract upgrades tracking
  3. Dual Interface

    • REST API for querying historical data
    • WebSocket for real-time event subscriptions
  4. Database-Driven Configuration

    • Reads deployed contract addresses from shared database
    • No hardcoded addresses
    • Automatically picks up new deployments

Architecture

High-Level Architecture

┌────────────────────────────────────────────────────────┐
│                     Blockchain                          │
│  (Ethereum, Base, Optimism, etc.)                      │
│                                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌─────────────┐ │
│  │ OwnerToken   │  │ NFTContractFactory   │  │ SimpleApp   │ │
│  │              │  │              │  │             │ │
│  │ Events:      │  │ Events:      │  │ Events:     │ │
│  │ - Transfer   │  │ - AppCreated │  │ - Minted    │ │
│  │ - Minted     │  │ - Upgraded   │  │ - Transfer  │ │
│  └──────────────┘  └──────────────┘  └─────────────┘ │
└────────────────────────────────────────────────────────┘

                            │ RPC calls

┌────────────────────────────────────────────────────────┐
│                    emprops-ponder                       │
│                                                         │
│  ┌──────────────────────────────────────────────────┐ │
│  │ Ponder Core                                       │ │
│  │ - Monitors contracts                              │ │
│  │ - Fetches events                                  │ │
│  │ - Calls event handlers                            │ │
│  └──────────────────────────────────────────────────┘ │
│                            │                            │
│                            ▼                            │
│  ┌──────────────────────────────────────────────────┐ │
│  │ Event Handlers (src/handlers/)                    │ │
│  │ - OwnerToken: Transfer, Mint                      │ │
│  │ - NFTContractFactory: AppCreated                          │ │
│  │ - SimpleApp: TokensMinted, Transfer               │ │
│  │ - ProxyAdmin: Upgraded                            │ │
│  └──────────────────────────────────────────────────┘ │
│                            │                            │
│                            ▼                            │
│  ┌──────────────────────────────────────────────────┐ │
│  │ PostgreSQL Database (ponder.schema.ts)            │ │
│  │ - owner_tokens                                    │ │
│  │ - owner_token_transfers                           │ │
│  │ - apps                                            │ │
│  │ - app_tokens                                      │ │
│  │ - app_token_mints                                 │ │
│  │ - app_token_transfers                             │ │
│  │ - contract_upgrades                               │ │
│  └──────────────────────────────────────────────────┘ │
│                            │                            │
│                ┌───────────┴───────────┐               │
│                ▼                       ▼               │
│  ┌─────────────────────┐   ┌─────────────────────┐   │
│  │ HTTP API (Hono)     │   │ WebSocket Server    │   │
│  │ - GET /api/apps     │   │ (Socket.io)          │   │
│  │ - GET /api/tokens   │   │ - Event streaming    │   │
│  │ - GET /api/mints    │   │ - Room subscriptions │   │
│  └─────────────────────┘   └─────────────────────┘   │
└────────────────────────────────────────────────────────┘

                            │ HTTP / WebSocket

┌────────────────────────────────────────────────────────┐
│                     Clients                             │
│  - nft-launchpad (Next.js app)                         │
│  - monitor (Real-time dashboard)                       │
│  - emprops-studio (NFT status)                         │
└────────────────────────────────────────────────────────┘

Data Flow

1. Contract emits event on blockchain

   ├─> Ponder detects event (polling RPC)

   ├─> Ponder calls appropriate event handler

   ├─> Handler processes event data

   ├─> Handler writes to PostgreSQL

   ├─> WebSocket server broadcasts event

   └─> Clients receive real-time update

Database Schema

Schema Definition

File: ponder.schema.ts

Ponder uses a custom schema DSL that generates PostgreSQL tables.

Tables Overview

TablePurposeKey Fields
owner_tokensOwner NFTs (collection ownership)id, owner, contractAddress, metadata
owner_token_transfersTransfer history of owner tokenstokenId, from, to, timestamp
appsNFT collections (SimpleApp instances)id, type, ownerTokenId, maxSupply, mintPrice
app_tokensIndividual NFTs in collectionsid, appId, owner, metadata
app_token_mintsBatch minting eventsappId, to, startTokenId, quantity
app_token_transfersTransfer history of app tokenstokenId, appId, from, to
contract_upgradesContract upgrade eventscontractType, implementation, timestamp

Detailed Schema

owner_tokens

typescript
export const ownerTokens = onchainTable("owner_tokens", (t) => ({
  id: t.text().primaryKey(),           // Token ID
  contractAddress: t.text().notNull(), // OwnerToken contract address
  owner: t.text(),                     // Current owner address
  metadata: t.json(),                  // Token metadata (JSON)
  mintedAt: t.numeric()                // Unix timestamp (seconds)
}))

export const ownerTokensRelations = relations(ownerTokens, ({ many }) => ({
  ownedApps: many(apps)  // One owner token → many apps
}))

Purpose: Tracks ownership of NFT collections. Each token represents the right to control one SimpleApp.

owner_token_transfers

typescript
export const ownerTokenTransfers = onchainTable("owner_token_transfers", (t) => ({
  id: t.text().primaryKey(),           // tx hash + log index
  tokenId: t.text().notNull(),         // Token ID transferred
  contractAddress: t.text().notNull(), // Contract address
  from: t.text(),                      // From address (0x0 for mint)
  to: t.text(),                        // To address
  timestamp: t.numeric()               // Unix timestamp
}))

export const ownerTokenTransfersRelations = relations(ownerTokenTransfers, ({ one }) => ({
  token: one(ownerTokens, {
    fields: [ownerTokenTransfers.tokenId, ownerTokenTransfers.contractAddress],
    references: [ownerTokens.id, ownerTokens.contractAddress]
  })
}))

Purpose: Complete transfer history for owner tokens. Used for provenance tracking.

apps

typescript
export const apps = onchainTable("apps", (t) => ({
  id: t.text().primaryKey(),           // App contract address
  type: t.text(),                      // App type (e.g., "SIMPLE_APP_V1")
  ownerTokenContract: t.text().notNull(), // OwnerToken contract
  ownerTokenId: t.text().notNull(),    // Token ID that owns this app
  maxSupply: t.numeric(),              // Maximum NFTs
  mintPrice: t.numeric(),              // Price per NFT in wei
  maxPerMint: t.numeric(),             // Max per transaction
  paused: t.boolean(),                 // Minting paused?
  startDateTime: t.numeric(),          // When minting starts
  totalMinted: t.numeric()             // Total minted (computed)
}))

export const appsRelations = relations(apps, ({ many, one }) => ({
  appTokens: many(appTokens),         // App → many tokens
  appTokenMints: many(appTokenMints), // App → many mint events
  ownerToken: one(ownerTokens, {      // App → one owner token
    fields: [apps.ownerTokenContract, apps.ownerTokenId],
    references: [ownerTokens.contractAddress, ownerTokens.id]
  })
}))

Purpose: NFT collection metadata. Each row = one SimpleApp contract.

app_tokens

typescript
export const appTokens = onchainTable("app_tokens", (t) => ({
  id: t.text().primaryKey(),    // Token ID (unique per app)
  appId: t.text().notNull(),    // App contract address
  owner: t.text(),              // Current owner
  metadata: t.json(),           // NFT metadata (JSON)
  mintedAt: t.numeric()         // Unix timestamp
}))

export const appTokensRelations = relations(appTokens, ({ one }) => ({
  app: one(apps, { fields: [appTokens.appId], references: [apps.id] })
}))

Purpose: Individual NFTs within collections. Each row = one minted NFT.

app_token_mints

typescript
export const appTokenMints = onchainTable("app_token_mints", (t) => ({
  id: t.text().primaryKey(),    // tx hash + log index
  appId: t.text().notNull(),    // App contract address
  to: t.text(),                 // Recipient address
  startTokenId: t.numeric(),    // First token ID in batch
  quantity: t.numeric(),        // Number minted
  timestamp: t.numeric()        // Unix timestamp
}))

export const appTokenMintsRelations = relations(appTokenMints, ({ one }) => ({
  app: one(apps, { fields: [appTokenMints.appId], references: [apps.id] })
}))

Purpose: Batch minting events. Captures who minted how many tokens.

app_token_transfers

typescript
export const appTokenTransfers = onchainTable("app_token_transfers", (t) => ({
  id: t.text().primaryKey(),    // tx hash + log index
  tokenId: t.text().notNull(),  // Token ID
  appId: t.text().notNull(),    // App contract address
  from: t.text(),               // From address
  to: t.text(),                 // To address
  timestamp: t.numeric()        // Unix timestamp
}))

export const appTokenTransfersRelations = relations(appTokenTransfers, ({ one }) => ({
  token: one(appTokens, {
    fields: [appTokenTransfers.tokenId],
    references: [appTokens.id]
  }),
  app: one(apps, {
    fields: [appTokenTransfers.appId],
    references: [apps.id]
  })
}))

Purpose: Transfer history for NFTs. Supports secondary market tracking.

contract_upgrades

typescript
export const contractUpgrades = onchainTable(
  "contract_upgrades",
  (t) => ({
    transactionHash: t.text(),       // Transaction hash
    logIndex: t.bigint(),           // Log index in transaction
    contractType: t.text().notNull(), // "NFTContractFactory" or "OwnerToken"
    implementation: t.hex().notNull(), // New implementation address
    timestamp: t.bigint().notNull()  // Unix timestamp (seconds)
  }),
  (table) => ({
    pk: primaryKey({ columns: [table.transactionHash, table.logIndex] })
  })
)

Purpose: Tracks contract upgrades for UUPS contracts. Important for auditing.


API Endpoints

HTTP API

Framework: Hono (fast, lightweight HTTP server) Base URL: http://localhost:42069 (Ponder default)

File: src/api/index.ts

Endpoints Overview

MethodEndpointPurposeResponse
GET/api/owner-tokensList all owner tokensArray of owner tokens
GET/api/owner-tokens/:idGet specific owner tokenSingle owner token
GET/api/appsList all NFT collectionsArray of apps with totalMinted
GET/api/apps/:app-idGet specific collectionSingle app with totalMinted
GET/api/app-tokensList all NFTsArray of app tokens
GET/api/app-tokens/:app-idGet NFTs for collectionArray of tokens for app
GET/api/app-token-mintsList all minting eventsArray of mint events
GET/api/app-token-mints/:app-idGet mints for collectionArray of mints for app
GET/api/read/:contract/:functionDirect contract readFunction result
GET/api/contract-info/:contractGet contract ABI & addressContract details

Detailed Endpoint Documentation

GET /api/owner-tokens

Purpose: List all owner tokens

Query Params: None

Response:

json
[
  {
    "id": "0",
    "contractAddress": "0x1234...",
    "owner": "0xabcd...",
    "metadata": { "name": "Collection #0" },
    "mintedAt": "1699564800"
  }
]

Ordering: Most recent first (desc(mintedAt))

GET /api/owner-tokens/:id

Purpose: Get specific owner token by ID

Params:

  • id - Token ID (string)

Response:

json
{
  "id": "0",
  "contractAddress": "0x1234...",
  "owner": "0xabcd...",
  "metadata": { "name": "Collection #0" },
  "mintedAt": "1699564800"
}

Error: 404 if token not found

GET /api/apps

Purpose: List all NFT collections with totalMinted count

Query Params: None

Response:

json
[
  {
    "id": "0x5678...",
    "address": "0x5678...",
    "type": "SIMPLE_APP_V1",
    "ownerTokenContract": "0x1234...",
    "ownerTokenId": "0",
    "maxSupply": "10000",
    "mintPrice": "1000000000000000",
    "maxPerMint": "10",
    "paused": false,
    "startDateTime": "1699564800",
    "totalMinted": "42"
  }
]

Note: Uses sum() aggregation on appTokenMints.quantity to compute totalMinted

GET /api/apps/:app-id

Purpose: Get specific NFT collection

Params:

  • app-id - App contract address

Response: Same as single item from /api/apps

Error: 404 if app not found

GET /api/app-tokens

Purpose: List all minted NFTs across all collections

Response:

json
[
  {
    "id": "0",
    "appId": "0x5678...",
    "owner": "0xabcd...",
    "metadata": { "name": "NFT #0", "image": "ipfs://..." },
    "mintedAt": "1699564800"
  }
]

Ordering: Most recent first (desc(id))

GET /api/app-tokens/:app-id

Purpose: Get all NFTs for a specific collection

Params:

  • app-id - App contract address

Response: Array of app tokens for that collection

GET /api/app-token-mints

Purpose: List all minting events

Response:

json
[
  {
    "id": "0x9abc...#0",
    "appId": "0x5678...",
    "to": "0xabcd...",
    "startTokenId": "0",
    "quantity": "3",
    "timestamp": "1699564800"
  }
]

Ordering: Most recent first (desc(timestamp))

GET /api/app-token-mints/:app-id

Purpose: Get minting events for specific collection

Params:

  • app-id - App contract address

Response: Array of mint events for that collection

GET /api/read/:contract/:function

Purpose: Direct contract read (uses Viem to query blockchain)

Params:

  • contract - Contract name (e.g., "SimpleAppImplContract")
  • function - Function name (e.g., "maxSupply")

Query Params:

  • address (required) - Contract address to read from
  • args (optional) - JSON array of function arguments

Example:

GET /api/read/SimpleAppImplContract/maxSupply?address=0x5678...

Response:

json
{
  "result": "10000"
}

Implementation:

  • Uses Viem publicClient.readContract()
  • Reads contract ABIs from database
  • Converts BigInt to string for JSON serialization

Error Responses:

  • 400 - Missing required parameters
  • 404 - Contract not found or no ABI
  • 500 - Contract read error

GET /api/contract-info/:contract

Purpose: Get contract ABI and address for frontend use

Params:

  • contract - Contract name (e.g., "NFTContractFactoryProxyContract")

Response:

json
{
  "name": "NFTContractFactoryProxyContract",
  "abi": [ /* full ABI array */ ],
  "address": "0x1234..."
}

Usage: Frontend calls this to get contract info for Wagmi/Viem


WebSocket Server

Socket.io Integration

Framework: Socket.io v4.7.4 Port: Same as HTTP API (42069) File: src/api/sockets/server.ts

Event Emission

File: src/api/sockets/events.ts

Ponder emits WebSocket events when database records are created/updated:

typescript
// After inserting/updating database records:
emitSocketEvent({
  event: 'emprops:app:created',
  data: {
    appAddress: app.id,
    ownerTokenId: app.ownerTokenId,
    type: app.type
  }
});

Available Events

EventTriggerPayload
emprops:ownerToken:mintedOwnerToken minted{ tokenId, owner, contractAddress }
emprops:ownerToken:transferredOwnerToken transferred{ tokenId, from, to }
emprops:app:createdNew collection created{ appAddress, ownerTokenId, type }
emprops:appToken:mintedNFTs minted{ appAddress, to, startTokenId, quantity }
emprops:appToken:transferredNFT transferred{ tokenId, appAddress, from, to }
emprops:contract:upgradedContract upgraded{ contractType, implementation }

Client Usage

From Frontend:

typescript
import { io } from 'socket.io-client';

const socket = io('http://localhost:42069');

// Subscribe to events
socket.on('emprops:appToken:minted', (data) => {
  console.log('New NFT minted:', data);
  // Update UI
});

socket.on('emprops:app:created', (data) => {
  console.log('New collection created:', data);
  // Refresh collection list
});

Room-Based Subscriptions

File: src/api/sockets/subscriptions.ts

Clients can subscribe to specific apps/collections:

typescript
// Subscribe to specific collection
socket.emit('subscribe', { room: `app:${appAddress}` });

// Unsubscribe
socket.emit('unsubscribe', { room: `app:${appAddress}` });

Server-side:

typescript
socket.on('subscribe', ({ room }) => {
  socket.join(room);
  console.log(`Client joined room: ${room}`);
});

// Emit to specific room
io.to(`app:${appAddress}`).emit('emprops:appToken:minted', data);

Event Handlers

Handler Structure

Location: src/handlers/

Ponder event handlers are registered in ponder.config.ts and implement the event processing logic.

Handler Registration

File: ponder.config.ts (example)

typescript
import { createConfig } from "@ponder/core";

export default createConfig({
  contracts: {
    OwnerToken: {
      abi: OwnerTokenAbi,
      address: ownerTokenAddress,
      network: "baseSepolia",
      startBlock: deploymentBlock,
      // Event handlers defined separately
    }
  }
});

Event Handler Files

ProxyAdmin Handler

File: src/handlers/ProxyAdmin.ts

Purpose: Track contract upgrades

Events Handled:

  • Upgraded(address indexed implementation)

Handler:

typescript
ponder.on("ProxyAdmin:Upgraded", async ({ event, context }) => {
  await context.db.contractUpgrades.create({
    id: `${event.transaction.hash}-${event.logIndex}`,
    data: {
      transactionHash: event.transaction.hash,
      logIndex: event.logIndex,
      contractType: "OwnerToken", // or "NFTContractFactory"
      implementation: event.args.implementation,
      timestamp: event.block.timestamp
    }
  });

  // Emit WebSocket event
  emitSocketEvent({
    event: 'emprops:contract:upgraded',
    data: {
      contractType: "OwnerToken",
      implementation: event.args.implementation
    }
  });
});

OwnerToken Handlers

Handlers:

  • Transfer - Track owner token transfers and mints
  • OwnerTokenMinted - Specific mint event

Example:

typescript
ponder.on("OwnerToken:Transfer", async ({ event, context }) => {
  const { from, to, tokenId } = event.args;

  // Update owner token owner
  await context.db.ownerTokens.upsert({
    id: tokenId.toString(),
    create: {
      id: tokenId.toString(),
      contractAddress: event.log.address,
      owner: to,
      mintedAt: event.block.timestamp
    },
    update: {
      owner: to
    }
  });

  // Record transfer
  await context.db.ownerTokenTransfers.create({
    id: `${event.transaction.hash}-${event.logIndex}`,
    data: {
      tokenId: tokenId.toString(),
      contractAddress: event.log.address,
      from,
      to,
      timestamp: event.block.timestamp
    }
  });

  // Emit event
  if (from === '0x0000000000000000000000000000000000000000') {
    emitSocketEvent({
      event: 'emprops:ownerToken:minted',
      data: { tokenId: tokenId.toString(), owner: to, contractAddress: event.log.address }
    });
  } else {
    emitSocketEvent({
      event: 'emprops:ownerToken:transferred',
      data: { tokenId: tokenId.toString(), from, to }
    });
  }
});

NFTContractFactory Handlers

Handlers:

  • AppCreated - New collection created

Example:

typescript
ponder.on("NFTContractFactory:AppCreated", async ({ event, context }) => {
  const { appType, app, owner, ownerTokenId } = event.args;

  // Create app record
  await context.db.apps.create({
    id: app, // App contract address
    data: {
      id: app,
      type: appType,
      ownerTokenContract: context.contracts.OwnerToken.address,
      ownerTokenId: ownerTokenId.toString(),
      paused: false
    }
  });

  // Emit event
  emitSocketEvent({
    event: 'emprops:app:created',
    data: {
      appAddress: app,
      ownerTokenId: ownerTokenId.toString(),
      type: appType
    }
  });
});

SimpleApp Handlers

Handlers:

  • TokensMinted - Batch minting event
  • Transfer - NFT transfer

Example:

typescript
ponder.on("SimpleApp:TokensMinted", async ({ event, context }) => {
  const { to, startTokenId, quantity } = event.args;
  const appAddress = event.log.address;

  // Record mint event
  await context.db.appTokenMints.create({
    id: `${event.transaction.hash}-${event.logIndex}`,
    data: {
      appId: appAddress,
      to,
      startTokenId: startTokenId.toString(),
      quantity: quantity.toString(),
      timestamp: event.block.timestamp
    }
  });

  // Create individual token records
  for (let i = 0; i < quantity; i++) {
    const tokenId = startTokenId + BigInt(i);
    await context.db.appTokens.create({
      id: `${appAddress}-${tokenId}`,
      data: {
        id: tokenId.toString(),
        appId: appAddress,
        owner: to,
        mintedAt: event.block.timestamp
      }
    });
  }

  // Emit event
  emitSocketEvent({
    event: 'emprops:appToken:minted',
    data: {
      appAddress,
      to,
      startTokenId: startTokenId.toString(),
      quantity: quantity.toString()
    }
  });
});

Configuration

Ponder Configuration

File: ponder.config.ts

Dynamic Configuration:

  • Reads contract addresses from PostgreSQL
  • Loaded at startup via getContractsFromRegistry()
  • No hardcoded addresses

Example:

typescript
import { createConfig } from "@ponder/core";
import { http } from "viem";
import { getContractsFromRegistry } from "./src/utils/db";

// Load contracts from database
const contracts = await getContractsFromRegistry();

export default createConfig({
  networks: {
    baseSepolia: {
      chainId: 84532,
      transport: http(process.env.PONDER_RPC_URL_84532)
    }
  },
  contracts: {
    OwnerToken: {
      abi: contracts.OwnerTokenProxyContract.abi,
      address: contracts.OwnerTokenProxyContract.address as `0x${string}`,
      network: "baseSepolia",
      startBlock: contracts.OwnerTokenProxyContract.startBlock
    },
    NFTContractFactory: {
      abi: contracts.NFTContractFactoryProxyContract.abi,
      address: contracts.NFTContractFactoryProxyContract.address as `0x${string}`,
      network: "baseSepolia",
      startBlock: contracts.NFTContractFactoryProxyContract.startBlock
    },
    // SimpleApp instances added dynamically when created
  }
});

Database Utility

File: src/utils/db.ts

typescript
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL
});

export async function getContractsFromRegistry() {
  const result = await pool.query(`
    SELECT name, address, abi, block_number as "startBlock"
    FROM contract_deployments
    WHERE network = $1
  `, ['baseSepolia']);

  const contracts: Record<string, any> = {};
  for (const row of result.rows) {
    contracts[row.name] = {
      address: row.address,
      abi: row.abi,
      startBlock: row.startBlock || 0
    };
  }
  return contracts;
}

Environment Variables

bash
# Database connection
DATABASE_URL="postgresql://user:pass@localhost:5432/emprops_nft"

# RPC URLs (per chain)
PONDER_RPC_URL_84532="https://base-sepolia.g.alchemy.com/v2/YOUR_KEY"
PONDER_RPC_URL_1="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

# Ponder config
PONDER_LOG_LEVEL="info"
PONDER_PORT="42069"

Directory Structure

emprops-ponder/
├── src/
│   ├── api/                    # HTTP API & WebSocket server
│   │   ├── index.ts            # Hono API setup + routes
│   │   └── sockets/
│   │       ├── server.ts       # Socket.io server initialization
│   │       ├── events.ts       # Event emission helpers
│   │       ├── subscriptions.ts # Room management
│   │       └── index.ts        # Exports
│   │
│   ├── handlers/               # Event handlers
│   │   ├── index.ts            # Handler exports
│   │   └── ProxyAdmin.ts       # Upgrade event handlers
│   │
│   └── utils/
│       └── db.ts               # Database utilities (contract registry)

├── test/
│   └── socket-test.ts          # WebSocket testing script

├── ponder.config.ts            # Main Ponder configuration (dynamic)
├── ponder.schema.ts            # Database schema (8 tables)
├── ponder-env.d.ts             # TypeScript environment types
├── tsconfig.json               # TypeScript configuration
├── package.json                # Dependencies
└── README.md                   # Setup instructions

Technology Stack

Core Dependencies

json
{
  "ponder": "^0.9.17",          // Blockchain indexing framework
  "hono": "^4.5.0",             // HTTP API framework
  "socket.io": "^4.7.4",        // WebSocket server
  "socket.io-client": "^4.7.4", // WebSocket client (for testing)
  "drizzle-orm": "^0.39.3",     // Type-safe database queries
  "pg": "^8.13.3",              // PostgreSQL client
  "@types/pg": "^8.11.11",      // PostgreSQL types
  "viem": "^2.21.3",            // Ethereum library
  "abitype": "^0.10.2"          // ABI TypeScript types
}

Ponder Framework

Ponder is a TypeScript framework for indexing blockchain data:

  • Event-driven - Define handlers for contract events
  • Type-safe - TypeScript throughout
  • Schema-first - Define database schema, Ponder generates tables
  • Automatic syncing - Handles reorganizations, missing blocks
  • Multi-chain - Index multiple networks simultaneously
  • API included - Built-in HTTP API (Hono)

Key Concepts:

  • ponder.schema.ts - Define database tables
  • ponder.config.ts - Configure contracts to monitor
  • Event handlers - Process blockchain events
  • Context API - Access database, contracts, network info

Development Workflow

Local Development

bash
# Install dependencies
pnpm install

# Start Ponder dev server
pnpm dev

Dev Server Features:

  • Hot reload on code changes
  • Auto-restarts on config changes
  • Real-time indexing from latest block
  • GraphQL playground (optional)
  • HTTP API at http://localhost:42069

Database Setup

Ponder automatically creates tables based on ponder.schema.ts:

bash
# Ponder creates tables on first run
pnpm dev

# To reset database
pnpm ponder db drop
pnpm dev

Testing WebSocket

Script: test/socket-test.ts

bash
# Run WebSocket test client
pnpm test

Test Script:

typescript
import { io } from 'socket.io-client';

const socket = io('http://localhost:42069');

socket.on('connect', () => {
  console.log('✅ Connected to Ponder WebSocket');
});

socket.on('emprops:app:created', (data) => {
  console.log('📦 New collection:', data);
});

socket.on('emprops:appToken:minted', (data) => {
  console.log('🎨 NFT minted:', data);
});

Production Deployment

bash
# Build for production
pnpm ponder build

# Start production server
pnpm ponder start

Production Considerations:

  • Use persistent PostgreSQL (not in-memory)
  • Set PONDER_RPC_URL_* to reliable RPC providers
  • Monitor indexing lag
  • Set up database backups
  • Configure CORS for WebSocket clients

Migration Notes

Moving to Monorepo

Current Location: /Users/the_dusky/code/emprops/nft_investigation/emprops-ponderTarget Location: emerge-turbo/apps/nft-indexer

Migration Steps

  1. Copy Package

    bash
    cp -r emprops-ponder emerge-turbo/apps/nft-indexer
  2. Update Package Name

    json
    {
      "name": "nft-indexer",  // Changed from "@webe3/ponder"
      "private": true
    }
  3. Update Database Connection

    • Point to shared PostgreSQL instance
    • Use separate schema or table prefix
    • Update src/utils/db.ts connection string
  4. Update Port (avoid conflicts)

    bash
    # .env
    PONDER_PORT=3338  # Changed from 42069
  5. Test Locally

    bash
    cd emerge-turbo
    pnpm --filter nft-indexer dev
  6. Update API Base URL in Clients

    typescript
    // nft-launchpad
    const API_URL = process.env.NEXT_PUBLIC_NFT_INDEXER_URL || 'http://localhost:3338';

Integration Points

With nft-contracts Package

Database Coordination:

  • nft-contracts writes deployments to PostgreSQL
  • nft-indexer reads from same database
  • No code coupling - just shared database

Flow:

1. Deploy contracts (nft-contracts)
   ├─> pnpm --filter nft-contracts deploy
   └─> pnpm --filter nft-contracts db:store-deployments

2. Ponder reads contracts (nft-indexer)
   ├─> ponder.config.ts calls getContractsFromRegistry()
   └─> Starts monitoring from stored addresses

With nft-launchpad App

API Consumption:

typescript
// apps/nft-launchpad/lib/api.ts
export async function fetchCollections() {
  const response = await fetch(`${NFT_INDEXER_URL}/api/apps`);
  return response.json();
}

WebSocket Subscription:

typescript
// apps/nft-launchpad/hooks/useRealtimeCollections.ts
import { io } from 'socket.io-client';
import { useEffect, useState } from 'react';

export function useRealtimeCollections() {
  const [collections, setCollections] = useState([]);

  useEffect(() => {
    const socket = io(NFT_INDEXER_URL);

    socket.on('emprops:app:created', (data) => {
      // Add new collection to list
      setCollections(prev => [...prev, data]);
    });

    return () => socket.disconnect();
  }, []);

  return collections;
}

Environment Variables Migration

From .env.local to emerge-turbo/.env.nft:

bash
# Old (standalone)
DATABASE_URL=postgresql://localhost/emprops_ponder

# New (monorepo)
NFT_INDEXER_DATABASE_URL=postgresql://localhost/emerge_turbo?schema=nft_indexer
NFT_INDEXER_PORT=3338
NEXT_PUBLIC_NFT_INDEXER_URL=http://localhost:3338

Database Schema Isolation

Option 1: Separate Schema (Recommended)

sql
CREATE SCHEMA nft_indexer;

-- Ponder tables created in nft_indexer schema
-- Configured via connection string:
-- postgresql://localhost/emerge_turbo?schema=nft_indexer

Option 2: Table Prefixing

  • Modify ponder.schema.ts to prefix all tables
  • owner_tokensnft_owner_tokens
  • Less clean but works if schema separation not available

Conclusion

emprops-ponder is a complete, production-ready blockchain indexer with:

8-Table Relational Schema - Comprehensive data model for NFT tracking ✅ Real-Time Indexing - Sub-second latency via Ponder framework ✅ Dual Interface - REST API + WebSocket for different use cases ✅ Dynamic Configuration - Reads contracts from database (no hardcoding) ✅ Event Handlers - Process mints, transfers, upgrades ✅ Type-Safe - TypeScript + Drizzle ORM

Migration Complexity: Low Migration Risk: Low (self-contained service) Estimated Migration Time: 1 day

The main work is:

  1. Copying package to monorepo
  2. Updating port and database connection
  3. Testing API endpoints and WebSocket
  4. Coordinating with nft-contracts deployment storage

The indexer is well-architected and ready for integration!

Released under the MIT License.