Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/ensadmin/src/app/mock/config-info/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useMemo, useState } from "react";

import {
buildEnsNodeStackInfo,
deserializeENSApiPublicConfig,
deserializeEnsApiPublicConfig,
deserializeEnsIndexerPublicConfig,
type EnsDbPublicConfig,
SerializedENSApiPublicConfig,
} from "@ensnode/ensnode-sdk";
Expand Down Expand Up @@ -41,14 +42,23 @@ export default function MockConfigPage() {

default:
try {
const ensApiPublicConfig = deserializeENSApiPublicConfig(mockConfigData[selectedConfig]);
const ensApiPublicConfig = deserializeEnsApiPublicConfig(mockConfigData[selectedConfig]);
const ensIndexerPublicConfig = deserializeEnsIndexerPublicConfig(
mockConfigData[selectedConfig],
);
Comment on lines +45 to +48
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deserializeEnsIndexerPublicConfig expects a SerializedEnsIndexerPublicConfig, but this code passes the entire SerializedENSApiPublicConfig object (which in the current mock JSON contains ensIndexerPublicConfig nested). As a result, deserialization will fail because required indexer fields aren’t at the top level.

Use the nested ensIndexerPublicConfig value from the mock JSON (or update the mock JSON to the new split shape), and update mockConfigData’s type accordingly so TypeScript reflects the actual structure.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong deserialization target for ENSIndexer config

mockConfigData[selectedConfig] is the outer combined object { versionInfo, theGraphFallback, ensIndexerPublicConfig: {...} }, but deserializeEnsIndexerPublicConfig expects an object with ENSIndexer-level fields (labelSet, indexedChainIds, etc.) at the top level. The ENSIndexer fields are one level deeper inside ensIndexerPublicConfig. As a result, deserializeEnsIndexerPublicConfig will always fail validation here — the page will always display a "Deserialization Error" instead of the intended mock state.

The correct source for the ENSIndexer config is the nested property, e.g. (mockConfigData[selectedConfig] as any).ensIndexerPublicConfig.

const ensRainbowPublicConfig = ensIndexerPublicConfig.ensRainbowPublicConfig;
const ensDbPublicConfig = {
versionInfo: {
postgresql: "18.1",
},
} satisfies EnsDbPublicConfig;
return {
ensNodeStackInfo: buildEnsNodeStackInfo(ensApiPublicConfig, ensDbPublicConfig),
ensNodeStackInfo: buildEnsNodeStackInfo(
ensApiPublicConfig,
ensDbPublicConfig,
ensIndexerPublicConfig,
ensRainbowPublicConfig,
),
} satisfies ENSNodeConfigInfoViewProps;
} catch (error) {
const errorMessage =
Expand Down
1 change: 0 additions & 1 deletion apps/ensadmin/src/app/mock/indexing-status-api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ const serializedEnsIndexerPublicConfig = {
} satisfies SerializedEnsIndexerPublicConfig;

export const serializedEnsApiPublicConfig = {
ensIndexerPublicConfig: serializedEnsIndexerPublicConfig,
theGraphFallback: {
canFallback: true,
url: "https://api.thegraph.com/subgraphs/name/ensdomains/ens",
Expand Down
16 changes: 14 additions & 2 deletions apps/ensapi/src/cache/stack-info.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,22 @@ async function loadEnsNodeStackInfo(
return cachedResult.result;
}

const ensApiPublicConfig = buildEnsApiPublicConfig(config);
const ensIndexerPublicConfig = await ensDbClient.getEnsIndexerPublicConfig();

if (!ensIndexerPublicConfig) {
throw new Error("EnsIndexerPublicConfig is not available in ENSDb");
}

const ensApiPublicConfig = buildEnsApiPublicConfig(config, ensIndexerPublicConfig);
const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig();
const ensRainbowPublicConfig = ensIndexerPublicConfig.ensRainbowPublicConfig;

return buildEnsNodeStackInfo(ensApiPublicConfig, ensDbPublicConfig);
return buildEnsNodeStackInfo(
ensApiPublicConfig,
ensDbPublicConfig,
ensIndexerPublicConfig,
ensRainbowPublicConfig,
);
}

// lazyProxy defers construction until first use so that this module can be
Expand Down
51 changes: 2 additions & 49 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,22 +155,11 @@ describe("buildEnsApiPublicConfig", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
rpcConfigs: new Map([
[
1,
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test expects buildEnsApiPublicConfig to return ensIndexerPublicConfig field, but the refactored function doesn't include it in the return value

Fix on Vercel

httpRPCs: [new URL(VALID_RPC_URL)],
websocketRPC: undefined,
} satisfies RpcConfig,
],
]),
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(mockConfig, ENSINDEXER_PUBLIC_CONFIG);

expect(result).toStrictEqual({
versionInfo: ensApiVersionInfo,
Expand All @@ -182,52 +171,16 @@ describe("buildEnsApiPublicConfig", () => {
});
});

it("preserves the complete ENSIndexer public config structure", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);

// Verify that all ENSIndexer public config fields are preserved
expect(result.ensIndexerPublicConfig.namespace).toBe(ENSINDEXER_PUBLIC_CONFIG.namespace);
expect(result.ensIndexerPublicConfig.plugins).toEqual(ENSINDEXER_PUBLIC_CONFIG.plugins);
expect(result.ensIndexerPublicConfig.versionInfo).toEqual(ENSINDEXER_PUBLIC_CONFIG.versionInfo);
expect(result.ensIndexerPublicConfig.indexedChainIds).toEqual(
ENSINDEXER_PUBLIC_CONFIG.indexedChainIds,
);
expect(result.ensIndexerPublicConfig.isSubgraphCompatible).toBe(
ENSINDEXER_PUBLIC_CONFIG.isSubgraphCompatible,
);
expect(result.ensIndexerPublicConfig.labelSet).toEqual(ENSINDEXER_PUBLIC_CONFIG.labelSet);
expect(result.ensIndexerPublicConfig.ensIndexerSchemaName).toBe(
ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
);
});

it("includes the theGraphFallback and redacts api key", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
ensDbUrl: BASE_ENV.ENSDB_URL,
ensIndexerPublicConfig: {
...ENSINDEXER_PUBLIC_CONFIG,
plugins: ["subgraph"],
isSubgraphCompatible: true,
},
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
ensIndexerSchemaName: ENSINDEXER_PUBLIC_CONFIG.ensIndexerSchemaName,
rpcConfigs: new Map(),
referralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
};

const result = buildEnsApiPublicConfig(mockConfig);
const result = buildEnsApiPublicConfig(mockConfig, ENSINDEXER_PUBLIC_CONFIG);

expect(result.theGraphFallback.canFallback).toBe(true);
// discriminate the type...
Expand Down
75 changes: 18 additions & 57 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import pRetry from "p-retry";
import { prettifyError, ZodError, z } from "zod/v4";

import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk";
import type { EnsApiPublicConfig, EnsIndexerPublicConfig } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
canFallbackToTheGraph,
ENSNamespaceSchema,
invariant_rpcConfigsSpecifiedForRootChain,
makeENSIndexerPublicConfigSchema,
OptionalPortNumberSchema,
RpcConfigsSchema,
TheGraphApiKeySchema,
} from "@ensnode/ensnode-sdk/internal";

