Skip to content

Ponder Blockchain Indexer Integration: Implementation Guide

Related ADR: Ponder Blockchain IndexerScope: Add Ponder indexer for NFT ownership tracking with Prisma integration


Overview

Add apps/ponder to the monorepo — a Ponder blockchain indexer that watches Transfer events from Diamond collection contracts on Arbitrum and writes ownership data into Prisma-managed PostgreSQL tables.

Phase Summary

PhaseScopeStatus
1Prisma Schema Migration (new models + deck field)Pending
2Ponder App Scaffolding (config, schema, ABIs)Pending
3Event Handlers + Prisma Sync LayerPending
4Database Package Exports (ownership queries)Pending
5Turbo Integration + Environment SetupPending
6Testing + Verification (Arbitrum One mainnet)Pending
7Monitor Ownership UIPending

Phase 1: Prisma Schema Migration

1.1 Schema Changes

File: packages/database/prisma/schema.prisma

The existing deck model already IS the NFT token record — it has token_id, mint_recipient, mint_tx_hash, mint_status, and links to collection (which has contract_address). No separate ownership table is needed. We add fields to deck and a transfer history table.

Add to existing deck model:

prisma
// On-chain ownership tracking — populated by Ponder indexer
current_owner         String?   // Current on-chain owner wallet address
last_transfer_block   BigInt?   // Block number of most recent transfer
last_transfer_at      DateTime? // Timestamp of most recent transfer
nft_transfers         nft_transfer[]

Add new nft_transfer model (transfer event history):

prisma
/// Transfer event history for NFT tokens, populated by the Ponder indexer.
/// Links directly to deck via deck_id FK — the deck IS the token.
model nft_transfer {
  id               String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  deck_id          String   @db.Uuid
  from_address     String
  to_address       String
  block_number     BigInt
  tx_hash          String
  log_index        Int
  timestamp        DateTime
  created_at       DateTime @default(now())
  deck             deck     @relation(fields: [deck_id], references: [id], onDelete: Cascade)

  @@unique([tx_hash, log_index])
  @@index([deck_id])
  @@index([from_address])
  @@index([to_address])
  @@index([block_number])
}

Add new ponder_indexer_state model (indexer health tracking):

prisma
/// Singleton table tracking the Ponder indexer's operational state.
model ponder_indexer_state {
  id                 String    @id @default("singleton")
  status             String    @default("idle")
  last_indexed_block BigInt?
  chain_id           Int?
  started_at         DateTime?
  updated_at         DateTime  @updatedAt
}

Why no nft_token_owner table? The deck model already represents the NFT token — it has token_id, collection_id (→ contract_address), mint_recipient, user_id, custodied_for. Adding current_owner to deck means ownership lives alongside the token record it describes. The nft_transfer table links to deck via deck_id FK, giving a clean deck.nft_transfers relation for transfer history.

1.3 Run Migration

bash
cd packages/database
pnpm prisma migrate dev --name ponder_ownership_tracking
pnpm build

Risk: Migration affects the shared database. Follow the existing multi-app coordination workflow — apply to staging first, verify, then production. The migration only adds new tables and a nullable column, so it's non-breaking.


Phase 2: Ponder App Scaffolding

2.1 Directory Structure

apps/ponder/
  package.json
  tsconfig.json
  ponder.config.ts
  ponder.schema.ts
  ponder-env.d.ts
  .env.example
  abis/
    DiamondFactory.ts
    ERC721Diamond.ts
  src/
    index.ts
    handlers/
      factory.ts
      transfer.ts
    lib/
      prisma-sync.ts
      reindex.ts
    api/
      index.ts

2.2 Package Configuration

File: apps/ponder/package.json

json
{
  "name": "@emp/ponder",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "ponder dev",
    "start": "ponder start",
    "build": "ponder build",
    "serve": "ponder serve",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@emergexyz/db": "workspace:*",
    "ponder": "^0.10.0",
    "hono": "^4.5.0",
    "viem": "^2.45.2"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0"
  }
}

File: apps/ponder/tsconfig.json

json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "composite": true
  },
  "include": ["src/**/*", "ponder.config.ts", "ponder.schema.ts", "abis/**/*"],
  "exclude": ["dist", "node_modules"]
}

2.3 ABI Files

Extract only the event signatures Ponder needs. Self-contained — no dependency on packages/nft-contracts build.

