From d3acb6237b7500c78a96cdb3a8479a6085799e72 Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 2 Dec 2025 09:48:42 -0500 Subject: [PATCH 1/9] feat: started OpenAPI implementation - Installs `hono-openapi` - Updates `validate.ts` to use `validator` from `hono-openapi` - Adds response zod schemas to ensnode-sdk - Adds `describeRoute` to resolution endpoints - Adds `GET /openapi.json` route --- apps/ensapi/package.json | 4 +- apps/ensapi/src/handlers/resolution-api.ts | 56 +++++++- apps/ensapi/src/index.ts | 19 +++ apps/ensapi/src/lib/handlers/validate.ts | 4 +- packages/ensnode-sdk/src/api/index.ts | 1 + packages/ensnode-sdk/src/api/zod-schemas.ts | 52 ++++++++ pnpm-lock.yaml | 140 +++++++++++++++++++- 7 files changed, 267 insertions(+), 9 deletions(-) diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index ae2f420c7..74165ace0 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -25,10 +25,11 @@ "@ensnode/ensnode-schema": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/ponder-subgraph": "workspace:*", - "@namehash/ens-referrals": "workspace:*", "@hono/node-server": "^1.19.5", "@hono/otel": "^0.2.2", + "@hono/standard-validator": "^0.2.0", "@hono/zod-validator": "^0.7.2", + "@namehash/ens-referrals": "workspace:*", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", "@opentelemetry/exporter-metrics-otlp-proto": "^0.202.0", @@ -42,6 +43,7 @@ "date-fns": "catalog:", "drizzle-orm": "catalog:", "hono": "catalog:", + "hono-openapi": "^1.1.1", "p-memoize": "^8.0.0", "p-reflect": "^3.1.0", "p-retry": "^7.1.0", diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts index 23ac4915b..fe42035fc 100644 --- a/apps/ensapi/src/handlers/resolution-api.ts +++ b/apps/ensapi/src/handlers/resolution-api.ts @@ -1,10 +1,13 @@ import { z } from "zod/v4"; -import type { - Duration, - ResolvePrimaryNameResponse, - ResolvePrimaryNamesResponse, - ResolveRecordsResponse, +import { + makeResolvePrimaryNameResponseSchema, + makeResolvePrimaryNamesResponseSchema, + makeResolveRecordsResponseSchema, + type Duration, + type ResolvePrimaryNameResponse, + type ResolvePrimaryNamesResponse, + type ResolveRecordsResponse, } from "@ensnode/ensnode-sdk"; import { params } from "@/lib/handlers/params.schema"; @@ -16,6 +19,7 @@ import { resolveReverse } from "@/lib/resolution/reverse-resolution"; import { captureTrace } from "@/lib/tracing/protocol-tracing"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; +import { describeRoute, resolver } from "hono-openapi"; /** * The effective distance for acceleration is indexing status cache time plus @@ -44,6 +48,20 @@ app.use(canAccelerateMiddleware); */ app.get( "/records/:name", + describeRoute({ + summary: "Resolve ENS Records", + description: "Resolves ENS records for a given name", + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: resolver(makeResolveRecordsResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ name: params.name })), validate( "query", @@ -99,6 +117,20 @@ app.get( */ app.get( "/primary-name/:address/:chainId", + describeRoute({ + summary: "Resolve Primary Name", + description: "Resolves a primary name for a given `address` and `chainId`", + responses: { + 200: { + description: "Successfully resolved name", + content: { + "application/json": { + schema: resolver(makeResolvePrimaryNameResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ address: params.address, chainId: params.defaultableChainId })), validate( "query", @@ -144,6 +176,20 @@ app.get( */ app.get( "/primary-names/:address", + describeRoute({ + summary: "Resolve Primary Names", + description: "Resolves all primary names for a given address across multiple chains", + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: resolver(makeResolvePrimaryNamesResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ address: params.address })), validate( "query", diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 5abaae064..d07546233 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { serve } from "@hono/node-server"; import { otel } from "@hono/otel"; +import { openAPIRouteHandler } from "hono-openapi"; import { cors } from "hono/cors"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; @@ -45,6 +46,24 @@ app.route("/subgraph", subgraphApi); // use ENSAnalytics API at /ensanalytics app.route("/ensanalytics", ensanalyticsApi); +// use OpenAPI Schema +app.get( + "/openapi.json", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "ENSApi", + version: packageJson.version, + description: "ENS resolution and analytics API", + }, + servers: [ + { url: `http://localhost:${config.port}`, description: "Local Development" }, + // Add your production servers here + ], + }, + }), +); + // will automatically 500 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { return c.json({ ok: true }); diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts index 4aa4d33f2..83246bbf2 100644 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ b/apps/ensapi/src/lib/handlers/validate.ts @@ -1,4 +1,4 @@ -import { zValidator } from "@hono/zod-validator"; +import { validator } from "hono-openapi"; import type { ValidationTargets } from "hono"; import type { ZodType } from "zod/v4"; @@ -18,7 +18,7 @@ export const validate = - zValidator(target, schema, (result, c) => { + validator(target, schema, (result, c) => { // if validation failed, return our custom-formatted ErrorResponse instead of default if (!result.success) return errorResponse(c, result.error); }); diff --git a/packages/ensnode-sdk/src/api/index.ts b/packages/ensnode-sdk/src/api/index.ts index 282cf2e14..1c29bdcf0 100644 --- a/packages/ensnode-sdk/src/api/index.ts +++ b/packages/ensnode-sdk/src/api/index.ts @@ -3,3 +3,4 @@ export * from "./registrar-actions"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/zod-schemas.ts b/packages/ensnode-sdk/src/api/zod-schemas.ts index da0963245..b959c12a4 100644 --- a/packages/ensnode-sdk/src/api/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/zod-schemas.ts @@ -15,6 +15,9 @@ import { RegistrarActionsResponseCodes, RegistrarActionsResponseError, RegistrarActionsResponseOk, + type ResolvePrimaryNameResponse, + type ResolvePrimaryNamesResponse, + type ResolveRecordsResponse, } from "./types"; export const ErrorResponseSchema = z.object({ @@ -113,3 +116,52 @@ export const makeRegistrarActionsResponseSchema = ( makeRegistrarActionsResponseOkSchema(valueLabel), makeRegistrarActionsResponseErrorSchema(valueLabel), ]); + +// Resolution API + +/** + * Schema for resolver records response (addresses, texts, name) + */ +const makeResolverRecordsResponseSchema = (_valueLabel: string = "Resolver Records Response") => + z.object({ + name: z.string().nullable().optional(), + addresses: z.record(z.string(), z.string().nullable()).optional(), + texts: z.record(z.string(), z.string().nullable()).optional(), + }); + +/** + * Schema for {@link ResolveRecordsResponse} + */ +export const makeResolveRecordsResponseSchema = (valueLabel: string = "Resolve Records Response") => + z.object({ + records: makeResolverRecordsResponseSchema(valueLabel), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNameResponse} + */ +export const makeResolvePrimaryNameResponseSchema = ( + _valueLabel: string = "Resolve Primary Name Response", +) => + z.object({ + name: z.string().nullable(), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNamesResponse} + */ +export const makeResolvePrimaryNamesResponseSchema = ( + _valueLabel: string = "Resolve Primary Names Response", +) => + z.object({ + names: z.record(z.number(), z.string().nullable()), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba22da63..ddba76423 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,9 @@ importers: '@hono/otel': specifier: ^0.2.2 version: 0.2.2(hono@4.10.3) + '@hono/standard-validator': + specifier: ^0.2.0 + version: 0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3) '@hono/zod-validator': specifier: ^0.7.2 version: 0.7.4(hono@4.10.3)(zod@3.25.76) @@ -334,6 +337,9 @@ importers: hono: specifier: 'catalog:' version: 4.10.3 + hono-openapi: + specifier: ^1.1.1 + version: 1.1.1(@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76))(@types/json-schema@7.0.15)(hono@4.10.3)(openapi-types@12.1.3) p-memoize: specifier: ^8.0.0 version: 8.0.0 @@ -1705,6 +1711,12 @@ packages: peerDependencies: hono: '*' + '@hono/standard-validator@0.2.0': + resolution: {integrity: sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ==} + peerDependencies: + '@standard-schema/spec': 1.0.0 + hono: '>=3.9.0' + '@hono/zod-validator@0.7.4': resolution: {integrity: sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q==} peerDependencies: @@ -3103,6 +3115,67 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-community/standard-json@0.3.5': + resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} + peerDependencies: + '@standard-schema/spec': ^1.0.0 + '@types/json-schema': ^7.0.15 + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.20 + effect: ^3.16.8 + quansync: ^0.2.11 + sury: ^10.0.0 + typebox: ^1.0.17 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.24.5 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + + '@standard-community/standard-openapi@0.2.9': + resolution: {integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==} + peerDependencies: + '@standard-community/standard-json': ^0.3.5 + '@standard-schema/spec': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.17.14 + openapi-types: ^12.1.3 + sury: ^10.0.0 + typebox: ^1.0.0 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-openapi: ^4 + peerDependenciesMeta: + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-openapi: + optional: true + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -3393,6 +3466,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5075,6 +5151,21 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hono-openapi@1.1.1: + resolution: {integrity: sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q==} + peerDependencies: + '@hono/standard-validator': ^0.1.2 + '@standard-community/standard-json': ^0.3.5 + '@standard-community/standard-openapi': ^0.2.8 + '@types/json-schema': ^7.0.15 + hono: ^4.8.3 + openapi-types: ^12.1.3 + peerDependenciesMeta: + '@hono/standard-validator': + optional: true + hono: + optional: true + hono@4.10.3: resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} engines: {node: '>=16.9.0'} @@ -5992,6 +6083,9 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -8980,6 +9074,11 @@ snapshots: '@opentelemetry/semantic-conventions': 1.37.0 hono: 4.10.3 + '@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3)': + dependencies: + '@standard-schema/spec': 1.0.0 + hono: 4.10.3 + '@hono/zod-validator@0.7.4(hono@4.10.3)(zod@3.25.76)': dependencies: hono: 4.10.3 @@ -10473,6 +10572,23 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/json-schema': 7.0.15 + quansync: 0.2.11 + optionalDependencies: + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76)': + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + '@standard-schema/spec': 1.0.0 + openapi-types: 12.1.3 + optionalDependencies: + zod: 3.25.76 + '@standard-schema/spec@1.0.0': {} '@swc/helpers@0.5.15': @@ -10783,6 +10899,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -10884,6 +11002,14 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -12723,6 +12849,16 @@ snapshots: help-me@5.0.0: {} + hono-openapi@1.1.1(@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76))(@types/json-schema@7.0.15)(hono@4.10.3)(openapi-types@12.1.3): + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76) + '@types/json-schema': 7.0.15 + openapi-types: 12.1.3 + optionalDependencies: + '@hono/standard-validator': 0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3) + hono: 4.10.3 + hono@4.10.3: {} html-encoding-sniffer@4.0.0: @@ -13818,6 +13954,8 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 + openapi-types@12.1.3: {} + outdent@0.5.0: {} ox@0.9.6(typescript@5.9.3)(zod@3.25.76): @@ -15556,7 +15694,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From fac5510aa2ff39af30321462d75b9d1d69abc5ce Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 2 Dec 2025 09:49:11 -0500 Subject: [PATCH 2/9] chore: lint --- apps/ensapi/src/handlers/resolution-api.ts | 4 ++-- apps/ensapi/src/index.ts | 2 +- apps/ensapi/src/lib/handlers/validate.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts index fe42035fc..e2b662b5f 100644 --- a/apps/ensapi/src/handlers/resolution-api.ts +++ b/apps/ensapi/src/handlers/resolution-api.ts @@ -1,10 +1,11 @@ +import { describeRoute, resolver } from "hono-openapi"; import { z } from "zod/v4"; import { + type Duration, makeResolvePrimaryNameResponseSchema, makeResolvePrimaryNamesResponseSchema, makeResolveRecordsResponseSchema, - type Duration, type ResolvePrimaryNameResponse, type ResolvePrimaryNamesResponse, type ResolveRecordsResponse, @@ -19,7 +20,6 @@ import { resolveReverse } from "@/lib/resolution/reverse-resolution"; import { captureTrace } from "@/lib/tracing/protocol-tracing"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; -import { describeRoute, resolver } from "hono-openapi"; /** * The effective distance for acceleration is indexing status cache time plus diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index d07546233..016e4c786 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -3,8 +3,8 @@ import config from "@/config"; import { serve } from "@hono/node-server"; import { otel } from "@hono/otel"; -import { openAPIRouteHandler } from "hono-openapi"; import { cors } from "hono/cors"; +import { openAPIRouteHandler } from "hono-openapi"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts index 83246bbf2..704437424 100644 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ b/apps/ensapi/src/lib/handlers/validate.ts @@ -1,5 +1,5 @@ -import { validator } from "hono-openapi"; import type { ValidationTargets } from "hono"; +import { validator } from "hono-openapi"; import type { ZodType } from "zod/v4"; import { errorResponse } from "./error-response"; From dc38b3be05cc211995a1259b65152df5765683bf Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 16 Dec 2025 14:45:28 -0500 Subject: [PATCH 3/9] chore: fix merge conflict aftermath --- .../ensnode-sdk/src/api/registrar-actions/index.ts | 1 + .../src/api/registrar-actions/zod-schemas.ts | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/ensnode-sdk/src/api/registrar-actions/index.ts b/packages/ensnode-sdk/src/api/registrar-actions/index.ts index 8f0b7c3f5..41b6680b7 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/index.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/index.ts @@ -5,3 +5,4 @@ export * from "./request"; export * from "./response"; export * from "./serialize"; export * from "./serialized-response"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts index 13ebd2351..ed99008e4 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts @@ -72,7 +72,7 @@ export const makeRegistrarActionsResponseSchema = ( /** * Schema for resolver records response (addresses, texts, name) */ -const makeResolverRecordsResponseSchema = (_valueLabel: string = "Resolver Records Response") => +const makeResolverRecordsResponseSchema = () => z.object({ name: z.string().nullable().optional(), addresses: z.record(z.string(), z.string().nullable()).optional(), @@ -82,9 +82,9 @@ const makeResolverRecordsResponseSchema = (_valueLabel: string = "Resolver Recor /** * Schema for {@link ResolveRecordsResponse} */ -export const makeResolveRecordsResponseSchema = (valueLabel: string = "Resolve Records Response") => +export const makeResolveRecordsResponseSchema = () => z.object({ - records: makeResolverRecordsResponseSchema(valueLabel), + records: makeResolverRecordsResponseSchema(), accelerationRequested: z.boolean(), accelerationAttempted: z.boolean(), trace: z.any().optional(), @@ -93,9 +93,7 @@ export const makeResolveRecordsResponseSchema = (valueLabel: string = "Resolve R /** * Schema for {@link ResolvePrimaryNameResponse} */ -export const makeResolvePrimaryNameResponseSchema = ( - _valueLabel: string = "Resolve Primary Name Response", -) => +export const makeResolvePrimaryNameResponseSchema = () => z.object({ name: z.string().nullable(), accelerationRequested: z.boolean(), @@ -106,9 +104,7 @@ export const makeResolvePrimaryNameResponseSchema = ( /** * Schema for {@link ResolvePrimaryNamesResponse} */ -export const makeResolvePrimaryNamesResponseSchema = ( - _valueLabel: string = "Resolve Primary Names Response", -) => +export const makeResolvePrimaryNamesResponseSchema = () => z.object({ names: z.record(z.number(), z.string().nullable()), accelerationRequested: z.boolean(), From 132f36b32d0b4b4696301bd04e83e42f16304726 Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 16 Dec 2025 14:47:10 -0500 Subject: [PATCH 4/9] chore: updated name and description for openapi spec --- apps/ensapi/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index fdf76568a..8726de2f2 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -53,9 +53,9 @@ app.get( openAPIRouteHandler(app, { documentation: { info: { - title: "ENSApi", + title: "ENSNode's APIs", version: packageJson.version, - description: "ENS resolution and analytics API", + description: "ENSNode resolution and analytics API", }, servers: [ { url: `http://localhost:${config.port}`, description: "Local Development" }, From 7f1ed5539bddc9d1cb316cd51bef32d664e6e58b Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 17 Dec 2025 23:05:05 -0500 Subject: [PATCH 5/9] chore: Updates to name-tokens-api types The Hono OpenAPI implementation with Zod produces a JSON schema where `z.undefined()` is [unrepresentable](https://zod.dev/json-schema#unrepresentable). This commit refactors the `NameTokensRequest` type and by consequence update the zod schema to pass. In order for OpenAPI JSON schema to work we cannot have any instances of unrepresentable APIs, and this so far was the only case. --- apps/ensapi/src/handlers/name-tokens-api.ts | 25 ++++++++++--------- .../src/api/name-tokens/request.ts | 21 +++++----------- packages/ensnode-sdk/src/client.ts | 2 +- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts index 15922625a..c13f59969 100644 --- a/apps/ensapi/src/handlers/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/name-tokens-api.ts @@ -44,16 +44,14 @@ app.use(nameTokensApiMiddleware); * Name Tokens API can be requested by either `name` or `domainId`, and * can never be requested by both, or neither. */ -const requestQuerySchema = z.union([ - z.object({ - domainId: makeNodeSchema("request.domainId"), - name: z.undefined(), - }), - z.object({ - domainId: z.undefined(), - name: params.name, - }), -]); +const requestQuerySchema = z + .object({ + domainId: makeNodeSchema("request.domainId").optional(), + name: params.name.optional(), + }) + .refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), { + message: "Exactly one of 'domainId' or 'name' must be provided", + }); app.get("/", validate("query", requestQuerySchema), async (c) => { // Invariant: context must be set by the required middleware @@ -67,7 +65,7 @@ app.get("/", validate("query", requestQuerySchema), async (c) => { } const request = c.req.valid("query") satisfies NameTokensRequest; - let domainId: Node | undefined; + let domainId: Node; if (request.name !== undefined) { const { name } = request; @@ -112,8 +110,11 @@ app.get("/", validate("query", requestQuerySchema), async (c) => { } domainId = namehash(name); - } else { + } else if (request.domainId !== undefined) { domainId = request.domainId; + } else { + // This should never happen due to Zod validation, but TypeScript needs this + throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided"); } const { omnichainSnapshot } = c.var.indexingStatus.snapshot; diff --git a/packages/ensnode-sdk/src/api/name-tokens/request.ts b/packages/ensnode-sdk/src/api/name-tokens/request.ts index 7aca3cd8f..7a9eac92d 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/request.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/request.ts @@ -2,26 +2,17 @@ import type { Name, Node } from "../../ens"; /** * Represents request to Name Tokens API. + * + * Either `domainId` or `name` must be provided, but not both. */ -export interface NameTokensRequestByDomainId { - domainId: Node; - +export interface NameTokensRequest { /** - * Name for which name tokens were requested. + * Domain ID (namehash) for which name tokens were requested. */ - name?: undefined; -} - -/** - * Represents request to Name Tokens API. - */ -export interface NameTokensRequestByName { - domainId?: undefined; + domainId?: Node; /** * Name for which name tokens were requested. */ - name: Name; + name?: Name; } - -export type NameTokensRequest = NameTokensRequestByDomainId | NameTokensRequestByName; diff --git a/packages/ensnode-sdk/src/client.ts b/packages/ensnode-sdk/src/client.ts index a5f5aae72..cd47f501c 100644 --- a/packages/ensnode-sdk/src/client.ts +++ b/packages/ensnode-sdk/src/client.ts @@ -755,7 +755,7 @@ export class ENSNodeClient { if (request.name !== undefined) { url.searchParams.set("name", request.name); - } else { + } else if (request.domainId !== undefined) { url.searchParams.set("domainId", request.domainId); } From 6993d26838299b36beaec0fa34623a9f7cb013bb Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 18 Dec 2025 10:53:08 -0500 Subject: [PATCH 6/9] chore: relocated resolution response schemas --- .../src/api/registrar-actions/zod-schemas.ts | 45 ------------------- .../ensnode-sdk/src/api/resolution/index.ts | 1 + .../src/api/resolution/zod-schemas.ts | 44 ++++++++++++++++++ 3 files changed, 45 insertions(+), 45 deletions(-) create mode 100644 packages/ensnode-sdk/src/api/resolution/zod-schemas.ts diff --git a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts index ed99008e4..e99436891 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.ts @@ -66,48 +66,3 @@ export const makeRegistrarActionsResponseSchema = ( makeRegistrarActionsResponseOkSchema(valueLabel), makeRegistrarActionsResponseErrorSchema(valueLabel), ]); - -// Resolution API - -/** - * Schema for resolver records response (addresses, texts, name) - */ -const makeResolverRecordsResponseSchema = () => - z.object({ - name: z.string().nullable().optional(), - addresses: z.record(z.string(), z.string().nullable()).optional(), - texts: z.record(z.string(), z.string().nullable()).optional(), - }); - -/** - * Schema for {@link ResolveRecordsResponse} - */ -export const makeResolveRecordsResponseSchema = () => - z.object({ - records: makeResolverRecordsResponseSchema(), - accelerationRequested: z.boolean(), - accelerationAttempted: z.boolean(), - trace: z.any().optional(), - }); - -/** - * Schema for {@link ResolvePrimaryNameResponse} - */ -export const makeResolvePrimaryNameResponseSchema = () => - z.object({ - name: z.string().nullable(), - accelerationRequested: z.boolean(), - accelerationAttempted: z.boolean(), - trace: z.any().optional(), - }); - -/** - * Schema for {@link ResolvePrimaryNamesResponse} - */ -export const makeResolvePrimaryNamesResponseSchema = () => - z.object({ - names: z.record(z.number(), z.string().nullable()), - accelerationRequested: z.boolean(), - accelerationAttempted: z.boolean(), - trace: z.any().optional(), - }); diff --git a/packages/ensnode-sdk/src/api/resolution/index.ts b/packages/ensnode-sdk/src/api/resolution/index.ts index eea524d65..f265637b1 100644 --- a/packages/ensnode-sdk/src/api/resolution/index.ts +++ b/packages/ensnode-sdk/src/api/resolution/index.ts @@ -1 +1,2 @@ export * from "./types"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts new file mode 100644 index 000000000..12748559b --- /dev/null +++ b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts @@ -0,0 +1,44 @@ +import z from "zod/v4"; + +/** + * Schema for resolver records response (addresses, texts, name) + */ +const makeResolverRecordsResponseSchema = () => + z.object({ + name: z.string().nullable().optional(), + addresses: z.record(z.string(), z.string().nullable()).optional(), + texts: z.record(z.string(), z.string().nullable()).optional(), + }); + +/** + * Schema for {@link ResolveRecordsResponse} + */ +export const makeResolveRecordsResponseSchema = () => + z.object({ + records: makeResolverRecordsResponseSchema(), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNameResponse} + */ +export const makeResolvePrimaryNameResponseSchema = () => + z.object({ + name: z.string().nullable(), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNamesResponse} + */ +export const makeResolvePrimaryNamesResponseSchema = () => + z.object({ + names: z.record(z.number(), z.string().nullable()), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.any().optional(), + }); From aed5cd47857b18f52189520d62ec87ed71b4d987 Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 18 Dec 2025 11:34:51 -0500 Subject: [PATCH 7/9] chore: updated resolution schemas --- packages/ensnode-sdk/src/api/resolution/zod-schemas.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts index 12748559b..5beab7a42 100644 --- a/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts @@ -18,7 +18,8 @@ export const makeResolveRecordsResponseSchema = () => records: makeResolverRecordsResponseSchema(), accelerationRequested: z.boolean(), accelerationAttempted: z.boolean(), - trace: z.any().optional(), + // TODO: Find a better way to handle recursive types, patch solution is .unknown() + trace: z.array(z.unknown()).optional(), }); /** @@ -29,7 +30,7 @@ export const makeResolvePrimaryNameResponseSchema = () => name: z.string().nullable(), accelerationRequested: z.boolean(), accelerationAttempted: z.boolean(), - trace: z.any().optional(), + trace: z.array(z.unknown()).optional(), }); /** @@ -40,5 +41,5 @@ export const makeResolvePrimaryNamesResponseSchema = () => names: z.record(z.number(), z.string().nullable()), accelerationRequested: z.boolean(), accelerationAttempted: z.boolean(), - trace: z.any().optional(), + trace: z.array(z.unknown()).optional(), }); From 4cad8dbb5cfcc1fa6e74045d3c40201b54e70d2c Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 18 Dec 2025 15:38:11 -0500 Subject: [PATCH 8/9] chore: revert registrar api --- apps/ensapi/src/handlers/ensnode-api.ts | 92 ++++++++++++++----- .../src/api/indexing-status/index.ts | 1 + .../ensnode-sdk/src/tokenscope/zod-schemas.ts | 2 + 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts index 5c3186cde..a10fc80ef 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/ensnode-api.ts @@ -1,9 +1,13 @@ import config from "@/config"; +import { describeRoute, resolver } from "hono-openapi"; + import { IndexingStatusResponseCodes, type IndexingStatusResponseError, type IndexingStatusResponseOk, + makeENSApiPublicConfigSchema, + makeIndexingStatusResponseSchema, serializeENSApiPublicConfig, serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; @@ -18,35 +22,77 @@ import resolutionApi from "./resolution-api"; const app = factory.createApp(); // include ENSApi Public Config endpoint -app.get("/config", async (c) => { - const ensApiPublicConfig = buildEnsApiPublicConfig(config); - return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); -}); +app.get( + "/config", + describeRoute({ + summary: "Get ENSApi Configuration", + description: "Returns the public configuration of the ENSApi instance", + responses: { + 200: { + description: "Successfully retrieved configuration", + content: { + "application/json": { + schema: resolver(makeENSApiPublicConfigSchema()), + }, + }, + }, + }, + }), + async (c) => { + const ensApiPublicConfig = buildEnsApiPublicConfig(config); + return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); + }, +); // include ENSIndexer Indexing Status endpoint -app.get("/indexing-status", async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`); - } +app.get( + "/indexing-status", + describeRoute({ + summary: "Get ENSIndexer Indexing Status", + description: "Returns the current indexing status of the ENSIndexer", + responses: { + 200: { + description: "Successfully retrieved indexing status", + content: { + "application/json": { + schema: resolver(makeIndexingStatusResponseSchema()), + }, + }, + }, + 500: { + description: "Error retrieving indexing status", + content: { + "application/json": { + schema: resolver(makeIndexingStatusResponseSchema()), + }, + }, + }, + }, + }), + async (c) => { + // context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`); + } + + if (c.var.indexingStatus instanceof Error) { + return c.json( + serializeIndexingStatusResponse({ + responseCode: IndexingStatusResponseCodes.Error, + } satisfies IndexingStatusResponseError), + 500, + ); + } - if (c.var.indexingStatus instanceof Error) { + // return successful response using the indexing status projection from the context return c.json( serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Error, - } satisfies IndexingStatusResponseError), - 500, + responseCode: IndexingStatusResponseCodes.Ok, + realtimeProjection: c.var.indexingStatus, + } satisfies IndexingStatusResponseOk), ); - } - - // return successful response using the indexing status projection from the context - return c.json( - serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Ok, - realtimeProjection: c.var.indexingStatus, - } satisfies IndexingStatusResponseOk), - ); -}); + }, +); // Name Tokens API app.route("/name-tokens", nameTokensApi); diff --git a/packages/ensnode-sdk/src/api/indexing-status/index.ts b/packages/ensnode-sdk/src/api/indexing-status/index.ts index 54913353d..919f4f586 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/index.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/index.ts @@ -3,3 +3,4 @@ export * from "./request"; export * from "./response"; export * from "./serialize"; export * from "./serialized-response"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts index 77bebabb4..24475e156 100644 --- a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts +++ b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts @@ -22,6 +22,8 @@ import { /** * Make schema for {@link AssetId}. + * + * TODO: Find a way to make this compatible with Zod JSON Schema: https://zod.dev/json-schema#unrepresentable */ export const makeAssetIdSchema = (valueLabel: string = "Asset ID Schema") => z.object({ From 349daeaab857ed700cdfca0af8fad48357ef427e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 19 Dec 2025 06:18:09 +0100 Subject: [PATCH 9/9] demo(ensapi.openapi): include Name Tokens API in `openapi.json` Updates relevant zod schemas with a feature flag that allows the schema caller to decide if it needs to be made of fully serializable values, or not. --- apps/ensapi/src/handlers/name-tokens-api.ts | 187 +++++++++++------- .../src/api/name-tokens/deserialize.ts | 4 +- .../src/api/name-tokens/zod-schemas.ts | 123 ++++++------ packages/ensnode-sdk/src/internal.ts | 1 + .../ensnode-sdk/src/tokenscope/zod-schemas.ts | 38 +++- 5 files changed, 211 insertions(+), 142 deletions(-) diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts index c13f59969..ce975c9e6 100644 --- a/apps/ensapi/src/handlers/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/name-tokens-api.ts @@ -1,5 +1,6 @@ import config from "@/config"; +import { describeRoute, resolver } from "hono-openapi"; import { namehash } from "viem"; import z from "zod/v4"; @@ -14,7 +15,7 @@ import { type PluginName, serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; -import { makeNodeSchema } from "@ensnode/ensnode-sdk/internal"; +import { makeNameTokensResponseSchema, makeNodeSchema } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; import { validate } from "@/lib/handlers/validate"; @@ -53,47 +54,117 @@ const requestQuerySchema = z message: "Exactly one of 'domainId' or 'name' must be provided", }); -app.get("/", validate("query", requestQuerySchema), async (c) => { - // Invariant: context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(name-tokens-api): indexingStatusMiddleware required`); - } - - // Invariant: Indexing Status has been resolved successfully. - if (c.var.indexingStatus instanceof Error) { - throw new Error(`Invariant(name-tokens-api): Indexing Status has to be resolved successfully`); - } - - const request = c.req.valid("query") satisfies NameTokensRequest; - let domainId: Node; +app.get( + "/", + describeRoute({ + summary: "Get Name Tokens", + description: "Returns name tokens for requested identifier (domainId, or name)", + responses: { + 200: { + description: "Successfully retrieved name tokens", + content: { + "application/json": { + schema: resolver(makeNameTokensResponseSchema("Name Tokens Response", true), { + elo: 1, + }), + }, + }, + }, + 500: { + description: "Error retrieving name tokens", + content: { + "application/json": { + schema: resolver(makeNameTokensResponseSchema("Name Tokens Response", true), { + elo: 2, + }), + }, + }, + }, + }, + }), + validate("query", requestQuerySchema), + async (c) => { + // Invariant: context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(name-tokens-api): indexingStatusMiddleware required`); + } - if (request.name !== undefined) { - const { name } = request; + // Invariant: Indexing Status has been resolved successfully. + if (c.var.indexingStatus instanceof Error) { + throw new Error( + `Invariant(name-tokens-api): Indexing Status has to be resolved successfully`, + ); + } - // return 404 when the requested name was the ENS Root - if (name === ENS_ROOT) { - return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Error, - errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed, - error: { - message: "No indexed Name Tokens found", - details: `The 'name' param must not be ENS Root, no tokens exist for it.`, - }, - } satisfies NameTokensResponseErrorNameTokensNotIndexed), - 404, + const request = c.req.valid("query") satisfies NameTokensRequest; + let domainId: Node; + + if (request.name !== undefined) { + const { name } = request; + + // return 404 when the requested name was the ENS Root + if (name === ENS_ROOT) { + return c.json( + serializeNameTokensResponse({ + responseCode: NameTokensResponseCodes.Error, + errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed, + error: { + message: "No indexed Name Tokens found", + details: `The 'name' param must not be ENS Root, no tokens exist for it.`, + }, + } satisfies NameTokensResponseErrorNameTokensNotIndexed), + 404, + ); + } + + const parentNode = namehash(getParentNameFQDN(name)); + const subregistry = indexedSubregistries.find( + (subregistry) => subregistry.node === parentNode, ); + + // Return 404 response with error code for Name Tokens Not Indexed when + // the parent name of the requested name was not registered in any of + // the actively indexed subregistries. + if (!subregistry) { + logger.error( + `This ENSNode instance has not been configured to index tokens for the requested name: '${name}'.`, + ); + + return c.json( + serializeNameTokensResponse({ + responseCode: NameTokensResponseCodes.Error, + errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed, + error: { + message: "No indexed Name Tokens found", + details: `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`, + }, + } satisfies NameTokensResponseErrorNameTokensNotIndexed), + 404, + ); + } + + domainId = namehash(name); + } else if (request.domainId !== undefined) { + domainId = request.domainId; + } else { + // This should never happen due to Zod validation, but TypeScript needs this + throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided"); } - const parentNode = namehash(getParentNameFQDN(name)); - const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode); + const { omnichainSnapshot } = c.var.indexingStatus.snapshot; + const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor; + + const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf); // Return 404 response with error code for Name Tokens Not Indexed when - // the parent name of the requested name was not registered in any of - // the actively indexed subregistries. - if (!subregistry) { + // the no name tokens were found for the domain ID associated with + // the requested name. + if (!registeredNameTokens) { + const errorMessageSubject = + request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`; + logger.error( - `This ENSNode instance has not been configured to index tokens for the requested name: '${name}'.`, + `This ENSNode instance has never indexed tokens for the requested ${errorMessageSubject}.`, ); return c.json( @@ -102,56 +173,20 @@ app.get("/", validate("query", requestQuerySchema), async (c) => { errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed, error: { message: "No indexed Name Tokens found", - details: `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`, + details: `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`, }, } satisfies NameTokensResponseErrorNameTokensNotIndexed), 404, ); } - domainId = namehash(name); - } else if (request.domainId !== undefined) { - domainId = request.domainId; - } else { - // This should never happen due to Zod validation, but TypeScript needs this - throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided"); - } - - const { omnichainSnapshot } = c.var.indexingStatus.snapshot; - const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor; - - const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf); - - // Return 404 response with error code for Name Tokens Not Indexed when - // the no name tokens were found for the domain ID associated with - // the requested name. - if (!registeredNameTokens) { - const errorMessageSubject = - request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`; - - logger.error( - `This ENSNode instance has never indexed tokens for the requested ${errorMessageSubject}.`, - ); - return c.json( serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Error, - errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed, - error: { - message: "No indexed Name Tokens found", - details: `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`, - }, - } satisfies NameTokensResponseErrorNameTokensNotIndexed), - 404, + responseCode: NameTokensResponseCodes.Ok, + registeredNameTokens, + }), ); - } - - return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Ok, - registeredNameTokens, - }), - ); -}); + }, +); export default app; diff --git a/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts b/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts index a0fb34ac0..c16c1de0f 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts @@ -9,7 +9,9 @@ import { makeNameTokensResponseSchema } from "./zod-schemas"; export function deserializedNameTokensResponse( maybeResponse: SerializedNameTokensResponse, ): NameTokensResponse { - const parsed = makeNameTokensResponseSchema().safeParse(maybeResponse); + const parsed = makeNameTokensResponseSchema("Name Tokens Response", false).safeParse( + maybeResponse, + ); if (parsed.error) { throw new Error(`Cannot deserialize NameTokensResponse:\n${prettifyError(parsed.error)}\n`); diff --git a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts index 6bf23db3e..344f2d108 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts @@ -1,6 +1,5 @@ import { namehash } from "viem"; import z from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; import { makeNodeSchema, @@ -22,78 +21,78 @@ import { type RegisteredNameTokens, } from "./response"; -function invariant_nameIsAssociatedWithDomainId(ctx: ParsePayload) { - const { name, domainId } = ctx.value; - - if (namehash(name) !== domainId) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'name' must be associated with 'domainId': ${domainId}`, - }); - } -} - -function invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown( - ctx: ParsePayload, -) { - const { tokens } = ctx.value; - const containsOwnershipNameWrapper = tokens.some( - (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.NameWrapper, - ); - const containsOwnershipFullyOnchainOrUnknown = tokens.some( - (t) => - t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain || - t.ownership.ownershipType === NameTokenOwnershipTypes.Unknown, - ); - if (containsOwnershipNameWrapper && !containsOwnershipFullyOnchainOrUnknown) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'tokens' must contain name token with ownership type 'fully-onchain' or 'unknown' when name token with ownership type 'namewrapper' in listed`, - }); - } -} - -function invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective( - ctx: ParsePayload, -) { - const { tokens } = ctx.value; - const tokensCountWithOwnershipFullyOnchain = tokens.filter( - (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain, - ).length; - if (tokensCountWithOwnershipFullyOnchain > 1) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'tokens' must contain at most one name token with ownership type 'fully-onchain', current count: ${tokensCountWithOwnershipFullyOnchain}`, - }); - } -} - /** * Schema for {@link RegisteredNameTokens}. */ -export const makeRegisteredNameTokenSchema = (valueLabel: string = "Registered Name Token") => +export const makeRegisteredNameTokenSchema = ( + valueLabel: string = "Registered Name Token", + serializable?: SerializableType, +) => z .object({ domainId: makeNodeSchema(`${valueLabel}.domainId`), name: makeReinterpretedNameSchema(valueLabel), - tokens: z.array(makeNameTokenSchema(`${valueLabel}.tokens`)).nonempty(), + tokens: z.array(makeNameTokenSchema(`${valueLabel}.tokens`, serializable)).nonempty(), expiresAt: makeUnixTimestampSchema(`${valueLabel}.expiresAt`), accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), }) - .check(invariant_nameIsAssociatedWithDomainId) - .check(invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective) - .check(invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown); + .check(function invariant_nameIsAssociatedWithDomainId(ctx) { + const { name, domainId } = ctx.value; + + if (namehash(name) !== domainId) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'name' must be associated with 'domainId': ${domainId}`, + }); + } + }) + .check( + function invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown( + ctx, + ) { + const { tokens } = ctx.value; + const containsOwnershipNameWrapper = tokens.some( + (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.NameWrapper, + ); + const containsOwnershipFullyOnchainOrUnknown = tokens.some( + (t) => + t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain || + t.ownership.ownershipType === NameTokenOwnershipTypes.Unknown, + ); + if (containsOwnershipNameWrapper && !containsOwnershipFullyOnchainOrUnknown) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'tokens' must contain name token with ownership type 'fully-onchain' or 'unknown' when name token with ownership type 'namewrapper' in listed`, + }); + } + }, + ) + .check(function invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective(ctx) { + const { tokens } = ctx.value; + const tokensCountWithOwnershipFullyOnchain = tokens.filter( + (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain, + ).length; + if (tokensCountWithOwnershipFullyOnchain > 1) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'tokens' must contain at most one name token with ownership type 'fully-onchain', current count: ${tokensCountWithOwnershipFullyOnchain}`, + }); + } + }); /** * Schema for {@link NameTokensResponseOk} */ -export const makeNameTokensResponseOkSchema = (valueLabel: string = "Name Tokens Response OK") => +export const makeNameTokensResponseOkSchema = ( + valueLabel: string = "Name Tokens Response OK", + serializable?: SerializableType, +) => z.strictObject({ responseCode: z.literal(NameTokensResponseCodes.Ok), - registeredNameTokens: makeRegisteredNameTokenSchema(`${valueLabel}.nameTokens`), + registeredNameTokens: makeRegisteredNameTokenSchema(`${valueLabel}.nameTokens`, serializable), }); /** @@ -145,8 +144,12 @@ export const makeNameTokensResponseErrorSchema = ( /** * Schema for {@link NameTokensResponse} */ -export const makeNameTokensResponseSchema = (valueLabel: string = "Name Tokens Response") => - z.discriminatedUnion("responseCode", [ - makeNameTokensResponseOkSchema(valueLabel), +export const makeNameTokensResponseSchema = ( + valueLabel: string = "Name Tokens Response", + serializable?: SerializableType, +) => { + return z.discriminatedUnion("responseCode", [ + makeNameTokensResponseOkSchema(valueLabel, serializable ?? false), makeNameTokensResponseErrorSchema(valueLabel), ]); +}; diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index dde184884..665ec56d1 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -13,6 +13,7 @@ */ export * from "./api/indexing-status/zod-schemas"; +export * from "./api/name-tokens/zod-schemas"; export * from "./api/registrar-actions/zod-schemas"; export * from "./api/shared/errors/zod-schemas"; export * from "./api/shared/pagination/zod-schemas"; diff --git a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts index 24475e156..118ff3744 100644 --- a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts +++ b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts @@ -20,17 +20,42 @@ import { type NameTokenOwnershipUnknown, } from "./name-token"; +const tokenIdSchemaSerializable = z.string(); +const tokenIdSchemaNative = z.preprocess( + (v) => (typeof v === "string" ? BigInt(v) : v), + z.bigint().positive(), +); + +export function makeTokenIdSchema( + _valueLabel: string, + serializable: SerializableType, +): SerializableType extends true ? typeof tokenIdSchemaSerializable : typeof tokenIdSchemaNative; +export function makeTokenIdSchema( + _valueLabel: string = "Token ID Schema", + serializable: true | false = false, +): typeof tokenIdSchemaSerializable | typeof tokenIdSchemaNative { + if (serializable) { + return tokenIdSchemaSerializable; + } else { + return tokenIdSchemaNative; + } +} + /** * Make schema for {@link AssetId}. * * TODO: Find a way to make this compatible with Zod JSON Schema: https://zod.dev/json-schema#unrepresentable */ -export const makeAssetIdSchema = (valueLabel: string = "Asset ID Schema") => - z.object({ +export const makeAssetIdSchema = ( + valueLabel: string = "Asset ID Schema", + serializable?: SerializableType, +) => { + return z.object({ assetNamespace: z.enum(AssetNamespaces), contract: makeAccountIdSchema(valueLabel), - tokenId: z.preprocess((v) => (typeof v === "string" ? BigInt(v) : v), z.bigint().positive()), + tokenId: makeTokenIdSchema(valueLabel, serializable ?? false), }); +}; /** * Make schema for {@link AssetIdString}. @@ -139,9 +164,12 @@ export const makeNameTokenOwnershipSchema = (valueLabel: string = "Name Token Ow /** * Make schema for {@link NameToken}. */ -export const makeNameTokenSchema = (valueLabel: string = "Name Token Schema") => +export const makeNameTokenSchema = ( + valueLabel: string = "Name Token Schema", + serializable?: SerializableType, +) => z.object({ - token: makeAssetIdSchema(`${valueLabel}.token`), + token: makeAssetIdSchema(`${valueLabel}.token`, serializable), ownership: makeNameTokenOwnershipSchema(`${valueLabel}.ownership`),