import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import ensDbConfig from "@/config/ensdb-config";
import type { EnsApiEnvironment } from "@/config/environment";
import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations";
import { ensDbClient } from "@/lib/ensdb/singleton";
import logger from "@/lib/logger";
import { ensApiVersionInfo } from "@/lib/version-info";

Expand All @@ -39,21 +31,15 @@ const ReferralProgramEditionConfigSetUrlSchema = z
})
.optional();

const EnsApiConfigSchema = z
.object({
port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT),
theGraphApiKey: TheGraphApiKeySchema,
namespace: ENSNamespaceSchema,
rpcConfigs: RpcConfigsSchema,
ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"),
referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema,
const EnsApiConfigSchema = z.object({
port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT),
theGraphApiKey: TheGraphApiKeySchema,
referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema,

// include the ENSDbConfig params in the EnsApiConfigSchema
ensDbUrl: z.string(),
ensIndexerSchemaName: z.string(),
})
.check(invariant_rpcConfigsSpecifiedForRootChain)
.check(invariant_ensIndexerPublicConfigVersionInfo);
// include the ENSDbConfig params in the EnsApiConfigSchema
ensDbUrl: z.string(),
ensIndexerSchemaName: z.string(),
});

export type EnsApiConfig = z.infer<typeof EnsApiConfigSchema>;

