Skip to content

ADR: Ponder Blockchain Indexer for NFT Ownership Tracking

Date: 2026-02-25 Status: Proposed Depends On: ADR 2026-02-06 (NFT Diamond Standard on Arbitrum), ADR 2026-02-09 (SolidState Diamond Migration) Decision Makers: Architecture Team


Executive Summary

Add a Ponder blockchain indexer (apps/ponder) that watches Transfer events from all Diamond collection contracts and writes ownership data into the existing Prisma database. This enables every app in the monorepo to query current NFT ownership and transfer history through the familiar @emergexyz/db Prisma client.

Key Decisions:

  • Dual-Write Strategy: Ponder event handlers write to Ponder's internal tables AND to Prisma-managed tables via @emergexyz/db
  • Factory Pattern: Use Ponder's factory() to auto-discover Diamond collection contracts from CollectionCreated events
  • Prisma Integration: Extend deck with current_owner + ownership tracking fields, add nft_transfer table (FK → deck) for transfer history, add ponder_indexer_state for health monitoring
  • Same Database: Ponder uses a separate PostgreSQL schema for its internal tables but writes to the public schema for app-facing Prisma tables

Context

Why Now?

The NFT system has a fully functional launch-and-mint pipeline:

  • Diamond contracts deployed on Arbitrum One and Sepolia
  • Custodial minting API tracks every mint in the deck table
  • Metadata serving, collection management, and monitor UI all working

However, once tokens are minted, there is no visibility into ownership changes. The system only knows about mints it performs itself. It cannot see:

  • Transfers after minting (secondary market, user-to-user)
  • Current token ownership state (who holds what right now)
  • Transfer history (provenance chain)

The original ADR 2026-02-06 deferred Ponder to Phase 6, reasoning that custodial minting means we track all mints server-side. This remains true for mints — but as collections go live and tokens begin circulating, tracking ownership becomes essential for:

  1. Showing users their owned tokens across the platform
  2. Enabling token-gated features and access control
  3. Building marketplace and secondary market features
  4. Maintaining accurate provenance records

Why Ponder?

AspectRoll Our OwnThe GraphPonder
LanguageTypeScriptAssemblyScriptTypeScript
HostingSelf-hostedHosted or self-hostedSelf-hosted
Hot ReloadManualNoYes (dev mode)
Type SafetyManualCodegenFull end-to-end
DatabaseCustomGraphQL onlyPostgreSQL + GraphQL + HTTP
Factory PatternCustomSubgraph templatesBuilt-in factory()
Reorg HandlingCustomBuilt-inBuilt-in
ComplexityHighMediumLow

Ponder was chosen because:

  1. TypeScript throughout — consistent with the monorepo (no AssemblyScript)
  2. PostgreSQL native — can share the same Neon database as Prisma
  3. Built-in factory() pattern — critical for discovering Diamond contracts deployed by DiamondFactory
  4. Dev mode with hot reload — rapid iteration during development
  5. Lightweight — single process, no external dependencies beyond PostgreSQL and an RPC endpoint

Why Prisma Integration?

All apps in the monorepo query data through @emergexyz/db:

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

Ownership data must be available through the same interface. Apps should not need to learn a new query library or connect to a separate service. By writing ownership data to Prisma-managed tables, existing apps get ownership queries for free.

Existing Reference Implementation

An external reference implementation exists at ~/code/emprops/nft_investigation/emprops-ponder (Ponder 0.9.17, ~60% complete). Key patterns carried forward:

  • Dynamic contract discovery via factory events
  • Drizzle ORM for internal queries
  • PostgreSQL for persistent storage
  • Custom Hono API endpoints

Key differences from the reference:

  • We use Ponder 0.10+ (stable onchainTable API)
  • We write to Prisma tables instead of relying solely on Ponder's internal tables
  • We index the Diamond Standard (ERC-2535) rather than ERC1167 proxies
  • No Socket.IO in v1 (apps poll Prisma instead)

Decision

Architecture: Dual-Write from Event Handlers

┌─────────────────────────────────────────────────────────────────┐
│                      Arbitrum Blockchain                         │
│                                                                  │
│  DiamondFactory ──CollectionCreated──▶ Diamond Proxy addresses   │
│  Diamond Proxy  ──Transfer──────────▶ Ownership changes          │
│  Diamond Proxy  ──Minted───────────▶ New token creation          │
└──────────────────────────┬──────────────────────────────────────┘
                           │ RPC polling

