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_tokens → nft_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.