File: apps/ponder/abis/DiamondFactory.ts

Source: packages/nft-contracts/contracts/DiamondFactory.sol:39-43

typescript
export const DiamondFactoryAbi = [
  {
    type: "event",
    name: "CollectionCreated",
    inputs: [
      { name: "diamond", type: "address", indexed: true },
      { name: "collectionOwner", type: "address", indexed: true },
      { name: "salt", type: "bytes32", indexed: false },
    ],
  },
] as const;

File: apps/ponder/abis/ERC721Diamond.ts

Sources:

  • Transfer: SolidState ERC721Base (standard ERC721 event)
  • Minted: packages/nft-contracts/contracts/facets/MintFacet.sol:13
typescript
export const ERC721DiamondAbi = [
  {
    type: "event",
    name: "Transfer",
    inputs: [
      { name: "from", type: "address", indexed: true },
      { name: "to", type: "address", indexed: true },
      { name: "tokenId", type: "uint256", indexed: true },
    ],
  },
  {
    type: "event",
    name: "Minted",
    inputs: [
      { name: "to", type: "address", indexed: true },
      { name: "tokenId", type: "uint256", indexed: true },
    ],
  },
] as const;

2.4 Ponder Config

File: apps/ponder/ponder.config.ts

typescript
import { createConfig, factory } from "ponder";
import { http, parseAbiItem } from "viem";
import { DiamondFactoryAbi } from "./abis/DiamondFactory";
import { ERC721DiamondAbi } from "./abis/ERC721Diamond";

// Factory addresses from packages/nft-contracts/deployments/*.json
const ARBITRUM_ONE_FACTORY = "0x4f885f1F940D5Bb33cC85741fd635e379B6581aB";

const COLLECTION_CREATED_EVENT = parseAbiItem(
  "event CollectionCreated(address indexed diamond, address indexed collectionOwner, bytes32 salt)"
);

export default createConfig({
  database: {
    kind: "postgres",
    connectionString: process.env.PONDER_DATABASE_URL,
  },
  networks: {
    arbitrumOne: {
      chainId: 42161,
      transport: http(process.env.PONDER_RPC_URL_ARBITRUM_ONE),
    },
  },
  contracts: {
    DiamondFactory: {
      abi: DiamondFactoryAbi,
      network: {
        arbitrumOne: {
          address: ARBITRUM_ONE_FACTORY,
          startBlock: 0, // TODO: Replace with actual deployment block
        },
      },
    },
    ERC721Diamond: {
      abi: ERC721DiamondAbi,
      network: {
        arbitrumOne: {
          address: factory({
            address: ARBITRUM_ONE_FACTORY,
            event: COLLECTION_CREATED_EVENT,
            parameter: "diamond",
          }),
          startBlock: 0, // TODO: Replace with actual deployment block
        },
      },
    },
  },
});

Note: startBlock must be looked up from the factory deployment transaction:

  • Arbitrum One: Deployed 2026-02-18 (deploy_tx_hash in packages/nft-contracts/deployments/arbitrum-one.json)

Use Arbiscan to convert tx hash → block number.

2.5 Ponder Schema (Internal Tables)

File: apps/ponder/ponder.schema.ts

typescript
import { index, onchainTable, primaryKey } from "ponder";

// Factory-discovered collections (for Ponder's internal tracking)
export const collections = onchainTable("collections", (t) => ({
  address: t.hex().primaryKey(),
  owner: t.hex().notNull(),
  salt: t.hex().notNull(),
  chainId: t.integer().notNull(),
  createdAtBlock: t.bigint().notNull(),
  createdAtTimestamp: t.bigint().notNull(),
}));

// Transfer log (Ponder's internal copy — the source of truth for Prisma sync)
export const transfers = onchainTable(
  "transfers",
  (t) => ({
    id: t.text().primaryKey(),
    contractAddress: t.hex().notNull(),
    tokenId: t.bigint().notNull(),
    from: t.hex().notNull(),
    to: t.hex().notNull(),
    blockNumber: t.bigint().notNull(),
    timestamp: t.bigint().notNull(),
    transactionHash: t.hex().notNull(),
  }),
  (table) => ({
    contractIdx: index().on(table.contractAddress),
    tokenIdx: index().on(table.contractAddress, table.tokenId),
    fromIdx: index().on(table.from),
    toIdx: index().on(table.to),
  })
);