┌─────────────────────────────────────────────────────────────────┐
│                     apps/ponder (Ponder Indexer)                  │
│                                                                  │
│  ponder.config.ts    ─ Networks: Arbitrum One                    │
│                      ─ Contracts: DiamondFactory (static addr)   │
│                      ─ Contracts: ERC721Diamond (factory pattern) │
│                                                                  │
│  Event Handlers:                                                 │
│    DiamondFactory:CollectionCreated → discover new collections   │
│    ERC721Diamond:Transfer           → track ownership changes    │
│                                                                  │
│  Dual Write:                                                     │
│    1. Ponder internal tables (onchainTable, ephemeral)           │
│    2. Prisma tables via @emergexyz/db (stable, app-facing)       │
└──────────────────────────┬──────────────────────────────────────┘
                           │ prisma.upsert / prisma.create

┌─────────────────────────────────────────────────────────────────┐
│                     Neon PostgreSQL Database                      │
│                                                                  │
│  Schema: ponder (Ponder internal)                                │
│    collections, transfers                                        │
│                                                                  │
│  Schema: public (Prisma managed, app-facing)                     │
│    deck.current_owner ─ Updated on each transfer                 │
│    nft_transfer     ─ Full transfer history (FK → deck)          │
│    ponder_indexer_state ─ Indexer health/progress                 │
└──────────────────────────┬──────────────────────────────────────┘
                           │ import { prisma } from '@emergexyz/db'

┌─────────────────────────────────────────────────────────────────┐
│               Existing Apps (unchanged query interface)           │
│                                                                  │
│  emprops-api   ─ /nft/collections/:id/tokens → includes owner   │
│  monitor       ─ collection dashboard → shows ownership stats    │
│  emprops-studio ─ user profile → shows owned tokens              │
│  miniapp       ─ token-gated features → checks ownership         │
└─────────────────────────────────────────────────────────────────┘

Contract Discovery: Ponder factory() Pattern

The DiamondFactory contract emits CollectionCreated when a new Diamond proxy is deployed:

solidity
event CollectionCreated(
    address indexed diamond,
    address indexed collectionOwner,
    bytes32 salt
);

Ponder's factory() function watches this event and automatically begins indexing Transfer events from each discovered Diamond address. No manual contract registration needed — every collection deployed through the factory is automatically tracked.

Events Indexed

EventSourcePurpose
CollectionCreated(address indexed diamond, address indexed collectionOwner, bytes32 salt)DiamondFactoryDiscover new collection contracts
Transfer(address indexed from, address indexed to, uint256 indexed tokenId)Diamond Proxy (ERC721)Track all ownership changes (including mints where from == 0x0)
Minted(address indexed to, uint256 indexed tokenId)Diamond Proxy (MintFacet)Supplementary mint tracking with custom event data

The Transfer event is the primary handler — it covers both mints (from == address(0)) and secondary transfers. The Minted event provides additional context but is not strictly necessary since Transfer captures the same information.

Prisma Schema Changes

No separate ownership table. The existing deck model already IS the NFT token record — it has token_id, collection_id (→ contract_address), mint_recipient, user_id, custodied_for. We extend it with on-chain ownership fields rather than duplicating the token concept.

New fields on deck:

FieldTypeDescription
current_ownerString?Current on-chain owner wallet address
last_transfer_blockBigInt?Block number of most recent transfer
last_transfer_atDateTime?Timestamp of most recent transfer

New nft_transfer model — Transfer event history, linked to deck via deck_id FK:

FieldTypeDescription
idUUIDPrimary key
deck_idUUID (FK)Direct link to the deck/token
from_addressStringSender (0x0 for mints)
to_addressStringRecipient
block_numberBigIntBlock number
tx_hashStringTransaction hash
log_indexIntLog index within transaction
timestampDateTimeBlock timestamp

Unique constraint: (tx_hash, log_index) — one record per log event. Relation: deck.nft_transfers for transfer history.

New ponder_indexer_state model — Singleton tracking indexer health:

FieldTypeDescription
idStringAlways "singleton"
statusStringidle / indexing / reindexing
last_indexed_blockBigInt?Most recent processed block
chain_idInt?Chain being indexed

