diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json
index a7c3ef8f3..391674616 100644
--- a/apps/ensapi/package.json
+++ b/apps/ensapi/package.json
@@ -17,7 +17,8 @@
"test": "vitest",
"lint": "biome check --write .",
"lint:ci": "biome ci",
- "typecheck": "tsgo --noEmit"
+ "typecheck": "tsgo --noEmit",
+ "generate:openapi": "tsx scripts/generate-openapi.ts"
},
"dependencies": {
"@ensdomains/ensjs": "^4.0.2",
@@ -27,7 +28,7 @@
"@ensnode/ponder-subgraph": "workspace:*",
"@hono/node-server": "^1.19.5",
"@hono/otel": "^0.2.2",
- "@hono/standard-validator": "^0.2.2",
+ "@hono/zod-openapi": "1.2.1",
"@namehash/ens-referrals": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.1",
@@ -51,7 +52,6 @@
"graphql": "^16.11.0",
"graphql-yoga": "^5.16.0",
"hono": "catalog:",
- "hono-openapi": "^1.1.2",
"p-memoize": "^8.0.0",
"p-retry": "^7.1.0",
"pg-connection-string": "catalog:",
diff --git a/apps/ensapi/scripts/generate-openapi.ts b/apps/ensapi/scripts/generate-openapi.ts
new file mode 100644
index 000000000..d28871c00
--- /dev/null
+++ b/apps/ensapi/scripts/generate-openapi.ts
@@ -0,0 +1,37 @@
+/**
+ * Generates the OpenAPI specification JSON for ENSApi.
+ *
+ * This script can run without any environment variables or config because it
+ * imports only from the lightweight `.routes.ts` description files via
+ * `createRoutesForSpec()`, which have zero config dependencies.
+ *
+ * The version is resolved from (in order of precedence):
+ * 1. OPENAPI_VERSION_OVERRIDE env var
+ * 2. package.json version
+ * 3. openapiDocumentation.info.version (placeholder fallback)
+ *
+ * Usage:
+ * pnpm generate:openapi > openapi.json
+ * pnpm generate:openapi # prints to stdout
+ * OPENAPI_VERSION_OVERRIDE=1.2.3 pnpm generate:openapi
+ */
+
+import packageJson from "@/../package.json" with { type: "json" };
+
+import { openapiDocumentation } from "@/openapi";
+import { createRoutesForSpec } from "@/openapi-routes";
+
+const version =
+ process.env.OPENAPI_VERSION_OVERRIDE || packageJson.version || openapiDocumentation.info.version;
+
+const app = createRoutesForSpec();
+const spec = app.getOpenAPI31Document({
+ ...openapiDocumentation,
+ info: {
+ ...openapiDocumentation.info,
+ version,
+ },
+});
+
+process.stdout.write(JSON.stringify(spec, null, 2));
+process.stdout.write("\n");
diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts
new file mode 100644
index 000000000..ff05146ce
--- /dev/null
+++ b/apps/ensapi/src/app.ts
@@ -0,0 +1,106 @@
+/**
+ * Application setup: creates the Hono app with all middleware and routes.
+ *
+ * Separated from index.ts (server startup/shutdown) so the app configuration
+ * is distinct from the runtime lifecycle concerns.
+ */
+
+import packageJson from "@/../package.json" with { type: "json" };
+import config from "@/config";
+
+import { otel } from "@hono/otel";
+import { cors } from "hono/cors";
+import { html } from "hono/html";
+
+import { errorResponse } from "@/lib/handlers/error-response";
+import { createApp } from "@/lib/hono-factory";
+import logger from "@/lib/logger";
+import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
+import { openapiDocumentation } from "@/openapi";
+
+import amIRealtimeApi from "./handlers/amirealtime-api";
+import { basePath as amIRealtimeBasePath } from "./handlers/amirealtime-api.routes";
+import ensanalyticsApi from "./handlers/ensanalytics-api";
+import { basePath as ensanalyticsBasePath } from "./handlers/ensanalytics-api.routes";
+import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1";
+import { basePath as ensanalyticsV1BasePath } from "./handlers/ensanalytics-api-v1.routes";
+import ensNodeApi from "./handlers/ensnode-api";
+import { basePath as ensnodeBasePath } from "./handlers/ensnode-api.routes";
+import subgraphApi from "./handlers/subgraph-api";
+
+const app = createApp();
+
+// set the X-ENSNode-Version header to the current version
+app.use(async (ctx, next) => {
+ ctx.header("x-ensnode-version", packageJson.version);
+ return next();
+});
+
+// use CORS middleware
+app.use(cors({ origin: "*" }));
+
+// include automatic OpenTelemetry instrumentation for incoming requests
+app.use(otel());
+
+// add ENSIndexer Indexing Status Middleware to all routes for convenience
+app.use(indexingStatusMiddleware);
+
+// host welcome page
+app.get("/", (c) =>
+ c.html(html`
+
+
+
+
+
+ ENSApi
+
+
+ Hello, World!
+ You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.
+
+
+`),
+);
+
+// use ENSNode HTTP API at /api
+app.route(ensnodeBasePath, ensNodeApi);
+
+// Subgraph API is a GraphQL middleware handler, not an OpenAPI route,
+// so it has no .routes.ts file or basePath export.
+app.route("/subgraph", subgraphApi);
+
+// use ENSAnalytics API at /ensanalytics (v0, implicit)
+app.route(ensanalyticsBasePath, ensanalyticsApi);
+
+// use ENSAnalytics API v1 at /v1/ensanalytics
+app.route(ensanalyticsV1BasePath, ensanalyticsApiV1);
+
+// use Am I Realtime API at /amirealtime
+app.route(amIRealtimeBasePath, amIRealtimeApi);
+
+// use OpenAPI Schema
+app.doc31("/openapi.json", {
+ ...openapiDocumentation,
+ info: {
+ ...openapiDocumentation.info,
+ version: packageJson.version,
+ },
+ servers: [
+ ...openapiDocumentation.servers,
+ { url: `http://localhost:${config.port}`, description: "Local Development" },
+ ],
+});
+
+// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware
+app.get("/health", async (c) => {
+ return c.json({ message: "fallback ok" });
+});
+
+// log hono errors to console
+app.onError((error, ctx) => {
+ logger.error(error);
+ return errorResponse(ctx, "Internal Server Error");
+});
+
+export default app;
diff --git a/apps/ensapi/src/handlers/amirealtime-api.routes.ts b/apps/ensapi/src/handlers/amirealtime-api.routes.ts
new file mode 100644
index 000000000..c9c6c0c51
--- /dev/null
+++ b/apps/ensapi/src/handlers/amirealtime-api.routes.ts
@@ -0,0 +1,43 @@
+import { createRoute } from "@hono/zod-openapi";
+import { minutesToSeconds } from "date-fns";
+import { z } from "zod/v4";
+
+import type { Duration } from "@ensnode/ensnode-sdk";
+import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal";
+
+import { params } from "@/lib/handlers/params.schema";
+
+export const basePath = "/amirealtime";
+
+// Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute.
+export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1);
+
+export const amIRealtimeGetMeta = createRoute({
+ method: "get",
+ path: "/",
+ tags: ["Meta"],
+ summary: "Check indexing progress",
+ description:
+ "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime",
+ request: {
+ query: z.object({
+ maxWorstCaseDistance: params.queryParam
+ .optional()
+ .default(AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE)
+ .pipe(makeDurationSchema("maxWorstCaseDistance query param"))
+ .describe("Maximum acceptable worst-case indexing distance in seconds"),
+ }),
+ },
+ responses: {
+ 200: {
+ description:
+ "Indexing progress is guaranteed to be within the requested distance of realtime",
+ },
+ 503: {
+ description:
+ "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable",
+ },
+ },
+});
+
+export const routes = [amIRealtimeGetMeta];
diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts
index b49d57b27..8d807c24e 100644
--- a/apps/ensapi/src/handlers/amirealtime-api.test.ts
+++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts
@@ -6,10 +6,11 @@ import {
type UnixTimestamp,
} from "@ensnode/ensnode-sdk";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import * as middleware from "@/middleware/indexing-status.middleware";
-import amIRealtimeApi, { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api"; // adjust import path as needed
+import amIRealtimeApi from "./amirealtime-api";
+import { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api.routes";
vi.mock("@/middleware/indexing-status.middleware", () => ({
indexingStatusMiddleware: vi.fn(),
@@ -44,11 +45,11 @@ describe("amirealtime-api", () => {
});
};
- let app: ReturnType;
+ let app: ReturnType;
beforeEach(() => {
// Create a fresh app instance for each test with middleware registered
- app = factory.createApp();
+ app = createApp();
app.use(middleware.indexingStatusMiddleware);
app.route("/amirealtime", amIRealtimeApi);
});
@@ -76,7 +77,10 @@ describe("amirealtime-api", () => {
it("should accept valid maxWorstCaseDistance query param (set to `0`)", async () => {
// Arrange: set `indexingStatus` context var
- arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now });
+ arrangeMockedIndexingStatusMiddleware({
+ now,
+ slowestChainIndexingCursor: now,
+ });
// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=0");
@@ -153,7 +157,10 @@ describe("amirealtime-api", () => {
describe("response", () => {
it("should return 200 when worstCaseDistance is below maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
- arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 9 });
+ arrangeMockedIndexingStatusMiddleware({
+ now,
+ slowestChainIndexingCursor: now - 9,
+ });
// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
@@ -170,7 +177,10 @@ describe("amirealtime-api", () => {
it("should return 200 when worstCaseDistance equals maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
- arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 });
+ arrangeMockedIndexingStatusMiddleware({
+ now,
+ slowestChainIndexingCursor: now - 10,
+ });
// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
@@ -187,7 +197,10 @@ describe("amirealtime-api", () => {
it("should return 503 when worstCaseDistance exceeds maxWorstCaseDistance", async () => {
// Arrange: set `indexingStatus` context var
- arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 11 });
+ arrangeMockedIndexingStatusMiddleware({
+ now,
+ slowestChainIndexingCursor: now - 11,
+ });
// Act
const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10");
diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts
index d87e4f135..7b45d33fb 100644
--- a/apps/ensapi/src/handlers/amirealtime-api.ts
+++ b/apps/ensapi/src/handlers/amirealtime-api.ts
@@ -1,87 +1,48 @@
-import { minutesToSeconds } from "date-fns";
-import { describeRoute } from "hono-openapi";
-import { z } from "zod/v4";
-
-import type { Duration } from "@ensnode/ensnode-sdk";
-import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal";
-
import { errorResponse } from "@/lib/handlers/error-response";
-import { params } from "@/lib/handlers/params.schema";
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
-const app = factory.createApp();
+import { amIRealtimeGetMeta } from "./amirealtime-api.routes";
-// Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute.
-export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1);
+const app = createApp();
// allow performance monitoring clients to read HTTP Status for the provided
// `maxWorstCaseDistance` param
-app.get(
- "/",
- describeRoute({
- tags: ["Meta"],
- summary: "Check indexing progress",
- description:
- "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime",
- responses: {
- 200: {
- description:
- "Indexing progress is guaranteed to be within the requested distance of realtime",
- },
- 503: {
- description:
- "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable",
- },
- },
- }),
- validate(
- "query",
- z.object({
- maxWorstCaseDistance: params.queryParam
- .optional()
- .default(AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE)
- .pipe(makeDurationSchema("maxWorstCaseDistance query param"))
- .describe("Maximum acceptable worst-case indexing distance in seconds"),
- }),
- ),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.indexingStatus === undefined) {
- throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`);
- }
-
- // return 503 response error with details on prerequisite being unavailable
- if (c.var.indexingStatus instanceof Error) {
- return errorResponse(
- c,
- `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`,
- 503,
- );
- }
-
- const { maxWorstCaseDistance } = c.req.valid("query");
- const { worstCaseDistance, snapshot } = c.var.indexingStatus;
- const { slowestChainIndexingCursor } = snapshot;
-
- // return 503 response error with details on
- // requested `maxWorstCaseDistance` vs. actual `worstCaseDistance`
- if (worstCaseDistance > maxWorstCaseDistance) {
- return errorResponse(
- c,
- `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`,
- 503,
- );
- }
-
- // return 200 response OK with current details on `maxWorstCaseDistance`,
- // `slowestChainIndexingCursor`, and `worstCaseDistance`
- return c.json({
- maxWorstCaseDistance,
- slowestChainIndexingCursor,
- worstCaseDistance,
- });
- },
-);
+app.openapi(amIRealtimeGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.indexingStatus === undefined) {
+ throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`);
+ }
+
+ // return 503 response error with details on prerequisite being unavailable
+ if (c.var.indexingStatus instanceof Error) {
+ return errorResponse(
+ c,
+ `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`,
+ 503,
+ );
+ }
+
+ const { maxWorstCaseDistance } = c.req.valid("query");
+ const { worstCaseDistance, snapshot } = c.var.indexingStatus;
+ const { slowestChainIndexingCursor } = snapshot;
+
+ // return 503 response error with details on
+ // requested `maxWorstCaseDistance` vs. actual `worstCaseDistance`
+ if (worstCaseDistance > maxWorstCaseDistance) {
+ return errorResponse(
+ c,
+ `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`,
+ 503,
+ );
+ }
+
+ // return 200 response OK with current details on `maxWorstCaseDistance`,
+ // `slowestChainIndexingCursor`, and `worstCaseDistance`
+ return c.json({
+ maxWorstCaseDistance,
+ slowestChainIndexingCursor,
+ worstCaseDistance,
+ });
+});
export default app;
diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts
new file mode 100644
index 000000000..4d9388c05
--- /dev/null
+++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts
@@ -0,0 +1,131 @@
+import { createRoute } from "@hono/zod-openapi";
+import {
+ MAX_EDITIONS_PER_REQUEST,
+ REFERRERS_PER_LEADERBOARD_PAGE_MAX,
+} from "@namehash/ens-referrals/v1";
+import {
+ makeReferralProgramEditionSlugSchema,
+ makeReferrerMetricsEditionsArraySchema,
+} from "@namehash/ens-referrals/v1/internal";
+import { z } from "zod/v4";
+
+import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal";
+
+export const basePath = "/v1/ensanalytics";
+
+/**
+ * Query parameters schema for referrer leaderboard page requests.
+ * Validates edition slug, page number, and records per page.
+ */
+export const referrerLeaderboardPageQuerySchema = z.object({
+ edition: makeReferralProgramEditionSlugSchema("edition"),
+ page: z
+ .optional(z.coerce.number().int().min(1, "Page must be a positive integer"))
+ .describe("Page number for pagination"),
+ recordsPerPage: z
+ .optional(
+ z.coerce
+ .number()
+ .int()
+ .min(1, "Records per page must be at least 1")
+ .max(
+ REFERRERS_PER_LEADERBOARD_PAGE_MAX,
+ `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
+ ),
+ )
+ .describe("Number of referrers per page"),
+});
+
+// Referrer address parameter schema
+export const referrerAddressSchema = z.object({
+ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"),
+});
+
+// Editions query parameter schema
+export const editionsQuerySchema = z.object({
+ editions: z
+ .string()
+ .describe("Comma-separated list of edition slugs")
+ .transform((value) => value.split(",").map((s) => s.trim()))
+ .pipe(makeReferrerMetricsEditionsArraySchema("editions")),
+});
+
+export const referralLeaderboardV1GetMeta = createRoute({
+ method: "get",
+ path: "/referral-leaderboard",
+ tags: ["ENSAwards"],
+ summary: "Get Referrer Leaderboard (v1)",
+ description: "Returns a paginated page from the referrer leaderboard for a specific edition",
+ request: {
+ query: referrerLeaderboardPageQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved referrer leaderboard page",
+ },
+ 404: {
+ description: "Unknown edition slug",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ 503: {
+ description: "Service unavailable",
+ },
+ },
+});
+
+export const referrerDetailV1GetMeta = createRoute({
+ method: "get",
+ path: "/referrer/:referrer",
+ tags: ["ENSAwards"],
+ summary: "Get Referrer Detail for Editions (v1)",
+ description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`,
+ request: {
+ params: referrerAddressSchema,
+ query: editionsQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved referrer detail for requested editions",
+ },
+ 400: {
+ description: "Invalid request",
+ },
+ 404: {
+ description: "Unknown edition slug",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ 503: {
+ description: "Service unavailable",
+ },
+ },
+});
+
+export const editionConfigSetGetMeta = createRoute({
+ method: "get",
+ path: "/editions",
+ tags: ["ENSAwards"],
+ summary: "Get Edition Config Set (v1)",
+ description:
+ "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).",
+ responses: {
+ 200: {
+ description: "Successfully retrieved edition config set",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ 503: {
+ description: "Service unavailable",
+ },
+ },
+});
+
+export const routes = [
+ referralLeaderboardV1GetMeta,
+ referrerDetailV1GetMeta,
+ editionConfigSetGetMeta,
+];
diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
index aa72ef716..b295a7902 100644
--- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
+++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts
@@ -91,26 +91,41 @@ describe("/v1/ensanalytics", () => {
const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator]();
// Arrange: create the test client from the app instance
- const client = testClient(app);
+ const client: any = testClient(app);
const recordsPerPage = 10;
const edition = "2025-12";
// Act: send test request to fetch 1st page
const responsePage1 = await client["referral-leaderboard"]
- .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
- .then((r) => r.json())
+ .$get(
+ {
+ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" },
+ },
+ {},
+ )
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Act: send test request to fetch 2nd page
const responsePage2 = await client["referral-leaderboard"]
- .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "2" } }, {})
- .then((r) => r.json())
+ .$get(
+ {
+ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "2" },
+ },
+ {},
+ )
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Act: send test request to fetch 3rd page
const responsePage3 = await client["referral-leaderboard"]
- .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {})
- .then((r) => r.json())
+ .$get(
+ {
+ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" },
+ },
+ {},
+ )
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Assert: 1st page results
@@ -208,14 +223,19 @@ describe("/v1/ensanalytics", () => {
});
// Arrange: create the test client from the app instance
- const client = testClient(app);
+ const client: any = testClient(app);
const recordsPerPage = 10;
const edition = "2025-12";
// Act: send test request to fetch 1st page
const response = await client["referral-leaderboard"]
- .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
- .then((r) => r.json())
+ .$get(
+ {
+ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" },
+ },
+ {},
+ )
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Assert: empty page results
@@ -259,8 +279,22 @@ describe("/v1/ensanalytics", () => {
// Mock edition set middleware to provide a mock edition set
const mockEditionConfigSet = new Map([
- ["test-edition-a", { slug: "test-edition-a", displayName: "Edition A", rules: {} as any }],
- ["test-edition-b", { slug: "test-edition-b", displayName: "Edition B", rules: {} as any }],
+ [
+ "test-edition-a",
+ {
+ slug: "test-edition-a",
+ displayName: "Edition A",
+ rules: {} as any,
+ },
+ ],
+ [
+ "test-edition-b",
+ {
+ slug: "test-edition-b",
+ displayName: "Edition B",
+ rules: {} as any,
+ },
+ ],
]);
vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation(
async (c, next) => {
@@ -278,13 +312,19 @@ describe("/v1/ensanalytics", () => {
});
// Arrange: create the test client from the app instance
- const client = testClient(app);
+ const client: any = testClient(app);
const recordsPerPage = 10;
const invalidEdition = "invalid-edition";
// Act: send test request with invalid edition slug
const httpResponse = await client["referral-leaderboard"].$get(
- { query: { edition: invalidEdition, recordsPerPage: `${recordsPerPage}`, page: "1" } },
+ {
+ query: {
+ edition: invalidEdition,
+ recordsPerPage: `${recordsPerPage}`,
+ page: "1",
+ },
+ },
{},
);
const responseData = await httpResponse.json();
@@ -802,7 +842,10 @@ describe("/v1/ensanalytics", () => {
100,
parseTimestamp("2025-12-01T00:00:00Z"),
parseTimestamp("2025-12-31T23:59:59Z"),
- { chainId: 1, address: "0x0000000000000000000000000000000000000000" },
+ {
+ chainId: 1,
+ address: "0x0000000000000000000000000000000000000000",
+ },
new URL("https://example.com/rules"),
),
},
@@ -817,7 +860,10 @@ describe("/v1/ensanalytics", () => {
100,
parseTimestamp("2026-03-01T00:00:00Z"),
parseTimestamp("2026-03-31T23:59:59Z"),
- { chainId: 1, address: "0x0000000000000000000000000000000000000000" },
+ {
+ chainId: 1,
+ address: "0x0000000000000000000000000000000000000000",
+ },
new URL("https://example.com/rules"),
),
},
@@ -832,7 +878,10 @@ describe("/v1/ensanalytics", () => {
100,
parseTimestamp("2026-06-01T00:00:00Z"),
parseTimestamp("2026-06-30T23:59:59Z"),
- { chainId: 1, address: "0x0000000000000000000000000000000000000000" },
+ {
+ chainId: 1,
+ address: "0x0000000000000000000000000000000000000000",
+ },
new URL("https://example.com/rules"),
),
},
diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts
index 5806befb0..925340fef 100644
--- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts
+++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts
@@ -1,13 +1,10 @@
import {
getReferrerEditionMetrics,
getReferrerLeaderboardPage,
- MAX_EDITIONS_PER_REQUEST,
- REFERRERS_PER_LEADERBOARD_PAGE_MAX,
type ReferralProgramEditionConfigSetResponse,
ReferralProgramEditionConfigSetResponseCodes,
type ReferralProgramEditionSlug,
type ReferrerLeaderboard,
- type ReferrerLeaderboardPageRequest,
type ReferrerLeaderboardPageResponse,
ReferrerLeaderboardPageResponseCodes,
type ReferrerMetricsEditionsData,
@@ -17,396 +14,287 @@ import {
serializeReferrerLeaderboardPageResponse,
serializeReferrerMetricsEditionsResponse,
} from "@namehash/ens-referrals/v1";
-import {
- makeReferralProgramEditionSlugSchema,
- makeReferrerMetricsEditionsArraySchema,
-} from "@namehash/ens-referrals/v1/internal";
-import { describeRoute } from "hono-openapi";
-import { z } from "zod/v4";
-
-import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal";
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import { makeLogger } from "@/lib/logger";
import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware";
import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware";
-const logger = makeLogger("ensanalytics-api-v1");
+import {
+ editionConfigSetGetMeta,
+ referralLeaderboardV1GetMeta,
+ referrerDetailV1GetMeta,
+} from "./ensanalytics-api-v1.routes";
-/**
- * Query parameters schema for referrer leaderboard page requests.
- * Validates edition slug, page number, and records per page.
- */
-const referrerLeaderboardPageQuerySchema = z.object({
- edition: makeReferralProgramEditionSlugSchema("edition"),
- page: z
- .optional(z.coerce.number().int().min(1, "Page must be a positive integer"))
- .describe("Page number for pagination"),
- recordsPerPage: z
- .optional(
- z.coerce
- .number()
- .int()
- .min(1, "Records per page must be at least 1")
- .max(
- REFERRERS_PER_LEADERBOARD_PAGE_MAX,
- `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
- ),
- )
- .describe("Number of referrers per page"),
-}) satisfies z.ZodType;
-
-const app = factory
- .createApp()
-
- // Apply referral program edition config set middleware
- .use(referralProgramEditionConfigSetMiddleware)
-
- // Apply referrer leaderboard cache middleware (depends on edition config set middleware)
- .use(referralLeaderboardEditionsCachesMiddleware)
-
- // Get a page from the referrer leaderboard for a specific edition
- .get(
- "/referral-leaderboard",
- describeRoute({
- tags: ["ENSAwards"],
- summary: "Get Referrer Leaderboard (v1)",
- description: "Returns a paginated page from the referrer leaderboard for a specific edition",
- responses: {
- 200: {
- description: "Successfully retrieved referrer leaderboard page",
- },
- 404: {
- description: "Unknown edition slug",
- },
- 500: {
- description: "Internal server error",
- },
- 503: {
- description: "Service unavailable",
- },
- },
- }),
- validate("query", referrerLeaderboardPageQuerySchema),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.referralLeaderboardEditionsCaches === undefined) {
- throw new Error(
- `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
- );
- }
-
- try {
- const { edition, page, recordsPerPage } = c.req.valid("query");
-
- // Check if edition set failed to load
- if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
- logger.error(
- { error: c.var.referralLeaderboardEditionsCaches },
- "Referral program edition set failed to load",
- );
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: "Referral program configuration is currently unavailable.",
- } satisfies ReferrerLeaderboardPageResponse),
- 503,
- );
- }
+const logger = makeLogger("ensanalytics-api-v1");
- // Get the specific edition's cache
- const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition);
+const app = createApp();
+
+// Apply referral program edition config set middleware
+app.use(referralProgramEditionConfigSetMiddleware);
+
+// Apply referrer leaderboard cache middleware (depends on edition config set middleware)
+app.use(referralLeaderboardEditionsCachesMiddleware);
+
+// Get a page from the referrer leaderboard for a specific edition
+app.openapi(referralLeaderboardV1GetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.referralLeaderboardEditionsCaches === undefined) {
+ throw new Error(
+ `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
+ );
+ }
+
+ try {
+ const { edition, page, recordsPerPage } = c.req.valid("query");
+
+ // Check if edition set failed to load
+ if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
+ logger.error(
+ { error: c.var.referralLeaderboardEditionsCaches },
+ "Referral program edition set failed to load",
+ );
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Service Unavailable",
+ errorMessage: "Referral program configuration is currently unavailable.",
+ } satisfies ReferrerLeaderboardPageResponse),
+ 503,
+ );
+ }
+
+ // Get the specific edition's cache
+ const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition);
+
+ if (!editionCache) {
+ const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys());
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Not Found",
+ errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(
+ ", ",
+ )}`,
+ } satisfies ReferrerLeaderboardPageResponse),
+ 404,
+ );
+ }
+
+ // Read from the edition's cache
+ const leaderboard = await editionCache.read();
+
+ // Check if this specific edition failed to build
+ if (leaderboard instanceof Error) {
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Service Unavailable",
+ errorMessage: `Failed to load leaderboard for edition ${edition}.`,
+ } satisfies ReferrerLeaderboardPageResponse),
+ 503,
+ );
+ }
+
+ const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard);
+
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
+ data: leaderboardPage,
+ } satisfies ReferrerLeaderboardPageResponse),
+ );
+ } catch (error) {
+ logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard endpoint");
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while processing your request";
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Internal server error",
+ errorMessage,
+ } satisfies ReferrerLeaderboardPageResponse),
+ 500,
+ );
+ }
+});
+// Get referrer detail for a specific address for requested editions
+app.openapi(referrerDetailV1GetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.referralLeaderboardEditionsCaches === undefined) {
+ throw new Error(
+ `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
+ );
+ }
+
+ try {
+ const { referrer } = c.req.valid("param");
+ const { editions } = c.req.valid("query");
+
+ // Check if edition set failed to load
+ if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
+ logger.error(
+ { error: c.var.referralLeaderboardEditionsCaches },
+ "Referral program edition set failed to load",
+ );
+ return c.json(
+ serializeReferrerMetricsEditionsResponse({
+ responseCode: ReferrerMetricsEditionsResponseCodes.Error,
+ error: "Service Unavailable",
+ errorMessage: "Referral program configuration is currently unavailable.",
+ } satisfies ReferrerMetricsEditionsResponse),
+ 503,
+ );
+ }
+
+ // Type narrowing: at this point we know it's not an Error
+ const editionsCaches = c.var.referralLeaderboardEditionsCaches;
+
+ // Validate that all requested editions are recognized (exist in the cache map)
+ const configuredEditions = Array.from(editionsCaches.keys());
+ const unrecognizedEditions = editions.filter((edition) => !editionsCaches.has(edition));
+
+ if (unrecognizedEditions.length > 0) {
+ return c.json(
+ serializeReferrerMetricsEditionsResponse({
+ responseCode: ReferrerMetricsEditionsResponseCodes.Error,
+ error: "Not Found",
+ errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(
+ ", ",
+ )}. Valid editions: ${configuredEditions.join(", ")}`,
+ } satisfies ReferrerMetricsEditionsResponse),
+ 404,
+ );
+ }
+
+ // Read all requested edition caches
+ const editionLeaderboards = await Promise.all(
+ editions.map(async (editionSlug) => {
+ const editionCache = editionsCaches.get(editionSlug);
if (!editionCache) {
- const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys());
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Not Found",
- errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(", ")}`,
- } satisfies ReferrerLeaderboardPageResponse),
- 404,
- );
+ throw new Error(`Invariant: edition cache for ${editionSlug} should exist`);
}
-
- // Read from the edition's cache
const leaderboard = await editionCache.read();
-
- // Check if this specific edition failed to build
- if (leaderboard instanceof Error) {
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: `Failed to load leaderboard for edition ${edition}.`,
- } satisfies ReferrerLeaderboardPageResponse),
- 503,
- );
- }
-
- const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard);
-
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
- data: leaderboardPage,
- } satisfies ReferrerLeaderboardPageResponse),
- );
- } catch (error) {
- logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard endpoint");
- const errorMessage =
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while processing your request";
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Internal server error",
- errorMessage,
- } satisfies ReferrerLeaderboardPageResponse),
- 500,
- );
- }
- },
- );
-
-// Referrer address parameter schema
-const referrerAddressSchema = z.object({
- referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"),
-});
-
-// Editions query parameter schema
-const editionsQuerySchema = z.object({
- editions: z
- .string()
- .describe("Comma-separated list of edition slugs")
- .transform((value) => value.split(",").map((s) => s.trim()))
- .pipe(makeReferrerMetricsEditionsArraySchema("editions")),
+ return { editionSlug, leaderboard };
+ }),
+ );
+
+ // Validate that all requested editions have cached data (no errors)
+ const uncachedEditions = editionLeaderboards
+ .filter(({ leaderboard }) => leaderboard instanceof Error)
+ .map(({ editionSlug }) => editionSlug);
+
+ if (uncachedEditions.length > 0) {
+ return c.json(
+ serializeReferrerMetricsEditionsResponse({
+ responseCode: ReferrerMetricsEditionsResponseCodes.Error,
+ error: "Service Unavailable",
+ errorMessage: `Referrer leaderboard data not cached for edition(s): ${uncachedEditions.join(
+ ", ",
+ )}`,
+ } satisfies ReferrerMetricsEditionsResponse),
+ 503,
+ );
+ }
+
+ // Type narrowing: at this point all leaderboards are guaranteed to be non-Error
+ const validEditionLeaderboards = editionLeaderboards.filter(
+ (
+ item,
+ ): item is {
+ editionSlug: ReferralProgramEditionSlug;
+ leaderboard: ReferrerLeaderboard;
+ } => !(item.leaderboard instanceof Error),
+ );
+
+ // Build response data for the requested editions
+ const editionsData = Object.fromEntries(
+ validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [
+ editionSlug,
+ getReferrerEditionMetrics(referrer, leaderboard),
+ ]),
+ ) as ReferrerMetricsEditionsData;
+
+ return c.json(
+ serializeReferrerMetricsEditionsResponse({
+ responseCode: ReferrerMetricsEditionsResponseCodes.Ok,
+ data: editionsData,
+ } satisfies ReferrerMetricsEditionsResponse),
+ );
+ } catch (error) {
+ logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint");
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while processing your request";
+ return c.json(
+ serializeReferrerMetricsEditionsResponse({
+ responseCode: ReferrerMetricsEditionsResponseCodes.Error,
+ error: "Internal server error",
+ errorMessage,
+ } satisfies ReferrerMetricsEditionsResponse),
+ 500,
+ );
+ }
});
-// Get referrer detail for a specific address for requested editions
-app
- .get(
- "/referrer/:referrer",
- describeRoute({
- tags: ["ENSAwards"],
- summary: "Get Referrer Detail for Editions (v1)",
- description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`,
- responses: {
- 200: {
- description: "Successfully retrieved referrer detail for requested editions",
- },
- 400: {
- description: "Invalid request",
- },
- 404: {
- description: "Unknown edition slug",
- },
- 500: {
- description: "Internal server error",
- },
- 503: {
- description: "Service unavailable",
+// Get configured edition config set
+app.openapi(editionConfigSetGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.referralProgramEditionConfigSet === undefined) {
+ throw new Error(
+ `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`,
+ );
+ }
+
+ try {
+ // Check if edition config set failed to load
+ if (c.var.referralProgramEditionConfigSet instanceof Error) {
+ logger.error(
+ { error: c.var.referralProgramEditionConfigSet },
+ "Referral program edition config set failed to load",
+ );
+ return c.json(
+ serializeReferralProgramEditionConfigSetResponse({
+ responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
+ error: "Service Unavailable",
+ errorMessage: "Referral program configuration is currently unavailable.",
+ } satisfies ReferralProgramEditionConfigSetResponse),
+ 503,
+ );
+ }
+
+ // Convert Map to array and sort by start timestamp descending
+ const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
+ (a, b) => b.rules.startTime - a.rules.startTime,
+ );
+
+ return c.json(
+ serializeReferralProgramEditionConfigSetResponse({
+ responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok,
+ data: {
+ editions,
},
- },
- }),
- validate("param", referrerAddressSchema),
- validate("query", editionsQuerySchema),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.referralLeaderboardEditionsCaches === undefined) {
- throw new Error(
- `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`,
- );
- }
-
- try {
- const { referrer } = c.req.valid("param");
- const { editions } = c.req.valid("query");
-
- // Check if edition set failed to load
- if (c.var.referralLeaderboardEditionsCaches instanceof Error) {
- logger.error(
- { error: c.var.referralLeaderboardEditionsCaches },
- "Referral program edition set failed to load",
- );
- return c.json(
- serializeReferrerMetricsEditionsResponse({
- responseCode: ReferrerMetricsEditionsResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: "Referral program configuration is currently unavailable.",
- } satisfies ReferrerMetricsEditionsResponse),
- 503,
- );
- }
-
- // Type narrowing: at this point we know it's not an Error
- const editionsCaches = c.var.referralLeaderboardEditionsCaches;
-
- // Validate that all requested editions are recognized (exist in the cache map)
- const configuredEditions = Array.from(editionsCaches.keys());
- const unrecognizedEditions = editions.filter((edition) => !editionsCaches.has(edition));
-
- if (unrecognizedEditions.length > 0) {
- return c.json(
- serializeReferrerMetricsEditionsResponse({
- responseCode: ReferrerMetricsEditionsResponseCodes.Error,
- error: "Not Found",
- errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(", ")}. Valid editions: ${configuredEditions.join(", ")}`,
- } satisfies ReferrerMetricsEditionsResponse),
- 404,
- );
- }
-
- // Read all requested edition caches
- const editionLeaderboards = await Promise.all(
- editions.map(async (editionSlug) => {
- const editionCache = editionsCaches.get(editionSlug);
- if (!editionCache) {
- throw new Error(`Invariant: edition cache for ${editionSlug} should exist`);
- }
- const leaderboard = await editionCache.read();
- return { editionSlug, leaderboard };
- }),
- );
-
- // Validate that all requested editions have cached data (no errors)
- const uncachedEditions = editionLeaderboards
- .filter(({ leaderboard }) => leaderboard instanceof Error)
- .map(({ editionSlug }) => editionSlug);
-
- if (uncachedEditions.length > 0) {
- return c.json(
- serializeReferrerMetricsEditionsResponse({
- responseCode: ReferrerMetricsEditionsResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: `Referrer leaderboard data not cached for edition(s): ${uncachedEditions.join(", ")}`,
- } satisfies ReferrerMetricsEditionsResponse),
- 503,
- );
- }
-
- // Type narrowing: at this point all leaderboards are guaranteed to be non-Error
- const validEditionLeaderboards = editionLeaderboards.filter(
- (
- item,
- ): item is {
- editionSlug: ReferralProgramEditionSlug;
- leaderboard: ReferrerLeaderboard;
- } => !(item.leaderboard instanceof Error),
- );
-
- // Build response data for the requested editions
- const editionsData = Object.fromEntries(
- validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [
- editionSlug,
- getReferrerEditionMetrics(referrer, leaderboard),
- ]),
- ) as ReferrerMetricsEditionsData;
-
- return c.json(
- serializeReferrerMetricsEditionsResponse({
- responseCode: ReferrerMetricsEditionsResponseCodes.Ok,
- data: editionsData,
- } satisfies ReferrerMetricsEditionsResponse),
- );
- } catch (error) {
- logger.error(
- { error },
- "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint",
- );
- const errorMessage =
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while processing your request";
- return c.json(
- serializeReferrerMetricsEditionsResponse({
- responseCode: ReferrerMetricsEditionsResponseCodes.Error,
- error: "Internal server error",
- errorMessage,
- } satisfies ReferrerMetricsEditionsResponse),
- 500,
- );
- }
- },
- )
-
- // Get configured edition config set
- .get(
- "/editions",
- describeRoute({
- tags: ["ENSAwards"],
- summary: "Get Edition Config Set (v1)",
- description:
- "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).",
- responses: {
- 200: {
- description: "Successfully retrieved edition config set",
- },
- 500: {
- description: "Internal server error",
- },
- 503: {
- description: "Service unavailable",
- },
- },
- }),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.referralProgramEditionConfigSet === undefined) {
- throw new Error(
- `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`,
- );
- }
-
- try {
- // Check if edition config set failed to load
- if (c.var.referralProgramEditionConfigSet instanceof Error) {
- logger.error(
- { error: c.var.referralProgramEditionConfigSet },
- "Referral program edition config set failed to load",
- );
- return c.json(
- serializeReferralProgramEditionConfigSetResponse({
- responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: "Referral program configuration is currently unavailable.",
- } satisfies ReferralProgramEditionConfigSetResponse),
- 503,
- );
- }
-
- // Convert Map to array and sort by start timestamp descending
- const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort(
- (a, b) => b.rules.startTime - a.rules.startTime,
- );
-
- return c.json(
- serializeReferralProgramEditionConfigSetResponse({
- responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok,
- data: {
- editions,
- },
- } satisfies ReferralProgramEditionConfigSetResponse),
- );
- } catch (error) {
- logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint");
- const errorMessage =
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while processing your request";
- return c.json(
- serializeReferralProgramEditionConfigSetResponse({
- responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
- error: "Internal server error",
- errorMessage,
- } satisfies ReferralProgramEditionConfigSetResponse),
- 500,
- );
- }
- },
- );
+ } satisfies ReferralProgramEditionConfigSetResponse),
+ );
+ } catch (error) {
+ logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint");
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while processing your request";
+ return c.json(
+ serializeReferralProgramEditionConfigSetResponse({
+ responseCode: ReferralProgramEditionConfigSetResponseCodes.Error,
+ error: "Internal server error",
+ errorMessage,
+ } satisfies ReferralProgramEditionConfigSetResponse),
+ 500,
+ );
+ }
+});
export default app;
diff --git a/apps/ensapi/src/handlers/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api.routes.ts
new file mode 100644
index 000000000..69b64d955
--- /dev/null
+++ b/apps/ensapi/src/handlers/ensanalytics-api.routes.ts
@@ -0,0 +1,74 @@
+import { createRoute } from "@hono/zod-openapi";
+import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "@namehash/ens-referrals";
+import { z } from "zod/v4";
+
+import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal";
+
+export const basePath = "/ensanalytics";
+
+// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest)
+export const paginationQuerySchema = z.object({
+ page: z
+ .optional(z.coerce.number().int().min(1, "Page must be a positive integer"))
+ .describe("Page number for pagination"),
+ recordsPerPage: z
+ .optional(
+ z.coerce
+ .number()
+ .int()
+ .min(1, "Records per page must be at least 1")
+ .max(
+ REFERRERS_PER_LEADERBOARD_PAGE_MAX,
+ `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
+ ),
+ )
+ .describe("Number of referrers per page"),
+});
+
+// Referrer address parameter schema
+export const referrerAddressSchema = z.object({
+ referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"),
+});
+
+export const referrerLeaderboardGetMeta = createRoute({
+ method: "get",
+ path: "/referrers",
+ tags: ["ENSAwards"],
+ summary: "Get Referrer Leaderboard",
+ description: "Returns a paginated page from the referrer leaderboard",
+ request: {
+ query: paginationQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved referrer leaderboard page",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ },
+});
+
+export const referrerDetailGetMeta = createRoute({
+ method: "get",
+ path: "/referrers/:referrer",
+ tags: ["ENSAwards"],
+ summary: "Get Referrer Detail",
+ description: "Returns detailed information for a specific referrer by address",
+ request: {
+ params: referrerAddressSchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved referrer detail",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ 503: {
+ description: "Service unavailable - referrer leaderboard data not yet cached",
+ },
+ },
+});
+
+export const routes = [referrerLeaderboardGetMeta, referrerDetailGetMeta];
diff --git a/apps/ensapi/src/handlers/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics-api.test.ts
index 64229e014..cb2506f24 100644
--- a/apps/ensapi/src/handlers/ensanalytics-api.test.ts
+++ b/apps/ensapi/src/handlers/ensanalytics-api.test.ts
@@ -54,25 +54,25 @@ describe("/ensanalytics", () => {
const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator]();
// Arrange: create the test client from the app instance
- const client = testClient(app);
+ const client: any = testClient(app);
const recordsPerPage = 10;
// Act: send test request to fetch 1st page
const responsePage1 = await client.referrers
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
- .then((r) => r.json())
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Act: send test request to fetch 2nd page
const responsePage2 = await client.referrers
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "2" } }, {})
- .then((r) => r.json())
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Act: send test request to fetch 3rd page
const responsePage3 = await client.referrers
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "3" } }, {})
- .then((r) => r.json())
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Assert: 1st page results
@@ -145,13 +145,13 @@ describe("/ensanalytics", () => {
});
// Arrange: create the test client from the app instance
- const client = testClient(app);
+ const client: any = testClient(app);
const recordsPerPage = 10;
// Act: send test request to fetch 1st page
const response = await client.referrers
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
- .then((r) => r.json())
+ .then((r: Response) => r.json())
.then(deserializeReferrerLeaderboardPageResponse);
// Assert: empty page results
diff --git a/apps/ensapi/src/handlers/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics-api.ts
index 1912a8bbf..8476f6e0a 100644
--- a/apps/ensapi/src/handlers/ensanalytics-api.ts
+++ b/apps/ensapi/src/handlers/ensanalytics-api.ts
@@ -1,186 +1,119 @@
import {
getReferrerDetail,
getReferrerLeaderboardPage,
- REFERRERS_PER_LEADERBOARD_PAGE_MAX,
type ReferrerDetailResponse,
ReferrerDetailResponseCodes,
- type ReferrerLeaderboardPageRequest,
type ReferrerLeaderboardPageResponse,
ReferrerLeaderboardPageResponseCodes,
serializeReferrerDetailResponse,
serializeReferrerLeaderboardPageResponse,
} from "@namehash/ens-referrals";
-import { describeRoute } from "hono-openapi";
-import { z } from "zod/v4";
-import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal";
-
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import { makeLogger } from "@/lib/logger";
import { referrerLeaderboardMiddleware } from "@/middleware/referrer-leaderboard.middleware";
-const logger = makeLogger("ensanalytics-api");
-
-// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest)
-const paginationQuerySchema = z.object({
- page: z
- .optional(z.coerce.number().int().min(1, "Page must be a positive integer"))
- .describe("Page number for pagination"),
- recordsPerPage: z
- .optional(
- z.coerce
- .number()
- .int()
- .min(1, "Records per page must be at least 1")
- .max(
- REFERRERS_PER_LEADERBOARD_PAGE_MAX,
- `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`,
- ),
- )
- .describe("Number of referrers per page"),
-}) satisfies z.ZodType;
+import { referrerDetailGetMeta, referrerLeaderboardGetMeta } from "./ensanalytics-api.routes";
-const app = factory
- .createApp()
-
- // Apply referrer leaderboard cache middleware to all routes in this handler
- .use(referrerLeaderboardMiddleware)
+const logger = makeLogger("ensanalytics-api");
- // Get a page from the referrer leaderboard
- .get(
- "/referrers",
- describeRoute({
- tags: ["ENSAwards"],
- summary: "Get Referrer Leaderboard",
- description: "Returns a paginated page from the referrer leaderboard",
- responses: {
- 200: {
- description: "Successfully retrieved referrer leaderboard page",
- },
- 500: {
- description: "Internal server error",
- },
- },
- }),
- validate("query", paginationQuerySchema),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.referrerLeaderboard === undefined) {
- throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`);
- }
+const app = createApp();
- try {
- if (c.var.referrerLeaderboard instanceof Error) {
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Internal Server Error",
- errorMessage: "Failed to load referrer leaderboard data.",
- } satisfies ReferrerLeaderboardPageResponse),
- 500,
- );
- }
+// Apply referrer leaderboard cache middleware to all routes in this handler
+app.use(referrerLeaderboardMiddleware);
- const { page, recordsPerPage } = c.req.valid("query");
- const leaderboardPage = getReferrerLeaderboardPage(
- { page, recordsPerPage },
- c.var.referrerLeaderboard,
- );
+// Get a page from the referrer leaderboard
+app.openapi(referrerLeaderboardGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.referrerLeaderboard === undefined) {
+ throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`);
+ }
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
- data: leaderboardPage,
- } satisfies ReferrerLeaderboardPageResponse),
- );
- } catch (error) {
- logger.error({ error }, "Error in /ensanalytics/referrers endpoint");
- const errorMessage =
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while processing your request";
- return c.json(
- serializeReferrerLeaderboardPageResponse({
- responseCode: ReferrerLeaderboardPageResponseCodes.Error,
- error: "Internal server error",
- errorMessage,
- } satisfies ReferrerLeaderboardPageResponse),
- 500,
- );
- }
- },
- );
+ try {
+ if (c.var.referrerLeaderboard instanceof Error) {
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Internal Server Error",
+ errorMessage: "Failed to load referrer leaderboard data.",
+ } satisfies ReferrerLeaderboardPageResponse),
+ 500,
+ );
+ }
-// Referrer address parameter schema
-const referrerAddressSchema = z.object({
- referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"),
+ const { page, recordsPerPage } = c.req.valid("query");
+ const leaderboardPage = getReferrerLeaderboardPage(
+ { page, recordsPerPage },
+ c.var.referrerLeaderboard,
+ );
+
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
+ data: leaderboardPage,
+ } satisfies ReferrerLeaderboardPageResponse),
+ );
+ } catch (error) {
+ logger.error({ error }, "Error in /ensanalytics/referrers endpoint");
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while processing your request";
+ return c.json(
+ serializeReferrerLeaderboardPageResponse({
+ responseCode: ReferrerLeaderboardPageResponseCodes.Error,
+ error: "Internal server error",
+ errorMessage,
+ } satisfies ReferrerLeaderboardPageResponse),
+ 500,
+ );
+ }
});
// Get referrer detail for a specific address
-app.get(
- "/referrers/:referrer",
- describeRoute({
- tags: ["ENSAwards"],
- summary: "Get Referrer Detail",
- description: "Returns detailed information for a specific referrer by address",
- responses: {
- 200: {
- description: "Successfully retrieved referrer detail",
- },
- 500: {
- description: "Internal server error",
- },
- 503: {
- description: "Service unavailable - referrer leaderboard data not yet cached",
- },
- },
- }),
- validate("param", referrerAddressSchema),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.referrerLeaderboard === undefined) {
- throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`);
- }
-
- try {
- // Check if leaderboard failed to load
- if (c.var.referrerLeaderboard instanceof Error) {
- return c.json(
- serializeReferrerDetailResponse({
- responseCode: ReferrerDetailResponseCodes.Error,
- error: "Service Unavailable",
- errorMessage: "Referrer leaderboard data has not been successfully cached yet.",
- } satisfies ReferrerDetailResponse),
- 503,
- );
- }
-
- const { referrer } = c.req.valid("param");
- const detail = getReferrerDetail(referrer, c.var.referrerLeaderboard);
-
- return c.json(
- serializeReferrerDetailResponse({
- responseCode: ReferrerDetailResponseCodes.Ok,
- data: detail,
- } satisfies ReferrerDetailResponse),
- );
- } catch (error) {
- logger.error({ error }, "Error in /ensanalytics/referrers/:referrer endpoint");
- const errorMessage =
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while processing your request";
+app.openapi(referrerDetailGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.referrerLeaderboard === undefined) {
+ throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`);
+ }
+
+ try {
+ // Check if leaderboard failed to load
+ if (c.var.referrerLeaderboard instanceof Error) {
return c.json(
serializeReferrerDetailResponse({
responseCode: ReferrerDetailResponseCodes.Error,
- error: "Internal server error",
- errorMessage,
+ error: "Service Unavailable",
+ errorMessage: "Referrer leaderboard data has not been successfully cached yet.",
} satisfies ReferrerDetailResponse),
- 500,
+ 503,
);
}
- },
-);
+
+ const { referrer } = c.req.valid("param");
+ const detail = getReferrerDetail(referrer, c.var.referrerLeaderboard);
+
+ return c.json(
+ serializeReferrerDetailResponse({
+ responseCode: ReferrerDetailResponseCodes.Ok,
+ data: detail,
+ } satisfies ReferrerDetailResponse),
+ );
+ } catch (error) {
+ logger.error({ error }, "Error in /ensanalytics/referrers/:referrer endpoint");
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while processing your request";
+ return c.json(
+ serializeReferrerDetailResponse({
+ responseCode: ReferrerDetailResponseCodes.Error,
+ error: "Internal server error",
+ errorMessage,
+ } satisfies ReferrerDetailResponse),
+ 500,
+ );
+ }
+});
export default app;
diff --git a/apps/ensapi/src/handlers/ensnode-api.routes.ts b/apps/ensapi/src/handlers/ensnode-api.routes.ts
new file mode 100644
index 000000000..12ed983d3
--- /dev/null
+++ b/apps/ensapi/src/handlers/ensnode-api.routes.ts
@@ -0,0 +1,54 @@
+import { createRoute } from "@hono/zod-openapi";
+
+import {
+ makeENSApiPublicConfigSchema,
+ makeIndexingStatusResponseSchema,
+} from "@ensnode/ensnode-sdk/internal";
+
+export const basePath = "/api";
+
+export const configGetMeta = createRoute({
+ method: "get",
+ path: "/config",
+ tags: ["Meta"],
+ summary: "Get ENSApi Public Config",
+ description: "Gets the public config of the ENSApi instance",
+ responses: {
+ 200: {
+ description: "Successfully retrieved ENSApi public config",
+ content: {
+ "application/json": {
+ schema: makeENSApiPublicConfigSchema(),
+ },
+ },
+ },
+ },
+});
+
+export const indexingStatusGetMeta = createRoute({
+ method: "get",
+ path: "/indexing-status",
+ tags: ["Meta"],
+ summary: "Get ENSIndexer Indexing Status",
+ description: "Returns the indexing status snapshot most recently captured from ENSIndexer",
+ responses: {
+ 200: {
+ description: "Successfully retrieved indexing status",
+ content: {
+ "application/json": {
+ schema: makeIndexingStatusResponseSchema(),
+ },
+ },
+ },
+ 503: {
+ description: "Indexing status snapshot unavailable",
+ content: {
+ "application/json": {
+ schema: makeIndexingStatusResponseSchema(),
+ },
+ },
+ },
+ },
+});
+
+export const routes = [configGetMeta, indexingStatusGetMeta];
diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts
index 46d458dad..77df7ec7e 100644
--- a/apps/ensapi/src/handlers/ensnode-api.ts
+++ b/apps/ensapi/src/handlers/ensnode-api.ts
@@ -1,7 +1,3 @@
-import config from "@/config";
-
-import { describeRoute, resolver as validationResolver } from "hono-openapi";
-
import {
IndexingStatusResponseCodes,
type IndexingStatusResponseError,
@@ -9,102 +5,59 @@ import {
serializeENSApiPublicConfig,
serializeIndexingStatusResponse,
} from "@ensnode/ensnode-sdk";
-import {
- makeENSApiPublicConfigSchema,
- makeIndexingStatusResponseSchema,
-} from "@ensnode/ensnode-sdk/internal";
import { buildEnsApiPublicConfig } from "@/config/config.schema";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
+import { basePath, configGetMeta, indexingStatusGetMeta } from "./ensnode-api.routes";
import ensnodeGraphQLApi from "./ensnode-graphql-api";
import nameTokensApi from "./name-tokens-api";
+import { basePath as nameTokensBasePath } from "./name-tokens-api.routes";
import registrarActionsApi from "./registrar-actions-api";
+import { basePath as registrarActionsBasePath } from "./registrar-actions-api.routes";
import resolutionApi from "./resolution-api";
+import { basePath as resolutionBasePath } from "./resolution-api.routes";
-const app = factory.createApp();
+const app = createApp();
-app.get(
- "/config",
- describeRoute({
- tags: ["Meta"],
- summary: "Get ENSApi Public Config",
- description: "Gets the public config of the ENSApi instance",
- responses: {
- 200: {
- description: "Successfully retrieved ENSApi public config",
- content: {
- "application/json": {
- schema: validationResolver(makeENSApiPublicConfigSchema()),
- },
- },
- },
- },
- }),
- async (c) => {
- const ensApiPublicConfig = buildEnsApiPublicConfig(config);
- return c.json(serializeENSApiPublicConfig(ensApiPublicConfig));
- },
-);
+app.openapi(configGetMeta, async (c) => {
+ const config = (await import("@/config")).default;
+ const ensApiPublicConfig = buildEnsApiPublicConfig(config);
+ return c.json(serializeENSApiPublicConfig(ensApiPublicConfig));
+});
-app.get(
- "/indexing-status",
- describeRoute({
- tags: ["Meta"],
- summary: "Get ENSIndexer Indexing Status",
- description: "Returns the indexing status snapshot most recently captured from ENSIndexer",
- responses: {
- 200: {
- description: "Successfully retrieved indexing status",
- content: {
- "application/json": {
- schema: validationResolver(makeIndexingStatusResponseSchema()),
- },
- },
- },
- 503: {
- description: "Indexing status snapshot unavailable",
- content: {
- "application/json": {
- schema: validationResolver(makeIndexingStatusResponseSchema()),
- },
- },
- },
- },
- }),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.indexingStatus === undefined) {
- throw new Error(`Invariant(indexing-status): indexingStatusMiddleware required`);
- }
+app.openapi(indexingStatusGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.indexingStatus === undefined) {
+ throw new Error(`Invariant(indexing-status): indexingStatusMiddleware required`);
+ }
- if (c.var.indexingStatus instanceof Error) {
- return c.json(
- serializeIndexingStatusResponse({
- responseCode: IndexingStatusResponseCodes.Error,
- } satisfies IndexingStatusResponseError),
- 503,
- );
- }
-
- // return successful response using the indexing status projection from the middleware context
+ if (c.var.indexingStatus instanceof Error) {
return c.json(
serializeIndexingStatusResponse({
- responseCode: IndexingStatusResponseCodes.Ok,
- realtimeProjection: c.var.indexingStatus,
- } satisfies IndexingStatusResponseOk),
+ responseCode: IndexingStatusResponseCodes.Error,
+ } satisfies IndexingStatusResponseError),
+ 503,
);
- },
-);
+ }
+
+ // return successful response using the indexing status projection from the middleware context
+ return c.json(
+ serializeIndexingStatusResponse({
+ responseCode: IndexingStatusResponseCodes.Ok,
+ realtimeProjection: c.var.indexingStatus,
+ } satisfies IndexingStatusResponseOk),
+ );
+});
// Name Tokens API
-app.route("/name-tokens", nameTokensApi);
+app.route(nameTokensBasePath.replace(basePath, ""), nameTokensApi);
// Registrar Actions API
-app.route("/registrar-actions", registrarActionsApi);
+app.route(registrarActionsBasePath.replace(basePath, ""), registrarActionsApi);
// Resolution API
-app.route("/resolve", resolutionApi);
+app.route(resolutionBasePath.replace(basePath, ""), resolutionApi);
// ENSNode GraphQL API
app.route("/graphql", ensnodeGraphQLApi);
diff --git a/apps/ensapi/src/handlers/ensnode-graphql-api.ts b/apps/ensapi/src/handlers/ensnode-graphql-api.ts
index adf92d7c9..da767eb70 100644
--- a/apps/ensapi/src/handlers/ensnode-graphql-api.ts
+++ b/apps/ensapi/src/handlers/ensnode-graphql-api.ts
@@ -1,7 +1,7 @@
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import { requireCorePluginMiddleware } from "@/middleware/require-core-plugin.middleware";
-const app = factory.createApp();
+const app = createApp();
app.use(requireCorePluginMiddleware("ensv2"));
app.use(async (c) => {
diff --git a/apps/ensapi/src/handlers/name-tokens-api.routes.ts b/apps/ensapi/src/handlers/name-tokens-api.routes.ts
new file mode 100644
index 000000000..d5d9edb04
--- /dev/null
+++ b/apps/ensapi/src/handlers/name-tokens-api.routes.ts
@@ -0,0 +1,83 @@
+import { createRoute } from "@hono/zod-openapi";
+import { z } from "zod/v4";
+
+import {
+ ErrorResponseSchema,
+ makeNameTokensResponseSchema,
+ makeNodeSchema,
+} from "@ensnode/ensnode-sdk/internal";
+
+import { params } from "@/lib/handlers/params.schema";
+
+export const basePath = "/api/name-tokens";
+
+/**
+ * Request Query Schema
+ *
+ * Name Tokens API can be requested by either `name` or `domainId`, and
+ * can never be requested by both, or neither.
+ */
+export const nameTokensQuerySchema = z
+ .object({
+ domainId: makeNodeSchema("request.domainId").optional().describe("Domain node hash identifier"),
+ name: params.name.optional().describe("ENS name to look up tokens for"),
+ })
+ .refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), {
+ message: "Exactly one of 'domainId' or 'name' must be provided",
+ });
+
+export const nameTokensGetMeta = createRoute({
+ method: "get",
+ path: "/",
+ tags: ["Explore"],
+ summary: "Get Name Tokens",
+ description: "Returns name tokens for the requested identifier (domainId or name)",
+ request: {
+ query: nameTokensQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Name tokens known",
+ content: {
+ "application/json": {
+ schema: makeNameTokensResponseSchema("Name Tokens Response", true),
+ },
+ },
+ },
+ 400: {
+ description: "Invalid input",
+ content: {
+ "application/json": {
+ schema: ErrorResponseSchema,
+ },
+ },
+ },
+ 404: {
+ description: "Name tokens not indexed",
+ content: {
+ "application/json": {
+ schema: makeNameTokensResponseSchema("Name Tokens Response", true),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: ErrorResponseSchema,
+ },
+ },
+ },
+ 503: {
+ description:
+ "Service unavailable - Name Tokens API prerequisites not met (indexing status not ready or required plugins not activated)",
+ content: {
+ "application/json": {
+ schema: makeNameTokensResponseSchema("Name Tokens Response", true),
+ },
+ },
+ },
+ },
+});
+
+export const routes = [nameTokensGetMeta];
diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts
index fead1a0da..75c24119d 100644
--- a/apps/ensapi/src/handlers/name-tokens-api.ts
+++ b/apps/ensapi/src/handlers/name-tokens-api.ts
@@ -1,8 +1,4 @@
-import config from "@/config";
-
-import { describeRoute, resolver as validationResolver } from "hono-openapi";
import { namehash } from "viem";
-import { z } from "zod/v4";
import {
ENS_ROOT,
@@ -15,46 +11,21 @@ import {
type PluginName,
serializeNameTokensResponse,
} from "@ensnode/ensnode-sdk";
-import {
- ErrorResponseSchema,
- makeNameTokensResponseSchema,
- makeNodeSchema,
-} from "@ensnode/ensnode-sdk/internal";
-
-import { params } from "@/lib/handlers/params.schema";
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+
+import { createApp } from "@/lib/hono-factory";
import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain";
import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries";
import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware";
-const app = factory.createApp();
+import { nameTokensGetMeta } from "./name-tokens-api.routes";
-const indexedSubregistries = getIndexedSubregistries(
- config.namespace,
- config.ensIndexerPublicConfig.plugins as PluginName[],
-);
+const app = createApp();
// Middleware managing access to Name Tokens API route.
// It makes the route available if all prerequisites are met,
// and if not returns the appropriate HTTP 503 (Service Unavailable) error.
app.use(nameTokensApiMiddleware);
-/**
- * Request Query Schema
- *
- * Name Tokens API can be requested by either `name` or `domainId`, and
- * can never be requested by both, or neither.
- */
-const nameTokensQuerySchema = z
- .object({
- domainId: makeNodeSchema("request.domainId").optional().describe("Domain node hash identifier"),
- name: params.name.optional().describe("ENS name to look up tokens for"),
- })
- .refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), {
- message: "Exactly one of 'domainId' or 'name' must be provided",
- });
-
/**
* Factory function for creating a 404 Name Tokens Not Indexed error response
*/
@@ -69,163 +40,116 @@ const makeNameTokensNotIndexedResponse = (
},
});
-app.get(
- "/",
- describeRoute({
- tags: ["Explore"],
- summary: "Get Name Tokens",
- description: "Returns name tokens for the requested identifier (domainId or name)",
- responses: {
- 200: {
- description: "Name tokens known",
- content: {
- "application/json": {
- schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)),
- },
- },
- },
- 400: {
- description: "Invalid input",
- content: {
- "application/json": {
- schema: validationResolver(ErrorResponseSchema),
- },
- },
- },
- 404: {
- description: "Name tokens not indexed",
- content: {
- "application/json": {
- schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)),
- },
- },
- },
- 500: {
- description: "Internal server error",
- content: {
- "application/json": {
- schema: validationResolver(ErrorResponseSchema),
- },
+app.openapi(nameTokensGetMeta, async (c): Promise => {
+ // Invariant: context must be set by the required middleware
+ if (c.var.indexingStatus === undefined) {
+ return c.json(
+ serializeNameTokensResponse({
+ responseCode: NameTokensResponseCodes.Error,
+ errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported,
+ error: {
+ message: "Name Tokens API is not available yet",
+ details: "Indexing status middleware is required but not initialized.",
},
- },
- 503: {
- description:
- "Service unavailable - Name Tokens API prerequisites not met (indexing status not ready or required plugins not activated)",
- content: {
- "application/json": {
- schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)),
- },
+ }),
+ 503,
+ );
+ }
+
+ // Check if Indexing Status resolution failed.
+ if (c.var.indexingStatus instanceof Error) {
+ return c.json(
+ serializeNameTokensResponse({
+ responseCode: NameTokensResponseCodes.Error,
+ errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported,
+ error: {
+ message: "Name Tokens API is not available yet",
+ details:
+ "Indexing status has not yet reached the required state to enable the Name Tokens API.",
},
- },
- },
- }),
- validate("query", nameTokensQuerySchema),
- async (c) => {
- // Invariant: context must be set by the required middleware
- if (c.var.indexingStatus === undefined) {
- return c.json(
- serializeNameTokensResponse({
- responseCode: NameTokensResponseCodes.Error,
- errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported,
- error: {
- message: "Name Tokens API is not available yet",
- details: "Indexing status middleware is required but not initialized.",
- },
- }),
- 503,
- );
- }
+ }),
+ 503,
+ );
+ }
- // Check if Indexing Status resolution failed.
- if (c.var.indexingStatus instanceof Error) {
- return c.json(
- serializeNameTokensResponse({
- responseCode: NameTokensResponseCodes.Error,
- errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported,
- error: {
- message: "Name Tokens API is not available yet",
- details:
- "Indexing status has not yet reached the required state to enable the Name Tokens API.",
- },
- }),
- 503,
- );
- }
+ const request = c.req.valid("query") satisfies NameTokensRequest;
+ let domainId: Node;
- const request = c.req.valid("query") satisfies NameTokensRequest;
- let domainId: Node;
+ // Lazy config import to avoid module-scope config dependency
+ const config = (await import("@/config")).default;
+ const indexedSubregistries = getIndexedSubregistries(
+ config.namespace,
+ config.ensIndexerPublicConfig.plugins as PluginName[],
+ );
- if (request.name !== undefined) {
- const { name } = request;
+ 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(
- makeNameTokensNotIndexedResponse(
- `The 'name' param must not be ENS Root, no tokens exist for it.`,
- ),
+ // return 404 when the requested name was the ENS Root
+ if (name === ENS_ROOT) {
+ return c.json(
+ serializeNameTokensResponse(
+ makeNameTokensNotIndexedResponse(
+ `The 'name' param must not be ENS Root, no tokens exist for it.`,
),
- 404,
- );
- }
-
- const parentNode = namehash(getParentNameFQDN(name));
- const subregistry = indexedSubregistries.find(
- (subregistry) => subregistry.node === parentNode,
+ ),
+ 404,
);
-
- // Return 404 response with error code for Name Tokens Not Indexed when
- // the parent name of the requested name does not match any of the
- // actively indexed subregistries.
- if (!subregistry) {
- return c.json(
- serializeNameTokensResponse(
- makeNameTokensNotIndexedResponse(
- `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`,
- ),
- ),
- 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);
+ 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
- // 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}'`;
-
+ // the parent name of the requested name does not match any of the
+ // actively indexed subregistries.
+ if (!subregistry) {
return c.json(
serializeNameTokensResponse(
makeNameTokensNotIndexedResponse(
- `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`,
+ `This ENSNode instance has not been configured to index tokens for the requested name: '${name}'`,
),
),
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
+ // 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}'`;
+
return c.json(
- serializeNameTokensResponse({
- responseCode: NameTokensResponseCodes.Ok,
- registeredNameTokens,
- }),
+ serializeNameTokensResponse(
+ makeNameTokensNotIndexedResponse(
+ `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`,
+ ),
+ ),
+ 404,
);
- },
-);
+ }
+
+ return c.json(
+ serializeNameTokensResponse({
+ responseCode: NameTokensResponseCodes.Ok,
+ registeredNameTokens,
+ }),
+ );
+});
export default app;
diff --git a/apps/ensapi/src/handlers/registrar-actions-api.routes.ts b/apps/ensapi/src/handlers/registrar-actions-api.routes.ts
new file mode 100644
index 000000000..4e762fe6a
--- /dev/null
+++ b/apps/ensapi/src/handlers/registrar-actions-api.routes.ts
@@ -0,0 +1,130 @@
+import { createRoute } from "@hono/zod-openapi";
+import { z } from "zod/v4";
+
+import {
+ RECORDS_PER_PAGE_DEFAULT,
+ RECORDS_PER_PAGE_MAX,
+ RegistrarActionsOrders,
+} from "@ensnode/ensnode-sdk";
+import {
+ makeLowercaseAddressSchema,
+ makeNodeSchema,
+ makePositiveIntegerSchema,
+ makeUnixTimestampSchema,
+} from "@ensnode/ensnode-sdk/internal";
+
+import { params } from "@/lib/handlers/params.schema";
+
+export const basePath = "/api/registrar-actions";
+
+// Shared query schema for registrar actions
+export const registrarActionsQuerySchema = z
+ .object({
+ orderBy: z
+ .enum(RegistrarActionsOrders)
+ .default(RegistrarActionsOrders.LatestRegistrarActions)
+ .describe("Order of results"),
+
+ page: params.queryParam
+ .optional()
+ .default(1)
+ .pipe(z.coerce.number())
+ .pipe(makePositiveIntegerSchema("page"))
+ .describe("Page number for pagination"),
+
+ recordsPerPage: params.queryParam
+ .optional()
+ .default(RECORDS_PER_PAGE_DEFAULT)
+ .pipe(z.coerce.number())
+ .pipe(makePositiveIntegerSchema("recordsPerPage").max(RECORDS_PER_PAGE_MAX))
+ .describe("Number of records per page"),
+
+ withReferral: params.boolstring
+ .optional()
+ .default(false)
+ .describe("Filter to only include actions with referrals"),
+
+ decodedReferrer: makeLowercaseAddressSchema("decodedReferrer")
+ .optional()
+ .describe("Filter by decoded referrer address"),
+
+ beginTimestamp: params.queryParam
+ .pipe(z.coerce.number())
+ .pipe(makeUnixTimestampSchema("beginTimestamp"))
+ .optional()
+ .describe("Filter actions at or after this Unix timestamp"),
+
+ endTimestamp: params.queryParam
+ .pipe(z.coerce.number())
+ .pipe(makeUnixTimestampSchema("endTimestamp"))
+ .optional()
+ .describe("Filter actions at or before this Unix timestamp"),
+ })
+ .refine(
+ (data) => {
+ // If both timestamps are provided, endTimestamp must be >= beginTimestamp
+ if (data.beginTimestamp !== undefined && data.endTimestamp !== undefined) {
+ return data.endTimestamp >= data.beginTimestamp;
+ }
+ return true;
+ },
+ {
+ message: "endTimestamp must be greater than or equal to beginTimestamp",
+ path: ["endTimestamp"],
+ },
+ );
+
+// Parent node parameter schema
+export const parentNodeParamSchema = z.object({
+ parentNode: makeNodeSchema("parentNode param").describe(
+ "Parent node to filter registrar actions",
+ ),
+});
+
+export const registrarActionsGetMeta = createRoute({
+ method: "get",
+ path: "/",
+ tags: ["Explore"],
+ summary: "Get Registrar Actions",
+ description: "Returns all registrar actions with optional filtering and pagination",
+ request: {
+ query: registrarActionsQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved registrar actions",
+ },
+ 400: {
+ description: "Invalid query",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ },
+});
+
+export const registrarActionsByParentNodeGetMeta = createRoute({
+ method: "get",
+ path: "/:parentNode",
+ tags: ["Explore"],
+ summary: "Get Registrar Actions by Parent Node",
+ description:
+ "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination",
+ request: {
+ params: parentNodeParamSchema,
+ query: registrarActionsQuerySchema,
+ },
+ responses: {
+ 200: {
+ description: "Successfully retrieved registrar actions",
+ },
+ 400: {
+ description: "Invalid input",
+ },
+ 500: {
+ description: "Internal server error",
+ },
+ },
+});
+
+export const routes = [registrarActionsGetMeta, registrarActionsByParentNodeGetMeta];
diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts
index 69bcea86a..67b11001e 100644
--- a/apps/ensapi/src/handlers/registrar-actions-api.ts
+++ b/apps/ensapi/src/handlers/registrar-actions-api.ts
@@ -1,34 +1,28 @@
-import { describeRoute } from "hono-openapi";
-import { z } from "zod/v4";
+import type { z } from "zod/v4";
import {
buildPageContext,
type Node,
- RECORDS_PER_PAGE_DEFAULT,
- RECORDS_PER_PAGE_MAX,
type RegistrarActionsFilter,
- RegistrarActionsOrders,
RegistrarActionsResponseCodes,
type RegistrarActionsResponseError,
type RegistrarActionsResponseOk,
registrarActionsFilter,
serializeRegistrarActionsResponse,
} from "@ensnode/ensnode-sdk";
-import {
- makeLowercaseAddressSchema,
- makeNodeSchema,
- makePositiveIntegerSchema,
- makeUnixTimestampSchema,
-} from "@ensnode/ensnode-sdk/internal";
-import { params } from "@/lib/handlers/params.schema";
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import { makeLogger } from "@/lib/logger";
import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions";
import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware";
-const app = factory.createApp();
+import {
+ registrarActionsByParentNodeGetMeta,
+ registrarActionsGetMeta,
+ type registrarActionsQuerySchema,
+} from "./registrar-actions-api.routes";
+
+const app = createApp();
const logger = makeLogger("registrar-actions-api");
@@ -36,63 +30,6 @@ const logger = makeLogger("registrar-actions-api");
// It makes the routes available if all prerequisites are met.
app.use(registrarActionsApiMiddleware);
-// Shared query schema for registrar actions
-const registrarActionsQuerySchema = z
- .object({
- orderBy: z
- .enum(RegistrarActionsOrders)
- .default(RegistrarActionsOrders.LatestRegistrarActions)
- .describe("Order of results"),
-
- page: params.queryParam
- .optional()
- .default(1)
- .pipe(z.coerce.number())
- .pipe(makePositiveIntegerSchema("page"))
- .describe("Page number for pagination"),
-
- recordsPerPage: params.queryParam
- .optional()
- .default(RECORDS_PER_PAGE_DEFAULT)
- .pipe(z.coerce.number())
- .pipe(makePositiveIntegerSchema("recordsPerPage").max(RECORDS_PER_PAGE_MAX))
- .describe("Number of records per page"),
-
- withReferral: params.boolstring
- .optional()
- .default(false)
- .describe("Filter to only include actions with referrals"),
-
- decodedReferrer: makeLowercaseAddressSchema("decodedReferrer")
- .optional()
- .describe("Filter by decoded referrer address"),
-
- beginTimestamp: params.queryParam
- .pipe(z.coerce.number())
- .pipe(makeUnixTimestampSchema("beginTimestamp"))
- .optional()
- .describe("Filter actions at or after this Unix timestamp"),
-
- endTimestamp: params.queryParam
- .pipe(z.coerce.number())
- .pipe(makeUnixTimestampSchema("endTimestamp"))
- .optional()
- .describe("Filter actions at or before this Unix timestamp"),
- })
- .refine(
- (data) => {
- // If both timestamps are provided, endTimestamp must be >= beginTimestamp
- if (data.beginTimestamp !== undefined && data.endTimestamp !== undefined) {
- return data.endTimestamp >= data.beginTimestamp;
- }
- return true;
- },
- {
- message: "endTimestamp must be greater than or equal to beginTimestamp",
- path: ["endTimestamp"],
- },
- );
-
// Shared business logic for fetching registrar actions
async function fetchRegistrarActions(
parentNode: Node | undefined,
@@ -151,159 +88,79 @@ async function fetchRegistrarActions(
* Get Registrar Actions (all records)
*
* Example: `GET /api/registrar-actions`
- *
- * @see {@link app.get("/:parentNode")} for response documentation
*/
-app.get(
- "/",
- describeRoute({
- tags: ["Explore"],
- summary: "Get Registrar Actions",
- description: "Returns all registrar actions with optional filtering and pagination",
- responses: {
- 200: {
- description: "Successfully retrieved registrar actions",
- },
- 400: {
- description: "Invalid query",
- },
- 500: {
- description: "Internal server error",
- },
- },
- }),
- validate("query", registrarActionsQuerySchema),
- async (c) => {
- try {
- const query = c.req.valid("query");
- const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query);
-
- // respond with success response
- return c.json(
- serializeRegistrarActionsResponse({
- responseCode: RegistrarActionsResponseCodes.Ok,
- registrarActions,
- pageContext,
- } satisfies RegistrarActionsResponseOk),
- );
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
- logger.error(errorMessage);
-
- // respond with 500 error response
- return c.json(
- serializeRegistrarActionsResponse({
- responseCode: RegistrarActionsResponseCodes.Error,
- error: {
- message: `Registrar Actions API Response is unavailable`,
- },
- } satisfies RegistrarActionsResponseError),
- 500,
- );
- }
- },
-);
+app.openapi(registrarActionsGetMeta, async (c) => {
+ try {
+ const query = c.req.valid("query");
+ const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query);
+
+ // respond with success response
+ return c.json(
+ serializeRegistrarActionsResponse({
+ responseCode: RegistrarActionsResponseCodes.Ok,
+ registrarActions,
+ pageContext,
+ } satisfies RegistrarActionsResponseOk),
+ );
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ logger.error(errorMessage);
+
+ // respond with 500 error response
+ return c.json(
+ serializeRegistrarActionsResponse({
+ responseCode: RegistrarActionsResponseCodes.Error,
+ error: {
+ message: `Registrar Actions API Response is unavailable`,
+ },
+ } satisfies RegistrarActionsResponseError),
+ 500,
+ );
+ }
+});
/**
* Get Registrar Actions (filtered by parent node)
- *
- * Examples of use:
- * - all records associated with `namehash('eth')` parent node:
- * `GET /api/registrar-actions/0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae`
- * - all records associated with `namehash('base.eth')` parent node:
- * `GET /api/registrar-actions/0xff1e3c0eb00ec714e34b6114125fbde1dea2f24a72fbf672e7b7fd5690328e10`
- * - all records associated with `namehash('linea.eth')` parent node:
- * `GET /api/registrar-actions/0x527aac89ac1d1de5dd84cff89ec92c69b028ce9ce3fa3d654882474ab4402ec3`
- *
- * Examples of use with testnets:
- * - all records associated with `namehash('linea-sepolia.eth')` parent node:
- * `GET /api/registrar-actions/0x1944d8f922dbda424d5bb8181be5344d513cd0210312d2dcccd37d54c11a17de`
- * - all records associated with `namehash('basetest.eth')` parent node:
- * `GET /api/registrar-actions/0x646204f07e7fcd394a508306bf1148a1e13d14287fa33839bf9ad63755f547c6`
- *
- * Responds with:
- * - 400 error response for bad input, such as:
- * - (if provided) `page` search param is not a positive integer.
- * - (if provided) `recordsPerPage` search param is not
- * a positive integer <= {@link RECORDS_PER_PAGE_MAX}.
- * - (if provided) `orderBy` search param is not part of {@link RegistrarActionsOrders}.
- * - (if provided) `beginTimestamp` or `endTimestamp` search params are not valid Unix timestamps.
- * - (if both provided) `endTimestamp` is less than `beginTimestamp`.
- * - 500 error response for cases such as:
- * - Connected ENSNode has not all required plugins set to active.
- * - Connected ENSNode is not in `omnichainStatus` of either
- * {@link OmnichainIndexingStatusIds.Completed} or
- * {@link OmnichainIndexingStatusIds.Following}.
- * - unknown server error occurs.
*/
-app.get(
- "/:parentNode",
- describeRoute({
- tags: ["Explore"],
- summary: "Get Registrar Actions by Parent Node",
- description:
- "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination",
- responses: {
- 200: {
- description: "Successfully retrieved registrar actions",
- },
- 400: {
- description: "Invalid input",
- },
- 500: {
- description: "Internal server error",
- },
- },
- }),
- validate(
- "param",
- z.object({
- parentNode: makeNodeSchema("parentNode param").describe(
- "Parent node to filter registrar actions",
- ),
- }),
- ),
- validate("query", registrarActionsQuerySchema),
- async (c) => {
- try {
- // Middleware ensures indexingStatus is available and not an Error
- // This check is for TypeScript type safety
- if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) {
- throw new Error("Invariant violation: indexingStatus should be validated by middleware");
- }
-
- const { parentNode } = c.req.valid("param");
- const query = c.req.valid("query");
- const { registrarActions, pageContext } = await fetchRegistrarActions(parentNode, query);
-
- // Get the accurateAsOf timestamp from the slowest chain indexing cursor
- const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor;
-
- // respond with success response
- return c.json(
- serializeRegistrarActionsResponse({
- responseCode: RegistrarActionsResponseCodes.Ok,
- registrarActions,
- pageContext,
- accurateAsOf,
- } satisfies RegistrarActionsResponseOk),
- );
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
- logger.error(errorMessage);
-
- // respond with 500 error response
- return c.json(
- serializeRegistrarActionsResponse({
- responseCode: RegistrarActionsResponseCodes.Error,
- error: {
- message: `Registrar Actions API Response is unavailable`,
- },
- } satisfies RegistrarActionsResponseError),
- 500,
- );
+app.openapi(registrarActionsByParentNodeGetMeta, async (c) => {
+ try {
+ // Middleware ensures indexingStatus is available and not an Error
+ // This check is for TypeScript type safety
+ if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) {
+ throw new Error("Invariant violation: indexingStatus should be validated by middleware");
}
- },
-);
+
+ const { parentNode } = c.req.valid("param");
+ const query = c.req.valid("query");
+ const { registrarActions, pageContext } = await fetchRegistrarActions(parentNode, query);
+
+ // Get the accurateAsOf timestamp from the slowest chain indexing cursor
+ const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor;
+
+ // respond with success response
+ return c.json(
+ serializeRegistrarActionsResponse({
+ responseCode: RegistrarActionsResponseCodes.Ok,
+ registrarActions,
+ pageContext,
+ accurateAsOf,
+ } satisfies RegistrarActionsResponseOk),
+ );
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ logger.error(errorMessage);
+
+ // respond with 500 error response
+ return c.json(
+ serializeRegistrarActionsResponse({
+ responseCode: RegistrarActionsResponseCodes.Error,
+ error: {
+ message: `Registrar Actions API Response is unavailable`,
+ },
+ } satisfies RegistrarActionsResponseError),
+ 500,
+ );
+ }
+});
export default app;
diff --git a/apps/ensapi/src/handlers/resolution-api.routes.ts b/apps/ensapi/src/handlers/resolution-api.routes.ts
new file mode 100644
index 000000000..24d43d4ff
--- /dev/null
+++ b/apps/ensapi/src/handlers/resolution-api.routes.ts
@@ -0,0 +1,104 @@
+import { createRoute } from "@hono/zod-openapi";
+import { z } from "zod/v4";
+
+import {
+ makeResolvePrimaryNameResponseSchema,
+ makeResolvePrimaryNamesResponseSchema,
+ makeResolveRecordsResponseSchema,
+} from "@ensnode/ensnode-sdk/internal";
+
+import { params } from "@/lib/handlers/params.schema";
+
+export const basePath = "/api/resolve";
+
+export const resolveRecordsGetMeta = createRoute({
+ method: "get",
+ path: "/records/:name",
+ tags: ["Resolution"],
+ summary: "Resolve ENS Records",
+ description: "Resolves ENS records for a given name",
+ request: {
+ params: z.object({ name: params.name }),
+ query: z
+ .object({
+ ...params.selectionParams.shape,
+ trace: params.trace,
+ accelerate: params.accelerate,
+ })
+ .transform((value) => {
+ const { trace, accelerate, ...selectionParams } = value;
+ const selection = params.selection.parse(selectionParams);
+ return { selection, trace, accelerate };
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Successfully resolved records",
+ content: {
+ "application/json": {
+ schema: makeResolveRecordsResponseSchema(),
+ },
+ },
+ },
+ },
+});
+
+export const resolvePrimaryNameGetMeta = createRoute({
+ method: "get",
+ path: "/primary-name/:address/:chainId",
+ tags: ["Resolution"],
+ summary: "Resolve Primary Name",
+ description: "Resolves a primary name for a given `address` and `chainId`",
+ request: {
+ params: z.object({
+ address: params.address,
+ chainId: params.defaultableChainId,
+ }),
+ query: z.object({
+ trace: params.trace,
+ accelerate: params.accelerate,
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Successfully resolved name",
+ content: {
+ "application/json": {
+ schema: makeResolvePrimaryNameResponseSchema(),
+ },
+ },
+ },
+ },
+});
+
+export const resolvePrimaryNamesGetMeta = createRoute({
+ method: "get",
+ path: "/primary-names/:address",
+ tags: ["Resolution"],
+ summary: "Resolve Primary Names",
+ description: "Resolves all primary names for a given address across multiple chains",
+ request: {
+ params: z.object({ address: params.address }),
+ query: z.object({
+ chainIds: params.chainIdsWithoutDefaultChainId,
+ trace: params.trace,
+ accelerate: params.accelerate,
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Successfully resolved records",
+ content: {
+ "application/json": {
+ schema: makeResolvePrimaryNamesResponseSchema(),
+ },
+ },
+ },
+ },
+});
+
+export const routes = [
+ resolveRecordsGetMeta,
+ resolvePrimaryNameGetMeta,
+ resolvePrimaryNamesGetMeta,
+];
diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts
index 08c94d0cd..cdab1b074 100644
--- a/apps/ensapi/src/handlers/resolution-api.ts
+++ b/apps/ensapi/src/handlers/resolution-api.ts
@@ -1,21 +1,11 @@
-import { describeRoute, resolver as validationResolver } from "hono-openapi";
-import { z } from "zod/v4";
-
import type {
Duration,
ResolvePrimaryNameResponse,
ResolvePrimaryNamesResponse,
ResolveRecordsResponse,
} from "@ensnode/ensnode-sdk";
-import {
- makeResolvePrimaryNameResponseSchema,
- makeResolvePrimaryNamesResponseSchema,
- makeResolveRecordsResponseSchema,
-} from "@ensnode/ensnode-sdk/internal";
-
-import { params } from "@/lib/handlers/params.schema";
-import { validate } from "@/lib/handlers/validate";
-import { factory } from "@/lib/hono-factory";
+
+import { createApp } from "@/lib/hono-factory";
import { resolveForward } from "@/lib/resolution/forward-resolution";
import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution";
import { resolveReverse } from "@/lib/resolution/reverse-resolution";
@@ -23,13 +13,19 @@ import { runWithTrace } from "@/lib/tracing/tracing-api";
import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware";
import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware";
+import {
+ resolvePrimaryNameGetMeta,
+ resolvePrimaryNamesGetMeta,
+ resolveRecordsGetMeta,
+} from "./resolution-api.routes";
+
/**
* The effective distance for acceleration is indexing status cache time plus
* MAX_REALTIME_DISTANCE_TO_ACCELERATE.
*/
const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds
-const app = factory.createApp();
+const app = createApp();
// inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_ACCELERATE
app.use(makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE));
@@ -48,63 +44,30 @@ app.use(canAccelerateMiddleware);
* 3. Combined resolution:
* GET /records/example.eth&name=true&addresses=60,0&texts=avatar,com.twitter
*/
-app.get(
- "/records/:name",
- describeRoute({
- tags: ["Resolution"],
- summary: "Resolve ENS Records",
- description: "Resolves ENS records for a given name",
- responses: {
- 200: {
- description: "Successfully resolved records",
- content: {
- "application/json": {
- schema: validationResolver(makeResolveRecordsResponseSchema()),
- },
- },
- },
- },
- }),
- validate("param", z.object({ name: params.name })),
- validate(
- "query",
- z
- .object({
- ...params.selectionParams.shape,
- trace: params.trace,
- accelerate: params.accelerate,
- })
- .transform((value) => {
- const { trace, accelerate, ...selectionParams } = value;
- const selection = params.selection.parse(selectionParams);
- return { selection, trace, accelerate };
- }),
- ),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.canAccelerate === undefined) {
- throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
- }
-
- const { name } = c.req.valid("param");
- const { selection, trace: showTrace, accelerate } = c.req.valid("query");
- const canAccelerate = c.var.canAccelerate;
-
- const { result, trace } = await runWithTrace(() =>
- resolveForward(name, selection, { accelerate, canAccelerate }),
- );
-
- const response = {
- records: result,
-
- accelerationRequested: accelerate,
- accelerationAttempted: accelerate && canAccelerate,
- ...(showTrace && { trace }),
- } satisfies ResolveRecordsResponse;
-
- return c.json(response);
- },
-);
+app.openapi(resolveRecordsGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.canAccelerate === undefined) {
+ throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
+ }
+
+ const { name } = c.req.valid("param");
+ const { selection, trace: showTrace, accelerate } = c.req.valid("query");
+ const canAccelerate = c.var.canAccelerate;
+
+ const { result, trace } = await runWithTrace(() =>
+ resolveForward(name, selection, { accelerate, canAccelerate }),
+ );
+
+ const response = {
+ records: result,
+
+ accelerationRequested: accelerate,
+ accelerationAttempted: accelerate && canAccelerate,
+ ...(showTrace && { trace }),
+ } satisfies ResolveRecordsResponse;
+
+ return c.json(response);
+});
/**
* Example queries for /primary-name:
@@ -118,56 +81,30 @@ app.get(
* 3. ENSIP-19 Primary Name (for 'default' EVM Chain)
* GET /primary-name/0x1234...abcd/0
*/
-app.get(
- "/primary-name/:address/:chainId",
- describeRoute({
- tags: ["Resolution"],
- 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: validationResolver(makeResolvePrimaryNameResponseSchema()),
- },
- },
- },
- },
- }),
- validate("param", z.object({ address: params.address, chainId: params.defaultableChainId })),
- validate(
- "query",
- z.object({
- trace: params.trace,
- accelerate: params.accelerate,
- }),
- ),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.canAccelerate === undefined) {
- throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
- }
-
- const { address, chainId } = c.req.valid("param");
- const { trace: showTrace, accelerate } = c.req.valid("query");
- const canAccelerate = c.var.canAccelerate;
-
- const { result, trace } = await runWithTrace(() =>
- resolveReverse(address, chainId, { accelerate, canAccelerate }),
- );
-
- const response = {
- name: result,
-
- accelerationRequested: accelerate,
- accelerationAttempted: accelerate && canAccelerate,
- ...(showTrace && { trace }),
- } satisfies ResolvePrimaryNameResponse;
-
- return c.json(response);
- },
-);
+app.openapi(resolvePrimaryNameGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.canAccelerate === undefined) {
+ throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
+ }
+
+ const { address, chainId } = c.req.valid("param");
+ const { trace: showTrace, accelerate } = c.req.valid("query");
+ const canAccelerate = c.var.canAccelerate;
+
+ const { result, trace } = await runWithTrace(() =>
+ resolveReverse(address, chainId, { accelerate, canAccelerate }),
+ );
+
+ const response = {
+ name: result,
+
+ accelerationRequested: accelerate,
+ accelerationAttempted: accelerate && canAccelerate,
+ ...(showTrace && { trace }),
+ } satisfies ResolvePrimaryNameResponse;
+
+ return c.json(response);
+});
/**
* Example queries for /primary-names:
@@ -178,56 +115,29 @@ app.get(
* 2. Multichain ENSIP-19 Primary Names Lookup (specific chain ids)
* GET /primary-names/0x1234...abcd?chainIds=1,10,8453
*/
-app.get(
- "/primary-names/:address",
- describeRoute({
- tags: ["Resolution"],
- 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: validationResolver(makeResolvePrimaryNamesResponseSchema()),
- },
- },
- },
- },
- }),
- validate("param", z.object({ address: params.address })),
- validate(
- "query",
- z.object({
- chainIds: params.chainIdsWithoutDefaultChainId,
- trace: params.trace,
- accelerate: params.accelerate,
- }),
- ),
- async (c) => {
- // context must be set by the required middleware
- if (c.var.canAccelerate === undefined) {
- throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
- }
-
- const { address } = c.req.valid("param");
- const { chainIds, trace: showTrace, accelerate } = c.req.valid("query");
- const canAccelerate = c.var.canAccelerate;
-
- const { result, trace } = await runWithTrace(() =>
- resolvePrimaryNames(address, chainIds, { accelerate, canAccelerate }),
- );
-
- const response = {
- names: result,
-
- accelerationRequested: accelerate,
- accelerationAttempted: accelerate && canAccelerate,
- ...(showTrace && { trace }),
- } satisfies ResolvePrimaryNamesResponse;
-
- return c.json(response);
- },
-);
+app.openapi(resolvePrimaryNamesGetMeta, async (c) => {
+ // context must be set by the required middleware
+ if (c.var.canAccelerate === undefined) {
+ throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`);
+ }
+
+ const { address } = c.req.valid("param");
+ const { chainIds, trace: showTrace, accelerate } = c.req.valid("query");
+ const canAccelerate = c.var.canAccelerate;
+
+ const { result, trace } = await runWithTrace(() =>
+ resolvePrimaryNames(address, chainIds, { accelerate, canAccelerate }),
+ );
+
+ const response = {
+ names: result,
+
+ accelerationRequested: accelerate,
+ accelerationAttempted: accelerate && canAccelerate,
+ ...(showTrace && { trace }),
+ } satisfies ResolvePrimaryNamesResponse;
+
+ return c.json(response);
+});
export default app;
diff --git a/apps/ensapi/src/handlers/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph-api.ts
index d426d5963..41e885c9a 100644
--- a/apps/ensapi/src/handlers/subgraph-api.ts
+++ b/apps/ensapi/src/handlers/subgraph-api.ts
@@ -6,7 +6,7 @@ import * as schema from "@ensnode/ensnode-schema";
import type { Duration } from "@ensnode/ensnode-sdk";
import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph";
-import { factory } from "@/lib/hono-factory";
+import { createApp } from "@/lib/hono-factory";
import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation";
import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix";
import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware";
@@ -20,7 +20,7 @@ const MAX_REALTIME_DISTANCE_TO_RESOLVE: Duration = 10 * 60; // 10 minutes in sec
// generate a subgraph-specific subset of the schema
const subgraphSchema = filterSchemaByPrefix("subgraph_", schema);
-const app = factory.createApp();
+const app = createApp();
// 404 if subgraph core plugin not enabled
app.use(requireCorePluginMiddleware("subgraph"));
@@ -35,7 +35,11 @@ app.use(thegraphFallbackMiddleware);
app.use(fixContentLengthMiddleware);
// inject api documentation into graphql introspection requests
-app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/subgraph" }));
+app.use(
+ createDocumentationMiddleware(makeSubgraphApiDocumentation(), {
+ path: "/subgraph",
+ }),
+);
// inject _meta into the hono (and yoga) context for the subgraph middleware
app.use(subgraphMetaMiddleware);
diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts
index 9483c4f6a..07cb9804d 100644
--- a/apps/ensapi/src/index.ts
+++ b/apps/ensapi/src/index.ts
@@ -1,131 +1,20 @@
-import packageJson from "@/../package.json" with { type: "json" };
+/**
+ * Server entrypoint: starts the HTTP server, manages caches, and handles
+ * graceful shutdown. The app itself (routes, middleware) is defined in app.ts.
+ */
+
import config from "@/config";
import { serve } from "@hono/node-server";
-import { otel } from "@hono/otel";
-import { cors } from "hono/cors";
-import { html } from "hono/html";
-import { openAPIRouteHandler } from "hono-openapi";
+import app from "@/app";
import { indexingStatusCache } from "@/cache/indexing-status.cache";
import { getReferralLeaderboardEditionsCaches } from "@/cache/referral-leaderboard-editions.cache";
import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache";
import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache";
import { redactEnsApiConfig } from "@/config/redact";
-import { errorResponse } from "@/lib/handlers/error-response";
-import { factory } from "@/lib/hono-factory";
import { sdk } from "@/lib/instrumentation";
import logger from "@/lib/logger";
-import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
-
-import amIRealtimeApi from "./handlers/amirealtime-api";
-import ensanalyticsApi from "./handlers/ensanalytics-api";
-import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1";
-import ensNodeApi from "./handlers/ensnode-api";
-import subgraphApi from "./handlers/subgraph-api";
-
-const app = factory.createApp();
-
-// set the X-ENSNode-Version header to the current version
-app.use(async (ctx, next) => {
- ctx.header("x-ensnode-version", packageJson.version);
- return next();
-});
-
-// use CORS middleware
-app.use(cors({ origin: "*" }));
-
-// include automatic OpenTelemetry instrumentation for incoming requests
-app.use(otel());
-
-// add ENSIndexer Indexing Status Middleware to all routes for convenience
-app.use(indexingStatusMiddleware);
-
-// host welcome page
-app.get("/", (c) =>
- c.html(html`
-
-
-
-
-
- ENSApi
-
-
- Hello, World!
- You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.
-
-
-`),
-);
-
-// use ENSNode HTTP API at /api
-app.route("/api", ensNodeApi);
-
-// use Subgraph GraphQL API at /subgraph
-app.route("/subgraph", subgraphApi);
-
-// use ENSAnalytics API at /ensanalytics (v0, implicit)
-app.route("/ensanalytics", ensanalyticsApi);
-
-// use ENSAnalytics API v1 at /v1/ensanalytics
-app.route("/v1/ensanalytics", ensanalyticsApiV1);
-
-// use Am I Realtime API at /amirealtime
-app.route("/amirealtime", amIRealtimeApi);
-
-// use OpenAPI Schema
-app.get(
- "/openapi.json",
- openAPIRouteHandler(app, {
- documentation: {
- info: {
- title: "ENSApi APIs",
- version: packageJson.version,
- description:
- "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode",
- },
- servers: [
- { url: "https://api.alpha.ensnode.io", description: "ENSNode Alpha (Mainnet)" },
- {
- url: "https://api.alpha-sepolia.ensnode.io",
- description: "ENSNode Alpha (Sepolia Testnet)",
- },
- { url: `http://localhost:${config.port}`, description: "Local Development" },
- ],
- tags: [
- {
- name: "Resolution",
- description: "APIs for resolving ENS names and addresses",
- },
- {
- name: "Meta",
- description: "APIs for indexing status, configuration, and realtime monitoring",
- },
- {
- name: "Explore",
- description:
- "APIs for exploring the indexed state of ENS, including name tokens and registrar actions",
- },
- {
- name: "ENSAwards",
- description: "APIs for ENSAwards functionality, including referrer data",
- },
- ],
- },
- }),
-);
-
-// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware
-app.get("/health", async (c) => {
- return c.json({ message: "fallback ok" });
-});
-
-// log hono errors to console
-app.onError((error, ctx) => {
- logger.error(error);
- return errorResponse(ctx, "Internal Server Error");
-});
// start ENSNode API OpenTelemetry SDK
sdk.start();
diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts
deleted file mode 100644
index 4316e07bb..000000000
--- a/apps/ensapi/src/lib/handlers/validate.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { SchemaError } from "@standard-schema/utils";
-import type { ValidationTargets } from "hono";
-import { validator } from "hono-openapi";
-import type { ZodType } from "zod/v4";
-
-import { errorResponse } from "./error-response";
-
-/**
- * Creates a Hono validation middleware with custom error formatting.
- *
- * Wraps the Hono validator with custom error handling that uses the
- * errorResponse function for consistent error formatting across the API.
- *
- * @param target - The validation target (param, query, json, etc.)
- * @param schema - The Zod schema to validate against
- * @returns Hono middleware that validates the specified target
- */
-export const validate = (
- target: Target,
- schema: T,
-) =>
- validator(target, schema, (result, c) => {
- // if validation failed, return our custom-formatted ErrorResponse instead of default
- if (!result.success) {
- // Wrap the Standard Schema issues in a SchemaError instance
- // for consistent error handling in errorResponse
- return errorResponse(c, new SchemaError(result.error));
- }
- });
diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts
index 2a4f47914..b37b82735 100644
--- a/apps/ensapi/src/lib/hono-factory.ts
+++ b/apps/ensapi/src/lib/hono-factory.ts
@@ -1,5 +1,7 @@
+import { OpenAPIHono } from "@hono/zod-openapi";
import { createFactory } from "hono/factory";
+import { errorResponse } from "@/lib/handlers/error-response";
import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware";
import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware";
import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware";
@@ -14,6 +16,17 @@ export type MiddlewareVariables = IndexingStatusMiddlewareVariables &
ReferralProgramEditionConfigSetMiddlewareVariables &
ReferralLeaderboardEditionsCachesMiddlewareVariables;
-export const factory = createFactory<{
- Variables: Partial;
-}>();
+type AppEnv = { Variables: Partial };
+
+// factory is kept for createMiddleware usage in middleware files
+export const factory = createFactory();
+
+export function createApp() {
+ return new OpenAPIHono({
+ defaultHook: (result, c) => {
+ if (!result.success) {
+ return errorResponse(c, result.error);
+ }
+ },
+ });
+}
diff --git a/apps/ensapi/src/openapi-routes.ts b/apps/ensapi/src/openapi-routes.ts
new file mode 100644
index 000000000..856850ed7
--- /dev/null
+++ b/apps/ensapi/src/openapi-routes.ts
@@ -0,0 +1,76 @@
+/**
+ * Lightweight route composition for OpenAPI spec generation.
+ *
+ * This module builds a minimal OpenAPIHono app with route definitions
+ * registered via `app.openapi()` with stub handlers. It imports only
+ * from `.routes.ts` files which have zero config/env dependencies,
+ * making it safe to import in contexts where the app config is not
+ * available (e.g. script-based spec generation).
+ *
+ * The runtime server (index.ts) does NOT use this module. It mounts
+ * the real handler files directly. This module exists solely so that
+ * `app.getOpenAPI31Document()` can inspect the route metadata.
+ */
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+
+import {
+ basePath as amIRealtimeBasePath,
+ routes as amIRealtimeRoutes,
+} from "./handlers/amirealtime-api.routes";
+import {
+ basePath as ensanalyticsBasePath,
+ routes as ensanalyticsRoutes,
+} from "./handlers/ensanalytics-api.routes";
+import {
+ basePath as ensanalyticsV1BasePath,
+ routes as ensanalyticsV1Routes,
+} from "./handlers/ensanalytics-api-v1.routes";
+import {
+ basePath as ensnodeBasePath,
+ routes as ensnodeRoutes,
+} from "./handlers/ensnode-api.routes";
+import {
+ basePath as nameTokensBasePath,
+ routes as nameTokensRoutes,
+} from "./handlers/name-tokens-api.routes";
+import {
+ basePath as registrarActionsBasePath,
+ routes as registrarActionsRoutes,
+} from "./handlers/registrar-actions-api.routes";
+import {
+ basePath as resolutionBasePath,
+ routes as resolutionRoutes,
+} from "./handlers/resolution-api.routes";
+
+type RouteGroup = {
+ basePath: string;
+ routes: readonly { method: string; path: string }[];
+};
+
+const routeGroups: RouteGroup[] = [
+ { basePath: ensnodeBasePath, routes: ensnodeRoutes },
+ { basePath: nameTokensBasePath, routes: nameTokensRoutes },
+ { basePath: registrarActionsBasePath, routes: registrarActionsRoutes },
+ { basePath: resolutionBasePath, routes: resolutionRoutes },
+ { basePath: ensanalyticsBasePath, routes: ensanalyticsRoutes },
+ { basePath: ensanalyticsV1BasePath, routes: ensanalyticsV1Routes },
+ { basePath: amIRealtimeBasePath, routes: amIRealtimeRoutes },
+];
+
+// Stub handler for spec generation - never actually called
+const stub = (c: any) => c.text("");
+
+export function createRoutesForSpec() {
+ const app = new OpenAPIHono();
+
+ for (const group of routeGroups) {
+ for (const route of group.routes) {
+ const fullPath = route.path === "/" ? group.basePath : `${group.basePath}${route.path}`;
+ // Override path to include the basePath prefix
+ app.openapi({ ...route, path: fullPath } as any, stub);
+ }
+ }
+
+ return app;
+}
diff --git a/apps/ensapi/src/openapi.ts b/apps/ensapi/src/openapi.ts
new file mode 100644
index 000000000..39a7ad845
--- /dev/null
+++ b/apps/ensapi/src/openapi.ts
@@ -0,0 +1,38 @@
+export const openapiDocumentation = {
+ openapi: "3.1.0" as const,
+ info: {
+ title: "ENSApi APIs",
+ version: "0.0.0", // replaced at runtime with package.json version
+ description:
+ "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode",
+ },
+ servers: [
+ {
+ url: "https://api.alpha.ensnode.io",
+ description: "ENSNode Alpha (Mainnet)",
+ },
+ {
+ url: "https://api.alpha-sepolia.ensnode.io",
+ description: "ENSNode Alpha (Sepolia Testnet)",
+ },
+ ],
+ tags: [
+ {
+ name: "Resolution",
+ description: "APIs for resolving ENS names and addresses",
+ },
+ {
+ name: "Meta",
+ description: "APIs for indexing status, configuration, and realtime monitoring",
+ },
+ {
+ name: "Explore",
+ description:
+ "APIs for exploring the indexed state of ENS, including name tokens and registrar actions",
+ },
+ {
+ name: "ENSAwards",
+ description: "APIs for ENSAwards functionality, including referrer data",
+ },
+ ],
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3dd605a4b..7a4b4076e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -324,9 +324,9 @@ importers:
'@hono/otel':
specifier: ^0.2.2
version: 0.2.2(hono@4.11.7)
- '@hono/standard-validator':
- specifier: ^0.2.2
- version: 0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7)
+ '@hono/zod-openapi':
+ specifier: 1.2.1
+ version: 1.2.1(hono@4.11.7)(zod@4.3.6)
'@namehash/ens-referrals':
specifier: workspace:*
version: link:../../packages/ens-referrals
@@ -396,9 +396,6 @@ importers:
hono:
specifier: 'catalog:'
version: 4.11.7
- hono-openapi:
- specifier: ^1.1.2
- version: 1.1.2(@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@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)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.11.7)(openapi-types@12.1.3)
p-memoize:
specifier: ^8.0.0
version: 8.0.0
@@ -1144,12 +1141,6 @@ packages:
'@antfu/utils@9.3.0':
resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==}
- '@ark/schema@0.56.0':
- resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
-
- '@ark/util@0.56.0':
- resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
-
'@asamuzakjp/css-color@4.0.5':
resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==}
@@ -1159,6 +1150,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+ '@asteasolutions/zod-to-openapi@8.4.1':
+ resolution: {integrity: sha512-WmJUsFINbnWxGvHSd16aOjgKf+5GsfdxruO2YDLcgplsidakCauik1lhlk83YDH06265Yd1XtUyF24o09uygpw==}
+ peerDependencies:
+ zod: ^4.0.0
+
'@astrojs/check@0.5.10':
resolution: {integrity: sha512-vliHXM9cu/viGeKiksUM4mXfO816ohWtawTl2ADPgTsd4nUMjFiyAl7xFZhF34yy4hq4qf7jvK1F2PlR3b5I5w==}
hasBin: true
@@ -2224,11 +2220,18 @@ packages:
peerDependencies:
hono: '*'
- '@hono/standard-validator@0.2.2':
- resolution: {integrity: sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw==}
+ '@hono/zod-openapi@1.2.1':
+ resolution: {integrity: sha512-aZza4V8wkqpdHBWFNPiCeWd0cGOXbYuQW9AyezHs/jwQm5p67GkUyXwfthAooAwnG7thTpvOJkThZpCoY6us8w==}
+ engines: {node: '>=16.0.0'}
+ peerDependencies:
+ hono: '>=4.3.6'
+ zod: ^4.0.0
+
+ '@hono/zod-validator@0.7.6':
+ resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==}
peerDependencies:
- '@standard-schema/spec': ^1.0.0
hono: '>=3.9.0'
+ zod: ^3.25.0 || ^4.0.0
'@iconify-json/lucide@1.2.71':
resolution: {integrity: sha512-KL+3JHW+wN8QqT3CN+7e1SzTe+gIunFBuUICtVmdCmdVRx+MdGNkX4xJhXoYHfhYO2azrEhoGPG+It9k30aZkw==}
@@ -3814,67 +3817,6 @@ packages:
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'}
- '@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==}
@@ -4258,9 +4200,6 @@ 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==}
@@ -4631,12 +4570,6 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
- arkregex@0.0.5:
- resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==}
-
- arktype@2.1.29:
- resolution: {integrity: sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==}
-
array-iterate@2.0.1:
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
@@ -6042,21 +5975,6 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
- hono-openapi@1.1.2:
- resolution: {integrity: sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g==}
- peerDependencies:
- '@hono/standard-validator': ^0.2.0
- '@standard-community/standard-json': ^0.3.5
- '@standard-community/standard-openapi': ^0.2.9
- '@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.11.7:
resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==}
engines: {node: '>=16.9.0'}
@@ -6983,8 +6901,8 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
- openapi-types@12.1.3:
- resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
+ openapi3-ts@4.5.0:
+ resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
@@ -8944,14 +8862,6 @@ snapshots:
'@antfu/utils@9.3.0': {}
- '@ark/schema@0.56.0':
- dependencies:
- '@ark/util': 0.56.0
- optional: true
-
- '@ark/util@0.56.0':
- optional: true
-
'@asamuzakjp/css-color@4.0.5':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -8970,6 +8880,11 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
+ '@asteasolutions/zod-to-openapi@8.4.1(zod@4.3.6)':
+ dependencies:
+ openapi3-ts: 4.5.0
+ zod: 4.3.6
+
'@astrojs/check@0.5.10(prettier@3.6.2)(typescript@5.9.3)':
dependencies:
'@astrojs/language-server': 2.15.5(prettier@3.6.2)(typescript@5.9.3)
@@ -10540,10 +10455,18 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.37.0
hono: 4.11.7
- '@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7)':
+ '@hono/zod-openapi@1.2.1(hono@4.11.7)(zod@4.3.6)':
+ dependencies:
+ '@asteasolutions/zod-to-openapi': 8.4.1(zod@4.3.6)
+ '@hono/zod-validator': 0.7.6(hono@4.11.7)(zod@4.3.6)
+ hono: 4.11.7
+ openapi3-ts: 4.5.0
+ zod: 4.3.6
+
+ '@hono/zod-validator@0.7.6(hono@4.11.7)(zod@4.3.6)':
dependencies:
- '@standard-schema/spec': 1.0.0
hono: 4.11.7
+ zod: 4.3.6
'@iconify-json/lucide@1.2.71':
dependencies:
@@ -12347,25 +12270,6 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6)':
- dependencies:
- '@standard-schema/spec': 1.0.0
- '@types/json-schema': 7.0.15
- quansync: 0.2.11
- optionalDependencies:
- arktype: 2.1.29
- zod: 4.3.6
- zod-to-json-schema: 3.24.6(zod@4.3.6)
-
- '@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)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6)':
- dependencies:
- '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6)
- '@standard-schema/spec': 1.0.0
- openapi-types: 12.1.3
- optionalDependencies:
- arktype: 2.1.29
- zod: 4.3.6
-
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -12746,8 +12650,6 @@ snapshots:
'@types/js-yaml@4.0.9': {}
- '@types/json-schema@7.0.15': {}
-
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -13163,18 +13065,6 @@ snapshots:
aria-query@5.3.2: {}
- arkregex@0.0.5:
- dependencies:
- '@ark/util': 0.56.0
- optional: true
-
- arktype@2.1.29:
- dependencies:
- '@ark/schema': 0.56.0
- '@ark/util': 0.56.0
- arkregex: 0.0.5
- optional: true
-
array-iterate@2.0.1: {}
array-union@2.1.0: {}
@@ -14836,16 +14726,6 @@ snapshots:
help-me@5.0.0: {}
- hono-openapi@1.1.2(@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@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)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.11.7)(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)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6)
- '@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)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6)
- '@types/json-schema': 7.0.15
- openapi-types: 12.1.3
- optionalDependencies:
- '@hono/standard-validator': 0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7)
- hono: 4.11.7
-
hono@4.11.7: {}
html-encoding-sniffer@4.0.0:
@@ -15959,7 +15839,9 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
- openapi-types@12.1.3: {}
+ openapi3-ts@4.5.0:
+ dependencies:
+ yaml: 2.8.1
outdent@0.5.0: {}
@@ -18137,11 +18019,6 @@ snapshots:
dependencies:
zod: 3.25.76
- zod-to-json-schema@3.24.6(zod@4.3.6):
- dependencies:
- zod: 4.3.6
- optional: true
-
zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76):
dependencies:
typescript: 5.9.3