{"name":"skill:build-ponder-indexer","files":[{"path":".imd/reads/skills/build-ponder-indexer.md","digest":"1a06b31b1e323a1111719f0f13f769e937c5e212becd3e93f35d3a6a7ac7feb6","content":"You are building the read side of a set of contracts: the thing a frontend, a dashboard or a report\nqueries instead of scanning logs. Ponder is a TypeScript framework that replays contract events\nfrom a start block into Postgres tables and serves them over GraphQL and SQL. The repository you\ndeliver is the release: it is published to GitHub as it stands, and the person who ordered it\ndeploys it themselves, on their own server with Docker Compose or on Railway. Nobody will rebuild\nit for them, so it has to deploy from a fresh clone with nothing but the variables the README names.\n\nThis describes Ponder 0.18. Pin the version you install exactly in `package.json` and check its\ndocs where an import differs; the API has been stable since 0.11 apart from the changes in its\nmigration guide.\n\n## What to index\n\nThe objective names the contracts in one of three ways. Work out which before writing anything.\n\n- **Addresses.** A chain and one or more addresses, with or without ABIs. Use an ABI the request\n  supplies. Otherwise take the verified ABI from Sourcify\n  (`https://sourcify.dev/server/v2/contract/<chainId>/<address>?fields=abi`, no key) or, when one\n  is configured on this machine, a block explorer API. A proxy is indexed under the proxy's address\n  with the implementation's ABI. Never write an ABI from memory or from what the contract\n  probably emits; an event signature that is one type off decodes nothing and fails silently.\n- **A project.** When `.imd/reads/project.md` exists, or the tree under you is a contract project,\n  the contracts are that project's. Take the ABIs from its source (`forge build` and the artifacts\n  in `out/`). When `.imd/reads/deployment.json` exists the contracts are live: its `chainId` is the\n  chain, each entry under `contracts` gives a contract's `name`, its `address` (the default address)\n  and its `blockNumber` (the start block), and `poolKey`, when present, is the launch's Uniswap v4\n  pool, whose swaps and liquidity changes are the PoolManager's events for that pool's id, the\n  manager's address being in `.imd/reads/network.json`. Never take an address from prose when that\n  file gives one.\n  Contracts that are not deployed are configured from environment variables with no default\n  address, and the README says so.\n- **A description.** \"Every Uniswap v4 pool on Base\", \"transfers of this token over a million\".\n  Resolve it to concrete contracts, events and filters first and write that resolution into\n  `INDEXER.md`. A factory's children are indexed with `factory()` in the address field, not by\n  listing children by hand. Check well-known addresses against the chain before you trust them.\n\nFind the start block: the deployment block of each contract, from the explorer, Sourcify's\ncreation transaction, or a binary search on `eth_getCode` over the RPC. A start block of 0 on a\nmainnet replays the whole chain, which is hours of RPC calls that index nothing. If a request asks\nfor less history (\"from this month\"), use that and say so.\n\n## Where it lives\n\nA new indexer is its own repository, with the Ponder project at the root, so Railway and Docker\nbuild it with no settings. When the tree under you is a contract project (a follow-up that adds an\nindexer to a launch), put the whole Ponder project in `indexer/` and leave everything outside it as\nit is: the root's build files are not this job's to change. Its Railway service then sets Root\nDirectory to `indexer`, and the README says so. `INDEXER.md` always sits at the repository root\nand says where the project is; every path below is relative to the project's directory.\n\n## The files\n\n**`ponder.config.ts`** says what to index. Chains are keyed by a name and carry an id and an RPC;\ncontracts carry an ABI, a chain, an address and a start block. Read each RPC from\n`PONDER_RPC_URL_<chainId>` and each address and start block from the environment with the real\ndeployment as the default, so the same repository follows a redeploy without an edit. Add `ws` from\n`PONDER_WS_URL_<chainId>` when it is set; realtime is cheaper over a websocket.\n\n```ts\nimport { createConfig } from \"ponder\";\nimport { ArenaAbi } from \"./abis/ArenaAbi\";\n\nexport default createConfig({\n  chains: {\n    mainnet: {\n      id: 1,\n      rpc: process.env.PONDER_RPC_URL_1!,\n      ws: process.env.PONDER_WS_URL_1,\n    },\n  },\n  contracts: {\n    Arena: {\n      abi: ArenaAbi,\n      chain: \"mainnet\",\n      address: (process.env.ARENA_ADDRESS ?? \"0x…\") as `0x${string}`,\n      startBlock: Number(process.env.ARENA_START_BLOCK ?? 21_000_000),\n    },\n  },\n});\n```\n\nA contract on several chains lists an address and start block per chain under `chain`, and\n`context.chain` tells the indexing function which one it is on. Put each ABI in `abis/` as a\nTypeScript `as const` export, not JSON, so event names and argument types are checked. Addresses on\n`event` are lowercase; compare them that way.\n\n**`ponder.schema.ts`** says what the tables are. One table per entity the events describe — a\nlisting, a round, an account, a transfer — with an explicit primary key; an event log that is its\nown row is keyed by `event.id`. Use `hex` for addresses and hashes, `bigint` for amounts, ids and\ntimestamps, and mark columns the events always supply as not null. Table names are at most 45\ncharacters. Add `relations` where a GraphQL reader would want to follow one table into another.\n\n```ts\nimport { index, onchainTable } from \"ponder\";\n\nexport const duel = onchainTable(\n  \"duel\",\n  (t) => ({\n    id: t.bigint().primaryKey(),\n    challenger: t.hex().notNull(),\n    opponent: t.hex(),\n    stake: t.bigint().notNull(),\n    createdAt: t.bigint().notNull(),\n    settledAt: t.bigint(),\n    winner: t.hex(),\n  }),\n  (table) => ({ challengerIdx: index().on(table.challenger) }),\n);\n```\n\n**`src/index.ts`** (or one file per contract) holds the indexing functions, one per\n`Contract:Event`, each receiving the decoded `event` and a `context` with the database and a client.\nUpsert where an event can arrive for a row that already exists.\n\n```ts\nimport { ponder } from \"ponder:registry\";\nimport { duel } from \"ponder:schema\";\n\nponder.on(\"Arena:DuelCreated\", async ({ event, context }) => {\n  await context.db.insert(duel).values({\n    id: event.args.duelId,\n    challenger: event.args.challenger,\n    stake: event.args.stake,\n    createdAt: event.block.timestamp,\n  });\n});\n\nponder.on(\"Arena:DuelSettled\", async ({ event, context }) => {\n  await context.db\n    .update(duel, { id: event.args.duelId })\n    .set({ winner: event.args.winner, settledAt: event.block.timestamp });\n});\n```\n\n`context.client.readContract` reads state at the event's block when the event does not carry what\na table needs. Each read is an RPC call during the backfill, so keep them few and never read in a\nhandler for an event that fires on every block or every transfer.\n\n**`src/api/index.ts`** is what serves the data; Ponder serves no GraphQL without it. Mount GraphQL\nand SQL over HTTP, and add a route only where a question needs more than a table query.\n\n```ts\nimport { db } from \"ponder:api\";\nimport schema from \"ponder:schema\";\nimport { Hono } from \"hono\";\nimport { client, graphql } from \"ponder\";\n\nconst app = new Hono();\n\napp.use(\"/graphql\", graphql({ db, schema }));\napp.use(\"/sql/*\", client({ db, schema }));\n\nexport default app;\n```\n\n`package.json` pins `ponder`, `hono` and `viem` exactly, and `typescript` and `@types/node` as dev\ndependencies at versions you ran the typecheck with; it declares\n`\"engines\": { \"node\": \">=22.12\" }` and `\"packageManager\"` for pnpm, and has the scripts `dev` (`ponder dev`), `start`\n(`ponder start`), `codegen` (`ponder codegen`) and `typecheck` (`tsc --noEmit`). Commit\n`pnpm-lock.yaml`, `ponder-env.d.ts` and `tsconfig.json`; ignore `node_modules`, `.ponder` and\n`generated` in `.gitignore`.\n\n## Deploying it\n\nPonder's production rules, which the files have to encode rather than leave to the person deploying:\n\n- **A schema per deployment.** `ponder start` refuses to run on Postgres without a database schema,\n  and two deployments must never share one. Railway's `RAILWAY_DEPLOYMENT_ID` is the schema Ponder's\n  own Railway guide uses: each deploy indexes into a fresh schema while the previous one keeps\n  serving, which is what makes a deploy zero-downtime. Anywhere else the image carries its own id,\n  written when it is built, so a rebuilt image gets a fresh schema and an unchanged one resumes.\n- **A views schema.** `--views-schema <name>` points a fixed schema's views at whichever deployment\n  last became ready, so anyone querying Postgres directly writes `SELECT * FROM <name>.duel` and never\n  has to know the deployment id. Default it to the project's name, at most 45 characters.\n- **The port.** `ponder start` listens on `PORT` when it is set, on `0.0.0.0`, which is what Railway\n  injects and checks.\n- **Readiness.** `/health` answers 200 as soon as the process is up; `/ready` answers 503 until the\n  backfill reaches the chain head on every chain, then 200. A platform that switches traffic on its\n  healthcheck should check `/ready`.\n- **The database.** `DATABASE_PRIVATE_URL`, else `DATABASE_URL`. Without either Ponder uses PGlite in\n  `.ponder/`, which is for development and lost with the container. Postgres must be close: Ponder\n  slows sharply above about 50 ms of round trip.\n\nPut the start logic in `scripts/start.sh`, so Compose, Railway and a person running it by hand all\nstart it the same way:\n\n```sh\n#!/bin/sh\nset -e\nschema=\"${DATABASE_SCHEMA:-${RAILWAY_DEPLOYMENT_ID:-$(cat .build-id 2>/dev/null || echo public)}}\"\nviews=\"${PONDER_VIEWS_SCHEMA:-arena}\"\nif [ \"$views\" = \"$schema\" ] || [ \"$views\" = \"off\" ]; then\n  exec pnpm exec ponder start --schema \"$schema\"\nfi\nexec pnpm exec ponder start --schema \"$schema\" --views-schema \"$views\"\n```\n\nThe **`Dockerfile`** installs from the lockfile and runs that script. Railway builds a root\n`Dockerfile` whenever there is one, with no other configuration.\n\n```dockerfile\nFROM node:22-slim\nRUN npm install -g pnpm@11.25.0\nWORKDIR /app\nCOPY package.json pnpm-lock.yaml ./\nRUN pnpm install --frozen-lockfile\nCOPY . .\nRUN echo \"b$(date +%s)\" > .build-id\nENV NODE_ENV=production\nEXPOSE 42069\nCMD [\"sh\", \"scripts/start.sh\"]\n```\n\nInstall pnpm at the version `packageManager` names; corepack in an older Node image can fail to\nverify a newer pnpm's signature. The `.build-id` line comes after `COPY . .`, so any change to the\nsource writes a new id. Install before `NODE_ENV=production` is set: Ponder compiles the\nTypeScript project when it starts, so `typescript` and the type packages have to be in the image. Add a `.dockerignore` for\n`node_modules`, `.ponder`, `generated`, `.git` and every `.env*` file, so a local secret never\nreaches an image.\n\n**`env.example`** (not `.env.example`: a delivered tree may not contain any `.env*` file) names every\nvariable the indexer reads, with a comment on each and the deployed address and start block as\nvalues. RPC values are left empty with a pointer to `.imd/reads/skills/public-rpcs/REFERENCE.md`'s\npublic endpoints for a trial; say plainly that a public endpoint rate-limits a backfill and a\nkeyed one is what production wants. Never commit an RPC key, a database URL or any other credential,\nincluding in the README.\n\nThe **`docker-compose.yml`** runs the indexer next to Postgres on any machine with Docker:\n\n```yaml\nservices:\n  postgres:\n    image: postgres:17\n    environment:\n      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}\n    volumes: [pgdata:/var/lib/postgresql/data]\n    healthcheck:\n      test: [\"CMD\", \"pg_isready\", \"-U\", \"postgres\"]\n      interval: 5s\n    restart: unless-stopped\n  indexer:\n    build: .\n    env_file: .env\n    environment:\n      DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres\n    ports: [\"42069:42069\"]\n    depends_on:\n      postgres: { condition: service_healthy }\n    restart: unless-stopped\nvolumes:\n  pgdata:\n```\n\nThe **README** has a \"Deploy\" section with two walkthroughs, step by step, for someone who has never\ndone either. When the request names another host, add a third for it with the same settings.\n\n**On your own server** (any VPS with Docker):\n\n1. Clone the repository, `cp env.example .env`, and fill in `PONDER_RPC_URL_<chainId>` for each\n   chain, any address or start-block override, `PONDER_VIEWS_SCHEMA` and `POSTGRES_PASSWORD`.\n2. `docker compose up -d --build`. `docker compose logs -f indexer` shows the backfill;\n   `curl localhost:42069/ready` answers 200 once it reaches the head.\n3. To ship a change, pull and run the same command. The API is down while the new build backfills,\n   because Compose stops the old container first; say so.\n4. Put a reverse proxy with TLS (Caddy is one line) in front of port 42069 before sharing the URL.\n\n**On Railway:**\n\n1. Push or fork this repository, then on Railway: New Project → Deploy from GitHub repo. The\n   Dockerfile is picked up as it is.\n2. In the project: Create → Database → Add PostgreSQL. On the indexer service's Variables, add a\n   reference to the Postgres service's `DATABASE_URL`, which is its private address. Keep both in one\n   region.\n3. Add `PONDER_RPC_URL_<chainId>` for each chain (and `PONDER_WS_URL_<chainId>` if there is one), any\n   address or start-block override, `PONDER_VIEWS_SCHEMA`, and `RAILWAY_HEALTHCHECK_TIMEOUT_SEC=3600`.\n4. Settings → Deploy → Healthcheck Path: `/ready`. Railway waits up to the timeout for the backfill\n   before it moves traffic; a backfill longer than an hour fails the deploy, so either start later or\n   check `/health` instead and accept that the API answers with partial data until it catches up.\n5. Settings → Networking → Generate Domain. GraphQL is at `https://<domain>/graphql`, SQL over HTTP\n   at `/sql`, progress at `/status`.\n\nOn both, each deploy leaves its old schema behind. Run `pnpm exec ponder db list` and\n`pnpm exec ponder db prune` against the database from time to time; prune drops only schemas no\nlive deployment uses. Say that heavy query traffic is served by more replicas of `ponder serve` on\nthe same schema rather than by a bigger indexer. Do not add `railway.json` or `railway.toml`:\nRailway has deprecated them, and a file that sets the start command overrides the Dockerfile in ways\nthe reader cannot see.\n\n## A record a later job can extend\n\nThis repository will be continued: someone will ask for another contract, another chain, a missing\nevent or a new query on top of what you deliver. `INDEXER.md` is what that job reads first, so it\nrecords the state, not the story:\n\n- each chain with its id, and each contract with its address, start block, the ABI's source (the\n  request, Sourcify, the project's artifacts) and whether it is a proxy or a factory;\n- each table with its primary key and the events that write it;\n- the events deliberately not indexed, each with its reason. An event the tables ignore is a\n  decision; say it.\n\nWhen you are the later job — `INDEXER.md` is already in the tree and `.imd/reads/project.md`\ndescribes it — extend what is there. Read `INDEXER.md` first and update it with your change. Add\ncontracts, chains, events and tables; do not rename or retype an existing table or column unless the\nrequest asks for it, because a deployed indexer re-indexes on any schema change and every query\nwritten against the old names breaks. A root `package.json` and lockfile cannot change in a tree that\nalready exists, so work with the dependencies they pin; if the request needs a new package or a\nPonder upgrade, deliver what you can without it and say in the README what is left and why.\n\n## Checking it\n\nThis worker has the network; the verifier does not and will not run anything. So before you\nsubmit, run `pnpm install`, `pnpm codegen` and `pnpm typecheck`, all of which must pass, and then\n`pnpm dev` against a real RPC long enough to see each table receive rows and each README query\nanswer. Run `docker compose up --build` if this machine has Docker. Record in the README what you ran, what it\nshowed, and what you could not run and why. The commands in the README are what a reader will try\nfirst; any you did not run yourself is a guess, and the README must say which."}]}