These are Ponder's internal bookkeeping tables. They are ephemeral — dropped and recreated on reindex. Apps never query these directly.

No tokenOwners table — the existing deck model in Prisma is the token record. Ponder updates deck.current_owner directly via the sync layer.

2.6 Environment File

File: apps/ponder/.env.example

bash
# RPC Endpoint (Alchemy, Infura, or similar)
PONDER_RPC_URL_ARBITRUM_ONE=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY

# Ponder internal tables (separate schema in same database)
PONDER_DATABASE_URL=postgresql://user:pass@host:5432/dbname?schema=ponder

# Prisma tables (same database, public schema)
DATABASE_URL=postgresql://user:pass@host:5432/dbname

# Ponder HTTP port (default 42069)
PONDER_PORT=42069

Phase 3: Event Handlers + Prisma Sync Layer

3.1 Handler Registration

File: apps/ponder/src/index.ts

typescript
import "./handlers/factory";
import "./handlers/transfer";

3.2 Factory Handler

File: apps/ponder/src/handlers/factory.ts

Handles DiamondFactory:CollectionCreated:

  1. Insert into Ponder's collections table
  2. Call syncCollectionCreated() to update existing Prisma collection record
typescript
import { ponder } from "ponder:registry";
import { collections } from "ponder:schema";
import { syncCollectionCreated } from "../lib/prisma-sync";

ponder.on("DiamondFactory:CollectionCreated", async ({ event, context }) => {
  const { diamond, collectionOwner, salt } = event.args;

  await context.db.insert(collections).values({
    address: diamond,
    owner: collectionOwner,
    salt,
    chainId: context.network.chainId,
    createdAtBlock: event.block.number,
    createdAtTimestamp: event.block.timestamp,
  });

  await syncCollectionCreated({
    contractAddress: diamond,
    owner: collectionOwner,
    chainId: context.network.chainId,
    blockNumber: event.block.number,
    timestamp: event.block.timestamp,
  });
});

3.3 Transfer Handler (Core)

File: apps/ponder/src/handlers/transfer.ts

Handles ERC721Diamond:Transfer:

  1. Detect mint (from == 0x0) vs secondary transfer
  2. Insert into Ponder's transfers table
  3. Call syncTransfer() to update deck.current_owner and insert nft_transfer
typescript
import { ponder } from "ponder:registry";
import { transfers } from "ponder:schema";
import { syncTransfer } from "../lib/prisma-sync";
import { zeroAddress } from "viem";

ponder.on("ERC721Diamond:Transfer", async ({ event, context }) => {
  const { from, to, tokenId } = event.args;
  const contractAddress = event.log.address;
  const isMint = from === zeroAddress;

  const transferId = `${event.transaction.hash}-${event.log.logIndex}`;

  // Ponder internal: record transfer
  await context.db.insert(transfers).values({
    id: transferId,
    contractAddress,
    tokenId,
    from,
    to,
    blockNumber: event.block.number,
    timestamp: event.block.timestamp,
    transactionHash: event.transaction.hash,
  });

  // Prisma sync: update deck.current_owner + insert nft_transfer
  await syncTransfer({
    contractAddress,
    chainId: context.network.chainId,
    tokenId: Number(tokenId),
    from,
    to,
    blockNumber: event.block.number,
    txHash: event.transaction.hash,
    logIndex: event.log.logIndex,
    timestamp: event.block.timestamp,
    isMint,
  });
});

3.4 Prisma Sync Module

File: apps/ponder/src/lib/prisma-sync.ts

The bridge between Ponder and Prisma. Imports @emergexyz/db and writes directly to deck + nft_transfer.

Key functions:

syncTransfer():

  1. Find the deck via collection.contract_address + deck.token_id
  2. Update deck.current_owner and deck.last_transfer_block / deck.last_transfer_at
  3. Insert nft_transfer record linked to the deck via deck_id FK
  4. Update ponder_indexer_state.last_indexed_block

syncCollectionCreated():

  1. Update existing collection record if it exists (set mint_status to "deployed")

All writes use upsert or createMany with skipDuplicates for idempotency.

3.5 Reindex Cleanup

File: apps/ponder/src/lib/reindex.ts

Called from Ponder's setup event (or startup hook) before historical replay:

  1. Delete all nft_transfer records
  2. Reset all deck.current_owner, deck.last_transfer_block, deck.last_transfer_at to null
  3. Set ponder_indexer_state.status to "reindexing"
