diff --git a/.changeset/config.json b/.changeset/config.json index 4638171c0..e6b4359c8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -16,8 +16,9 @@ "@ensnode/ponder-metadata", "@ensnode/ensnode-schema", "@ensnode/ensnode-react", - "@ensnode/ponder-subgraph", "@ensnode/ensnode-sdk", + "@ensnode/ponder-sdk", + "@ensnode/ponder-subgraph", "@ensnode/shared-configs", "@docs/ensnode", "@docs/ensrainbow", diff --git a/.changeset/cozy-turkeys-fix.md b/.changeset/cozy-turkeys-fix.md new file mode 100644 index 000000000..fa560eb0f --- /dev/null +++ b/.changeset/cozy-turkeys-fix.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ponder-sdk": minor +--- + +Introduce the `ponder-sdk` package, including an initial `PonderClient` implementation. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b676d0294..f901fce25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,7 @@ jobs: or .name == "@ensnode/ensrainbow-sdk" or .name == "@ensnode/namehash-ui" or .name == "@ensnode/ponder-metadata" + or .name == "@ensnode/ponder-sdk" or .name == "@ensnode/ponder-subgraph" )) diff --git a/.github/workflows/release_preview.yml b/.github/workflows/release_preview.yml index 5647dcd62..1063751ea 100644 --- a/.github/workflows/release_preview.yml +++ b/.github/workflows/release_preview.yml @@ -305,8 +305,9 @@ jobs: pnpm add @ensnode/ensrainbow-sdk@${{ needs.validate-and-prepare.outputs.dist-tag }} pnpm add @ensnode/ensnode-schema@${{ needs.validate-and-prepare.outputs.dist-tag }} pnpm add @ensnode/ensnode-sdk@${{ needs.validate-and-prepare.outputs.dist-tag }} - pnpm add @ensnode/ponder-subgraph@${{ needs.validate-and-prepare.outputs.dist-tag }} pnpm add @ensnode/ponder-metadata@${{ needs.validate-and-prepare.outputs.dist-tag }} + pnpm add @ensnode/ponder-sdk@${{ needs.validate-and-prepare.outputs.dist-tag }} + pnpm add @ensnode/ponder-subgraph@${{ needs.validate-and-prepare.outputs.dist-tag }} pnpm add @ensnode/ens-referrals@${{ needs.validate-and-prepare.outputs.dist-tag }} pnpm add @ensnode/namehash-ui@${{ needs.validate-and-prepare.outputs.dist-tag }} diff --git a/README.md b/README.md index 99b35f564..2f4ffa580 100644 --- a/README.md +++ b/README.md @@ -173,15 +173,19 @@ TypeScript library for interacting with the [ENSRainbow API](apps/ensrainbow). ### [`packages/ensnode-schema`](packages/ensnode-schema) -Shared Drizzle schema definitions used by ENSNode +Shared Drizzle schema definitions used by ENSNode. + +### [`packages/ponder-sdk`](packages/ponder-sdk) + +A utility library for interacting with Ponder apps and data. ### [`packages/ponder-subgraph`](packages/ponder-subgraph) -Subgraph-compatible GraphQL API +Subgraph-compatible GraphQL API. ### [`packages/shared-configs`](packages/shared-configs) -Shared internal configuration files +Shared internal configuration files. ## Docs diff --git a/packages/ponder-sdk/LICENSE b/packages/ponder-sdk/LICENSE new file mode 100644 index 000000000..24d66814d --- /dev/null +++ b/packages/ponder-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 NameHash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ponder-sdk/README.md b/packages/ponder-sdk/README.md new file mode 100644 index 000000000..939aa9844 --- /dev/null +++ b/packages/ponder-sdk/README.md @@ -0,0 +1,3 @@ +# Ponder SDK + +This package is a utility library for interacting with Ponder apps and data. diff --git a/packages/ponder-sdk/package.json b/packages/ponder-sdk/package.json new file mode 100644 index 000000000..4309c6646 --- /dev/null +++ b/packages/ponder-sdk/package.json @@ -0,0 +1,59 @@ +{ + "name": "@ensnode/ponder-sdk", + "version": "1.5.1", + "type": "module", + "description": "A utility library for interacting with Ponder apps and data.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/namehash/ensnode.git", + "directory": "packages/ponder-sdk" + }, + "homepage": "https://github.com/namehash/ensnode/tree/main/packages/ponder-sdk", + "keywords": [ + "ENSNode", + "Ponder" + ], + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "scripts": { + "prepublish": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest", + "lint": "biome check --write .", + "lint:ci": "biome ci" + }, + "devDependencies": { + "@ensnode/shared-configs": "workspace:*", + "@types/node": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "zod": "catalog:" + } +} diff --git a/packages/ponder-sdk/src/blocks.ts b/packages/ponder-sdk/src/blocks.ts new file mode 100644 index 000000000..e80169be2 --- /dev/null +++ b/packages/ponder-sdk/src/blocks.ts @@ -0,0 +1,27 @@ +import { z } from "zod/v4"; + +import { nonnegativeIntegerSchema } from "./numbers"; +import { unixTimestampSchema } from "./time"; + +//// Block Number + +export const blockNumberSchema = nonnegativeIntegerSchema; + +/** + * Block Number + * + * Guaranteed to be a non-negative integer. + */ +export type BlockNumber = z.infer; + +export const blockRefSchema = z.object({ + number: blockNumberSchema, + timestamp: unixTimestampSchema, +}); + +/** + * BlockRef + * + * Reference to a block. + */ +export type BlockRef = z.infer; diff --git a/packages/ponder-sdk/src/chains.ts b/packages/ponder-sdk/src/chains.ts new file mode 100644 index 000000000..d27e6c18e --- /dev/null +++ b/packages/ponder-sdk/src/chains.ts @@ -0,0 +1,17 @@ +import type { z } from "zod/v4"; + +import { positiveIntegerSchema } from "./numbers"; + +// Chain ID + +export const chainIdSchema = positiveIntegerSchema; + +/** + * Chain ID + * + * Represents a unique identifier for a chain. + * Guaranteed to be a positive integer. + * + * Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains + **/ +export type ChainId = z.infer; diff --git a/packages/ponder-sdk/src/client.test.ts b/packages/ponder-sdk/src/client.test.ts new file mode 100644 index 000000000..f509a61af --- /dev/null +++ b/packages/ponder-sdk/src/client.test.ts @@ -0,0 +1,122 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { PonderClient } from "./client"; +import { deserializePonderIndexingStatus } from "./deserialize/indexing-status"; +import { + mockSerializedPonderIndexingStatusInvalidBlockNumber, + mockSerializedPonderIndexingStatusInvalidChainId, + mockSerializedPonderIndexingStatusValid, +} from "./deserialize/indexing-status.mock"; + +// Mock Fetch API +const mockFetch = vi.fn(); + +describe("Ponder Client", () => { + beforeAll(() => { + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + mockFetch.mockReset(); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + describe("status()", () => { + it("should handle valid Ponder status response", async () => { + // Arrange + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockSerializedPonderIndexingStatusValid), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + + // Act & Assert + await expect(ponderClient.status()).resolves.toStrictEqual( + deserializePonderIndexingStatus(mockSerializedPonderIndexingStatusValid), + ); + }); + + describe("Invalid response handling", () => { + it("should handle invalid block numbers in the response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockSerializedPonderIndexingStatusInvalidBlockNumber), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + + await expect(ponderClient.status()).rejects.toThrowError( + /Invalid serialized Ponder Indexing Status.*Value must be a non-negative integer/, + ); + }); + + it("should handle invalid chain IDs in the response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockSerializedPonderIndexingStatusInvalidChainId), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + + await expect(ponderClient.status()).rejects.toThrowError( + /Invalid serialized Ponder Indexing Status.*Value must be a positive integer/, + ); + }); + + it("should handle zero indexed chains in the response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + + await expect(ponderClient.status()).rejects.toThrowError( + /Invalid serialized Ponder Indexing Status.*Ponder Indexing Status must include at least one indexed chain/, + ); + }); + }); + + describe("HTTP error handling", () => { + it("should handle non-OK HTTP responses", async () => { + // Arrange + mockFetch.mockResolvedValueOnce( + new Response("Internal Server Error", { + status: 500, + statusText: "Internal Server Error", + }), + ); + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + // Act & Assert + await expect(ponderClient.status()).rejects.toThrowError( + /Failed to fetch Ponder Indexing Status response/, + ); + }); + + it("should handle JSON parsing errors", async () => { + mockFetch.mockResolvedValueOnce( + new Response("not valid json", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const ponderClient = new PonderClient(new URL("http://localhost:3000")); + await expect(ponderClient.status()).rejects.toThrowError( + /Failed to parse Ponder Indexing Status response as JSON/, + ); + }); + }); + }); +}); diff --git a/packages/ponder-sdk/src/client.ts b/packages/ponder-sdk/src/client.ts new file mode 100644 index 000000000..56ec23074 --- /dev/null +++ b/packages/ponder-sdk/src/client.ts @@ -0,0 +1,36 @@ +import { deserializePonderIndexingStatus } from "./deserialize/indexing-status"; +import type { PonderIndexingStatus } from "./indexing-status"; + +/** + * PonderClient for fetching data from Ponder apps. + */ +export class PonderClient { + constructor(private baseUrl: URL) {} + + /** + * Get Ponder Indexing Status + * + * @returns Ponder Indexing Status. + * @throws Error if the response could not be fetched or was invalid. + */ + async status(): Promise { + const requestUrl = new URL("/status", this.baseUrl); + const response = await fetch(requestUrl); + + if (!response.ok) { + throw new Error( + `Failed to fetch Ponder Indexing Status response: ${response.status} ${response.statusText}`, + ); + } + + let responseData: unknown; + + try { + responseData = await response.json(); + } catch { + throw new Error("Failed to parse Ponder Indexing Status response as JSON"); + } + + return deserializePonderIndexingStatus(responseData); + } +} diff --git a/packages/ponder-sdk/src/deserialize/indexing-status.mock.ts b/packages/ponder-sdk/src/deserialize/indexing-status.mock.ts new file mode 100644 index 000000000..de591373f --- /dev/null +++ b/packages/ponder-sdk/src/deserialize/indexing-status.mock.ts @@ -0,0 +1,66 @@ +import type { SerializedPonderIndexingStatus } from "./indexing-status"; + +export const mockSerializedPonderIndexingStatusValid = { + mainnet: { + id: 1, + block: { + number: 24375715, + timestamp: 1770114251, + }, + }, + optimism: { + id: 10, + block: { + number: 147257736, + timestamp: 1770114249, + }, + }, + base: { + id: 8453, + block: { + number: 41662451, + timestamp: 1770114249, + }, + }, + arbitrum: { + id: 42161, + block: { + number: 428158835, + timestamp: 1770114250, + }, + }, + linea: { + id: 59144, + block: { + number: 28577755, + timestamp: 1770114244, + }, + }, + scroll: { + id: 534352, + block: { + number: 29352537, + timestamp: 1770114250, + }, + }, +} satisfies SerializedPonderIndexingStatus; + +export const mockSerializedPonderIndexingStatusInvalidBlockNumber = { + mainnet: { + id: 1, + block: { + number: -24375715, // Invalid negative block number + timestamp: 1770114251, + }, + }, +} satisfies SerializedPonderIndexingStatus; + +export const mockSerializedPonderIndexingStatusInvalidChainId = { + mainnet: { + id: 0, // Invalid non-positive chain ID + block: { + number: 24375715, + timestamp: 1770114251, + }, + }, +} satisfies SerializedPonderIndexingStatus; diff --git a/packages/ponder-sdk/src/deserialize/indexing-status.ts b/packages/ponder-sdk/src/deserialize/indexing-status.ts new file mode 100644 index 000000000..68eba16ce --- /dev/null +++ b/packages/ponder-sdk/src/deserialize/indexing-status.ts @@ -0,0 +1,87 @@ +/** + * Ponder Indexing Status + * + * Defines the structure and validation for the Ponder Indexing Status response + * from `GET /status` endpoint. + * @see https://ponder.sh/docs/advanced/observability#indexing-status + */ + +import { prettifyError, z } from "zod/v4"; +import type { ParsePayload } from "zod/v4/core"; + +import type { BlockRef } from "../blocks"; +import { blockRefSchema } from "../blocks"; +import type { ChainId } from "../chains"; +import { chainIdSchema } from "../chains"; +import type { PonderIndexingStatus } from "../indexing-status"; + +const schemaSerializedChainName = z.string(); + +const schemaSerializedChainBlockRef = z.object({ + id: chainIdSchema, + block: blockRefSchema, +}); + +function invariant_includesAtLeastOneIndexedChain( + ctx: ParsePayload, +) { + const records = ctx.value; + if (Object.keys(records).length === 0) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: "Ponder Indexing Status must include at least one indexed chain.", + }); + } +} + +/** + * Schema describing the response of fetching `GET /status` from a Ponder app. + */ +const schemaSerializedPonderIndexingStatus = z + .record(schemaSerializedChainName, schemaSerializedChainBlockRef) + .check(invariant_includesAtLeastOneIndexedChain); + +/** + * Serialized Ponder Indexing Status. + */ +export type SerializedPonderIndexingStatus = z.infer; + +/** + * Build Ponder Indexing Status + * + * @param data Validated serialized Ponder Indexing Status. + * @returns Ponder Indexing Status. + */ +function buildPonderIndexingStatus(data: SerializedPonderIndexingStatus): PonderIndexingStatus { + const chains = new Map(); + + for (const [, chainData] of Object.entries(data)) { + chains.set(chainData.id, chainData.block); + } + + return { + chains, + }; +} + +/** + * Deserialize and validate a Serialized Ponder Indexing Status. + * + * @param data Maybe a Serialized Ponder Indexing Status. + * @returns Deserialized and validated Ponder Indexing Status. + * @throws Error if data cannot be deserialized into a valid Ponder Indexing Status. + */ +export function deserializePonderIndexingStatus( + data: SerializedPonderIndexingStatus | unknown, +): PonderIndexingStatus { + const validation = schemaSerializedPonderIndexingStatus.safeParse(data); + + if (!validation.success) { + throw new Error( + `Invalid serialized Ponder Indexing Status: ${prettifyError(validation.error)}`, + ); + } + + return buildPonderIndexingStatus(validation.data); +} diff --git a/packages/ponder-sdk/src/index.ts b/packages/ponder-sdk/src/index.ts new file mode 100644 index 000000000..5ec76921e --- /dev/null +++ b/packages/ponder-sdk/src/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/packages/ponder-sdk/src/indexing-status.ts b/packages/ponder-sdk/src/indexing-status.ts new file mode 100644 index 000000000..8bd444b4d --- /dev/null +++ b/packages/ponder-sdk/src/indexing-status.ts @@ -0,0 +1,20 @@ +import type { BlockRef } from "./blocks"; +import type { ChainId } from "./chains"; + +/** + * Ponder Indexing Status + * + * Represents the chain indexing status in a Ponder application. + */ +export interface PonderIndexingStatus { + /** + * Map of indexed chain IDs to their block reference. + * + * Guarantees: + * - Includes entry for at least one indexed chain. + * - BlockRef corresponds to either: + * - The first block to be indexed (when chain indexing is currently queued). + * - The last indexed block (when chain indexing is currently in progress). + */ + chains: Map; +} diff --git a/packages/ponder-sdk/src/numbers.ts b/packages/ponder-sdk/src/numbers.ts new file mode 100644 index 000000000..43cd3bfad --- /dev/null +++ b/packages/ponder-sdk/src/numbers.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +// Numbers + +/** + * Any finite number. Rejects non-finite numbers such as `NaN` and `Infinity`. + */ +export const numberSchema = z.number({ error: `Value must be a number` }); + +export const integerSchema = numberSchema.int({ error: `Value must be an integer` }); + +export const nonnegativeNumberSchema = numberSchema.nonnegative({ + error: `Value must be non-negative`, +}); + +export const positiveNumberSchema = numberSchema.positive({ error: `Value must be positive` }); + +export const nonnegativeIntegerSchema = integerSchema.nonnegative({ + error: `Value must be a non-negative integer`, +}); + +export const positiveIntegerSchema = integerSchema.positive({ + error: `Value must be a positive integer`, +}); diff --git a/packages/ponder-sdk/src/time.ts b/packages/ponder-sdk/src/time.ts new file mode 100644 index 000000000..bc130264c --- /dev/null +++ b/packages/ponder-sdk/src/time.ts @@ -0,0 +1,17 @@ +import type { z } from "zod/v4"; + +import { integerSchema } from "./numbers"; + +//// Unix Timestamp +export const unixTimestampSchema = integerSchema; + +/** + * Unix timestamp value + * + * Represents the number of seconds that have elapsed + * since January 1, 1970 (midnight UTC/GMT). + * + * Guaranteed to be an integer. May be zero or negative to represent a time at or + * before Jan 1, 1970. + */ +export type UnixTimestamp = z.infer; diff --git a/packages/ponder-sdk/tsconfig.json b/packages/ponder-sdk/tsconfig.json new file mode 100644 index 000000000..8eddfe35c --- /dev/null +++ b/packages/ponder-sdk/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@ensnode/shared-configs/tsconfig.ponder.json", + "include": ["src/**/*"] +} diff --git a/packages/ponder-sdk/tsup.config.ts b/packages/ponder-sdk/tsup.config.ts new file mode 100644 index 000000000..a97448258 --- /dev/null +++ b/packages/ponder-sdk/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + platform: "neutral", + format: ["esm", "cjs"], + target: "es2022", + bundle: true, + splitting: false, + sourcemap: true, + dts: true, + clean: true, + external: ["zod/*"], // Mark peer dependencies as external + outDir: "./dist", +}); diff --git a/packages/ponder-sdk/vitest.config.ts b/packages/ponder-sdk/vitest.config.ts new file mode 100644 index 000000000..0207a7b67 --- /dev/null +++ b/packages/ponder-sdk/vitest.config.ts @@ -0,0 +1,14 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineProject } from "vitest/config"; + +const cwd = dirname(fileURLToPath(import.meta.url)); + +export default defineProject({ + resolve: { + alias: { + "@": resolve(cwd, "./src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c709764..1b159c5c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1048,6 +1048,27 @@ importers: specifier: 'catalog:' version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + packages/ponder-sdk: + devDependencies: + '@ensnode/shared-configs': + specifier: workspace:* + version: link:../shared-configs + '@types/node': + specifier: 'catalog:' + version: 24.10.9 + tsup: + specifier: 'catalog:' + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + zod: + specifier: 'catalog:' + version: 4.3.6 + packages/ponder-subgraph: dependencies: '@escape.tech/graphql-armor-max-aliases':