Skip to content
2 changes: 2 additions & 0 deletions apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@ensnode/ponder-subgraph": "workspace:*",
"@hono/node-server": "^1.19.5",
"@hono/otel": "^0.2.2",
"@hono/standard-validator": "^0.2.0",
"@hono/zod-validator": "^0.7.2",
"@namehash/ens-referrals": "workspace:*",
"@opentelemetry/api": "^1.9.0",
Expand All @@ -43,6 +44,7 @@
"date-fns": "catalog:",
"drizzle-orm": "catalog:",
"hono": "catalog:",
"hono-openapi": "^1.1.1",
"p-memoize": "^8.0.0",
"p-retry": "^7.1.0",
"pg-connection-string": "catalog:",
Expand Down
92 changes: 69 additions & 23 deletions apps/ensapi/src/handlers/ensnode-api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import config from "@/config";

import { describeRoute, resolver } from "hono-openapi";

import {
IndexingStatusResponseCodes,
type IndexingStatusResponseError,
type IndexingStatusResponseOk,
makeENSApiPublicConfigSchema,
makeIndexingStatusResponseSchema,
serializeENSApiPublicConfig,
serializeIndexingStatusResponse,
} from "@ensnode/ensnode-sdk";
Expand All @@ -18,35 +22,77 @@ import resolutionApi from "./resolution-api";
const app = factory.createApp();

// include ENSApi Public Config endpoint
app.get("/config", async (c) => {
const ensApiPublicConfig = buildEnsApiPublicConfig(config);
return c.json(serializeENSApiPublicConfig(ensApiPublicConfig));
});
app.get(
"/config",
describeRoute({
summary: "Get ENSApi Configuration",
description: "Returns the public configuration of the ENSApi instance",
responses: {
200: {
description: "Successfully retrieved configuration",
content: {
"application/json": {
schema: resolver(makeENSApiPublicConfigSchema()),
},
},
},
},
}),
async (c) => {
const ensApiPublicConfig = buildEnsApiPublicConfig(config);
return c.json(serializeENSApiPublicConfig(ensApiPublicConfig));
},
);

// include ENSIndexer Indexing Status endpoint
app.get("/indexing-status", async (c) => {
// context must be set by the required middleware
if (c.var.indexingStatus === undefined) {
throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`);
}
app.get(
"/indexing-status",
describeRoute({
summary: "Get ENSIndexer Indexing Status",
description: "Returns the current indexing status of the ENSIndexer",
responses: {
200: {
description: "Successfully retrieved indexing status",
content: {
"application/json": {
schema: resolver(makeIndexingStatusResponseSchema()),
},
},
},
500: {
description: "Error retrieving indexing status",
content: {
"application/json": {
schema: resolver(makeIndexingStatusResponseSchema()),
},
},
},
},
}),
async (c) => {
// context must be set by the required middleware
if (c.var.indexingStatus === undefined) {
throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`);
}

if (c.var.indexingStatus instanceof Error) {
return c.json(
serializeIndexingStatusResponse({
responseCode: IndexingStatusResponseCodes.Error,
} satisfies IndexingStatusResponseError),
500,
);
}

if (c.var.indexingStatus instanceof Error) {
// return successful response using the indexing status projection from the context
return c.json(
serializeIndexingStatusResponse({
responseCode: IndexingStatusResponseCodes.Error,
} satisfies IndexingStatusResponseError),
500,
responseCode: IndexingStatusResponseCodes.Ok,
realtimeProjection: c.var.indexingStatus,
} satisfies IndexingStatusResponseOk),
);
}

// return successful response using the indexing status projection from the context
return c.json(
serializeIndexingStatusResponse({
responseCode: IndexingStatusResponseCodes.Ok,
realtimeProjection: c.var.indexingStatus,
} satisfies IndexingStatusResponseOk),
);
});
},
);

// Name Tokens API
app.route("/name-tokens", nameTokensApi);
Expand Down
202 changes: 119 additions & 83 deletions apps/ensapi/src/handlers/name-tokens-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import config from "@/config";

import { describeRoute, resolver } from "hono-openapi";
import { namehash } from "viem";
import z from "zod/v4";

Expand All @@ -14,7 +15,7 @@ import {
type PluginName,
serializeNameTokensResponse,
} from "@ensnode/ensnode-sdk";
import { makeNodeSchema } from "@ensnode/ensnode-sdk/internal";
import { makeNameTokensResponseSchema, makeNodeSchema } from "@ensnode/ensnode-sdk/internal";