typescript
import { prisma } from "@emergexyz/db";

export async function clearPrismaOwnershipData() {
  await prisma.nft_transfer.deleteMany({});
  await prisma.deck.updateMany({
    where: { current_owner: { not: null } },
    data: { current_owner: null, last_transfer_block: null, last_transfer_at: null },
  });
  await prisma.ponder_indexer_state.upsert({
    where: { id: "singleton" },
    create: { status: "reindexing", started_at: new Date() },
    update: { status: "reindexing", started_at: new Date(), last_indexed_block: null },
  });
}

Phase 4: Database Package Exports

4.1 Ownership Query Helpers

File: packages/database/src/nft-ownership.ts (new)

typescript
import { prisma } from './client.js';

export class NftOwnershipOperations {
  /** Get current owner of a specific token (via deck) */
  static async getTokenOwner(collectionId: string, tokenId: number) {
    return prisma.deck.findFirst({
      where: { collection_id: collectionId, token_id: tokenId },
      select: {
        id: true,
        token_id: true,
        current_owner: true,
        mint_recipient: true,
        mint_status: true,
        minted_at: true,
        last_transfer_block: true,
        last_transfer_at: true,
      },
    });
  }

  /** Get all tokens owned by a wallet address */
  static async getTokensByOwner(ownerAddress: string) {
    return prisma.deck.findMany({
      where: { current_owner: ownerAddress },
      include: { collection: { select: { id: true, name: true, contract_address: true } } },
      orderBy: { minted_at: 'desc' },
    });
  }

  /** Get all minted tokens in a collection with current owners */
  static async getCollectionTokens(collectionId: string) {
    return prisma.deck.findMany({
      where: { collection_id: collectionId, token_id: { not: null } },
      select: {
        id: true,
        token_id: true,
        current_owner: true,
        mint_recipient: true,
        mint_status: true,
      },
      orderBy: { token_id: 'asc' },
    });
  }

  /** Get transfer history for a specific deck/token */
  static async getDeckTransfers(deckId: string) {
    return prisma.nft_transfer.findMany({
      where: { deck_id: deckId },
      orderBy: { block_number: 'desc' },
    });
  }

  /** Get all transfers for a wallet (sent or received) */
  static async getWalletTransfers(address: string) {
    return prisma.nft_transfer.findMany({
      where: {
        OR: [
          { from_address: address },
          { to_address: address },
        ],
      },
      include: { deck: { select: { id: true, token_id: true, collection_id: true } } },
      orderBy: { block_number: 'desc' },
    });
  }

  /** Get indexer operational state */
  static async getIndexerState() {
    return prisma.ponder_indexer_state.findUnique({
      where: { id: 'singleton' },
    });
  }
}

4.2 Update Package Exports

File: packages/database/src/index.ts

Add to exports:

typescript
// NFT Ownership operations (populated by Ponder indexer)
export { NftOwnershipOperations } from './nft-ownership.js';

// Add to type-only re-exports:
export type {
  nft_transfer,
  ponder_indexer_state
} from '@prisma/client';

4.3 Usage Example

After these changes, any app can query ownership through the deck model:

typescript
import { NftOwnershipOperations, prisma } from '@emergexyz/db';

// High-level API
const owner = await NftOwnershipOperations.getTokenOwner(collectionId, 1);
const myTokens = await NftOwnershipOperations.getTokensByOwner(walletAddress);
const history = await NftOwnershipOperations.getDeckTransfers(deckId);

// Or direct Prisma queries — deck already has ownership
const deck = await prisma.deck.findFirst({
  where: { collection_id: collectionId, token_id: 1 },
  select: { id: true, current_owner: true, mint_recipient: true, mint_status: true },
});

// Get all decks with their transfer history
const decksWithHistory = await prisma.deck.findMany({
  where: { collection_id: collectionId, token_id: { not: null } },
  include: { nft_transfers: { orderBy: { block_number: 'desc' } } },
});

Phase 5: Turbo Integration + Environment Setup

5.1 Turbo Pipeline

File: turbo.json

Add new tasks:

json
{
  "ponder:dev": {
    "cache": false,
    "persistent": true
  },
  "ponder:start": {
    "dependsOn": ["^build"]
  }
}

5.2 Root Scripts

File: Root package.json

