Server-side artifact metadata caching for Ponder indexers. Resolves NFT token and collection metadata (ERC721 + ERC1155) on demand, caches it with a 30-day TTL, and serves it via ready-to-mount Hono API routes.
Works with both PostgreSQL and PGlite (Ponder's default embedded database) — no Postgres required for development.
Ponder rebuilds its onchain tables from scratch on every reindex. Token metadata (names, images, descriptions) doesn't change often, but fetching it from IPFS/Arweave on every page load is slow and redundant across clients.
This package stores artifact metadata in a separate offchain table that persists across reindexes. Metadata is resolved lazily on first request and cached with a 30-day TTL. Many concurrent requests for the same token result in a single RPC + IPFS lookup — not one per client.
pnpm add @1001-digital/ponder-artifactsPeer dependencies (your ponder app should already have these):
pnpm add drizzle-orm hono viem// src/api/index.ts
import { db, publicClients } from "ponder:api";
import schema from "ponder:schema";
import { Hono } from "hono";
import { client, graphql } from "ponder";
import {
createArtifactRoutes,
createOffchainDb,
} from "@1001-digital/ponder-artifacts";
const { db: artifactDb } = await createOffchainDb();
const app = new Hono();
app.route(
"/artifacts",
createArtifactRoutes({
client: publicClients["ethereum"],
db: artifactDb,
}),
);
app.use("/sql/*", client({ db, schema }));
app.use("/", graphql({ db, schema }));
export default app;That's it. You now have:
| Method | Path | Behavior |
|---|---|---|
| GET | /artifacts/:address |
Returns cached collection info, refreshes if stale (>30 days) |
| POST | /artifacts/:address |
Force refresh collection from chain |
| GET | /artifacts/:collection/:tokenId |
Returns cached token metadata, refreshes if stale |
| POST | /artifacts/:collection/:tokenId |
Force refresh token from chain |
On fetch failure, stale cache is returned if available; 500 otherwise.
createOffchainDb() auto-detects your database setup:
- With
DATABASE_URL(orDATABASE_PRIVATE_URL): connects to PostgreSQL, creates theoffchainschema and tables if they don't exist. - Without
DATABASE_URL: uses PGlite (Postgres-in-WASM), stores data in.ponder/artifacts/by default.
// Auto-detect (recommended)
const { db } = await createOffchainDb();
// Explicit Postgres
const { db } = await createOffchainDb({ databaseUrl: "postgresql://..." });
// Explicit PGlite with custom directory
const { db } = await createOffchainDb({ dataDir: ".data/artifacts" });For metadata resolution outside of API routes (e.g. in scripts), use createArtifactService:
import {
createArtifactService,
createOffchainDb,
} from "@1001-digital/ponder-artifacts";
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({ chain: mainnet, transport: http() });
const { db } = await createOffchainDb();
const artifacts = createArtifactService({ client, db });
// Fetch cached token (returns null if not yet cached)
const token = await artifacts.fetchToken("0xbc4c...", "42");
// Update token metadata from chain + IPFS
await artifacts.updateToken("0xbc4c...", "42");
// Fetch cached collection
const collection = await artifacts.fetchCollection("0xbc4c...");
// Update collection metadata from chain
await artifacts.updateCollection("0xbc4c...");
// Check if a cached timestamp is still fresh
artifacts.isFresh(token?.updatedAt ?? null);If you manage your own offchain database, skip createOffchainDb and pass your drizzle instance directly:
import { createArtifactRoutes } from "@1001-digital/ponder-artifacts";
import { getOffchainDb } from "./services/database";
app.route(
"/artifacts",
createArtifactRoutes({
client: publicClients["ethereum"],
db: getOffchainDb(),
}),
);The package exports the schema for your drizzle config:
// offchain.schema.ts
export { artifactToken, artifactCollection } from "@1001-digital/ponder-artifacts";The service detects ERC721 vs ERC1155 via ERC165 supportsInterface:
- ERC721 (
0x80ac58cd): callstokenURI(tokenId) - ERC1155 (
0xd9b67a26): callsuri(tokenId), replaces{id}with zero-padded hex token ID - Unknown: falls back to ERC721
tokenURI
Token and collection URIs are resolved inline (no external dependency):
ipfs://...— resolved via IPFS gateway (default:https://ipfs.io/ipfs/)ar://...— resolved via Arweave gateway (default:https://arweave.net/)Qm.../baf...— treated as raw IPFS hashesdata:application/json;base64,...— decoded inlinedata:application/json,...— URL-decoded inline- Everything else — fetched as-is
Custom gateways can be passed via config:
createArtifactRoutes({
client: publicClients["ethereum"],
db: artifactDb,
ipfsGateway: "https://my-gateway.io/ipfs/",
arweaveGateway: "https://my-arweave.io/",
});| Option | Type | Description |
|---|---|---|
client |
viem PublicClient |
Client for on-chain reads (ERC165, tokenURI, contractURI, etc.) |
db |
drizzle instance | For reading and writing metadata. Use createOffchainDb() or bring your own. |
cacheTtl |
number (ms) |
Cache freshness window. Defaults to 30 days. |
ipfsGateway |
string |
IPFS gateway URL. Defaults to https://ipfs.io/ipfs/. |
arweaveGateway |
string |
Arweave gateway URL. Defaults to https://arweave.net/. |
{
collection: string; // Lowercase hex address
tokenId: string; // String representation of bigint
tokenStandard: string; // "erc721" | "erc1155" | "unknown"
tokenUri: string | null; // Raw URI from contract
name: string | null;
description: string | null;
image: string | null;
animationUrl: string | null;
data: object | null; // Full raw metadata blob
updatedAt: number; // Unix timestamp (seconds)
}{
collection: string; // Lowercase hex address
tokenStandard: string; // "erc721" | "erc1155" | "unknown"
name: string | null; // From on-chain name() or contractURI
symbol: string | null; // From on-chain symbol() or contractURI
owner: string | null; // From on-chain owner()
contractUri: string | null;
description: string | null;
image: string | null;
data: object | null; // Full raw contractURI metadata blob
updatedAt: number; // Unix timestamp (seconds)
}