diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 420b7861f..af341698f 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -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", @@ -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:", diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts index 5c3186cde..a10fc80ef 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/ensnode-api.ts @@ -1,9 +1,13 @@ import config from "@/config"; +import { describeRoute, resolver } from "hono-openapi"; + import { IndexingStatusResponseCodes, type IndexingStatusResponseError, type IndexingStatusResponseOk, + makeENSApiPublicConfigSchema, + makeIndexingStatusResponseSchema, serializeENSApiPublicConfig, serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; @@ -18,35 +22,77 @@ import resolutionApi from "./resolution-api"; const app = factory.createApp(); // include ENSApi Public Config endpoint -app.get("/config", async (c) => { - const ensApiPublicConfig = buildEnsApiPublicConfig(config); - return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); -}); +app.get( + "/config", + describeRoute({ + summary: "Get ENSApi Configuration", + description: "Returns the public configuration of the ENSApi instance", + responses: { + 200: { + description: "Successfully retrieved configuration", + content: { + "application/json": { + schema: resolver(makeENSApiPublicConfigSchema()), + }, + }, + }, + }, + }), + async (c) => { + const ensApiPublicConfig = buildEnsApiPublicConfig(config); + return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); + }, +); // include ENSIndexer Indexing Status endpoint -app.get("/indexing-status", async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`); - } +app.get( + "/indexing-status", + describeRoute({ + summary: "Get ENSIndexer Indexing Status", + description: "Returns the current indexing status of the ENSIndexer", + responses: { + 200: { + description: "Successfully retrieved indexing status", + content: { + "application/json": { + schema: resolver(makeIndexingStatusResponseSchema()), + }, + }, + }, + 500: { + description: "Error retrieving indexing status", + content: { + "application/json": { + schema: resolver(makeIndexingStatusResponseSchema()), + }, + }, + }, + }, + }), + async (c) => { + // context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(ensnode-api): indexingStatusMiddleware required`); + } + + if (c.var.indexingStatus instanceof Error) { + return c.json( + serializeIndexingStatusResponse({ + responseCode: IndexingStatusResponseCodes.Error, + } satisfies IndexingStatusResponseError), + 500, + ); + } - if (c.var.indexingStatus instanceof Error) { + // return successful response using the indexing status projection from the context return c.json( serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Error, - } satisfies IndexingStatusResponseError), - 500, + responseCode: IndexingStatusResponseCodes.Ok, + realtimeProjection: c.var.indexingStatus, + } satisfies IndexingStatusResponseOk), ); - } - - // return successful response using the indexing status projection from the context - return c.json( - serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Ok, - realtimeProjection: c.var.indexingStatus, - } satisfies IndexingStatusResponseOk), - ); -}); + }, +); // Name Tokens API app.route("/name-tokens", nameTokensApi); diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts index 15922625a..ce975c9e6 100644 --- a/apps/ensapi/src/handlers/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/name-tokens-api.ts @@ -1,5 +1,6 @@ import config from "@/config"; +import { describeRoute, resolver } from "hono-openapi"; import { namehash } from "viem"; import z from "zod/v4"; @@ -14,7 +15,7 @@ import { type PluginName, serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; -import { makeNodeSchema } from "@ensnode/ensnode-sdk/internal"; +import { makeNameTokensResponseSchema, makeNodeSchema } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; import { validate } from "@/lib/handlers/validate"; @@ -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( @@ -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; diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts index e981ca221..9ac261518 100644 --- a/apps/ensapi/src/handlers/resolution-api.ts +++ b/apps/ensapi/src/handlers/resolution-api.ts @@ -1,10 +1,14 @@ +import { describeRoute, resolver } from "hono-openapi"; import { z } from "zod/v4"; -import type { - Duration, - ResolvePrimaryNameResponse, - ResolvePrimaryNamesResponse, - ResolveRecordsResponse, +import { + type Duration, + makeResolvePrimaryNameResponseSchema, + makeResolvePrimaryNamesResponseSchema, + makeResolveRecordsResponseSchema, + type ResolvePrimaryNameResponse, + type ResolvePrimaryNamesResponse, + type ResolveRecordsResponse, } from "@ensnode/ensnode-sdk"; import { params } from "@/lib/handlers/params.schema"; @@ -44,6 +48,20 @@ app.use(canAccelerateMiddleware); */ app.get( "/records/:name", + describeRoute({ + summary: "Resolve ENS Records", + description: "Resolves ENS records for a given name", + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: resolver(makeResolveRecordsResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ name: params.name })), validate( "query", @@ -99,6 +117,20 @@ app.get( */ app.get( "/primary-name/:address/:chainId", + describeRoute({ + summary: "Resolve Primary Name", + description: "Resolves a primary name for a given `address` and `chainId`", + responses: { + 200: { + description: "Successfully resolved name", + content: { + "application/json": { + schema: resolver(makeResolvePrimaryNameResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ address: params.address, chainId: params.defaultableChainId })), validate( "query", @@ -144,6 +176,20 @@ app.get( */ app.get( "/primary-names/:address", + describeRoute({ + summary: "Resolve Primary Names", + description: "Resolves all primary names for a given address across multiple chains", + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: resolver(makeResolvePrimaryNamesResponseSchema()), + }, + }, + }, + }, + }), validate("param", z.object({ address: params.address })), validate( "query", diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 3c19147c3..8726de2f2 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -4,6 +4,7 @@ import config from "@/config"; import { serve } from "@hono/node-server"; import { otel } from "@hono/otel"; import { cors } from "hono/cors"; +import { openAPIRouteHandler } from "hono-openapi"; import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; @@ -46,6 +47,24 @@ app.route("/subgraph", subgraphApi); // use ENSAnalytics API at /ensanalytics app.route("/ensanalytics", ensanalyticsApi); +// use OpenAPI Schema +app.get( + "/openapi.json", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "ENSNode's APIs", + version: packageJson.version, + description: "ENSNode resolution and analytics API", + }, + servers: [ + { url: `http://localhost:${config.port}`, description: "Local Development" }, + // Add your production servers here + ], + }, + }), +); + // will automatically 500 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { return c.json({ ok: true }); diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts index 4aa4d33f2..704437424 100644 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ b/apps/ensapi/src/lib/handlers/validate.ts @@ -1,5 +1,5 @@ -import { zValidator } from "@hono/zod-validator"; import type { ValidationTargets } from "hono"; +import { validator } from "hono-openapi"; import type { ZodType } from "zod/v4"; import { errorResponse } from "./error-response"; @@ -18,7 +18,7 @@ export const validate = - zValidator(target, schema, (result, c) => { + validator(target, schema, (result, c) => { // if validation failed, return our custom-formatted ErrorResponse instead of default if (!result.success) return errorResponse(c, result.error); }); diff --git a/packages/ensnode-sdk/src/api/indexing-status/index.ts b/packages/ensnode-sdk/src/api/indexing-status/index.ts index 54913353d..919f4f586 100644 --- a/packages/ensnode-sdk/src/api/indexing-status/index.ts +++ b/packages/ensnode-sdk/src/api/indexing-status/index.ts @@ -3,3 +3,4 @@ export * from "./request"; export * from "./response"; export * from "./serialize"; export * from "./serialized-response"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts b/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts index a0fb34ac0..c16c1de0f 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/deserialize.ts @@ -9,7 +9,9 @@ import { makeNameTokensResponseSchema } from "./zod-schemas"; export function deserializedNameTokensResponse( maybeResponse: SerializedNameTokensResponse, ): NameTokensResponse { - const parsed = makeNameTokensResponseSchema().safeParse(maybeResponse); + const parsed = makeNameTokensResponseSchema("Name Tokens Response", false).safeParse( + maybeResponse, + ); if (parsed.error) { throw new Error(`Cannot deserialize NameTokensResponse:\n${prettifyError(parsed.error)}\n`); diff --git a/packages/ensnode-sdk/src/api/name-tokens/request.ts b/packages/ensnode-sdk/src/api/name-tokens/request.ts index 7aca3cd8f..7a9eac92d 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/request.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/request.ts @@ -2,26 +2,17 @@ import type { Name, Node } from "../../ens"; /** * Represents request to Name Tokens API. + * + * Either `domainId` or `name` must be provided, but not both. */ -export interface NameTokensRequestByDomainId { - domainId: Node; - +export interface NameTokensRequest { /** - * Name for which name tokens were requested. + * Domain ID (namehash) for which name tokens were requested. */ - name?: undefined; -} - -/** - * Represents request to Name Tokens API. - */ -export interface NameTokensRequestByName { - domainId?: undefined; + domainId?: Node; /** * Name for which name tokens were requested. */ - name: Name; + name?: Name; } - -export type NameTokensRequest = NameTokensRequestByDomainId | NameTokensRequestByName; diff --git a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts index 6bf23db3e..344f2d108 100644 --- a/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/name-tokens/zod-schemas.ts @@ -1,6 +1,5 @@ import { namehash } from "viem"; import z from "zod/v4"; -import type { ParsePayload } from "zod/v4/core"; import { makeNodeSchema, @@ -22,78 +21,78 @@ import { type RegisteredNameTokens, } from "./response"; -function invariant_nameIsAssociatedWithDomainId(ctx: ParsePayload) { - const { name, domainId } = ctx.value; - - if (namehash(name) !== domainId) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'name' must be associated with 'domainId': ${domainId}`, - }); - } -} - -function invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown( - ctx: ParsePayload, -) { - const { tokens } = ctx.value; - const containsOwnershipNameWrapper = tokens.some( - (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.NameWrapper, - ); - const containsOwnershipFullyOnchainOrUnknown = tokens.some( - (t) => - t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain || - t.ownership.ownershipType === NameTokenOwnershipTypes.Unknown, - ); - if (containsOwnershipNameWrapper && !containsOwnershipFullyOnchainOrUnknown) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'tokens' must contain name token with ownership type 'fully-onchain' or 'unknown' when name token with ownership type 'namewrapper' in listed`, - }); - } -} - -function invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective( - ctx: ParsePayload, -) { - const { tokens } = ctx.value; - const tokensCountWithOwnershipFullyOnchain = tokens.filter( - (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain, - ).length; - if (tokensCountWithOwnershipFullyOnchain > 1) { - ctx.issues.push({ - code: "custom", - input: ctx.value, - message: `'tokens' must contain at most one name token with ownership type 'fully-onchain', current count: ${tokensCountWithOwnershipFullyOnchain}`, - }); - } -} - /** * Schema for {@link RegisteredNameTokens}. */ -export const makeRegisteredNameTokenSchema = (valueLabel: string = "Registered Name Token") => +export const makeRegisteredNameTokenSchema = ( + valueLabel: string = "Registered Name Token", + serializable?: SerializableType, +) => z .object({ domainId: makeNodeSchema(`${valueLabel}.domainId`), name: makeReinterpretedNameSchema(valueLabel), - tokens: z.array(makeNameTokenSchema(`${valueLabel}.tokens`)).nonempty(), + tokens: z.array(makeNameTokenSchema(`${valueLabel}.tokens`, serializable)).nonempty(), expiresAt: makeUnixTimestampSchema(`${valueLabel}.expiresAt`), accurateAsOf: makeUnixTimestampSchema(`${valueLabel}.accurateAsOf`), }) - .check(invariant_nameIsAssociatedWithDomainId) - .check(invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective) - .check(invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown); + .check(function invariant_nameIsAssociatedWithDomainId(ctx) { + const { name, domainId } = ctx.value; + + if (namehash(name) !== domainId) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'name' must be associated with 'domainId': ${domainId}`, + }); + } + }) + .check( + function invariant_nameTokensOwnershipTypeNameWrapperRequiresOwnershipTypeFullyOnchainOrUnknown( + ctx, + ) { + const { tokens } = ctx.value; + const containsOwnershipNameWrapper = tokens.some( + (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.NameWrapper, + ); + const containsOwnershipFullyOnchainOrUnknown = tokens.some( + (t) => + t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain || + t.ownership.ownershipType === NameTokenOwnershipTypes.Unknown, + ); + if (containsOwnershipNameWrapper && !containsOwnershipFullyOnchainOrUnknown) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'tokens' must contain name token with ownership type 'fully-onchain' or 'unknown' when name token with ownership type 'namewrapper' in listed`, + }); + } + }, + ) + .check(function invariant_nameTokensContainAtMostOneWithOwnershipTypeEffective(ctx) { + const { tokens } = ctx.value; + const tokensCountWithOwnershipFullyOnchain = tokens.filter( + (t) => t.ownership.ownershipType === NameTokenOwnershipTypes.FullyOnchain, + ).length; + if (tokensCountWithOwnershipFullyOnchain > 1) { + ctx.issues.push({ + code: "custom", + input: ctx.value, + message: `'tokens' must contain at most one name token with ownership type 'fully-onchain', current count: ${tokensCountWithOwnershipFullyOnchain}`, + }); + } + }); /** * Schema for {@link NameTokensResponseOk} */ -export const makeNameTokensResponseOkSchema = (valueLabel: string = "Name Tokens Response OK") => +export const makeNameTokensResponseOkSchema = ( + valueLabel: string = "Name Tokens Response OK", + serializable?: SerializableType, +) => z.strictObject({ responseCode: z.literal(NameTokensResponseCodes.Ok), - registeredNameTokens: makeRegisteredNameTokenSchema(`${valueLabel}.nameTokens`), + registeredNameTokens: makeRegisteredNameTokenSchema(`${valueLabel}.nameTokens`, serializable), }); /** @@ -145,8 +144,12 @@ export const makeNameTokensResponseErrorSchema = ( /** * Schema for {@link NameTokensResponse} */ -export const makeNameTokensResponseSchema = (valueLabel: string = "Name Tokens Response") => - z.discriminatedUnion("responseCode", [ - makeNameTokensResponseOkSchema(valueLabel), +export const makeNameTokensResponseSchema = ( + valueLabel: string = "Name Tokens Response", + serializable?: SerializableType, +) => { + return z.discriminatedUnion("responseCode", [ + makeNameTokensResponseOkSchema(valueLabel, serializable ?? false), makeNameTokensResponseErrorSchema(valueLabel), ]); +}; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/index.ts b/packages/ensnode-sdk/src/api/registrar-actions/index.ts index 8f0b7c3f5..41b6680b7 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/index.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/index.ts @@ -5,3 +5,4 @@ export * from "./request"; export * from "./response"; export * from "./serialize"; export * from "./serialized-response"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/resolution/index.ts b/packages/ensnode-sdk/src/api/resolution/index.ts index eea524d65..f265637b1 100644 --- a/packages/ensnode-sdk/src/api/resolution/index.ts +++ b/packages/ensnode-sdk/src/api/resolution/index.ts @@ -1 +1,2 @@ export * from "./types"; +export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts new file mode 100644 index 000000000..5beab7a42 --- /dev/null +++ b/packages/ensnode-sdk/src/api/resolution/zod-schemas.ts @@ -0,0 +1,45 @@ +import z from "zod/v4"; + +/** + * Schema for resolver records response (addresses, texts, name) + */ +const makeResolverRecordsResponseSchema = () => + z.object({ + name: z.string().nullable().optional(), + addresses: z.record(z.string(), z.string().nullable()).optional(), + texts: z.record(z.string(), z.string().nullable()).optional(), + }); + +/** + * Schema for {@link ResolveRecordsResponse} + */ +export const makeResolveRecordsResponseSchema = () => + z.object({ + records: makeResolverRecordsResponseSchema(), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + // TODO: Find a better way to handle recursive types, patch solution is .unknown() + trace: z.array(z.unknown()).optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNameResponse} + */ +export const makeResolvePrimaryNameResponseSchema = () => + z.object({ + name: z.string().nullable(), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.array(z.unknown()).optional(), + }); + +/** + * Schema for {@link ResolvePrimaryNamesResponse} + */ +export const makeResolvePrimaryNamesResponseSchema = () => + z.object({ + names: z.record(z.number(), z.string().nullable()), + accelerationRequested: z.boolean(), + accelerationAttempted: z.boolean(), + trace: z.array(z.unknown()).optional(), + }); diff --git a/packages/ensnode-sdk/src/client.ts b/packages/ensnode-sdk/src/client.ts index a5f5aae72..cd47f501c 100644 --- a/packages/ensnode-sdk/src/client.ts +++ b/packages/ensnode-sdk/src/client.ts @@ -755,7 +755,7 @@ export class ENSNodeClient { if (request.name !== undefined) { url.searchParams.set("name", request.name); - } else { + } else if (request.domainId !== undefined) { url.searchParams.set("domainId", request.domainId); } diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index dde184884..665ec56d1 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -13,6 +13,7 @@ */ export * from "./api/indexing-status/zod-schemas"; +export * from "./api/name-tokens/zod-schemas"; export * from "./api/registrar-actions/zod-schemas"; export * from "./api/shared/errors/zod-schemas"; export * from "./api/shared/pagination/zod-schemas"; diff --git a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts index 77bebabb4..118ff3744 100644 --- a/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts +++ b/packages/ensnode-sdk/src/tokenscope/zod-schemas.ts @@ -20,15 +20,42 @@ import { type NameTokenOwnershipUnknown, } from "./name-token"; +const tokenIdSchemaSerializable = z.string(); +const tokenIdSchemaNative = z.preprocess( + (v) => (typeof v === "string" ? BigInt(v) : v), + z.bigint().positive(), +); + +export function makeTokenIdSchema( + _valueLabel: string, + serializable: SerializableType, +): SerializableType extends true ? typeof tokenIdSchemaSerializable : typeof tokenIdSchemaNative; +export function makeTokenIdSchema( + _valueLabel: string = "Token ID Schema", + serializable: true | false = false, +): typeof tokenIdSchemaSerializable | typeof tokenIdSchemaNative { + if (serializable) { + return tokenIdSchemaSerializable; + } else { + return tokenIdSchemaNative; + } +} + /** * Make schema for {@link AssetId}. + * + * TODO: Find a way to make this compatible with Zod JSON Schema: https://zod.dev/json-schema#unrepresentable */ -export const makeAssetIdSchema = (valueLabel: string = "Asset ID Schema") => - z.object({ +export const makeAssetIdSchema = ( + valueLabel: string = "Asset ID Schema", + serializable?: SerializableType, +) => { + return z.object({ assetNamespace: z.enum(AssetNamespaces), contract: makeAccountIdSchema(valueLabel), - tokenId: z.preprocess((v) => (typeof v === "string" ? BigInt(v) : v), z.bigint().positive()), + tokenId: makeTokenIdSchema(valueLabel, serializable ?? false), }); +}; /** * Make schema for {@link AssetIdString}. @@ -137,9 +164,12 @@ export const makeNameTokenOwnershipSchema = (valueLabel: string = "Name Token Ow /** * Make schema for {@link NameToken}. */ -export const makeNameTokenSchema = (valueLabel: string = "Name Token Schema") => +export const makeNameTokenSchema = ( + valueLabel: string = "Name Token Schema", + serializable?: SerializableType, +) => z.object({ - token: makeAssetIdSchema(`${valueLabel}.token`), + token: makeAssetIdSchema(`${valueLabel}.token`, serializable), ownership: makeNameTokenOwnershipSchema(`${valueLabel}.ownership`), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fec97a1..b48337690 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: '@hono/otel': specifier: ^0.2.2 version: 0.2.2(hono@4.10.3) + '@hono/standard-validator': + specifier: ^0.2.0 + version: 0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3) '@hono/zod-validator': specifier: ^0.7.2 version: 0.7.4(hono@4.10.3)(zod@3.25.76) @@ -343,6 +346,9 @@ importers: hono: specifier: 'catalog:' version: 4.10.3 + hono-openapi: + specifier: ^1.1.1 + version: 1.1.1(@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76))(@types/json-schema@7.0.15)(hono@4.10.3)(openapi-types@12.1.3) p-memoize: specifier: ^8.0.0 version: 8.0.0 @@ -1699,6 +1705,12 @@ packages: peerDependencies: hono: '*' + '@hono/standard-validator@0.2.0': + resolution: {integrity: sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ==} + peerDependencies: + '@standard-schema/spec': 1.0.0 + hono: '>=3.9.0' + '@hono/zod-validator@0.7.4': resolution: {integrity: sha512-biKGn3BRJVaftZlIPMyK+HCe/UHAjJ6sH0UyXe3+v0OcgVr9xfImDROTJFLtn9e3XEEAHGZIM9U6evu85abm8Q==} peerDependencies: @@ -3097,6 +3109,67 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-community/standard-json@0.3.5': + resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} + peerDependencies: + '@standard-schema/spec': ^1.0.0 + '@types/json-schema': ^7.0.15 + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.20 + effect: ^3.16.8 + quansync: ^0.2.11 + sury: ^10.0.0 + typebox: ^1.0.17 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.24.5 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + + '@standard-community/standard-openapi@0.2.9': + resolution: {integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==} + peerDependencies: + '@standard-community/standard-json': ^0.3.5 + '@standard-schema/spec': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.17.14 + openapi-types: ^12.1.3 + sury: ^10.0.0 + typebox: ^1.0.0 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-openapi: ^4 + peerDependenciesMeta: + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-openapi: + optional: true + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -3377,6 +3450,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5056,6 +5132,21 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hono-openapi@1.1.1: + resolution: {integrity: sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q==} + peerDependencies: + '@hono/standard-validator': ^0.1.2 + '@standard-community/standard-json': ^0.3.5 + '@standard-community/standard-openapi': ^0.2.8 + '@types/json-schema': ^7.0.15 + hono: ^4.8.3 + openapi-types: ^12.1.3 + peerDependenciesMeta: + '@hono/standard-validator': + optional: true + hono: + optional: true + hono@4.10.3: resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} engines: {node: '>=16.9.0'} @@ -5969,6 +6060,9 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -8940,6 +9034,11 @@ snapshots: '@opentelemetry/semantic-conventions': 1.37.0 hono: 4.10.3 + '@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3)': + dependencies: + '@standard-schema/spec': 1.0.0 + hono: 4.10.3 + '@hono/zod-validator@0.7.4(hono@4.10.3)(zod@3.25.76)': dependencies: hono: 4.10.3 @@ -10433,6 +10532,23 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/json-schema': 7.0.15 + quansync: 0.2.11 + optionalDependencies: + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76)': + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + '@standard-schema/spec': 1.0.0 + openapi-types: 12.1.3 + optionalDependencies: + zod: 3.25.76 + '@standard-schema/spec@1.0.0': {} '@swc/helpers@0.5.15': @@ -10730,6 +10846,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -10831,6 +10949,14 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -12668,6 +12794,16 @@ snapshots: help-me@5.0.0: {} + hono-openapi@1.1.1(@hono/standard-validator@0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76))(@types/json-schema@7.0.15)(hono@4.10.3)(openapi-types@12.1.3): + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.0.0)(openapi-types@12.1.3)(zod@3.25.76) + '@types/json-schema': 7.0.15 + openapi-types: 12.1.3 + optionalDependencies: + '@hono/standard-validator': 0.2.0(@standard-schema/spec@1.0.0)(hono@4.10.3) + hono: 4.10.3 + hono@4.10.3: {} html-encoding-sniffer@4.0.0: @@ -13763,6 +13899,8 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 + openapi-types@12.1.3: {} + outdent@0.5.0: {} ox@0.9.6(typescript@5.9.3)(zod@3.25.76): @@ -15488,7 +15626,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5