Expand All @@ -65,39 +51,10 @@ export type EnsApiConfig = z.infer<typeof EnsApiConfigSchema>;
*/
export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise<EnsApiConfig> {
try {
// TODO: transfer the responsibility of fetching
// the ENSIndexer Public Config to a middleware layer, as per:
// https://github.com/namehash/ensnode/issues/1806
const ensIndexerPublicConfig = await pRetry(
async () => {
const config = await ensDbClient.getEnsIndexerPublicConfig();

if (!config) {
throw new Error("ENSIndexer Public Config not yet available in ENSDb.");
}

return config;
},
{
retries: 13, // This allows for a total of over 1 hour of retries with the exponential backoff strategy
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
logger.info(
`ENSIndexer Public Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`,
);
},
},
);

const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace);

return EnsApiConfigSchema.parse({
port: env.PORT,
theGraphApiKey: env.THEGRAPH_API_KEY,
ensIndexerPublicConfig,
namespace: ensIndexerPublicConfig.namespace,
rpcConfigs,
referralProgramEditionConfigSetUrl: env.REFERRAL_PROGRAM_EDITIONS,

// include the validated ENSDb config values in the parsed EnsApiConfig
ensDbUrl: ensDbConfig.ensDbUrl,
ensIndexerSchemaName: ensDbConfig.ensIndexerSchemaName,
Expand All @@ -121,17 +78,21 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
* @param config - The validated EnsApiConfig object
* @returns A complete ENSApiPublicConfig object
*/
export function buildEnsApiPublicConfig(config: EnsApiConfig): EnsApiPublicConfig {
export function buildEnsApiPublicConfig(
ensApiConfig: EnsApiConfig,
ensIndexerPublicConfig: EnsIndexerPublicConfig,
): EnsApiPublicConfig {
const { isSubgraphCompatible, namespace } = ensIndexerPublicConfig;

return {
versionInfo: ensApiVersionInfo,
theGraphFallback: canFallbackToTheGraph({
namespace: config.namespace,
namespace,
isSubgraphCompatible,
// NOTE: very important here that we replace the actual server-side api key with a placeholder
// so that it's not sent to clients as part of the `theGraphFallback.url`. The placeholder must
// pass validation, of course, but the only validation necessary is that it is a string.
theGraphApiKey: config.theGraphApiKey ? "<API_KEY>" : undefined,
isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible,
theGraphApiKey: ensApiConfig.theGraphApiKey ? "<API_KEY>" : undefined,
}),
ensIndexerPublicConfig: config.ensIndexerPublicConfig,
};
}
5 changes: 1 addition & 4 deletions apps/ensapi/src/config/redact.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redactRpcConfigs, redactString, redactUrl } from "@ensnode/ensnode-sdk/internal";
import { redactString, redactUrl } from "@ensnode/ensnode-sdk/internal";

import type { EnsApiConfig } from "@/config/config.schema";

Expand All @@ -8,13 +8,10 @@ import type { EnsApiConfig } from "@/config/config.schema";
export function redactEnsApiConfig(config: EnsApiConfig) {
return {
port: config.port,
namespace: config.namespace,
referralProgramEditionConfigSetUrl: config.referralProgramEditionConfigSetUrl
? redactUrl(config.referralProgramEditionConfigSetUrl)
: undefined,
ensIndexerPublicConfig: config.ensIndexerPublicConfig,
ensDbUrl: redactString(config.ensDbUrl),
rpcConfigs: redactRpcConfigs(config.rpcConfigs),
ensIndexerSchemaName: config.ensIndexerSchemaName,
theGraphApiKey: config.theGraphApiKey ? redactString(config.theGraphApiKey) : undefined,
};
Expand Down
33 changes: 23 additions & 10 deletions apps/ensapi/src/handlers/api/explore/name-tokens-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import config from "@/config";

import {
asInterpretedName,
getParentInterpretedName,
Expand All @@ -22,16 +20,16 @@ import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-t
import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries";
import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware";
import {
ensureEnsNodeStackInfoAvailable,
stackInfoMiddleware,
} from "@/middleware/stack-info.middleware";

import { getNameTokensRoute } from "./name-tokens-api.routes";

const app = createApp({ middlewares: [indexingStatusMiddleware, nameTokensApiMiddleware] });

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const indexedSubregistries = lazyProxy(() =>
getIndexedSubregistries(config.namespace, config.ensIndexerPublicConfig.plugins as PluginName[]),
);
const app = createApp({
middlewares: [stackInfoMiddleware, indexingStatusMiddleware, nameTokensApiMiddleware],
});

/**
* Factory function for creating a 404 Name Tokens Not Indexed error response
Expand All @@ -48,6 +46,8 @@ const makeNameTokensNotIndexedResponse = (
});

app.openapi(getNameTokensRoute, async (c) => {
ensureEnsNodeStackInfoAvailable(c);

// Check if Indexing Status resolution failed.
if (c.var.indexingStatus instanceof Error) {
return c.json(
Expand Down Expand Up @@ -84,6 +84,14 @@ app.openapi(getNameTokensRoute, async (c) => {
}

const parentNode = namehashInterpretedName(parentName);
const { namespace, plugins } = c.var.stackInfo.ensIndexer;

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
const indexedSubregistries = lazyProxy(() =>
getIndexedSubregistries(namespace, plugins as PluginName[]),
);

const subregistry = indexedSubregistries.find((s) => s.node === parentNode);

// Return 404 response with error code for Name Tokens Not Indexed when
Expand Down Expand Up @@ -111,7 +119,12 @@ app.openapi(getNameTokensRoute, async (c) => {
const { omnichainSnapshot } = c.var.indexingStatus.snapshot;
const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor;

const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf);
const { namespace } = c.var.stackInfo.ensIndexer;
const registeredNameTokens = await findRegisteredNameTokensForDomain(
namespace,
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
Expand Down
8 changes: 6 additions & 2 deletions apps/ensapi/src/handlers/api/meta/status-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import {

import { createApp } from "@/lib/hono-factory";
import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";
import { stackInfoMiddleware } from "@/middleware/stack-info.middleware";
import {
ensureEnsNodeStackInfoAvailable,
stackInfoMiddleware,
} from "@/middleware/stack-info.middleware";

import { getIndexingStatusRoute } from "./status-api.routes";

const app = createApp({ middlewares: [stackInfoMiddleware, indexingStatusMiddleware] });

app.openapi(getIndexingStatusRoute, async (c) => {
if (c.var.indexingStatus instanceof Error || c.var.stackInfo instanceof Error) {
ensureEnsNodeStackInfoAvailable(c);
if (c.var.indexingStatus instanceof Error) {
return c.json(
serializeEnsApiIndexingStatusResponse({
responseCode: EnsApiIndexingStatusResponseCodes.Error,
Expand Down
Loading
Loading