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 fromCollectionCreatedevents - Prisma Integration: Extend
deckwithcurrent_owner+ ownership tracking fields, addnft_transfertable (FK → deck) for transfer history, addponder_indexer_statefor health monitoring - Same Database: Ponder uses a separate PostgreSQL schema for its internal tables but writes to the
publicschema 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
decktable - 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:
- Showing users their owned tokens across the platform
- Enabling token-gated features and access control
- Building marketplace and secondary market features
- Maintaining accurate provenance records
Why Ponder?
| Aspect | Roll Our Own | The Graph | Ponder |
|---|---|---|---|
| Language | TypeScript | AssemblyScript | TypeScript |
| Hosting | Self-hosted | Hosted or self-hosted | Self-hosted |
| Hot Reload | Manual | No | Yes (dev mode) |
| Type Safety | Manual | Codegen | Full end-to-end |
| Database | Custom | GraphQL only | PostgreSQL + GraphQL + HTTP |
| Factory Pattern | Custom | Subgraph templates | Built-in factory() |
| Reorg Handling | Custom | Built-in | Built-in |
| Complexity | High | Medium | Low |
Ponder was chosen because:
- TypeScript throughout — consistent with the monorepo (no AssemblyScript)
- PostgreSQL native — can share the same Neon database as Prisma
- Built-in
factory()pattern — critical for discovering Diamond contracts deployed byDiamondFactory - Dev mode with hot reload — rapid iteration during development
- 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:
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
onchainTableAPI) - 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:
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
| Event | Source | Purpose |
|---|---|---|
CollectionCreated(address indexed diamond, address indexed collectionOwner, bytes32 salt) | DiamondFactory | Discover 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:
| Field | Type | Description |
|---|---|---|
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 |
New nft_transfer model — Transfer event history, linked to deck via deck_id FK:
| Field | Type | Description |
|---|---|---|
id | UUID | Primary key |
deck_id | UUID (FK) | Direct link to the deck/token |
from_address | String | Sender (0x0 for mints) |
to_address | String | Recipient |
block_number | BigInt | Block number |
tx_hash | String | Transaction hash |
log_index | Int | Log index within transaction |
timestamp | DateTime | Block 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:
| Field | Type | Description |
|---|---|---|
id | String | Always "singleton" |
status | String | idle / indexing / reindexing |
last_indexed_block | BigInt? | Most recent processed block |
chain_id | Int? | 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):
- Ponder drops and recreates its internal
onchainTabletables automatically - A setup handler clears
nft_transferrecords and resetsdeck.current_owner/deck.last_transfer_block/deck.last_transfer_at - The
ponder_indexer_state.statusis set to"reindexing"so apps can show a "syncing" indicator - All event handlers replay from
startBlock, rebuilding both Ponder and Prisma tables - All Prisma writes use
upsertfor idempotency — safe during replays
Consequences
Positive
- Zero app changes needed — Ownership data available through existing
@emergexyz/dbimports - Automatic contract discovery — Every Diamond deployed by the factory is indexed without manual configuration
- Complete ownership history — Full provenance chain stored in
nft_transfertable - 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 enrichment —
deck.current_ownerupdated 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
- Side effects: All Prisma writes are idempotent (
upsertwith unique constraints). If Ponder replays events, the result is the same. Theponder_indexer_statetable coordinates cleanup on full reindex. - Connection pools: Configure Ponder with a small internal pool (5 connections). Prisma's pool is already configured for 100 connections.
- Reindex downtime: Apps check
ponder_indexer_state.statusand show "indexer syncing" UI rather than empty ownership data. - Infrastructure: Ponder is a single Node.js process — can run alongside existing services in the deployment.
- 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
Related Documentation
- NFT Diamond Standard on Arbitrum — Contract architecture
- SolidState Diamond Migration — ERC721 + Diamond library
- Implementation Guide — Step-by-step build plan
- Ponder Reference Documentation — External reference implementation analysis
- Ponder Official Docs — Framework documentation