import { params } from "@/lib/handlers/params.schema";
import { validate } from "@/lib/handlers/validate";
Expand Down Expand Up @@ -44,58 +45,126 @@ app.use(nameTokensApiMiddleware);
* Name Tokens API can be requested by either `name` or `domainId`, and
* can never be requested by both, or neither.
*/
const requestQuerySchema = z.union([
z.object({
domainId: makeNodeSchema("request.domainId"),
name: z.undefined(),
}),
z.object({
domainId: z.undefined(),
name: params.name,
const requestQuerySchema = z
.object({
domainId: makeNodeSchema("request.domainId").optional(),
name: params.name.optional(),
})
.refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), {
message: "Exactly one of 'domainId' or 'name' must be provided",
});

app.get(
"/",
describeRoute({
summary: "Get Name Tokens",
description: "Returns name tokens for requested identifier (domainId, or name)",
responses: {
200: {
description: "Successfully retrieved name tokens",
content: {
"application/json": {
schema: resolver(makeNameTokensResponseSchema("Name Tokens Response", true), {
elo: 1,
}),
},
},
},
500: {
description: "Error retrieving name tokens",
content: {
"application/json": {
schema: resolver(makeNameTokensResponseSchema("Name Tokens Response", true), {
elo: 2,
}),
},
},
},
},
}),
]);

app.get("/", validate("query", requestQuerySchema), async (c) => {
// Invariant: context must be set by the required middleware
if (c.var.indexingStatus === undefined) {
throw new Error(`Invariant(name-tokens-api): indexingStatusMiddleware required`);
}

// Invariant: Indexing Status has been resolved successfully.
if (c.var.indexingStatus instanceof Error) {
throw new Error(`Invariant(name-tokens-api): Indexing Status has to be resolved successfully`);
}

const request = c.req.valid("query") satisfies NameTokensRequest;
let domainId: Node | undefined;
validate("query", requestQuerySchema),
async (c) => {
// Invariant: context must be set by the required middleware
if (c.var.indexingStatus === undefined) {
throw new Error(`Invariant(name-tokens-api): indexingStatusMiddleware required`);
}

if (request.name !== undefined) {
const { name } = request;
// Invariant: Indexing Status has been resolved successfully.
if (c.var.indexingStatus instanceof Error) {
throw new Error(
`Invariant(name-tokens-api): Indexing Status has to be resolved successfully`,
);
}

// return 404 when the requested name was the ENS Root
if (name === ENS_ROOT) {
return c.json(
serializeNameTokensResponse({
responseCode: NameTokensResponseCodes.Error,
errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed,
error: {
message: "No indexed Name Tokens found",
details: `The 'name' param must not be ENS Root, no tokens exist for it.`,
},
} satisfies NameTokensResponseErrorNameTokensNotIndexed),
404,
const request = c.req.valid("query") satisfies NameTokensRequest;
let domainId: Node;

if (request.name !== undefined) {
const { name } = request;

// return 404 when the requested name was the ENS Root
if (name === ENS_ROOT) {
return c.json(
serializeNameTokensResponse({
responseCode: NameTokensResponseCodes.Error,
errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed,
error: {
message: "No indexed Name Tokens found",
details: `The 'name' param must not be ENS Root, no tokens exist for it.`,
},
} satisfies NameTokensResponseErrorNameTokensNotIndexed),
404,
);
}

const parentNode = namehash(getParentNameFQDN(name));
const subregistry = indexedSubregistries.find(
(subregistry) => subregistry.node === parentNode,
);

// Return 404 response with error code for Name Tokens Not Indexed when
// the parent name of the requested name was not registered in any of
// the actively indexed subregistries.
if (!subregistry) {
logger.error(
`This ENSNode instance has not been configured to index tokens for the requested name: '${name}'.`,
);

return c.json(
serializeNameTokensResponse({
responseCode: NameTokensResponseCodes.Error,
errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed,
error: {
message: "No indexed Name Tokens found",
details: `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`,
},
} satisfies NameTokensResponseErrorNameTokensNotIndexed),
404,
);
}

domainId = namehash(name);
} else if (request.domainId !== undefined) {
domainId = request.domainId;
} else {
// This should never happen due to Zod validation, but TypeScript needs this
throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided");
}

const parentNode = namehash(getParentNameFQDN(name));
const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode);
const { omnichainSnapshot } = c.var.indexingStatus.snapshot;
const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor;

const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf);

// Return 404 response with error code for Name Tokens Not Indexed when
// the parent name of the requested name was not registered in any of
// the actively indexed subregistries.
if (!subregistry) {
// the no name tokens were found for the domain ID associated with
// the requested name.
if (!registeredNameTokens) {
const errorMessageSubject =
request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`;

logger.error(
`This ENSNode instance has not been configured to index tokens for the requested name: '${name}'.`,
`This ENSNode instance has never indexed tokens for the requested ${errorMessageSubject}.`,
);

return c.json(
Expand All @@ -104,53 +173,20 @@ app.get("/", validate("query", requestQuerySchema), async (c) => {
errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed,
error: {
message: "No indexed Name Tokens found",
details: `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`,
details: `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`,
},
} satisfies NameTokensResponseErrorNameTokensNotIndexed),
404,
);
}

domainId = namehash(name);
} else {
domainId = request.domainId;
}

const { omnichainSnapshot } = c.var.indexingStatus.snapshot;
const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor;

const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf);

// Return 404 response with error code for Name Tokens Not Indexed when
// the no name tokens were found for the domain ID associated with
// the requested name.
if (!registeredNameTokens) {
const errorMessageSubject =
request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`;

logger.error(
`This ENSNode instance has never indexed tokens for the requested ${errorMessageSubject}.`,
);

return c.json(
serializeNameTokensResponse({
responseCode: NameTokensResponseCodes.Error,
errorCode: NameTokensResponseErrorCodes.NameTokensNotIndexed,
error: {
message: "No indexed Name Tokens found",
details: `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`,
},
} satisfies NameTokensResponseErrorNameTokensNotIndexed),
404,
responseCode: NameTokensResponseCodes.Ok,
registeredNameTokens,
}),
);
}

return c.json(
serializeNameTokensResponse({
responseCode: NameTokensResponseCodes.Ok,
registeredNameTokens,
}),
);
});
},
);

export default app;
Loading