Add scripts:

json
{
  "ponder:dev": "turbo run ponder:dev --filter=@emp/ponder",
  "ponder:start": "turbo run ponder:start --filter=@emp/ponder"
}

5.3 Environment Variables

VariableDescriptionSource
PONDER_RPC_URL_ARBITRUM_ONEArbitrum One RPCSame provider as ARBITRUM_ONE_RPC_URL
PONDER_DATABASE_URLPostgreSQL with ?schema=ponderSame Neon DB, separate schema
DATABASE_URLPostgreSQL for Prisma writesSame as other apps
PONDER_PORTHTTP port (default 42069)Override if port conflicts

5.4 Custom API Endpoints

File: apps/ponder/src/api/index.ts

Hono-based supplementary API served by Ponder:

EndpointPurpose
GET /ownership/:contractAddress/:tokenIdCurrent owner of a token
GET /ownership/wallet/:addressAll tokens owned by a wallet
GET /transfers/:contractAddress/:tokenIdTransfer history for a token
GET /healthIndexer status + last indexed block

These are supplementary to Prisma queries — useful for real-time data or external consumers.


Phase 6: Testing + Verification

Development Workflow

bash
# Terminal 1: Start Ponder dev server (Arbitrum One mainnet)
cd apps/ponder
cp .env.example .env  # Fill in RPC URL and DB URLs
pnpm dev

# Terminal 2: Monitor logs
# Ponder logs show discovered contracts and processed events

Verification Checklist

  • [ ] Migration: pnpm prisma migrate dev creates nft_token_owner, nft_transfer, ponder_indexer_state tables and deck.current_owner column
  • [ ] Build: pnpm build succeeds across the full monorepo
  • [ ] Discovery: Ponder discovers existing Diamond collections via CollectionCreated factory events
  • [ ] Mint indexing: Mint a token via emprops-api → nft_token_owner record created with correct owner_address and minted_to
  • [ ] Deck update: After mint, deck.current_owner is populated automatically
  • [ ] Transfer indexing: Transfer a token on-chain → nft_token_owner.owner_address updated, nft_transfer record created
  • [ ] Query helpers: NftOwnershipOperations.getTokensByOwner() returns correct results
  • [ ] Reindex: Restart Ponder → Prisma tables cleared and rebuilt from chain state
  • [ ] Indexer state: ponder_indexer_state shows correct status and last_indexed_block
  • [ ] Ponder API: GET /indexer/status returns indexer status (Ponder reserves /health, /ready, /status, /metrics)

Integration Test Scenarios

  1. Fresh start: Ponder starts, discovers all existing collections, indexes all historical events, Prisma tables match on-chain state
  2. New collection: Deploy a new collection while Ponder is running → automatically discovered and indexed
  3. Mint + transfer: Mint token → transfer to different address → verify nft_token_owner reflects final owner, nft_transfer has both events
  4. Multi-collection: Multiple collections with independent token ID spaces → verify no cross-contamination
  5. Reindex: Change ponder.schema.ts → Ponder reindexes → verify Prisma tables rebuilt correctly

Phase 7: Monitor Ownership UI

Add an "Ownership" tab to the monitor's NFT page (apps/monitor/src/app/nft/page.tsx) that displays Ponder-indexed ownership data from Prisma.

7.1 API Routes

Three new Next.js API routes proxy ownership data from emprops-api:

GET /api/nft/ownership?collectionId=<uuid> — Token ownership for a collection

Returns all minted tokens in a collection with their current on-chain owners (from deck.current_owner), mint recipients, mint status, and last transfer timestamps.

GET /api/nft/ownership/transfers?deckId=<uuid> — Transfer history for a token

Returns the full transfer history for a specific deck/token from nft_transfer, ordered by block number descending. Each record includes from/to addresses, block number, tx hash, and timestamp.

GET /api/nft/ownership/indexer-status — Ponder indexer health

Returns the ponder_indexer_state singleton: status (idle/indexing/reindexing), last indexed block, chain ID, and timestamps.

7.2 Ownership Tab UI

The 6th tab on the NFT page with three sections:

Indexer Status Banner:

  • Shows Ponder indexer operational state (idle/indexing/reindexing)
  • Last indexed block number
  • "Syncing" indicator during reindex