Database Schema Isolation

Ponder's internal tables live in a separate PostgreSQL schema (ponder), configured via ?schema=ponder in the connection string. This keeps Ponder's ephemeral tables (dropped/recreated on reindex) completely separated from Prisma-managed tables in the public schema.

Reindex Safety

When Ponder needs to reindex (schema change, config change, or manual trigger):

  1. Ponder drops and recreates its internal onchainTable tables automatically
  2. A setup handler clears nft_transfer records and resets deck.current_owner / deck.last_transfer_block / deck.last_transfer_at
  3. The ponder_indexer_state.status is set to "reindexing" so apps can show a "syncing" indicator
  4. All event handlers replay from startBlock, rebuilding both Ponder and Prisma tables
  5. All Prisma writes use upsert for idempotency — safe during replays

Consequences

Positive

  • Zero app changes needed — Ownership data available through existing @emergexyz/db imports
  • Automatic contract discovery — Every Diamond deployed by the factory is indexed without manual configuration
  • Complete ownership history — Full provenance chain stored in nft_transfer table
  • Real-time-ish updates — Ponder polls the chain and processes events within seconds
  • Reorg safe — Ponder handles chain reorganizations automatically
  • Dev-friendly — Hot reload in dev mode, TypeScript end-to-end
  • Deck enrichmentdeck.current_owner updated automatically, enriching existing queries

Negative

  • Side effects in event handlers — Writing to Prisma from Ponder handlers is technically a side effect. Ponder expects handlers to be deterministic; external writes could cause issues if Ponder's internal rollback doesn't also roll back Prisma writes
  • Connection pool pressure — Ponder's internal connection + Prisma connections to the same Neon database. Must keep pool sizes reasonable
  • Reindex downtime — During reindex, ownership data is temporarily cleared. Apps must handle the "reindexing" state gracefully
  • Additional infrastructure — One more process to deploy and monitor
  • Coupled to Ponder versioning — Breaking changes in Ponder's API would require migration

Mitigations

  1. Side effects: All Prisma writes are idempotent (upsert with unique constraints). If Ponder replays events, the result is the same. The ponder_indexer_state table coordinates cleanup on full reindex.
  2. Connection pools: Configure Ponder with a small internal pool (5 connections). Prisma's pool is already configured for 100 connections.
  3. Reindex downtime: Apps check ponder_indexer_state.status and show "indexer syncing" UI rather than empty ownership data.
  4. Infrastructure: Ponder is a single Node.js process — can run alongside existing services in the deployment.
  5. Versioning: Pin Ponder to a specific minor version. The onchainTable/factory() API is stable in 0.10+.

Alternatives Considered

1. Query On-Chain Directly (No Indexer)

  • Pro: No additional infrastructure
  • Con: Requires individual ownerOf() calls per token — O(n) RPC calls to list all tokens for an address. No transfer history. Slow, expensive at scale.
  • Rejected: Unusable for listing "all tokens owned by user" across multiple collections

2. The Graph (Subgraph)

  • Pro: Battle-tested, hosted option available
  • Con: AssemblyScript (not TypeScript), separate GraphQL endpoint (not Prisma), external dependency, complex deployment
  • Rejected: Language mismatch with monorepo, no Prisma integration, adds external service dependency

3. Custom Event Listener (No Ponder)

  • Pro: Full control, no framework dependency
  • Con: Must handle reorgs, backfills, cursor management, reconnection, rate limiting — all solved problems in Ponder
  • Rejected: Reinventing the wheel (per CLAUDE.md principle: "Check Context7 MCP for existing libraries before building")

4. Ponder Tables as Prisma Models (Read-Only Mapping)

  • Pro: Single write path (only Ponder), Prisma reads Ponder tables via @@map
  • Con: Ponder drops and recreates tables on reindex — would break Prisma's schema expectations. Ponder table names and schemas are internal and may change between versions.
  • Rejected: Too fragile; Ponder's table lifecycle is incompatible with Prisma's migration model

5. Webhook Sync (Ponder API → Prisma via HTTP)

  • Pro: Clean separation of concerns
  • Con: Additional HTTP layer, retry logic, potential for data loss or ordering issues, more moving parts
  • Rejected: Adds unnecessary complexity when direct Prisma client import is simpler

Released under the MIT License.