Collection Token Ownership Table:

  • Collection selector (reuses existing dropdown pattern from Mint/Admin tabs)
  • Table columns: Token ID, Current Owner, Minted To, Mint Status, Last Transfer
  • Owner addresses with copy-to-clipboard buttons
  • Click a row to expand and show transfer history inline

Transfer History Panel (expandable per token):

  • Shows: From → To, Block #, Tx Hash, Timestamp
  • Tx hash links to Arbiscan (chain-aware)
  • Mint events highlighted (from = 0x0000...)

7.3 Files

FileChange
apps/monitor/src/app/nft/page.tsxAdd Ownership tab (6th tab) with OwnershipTab component
apps/monitor/src/app/api/nft/ownership/route.tsNew: token ownership endpoint
apps/monitor/src/app/api/nft/ownership/transfers/route.tsNew: transfer history endpoint
apps/monitor/src/app/api/nft/ownership/indexer-status/route.tsNew: indexer status endpoint

7.4 Verification

  • Start Ponder against Arbitrum One, wait for it to index
  • Open monitor → NFT → Ownership tab
  • Select a collection → see tokens with current owners
  • Mint a new token → verify it appears with owner after Ponder indexes
  • Transfer a token on-chain → verify owner updates and transfer history shows

Key Gotchas

  1. startBlock values: Must be resolved from factory deployment tx hashes. Using 0 works but is slow (scans from genesis). Look up actual block numbers via Arbiscan.

  2. Diamond proxy events: Transfer events are emitted at the Diamond proxy address, not the facet address. This is correct behavior — Ponder indexes by contract address, and the Diamond proxy IS the contract address.

  3. Ponder factory() start block: The factory's startBlock must be ≤ the earliest CollectionCreated event. If set too late, early collections won't be discovered.

  4. BigInt handling: Ponder uses bigint for block numbers and token IDs. Prisma uses BigInt. The sync layer must handle conversion: Number(tokenId) for Prisma Int fields, pass bigint directly for Prisma BigInt fields.

  5. Address checksumming: Ponder returns lowercase hex addresses. Prisma stores whatever you give it. Normalize to lowercase in the sync layer to avoid duplicate records with different casing.

  6. Neon connection limits: Ponder's internal pool + Prisma pool both connect to Neon. Keep Ponder's pool small (default is fine) and ensure the total doesn't exceed Neon's plan limits.

  7. Ponder version: Use 0.10.x (stable onchainTable and factory() API). Avoid 0.11+ until its breaking changes are evaluated.

  8. Reindex side effects: When Ponder reindexes, it replays all events. The Prisma sync layer must be idempotent (use upsert, not create). The clearPrismaOwnershipData() function should run once at the start of reindex.


Files Modified

FileChange
packages/database/prisma/schema.prismaAdd nft_transfer + ponder_indexer_state models, add fields to deck
packages/database/src/index.tsExport new types + operations
turbo.jsonAdd ponder pipeline tasks
Root package.jsonAdd ponder scripts
apps/docs/src/.vitepress/config.tsSidebar links for ADR + IMPL
apps/monitor/src/app/nft/page.tsxAdd Ownership tab (Phase 7)

Files Created

FilePurpose
apps/ponder/package.jsonApp definition
apps/ponder/tsconfig.jsonTypeScript config
apps/ponder/ponder.config.tsNetwork + contract configuration (Arbitrum One only)
apps/ponder/ponder.schema.tsPonder internal tables
apps/ponder/ponder-env.d.tsType environment
apps/ponder/.env.exampleEnvironment template
apps/ponder/abis/DiamondFactory.tsFactory event ABI
apps/ponder/abis/ERC721Diamond.tsERC721 event ABIs
apps/ponder/src/index.tsHandler registration
apps/ponder/src/handlers/factory.tsCollectionCreated handler
apps/ponder/src/handlers/transfer.tsTransfer handler (core)
apps/ponder/src/lib/prisma-sync.tsPrisma write layer
apps/ponder/src/lib/reindex.tsReindex cleanup
apps/ponder/src/api/index.tsCustom API endpoints
packages/database/src/nft-ownership.tsOwnership query helpers
apps/monitor/src/app/api/nft/ownership/route.tsToken ownership endpoint
apps/monitor/src/app/api/nft/ownership/transfers/route.tsTransfer history endpoint
apps/monitor/src/app/api/nft/ownership/indexer-status/route.tsIndexer status endpoint

Released under the MIT License.