diff --git a/.changeset/chilly-plums-lose.md b/.changeset/chilly-plums-lose.md new file mode 100644 index 000000000..2973da51e --- /dev/null +++ b/.changeset/chilly-plums-lose.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Refactored HTTP handlers for Indexing Status API and Config API to rely solely on ENSDb Client for data. diff --git a/.changeset/fancy-cloths-fly.md b/.changeset/fancy-cloths-fly.md new file mode 100644 index 000000000..ee22cec4c --- /dev/null +++ b/.changeset/fancy-cloths-fly.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Added `validateEnsApiPublicConfig` function. diff --git a/apps/ensapi/.env.local.example b/apps/ensapi/.env.local.example index 6de2ee84b..942756579 100644 --- a/apps/ensapi/.env.local.example +++ b/apps/ensapi/.env.local.example @@ -12,9 +12,18 @@ ENSINDEXER_URL=http://localhost:42069 # It should be in the format of `postgresql://:@:/` # # See https://ensnode.io/ensindexer/usage/configuration/ for additional information. -# NOTE that ENSApi does NOT need to define DATABASE_SCHEMA, as it is inferred from the connected ENSIndexer's Config. DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database +# ENSDb: schema name +# Required. This is a namespace for the tables that the ENSDbClient for ENSApi will apply to read ENSDb data. +# Must be set to an existing namespace in the connected ENSDb at DATABASE_URL. +DATABASE_SCHEMA=public + +# ENS Namespace Configuration +# Required. Must be an ENS namespace's Identifier such as mainnet, sepolia, or ens-test-env. +# (see `@ensnode/datasources` for available options). +NAMESPACE=mainnet + # ENSApi: RPC Configuration # Required. ENSApi requires an HTTP RPC to the connected ENSIndexer's ENS Root Chain, which depends # on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC diff --git a/apps/ensapi/src/cache/indexing-status.cache.ts b/apps/ensapi/src/cache/indexing-status.cache.ts index 4a4364e11..2e209a876 100644 --- a/apps/ensapi/src/cache/indexing-status.cache.ts +++ b/apps/ensapi/src/cache/indexing-status.cache.ts @@ -1,26 +1,21 @@ -import config from "@/config"; - -import { - type CrossChainIndexingStatusSnapshot, - ENSNodeClient, - IndexingStatusResponseCodes, - SWRCache, -} from "@ensnode/ensnode-sdk"; +import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk"; +import { ensDbClient } from "@/lib/ensdb-client/singleton"; import { makeLogger } from "@/lib/logger"; const logger = makeLogger("indexing-status.cache"); -const client = new ENSNodeClient({ url: config.ensIndexerUrl }); export const indexingStatusCache = new SWRCache({ fn: async (_cachedResult) => - client - .indexingStatus() // fetch a new indexing status snapshot - .then((response) => { - if (response.responseCode !== IndexingStatusResponseCodes.Ok) { - // An indexing status response was successfully fetched, but the response code contained within the response was not 'ok'. + ensDbClient + .getIndexingStatusSnapshot() // get the latest indexing status snapshot + .then((snapshot) => { + if (snapshot === undefined) { + // An indexing status snapshot has not been found in ENSDb yet. + // This might happen during application startup, i.e. when ENSDb + // has not yet been populated with the first snapshot. // Therefore, throw an error to trigger the subsequent `.catch` handler. - throw new Error("Received Indexing Status response with responseCode other than 'ok'."); + throw new Error("Indexing Status snapshot not found in ENSDb yet."); } logger.info("Fetched Indexing Status to be cached"); @@ -29,10 +24,10 @@ export const indexingStatusCache = new SWRCache { - // Either the indexing status snapshot fetch failed, or the indexing status response was not 'ok'. + // Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet. // Therefore, throw an error so that this current invocation of `readCache` will: // - Reject the newly fetched response (if any) such that it won't be cached. // - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value. diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 407effcb2..73e9fb5d8 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -2,14 +2,9 @@ import packageJson from "@/../package.json" with { type: "json" }; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - type ENSIndexerPublicConfig, - PluginName, - serializeENSIndexerPublicConfig, -} from "@ensnode/ensnode-sdk"; import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; -import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; import logger from "@/lib/logger"; @@ -25,54 +20,24 @@ const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234"; const BASE_ENV = { DATABASE_URL: "postgresql://user:password@localhost:5432/mydb", + DATABASE_SCHEMA: "ensapi", ENSINDEXER_URL: "http://localhost:42069", + NAMESPACE: "mainnet", RPC_URL_1: VALID_RPC_URL, } satisfies EnsApiEnvironment; -const ENSINDEXER_PUBLIC_CONFIG = { - namespace: "mainnet", - databaseSchemaName: "ensapi", - ensRainbowPublicConfig: { - version: packageJson.version, - labelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, - recordsCount: 100, - }, - indexedChainIds: new Set([1]), - isSubgraphCompatible: false, - labelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, - plugins: [PluginName.Subgraph], - versionInfo: { - ensDb: packageJson.version, - ensIndexer: packageJson.version, - ensNormalize: "1.1.1", - nodejs: "1.1.1", - ponder: "1.1.1", - }, -} satisfies ENSIndexerPublicConfig; - -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); - describe("buildConfigFromEnvironment", () => { - afterEach(() => { - mockFetch.mockReset(); - }); - - it("returns a valid config object using environment variables", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), - }); + it("returns a valid config object using environment variables", () => { + const result = buildConfigFromEnvironment(BASE_ENV); - await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({ + expect(result).toStrictEqual({ + version: packageJson.version, port: ENSApi_DEFAULT_PORT, databaseUrl: BASE_ENV.DATABASE_URL, + databaseSchemaName: BASE_ENV.DATABASE_SCHEMA, ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL), theGraphApiKey: undefined, - - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, + namespace: BASE_ENV.NAMESPACE, rpcConfigs: new Map([ [ 1, @@ -86,15 +51,10 @@ describe("buildConfigFromEnvironment", () => { }); }); - it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", async () => { + it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", () => { const customUrl = "https://example.com/editions.json"; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), - }); - - const config = await buildConfigFromEnvironment({ + const config = buildConfigFromEnvironment({ ...BASE_ENV, CUSTOM_REFERRAL_PROGRAM_EDITIONS: customUrl, }); @@ -116,16 +76,13 @@ describe("buildConfigFromEnvironment", () => { const TEST_ENV: EnsApiEnvironment = { DATABASE_URL: BASE_ENV.DATABASE_URL, + DATABASE_SCHEMA: BASE_ENV.DATABASE_SCHEMA, ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL, + NAMESPACE: "mainnet", }; - it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), - }); - - await buildConfigFromEnvironment({ + it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", () => { + buildConfigFromEnvironment({ ...TEST_ENV, CUSTOM_REFERRAL_PROGRAM_EDITIONS: "not-a-url", }); @@ -136,13 +93,8 @@ describe("buildConfigFromEnvironment", () => { expect(process.exit).toHaveBeenCalledWith(1); }); - it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), - }); - - await buildConfigFromEnvironment({ + it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", () => { + buildConfigFromEnvironment({ ...TEST_ENV, QUICKNODE_API_KEY: "my-api-key", }); @@ -156,13 +108,8 @@ describe("buildConfigFromEnvironment", () => { expect(process.exit).toHaveBeenCalledWith(1); }); - it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)), - }); - - await buildConfigFromEnvironment({ + it("logs error message when QuickNode RPC config was partially configured (missing API key)", () => { + buildConfigFromEnvironment({ ...TEST_ENV, QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", }); @@ -177,94 +124,3 @@ describe("buildConfigFromEnvironment", () => { }); }); }); - -describe("buildEnsApiPublicConfig", () => { - it("returns a valid ENSApi public config with correct structure", () => { - const mockConfig = { - port: ENSApi_DEFAULT_PORT, - databaseUrl: BASE_ENV.DATABASE_URL, - ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL), - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, - rpcConfigs: new Map([ - [ - 1, - { - httpRPCs: [new URL(VALID_RPC_URL)], - websocketRPC: undefined, - } satisfies RpcConfig, - ], - ]), - customReferralProgramEditionConfigSetUrl: undefined, - }; - - const result = buildEnsApiPublicConfig(mockConfig); - - expect(result).toStrictEqual({ - version: packageJson.version, - theGraphFallback: { - canFallback: false, - reason: "not-subgraph-compatible", - }, - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - }); - }); - - it("preserves the complete ENSIndexer public config structure", () => { - const mockConfig = { - port: ENSApi_DEFAULT_PORT, - databaseUrl: BASE_ENV.DATABASE_URL, - ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL), - ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, - rpcConfigs: new Map(), - customReferralProgramEditionConfigSetUrl: 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.databaseSchemaName).toBe( - ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, - ); - }); - - it("includes the theGraphFallback and redacts api key", () => { - const mockConfig = { - port: ENSApi_DEFAULT_PORT, - databaseUrl: BASE_ENV.DATABASE_URL, - ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL), - ensIndexerPublicConfig: { - ...ENSINDEXER_PUBLIC_CONFIG, - plugins: ["subgraph"], - isSubgraphCompatible: true, - }, - namespace: ENSINDEXER_PUBLIC_CONFIG.namespace, - databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName, - rpcConfigs: new Map(), - customReferralProgramEditionConfigSetUrl: undefined, - theGraphApiKey: "secret-api-key", - }; - - const result = buildEnsApiPublicConfig(mockConfig); - - expect(result.theGraphFallback.canFallback).toBe(true); - // discriminate the type... - if (!result.theGraphFallback.canFallback) throw new Error("never"); - - // shouldn't have the secret-api-key in the url - expect(result.theGraphFallback.url).not.toMatch(/secret-api-key/gi); - }); -}); diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index ec402ba89..b77614372 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,18 +1,15 @@ import packageJson from "@/../package.json" with { type: "json" }; -import pRetry from "p-retry"; import { parse as parseConnectionString } from "pg-connection-string"; import { prettifyError, ZodError, z } from "zod/v4"; -import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk"; import { buildRpcConfigsFromEnv, - canFallbackToTheGraph, DatabaseSchemaNameSchema, ENSNamespaceSchema, EnsIndexerUrlSchema, invariant_rpcConfigsSpecifiedForRootChain, - makeENSIndexerPublicConfigSchema, + makeEnsApiVersionSchema, OptionalPortNumberSchema, RpcConfigsSchema, TheGraphApiKeySchema, @@ -20,8 +17,6 @@ import { import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import type { EnsApiEnvironment } from "@/config/environment"; -import { invariant_ensIndexerPublicConfigVersionInfo } from "@/config/validations"; -import { fetchENSIndexerConfig } from "@/lib/fetch-ensindexer-config"; import logger from "@/lib/logger"; export const DatabaseUrlSchema = z.string().refine( @@ -60,8 +55,11 @@ const CustomReferralProgramEditionConfigSetUrlSchema = z }) .optional(); +const EnsApiVersionSchema = makeEnsApiVersionSchema(); + const EnsApiConfigSchema = z .object({ + version: EnsApiVersionSchema, port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT), databaseUrl: DatabaseUrlSchema, databaseSchemaName: DatabaseSchemaNameSchema, @@ -69,43 +67,31 @@ const EnsApiConfigSchema = z theGraphApiKey: TheGraphApiKeySchema, namespace: ENSNamespaceSchema, rpcConfigs: RpcConfigsSchema, - ensIndexerPublicConfig: makeENSIndexerPublicConfigSchema("ensIndexerPublicConfig"), customReferralProgramEditionConfigSetUrl: CustomReferralProgramEditionConfigSetUrlSchema, }) - .check(invariant_rpcConfigsSpecifiedForRootChain) - .check(invariant_ensIndexerPublicConfigVersionInfo); + .check(invariant_rpcConfigsSpecifiedForRootChain); export type EnsApiConfig = z.infer; /** - * Builds the EnsApiConfig from an EnsApiEnvironment object, fetching the EnsIndexerPublicConfig. + * Builds the EnsApiConfig from an EnsApiEnvironment object. * * @returns A validated EnsApiConfig object * @throws Error with formatted validation messages if environment parsing fails */ -export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promise { +export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig { try { - const ensIndexerUrl = EnsIndexerUrlSchema.parse(env.ENSINDEXER_URL); - - const ensIndexerPublicConfig = await pRetry(() => fetchENSIndexerConfig(ensIndexerUrl), { - retries: 3, - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - logger.info( - `ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, - ); - }, - }); - - const rpcConfigs = buildRpcConfigsFromEnv(env, ensIndexerPublicConfig.namespace); + const namespace = ENSNamespaceSchema.parse(env.NAMESPACE); + const rpcConfigs = buildRpcConfigsFromEnv(env, namespace); return EnsApiConfigSchema.parse({ + version: packageJson.version, port: env.PORT, databaseUrl: env.DATABASE_URL, + databaseSchemaName: env.DATABASE_SCHEMA, ensIndexerUrl: env.ENSINDEXER_URL, theGraphApiKey: env.THEGRAPH_API_KEY, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName, + namespace: env.NAMESPACE, rpcConfigs, customReferralProgramEditionConfigSetUrl: env.CUSTOM_REFERRAL_PROGRAM_EDITIONS, }); @@ -121,24 +107,3 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis process.exit(1); } } - -/** - * Builds the ENSApi public configuration from an EnsApiConfig object. - * - * @param config - The validated EnsApiConfig object - * @returns A complete ENSApiPublicConfig object - */ -export function buildEnsApiPublicConfig(config: EnsApiConfig): EnsApiPublicConfig { - return { - version: packageJson.version, - theGraphFallback: canFallbackToTheGraph({ - namespace: config.namespace, - // 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 ? "" : undefined, - isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible, - }), - ensIndexerPublicConfig: config.ensIndexerPublicConfig, - }; -} diff --git a/apps/ensapi/src/config/environment.ts b/apps/ensapi/src/config/environment.ts index 119490fdf..778745822 100644 --- a/apps/ensapi/src/config/environment.ts +++ b/apps/ensapi/src/config/environment.ts @@ -15,8 +15,9 @@ import type { * their state in `process.env`. This interface is intended to be the source type which then gets * mapped/parsed into a structured configuration object like `EnsApiConfig`. */ -export type EnsApiEnvironment = Omit & - EnsIndexerUrlEnvironment & +export type EnsApiEnvironment = DatabaseEnvironment & { + NAMESPACE?: string; +} & EnsIndexerUrlEnvironment & RpcEnvironment & PortEnvironment & LogLevelEnvironment & diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts index 96e65d0c9..776eb5755 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/ensnode-api.ts @@ -1,15 +1,13 @@ -import config from "@/config"; - import { EnsApiIndexingStatusResponseCodes, type EnsApiIndexingStatusResponseError, type EnsApiIndexingStatusResponseOk, - serializeENSApiPublicConfig, serializeEnsApiIndexingStatusResponse, + serializeEnsApiPublicConfig, } from "@ensnode/ensnode-sdk"; -import { buildEnsApiPublicConfig } from "@/config/config.schema"; import { createApp } from "@/lib/hono-factory"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { getConfigRoute, getIndexingStatusRoute } from "./ensnode-api.routes"; import ensnodeGraphQLApi from "./ensnode-graphql-api"; @@ -20,8 +18,8 @@ import resolutionApi from "./resolution-api"; const app = createApp(); app.openapi(getConfigRoute, async (c) => { - const ensApiPublicConfig = buildEnsApiPublicConfig(config); - return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); + const ensApiPublicConfig = await publicConfigBuilder.getPublicConfig(); + return c.json(serializeEnsApiPublicConfig(ensApiPublicConfig)); }); app.openapi(getIndexingStatusRoute, async (c) => { diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts index 888d9b645..50070b872 100644 --- a/apps/ensapi/src/handlers/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/name-tokens-api.ts @@ -17,17 +17,13 @@ import { 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 { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; import { getNameTokensRoute } from "./name-tokens-api.routes"; const app = createApp(); -const indexedSubregistries = getIndexedSubregistries( - config.namespace, - config.ensIndexerPublicConfig.plugins as PluginName[], -); - // 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. @@ -98,6 +94,12 @@ app.openapi(getNameTokensRoute, async (c) => { } const parentNode = namehash(getParentNameFQDN(name)); + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + + const indexedSubregistries = getIndexedSubregistries( + config.namespace, + ensIndexerPublicConfig.plugins as PluginName[], + ); const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode); // Return 404 response with error code for Name Tokens Not Indexed when diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 87fdcb2a1..b8bfde271 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -15,6 +15,7 @@ import { errorResponse } from "@/lib/handlers/error-response"; import { factory } from "@/lib/hono-factory"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { generateOpenApi31Document } from "@/openapi-document"; @@ -84,7 +85,17 @@ app.get("/openapi.json", (c) => { // will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { - return c.json({ message: "fallback ok" }); + try { + // ENSApi is healthy when its Public Config can be successfully built. + await publicConfigBuilder.getPublicConfig(); + + return c.json({ message: "fallback ok" }); + } catch (error) { + logger.error(error, "Health check failed: unable to get ENSApi Public Config"); + + // Report unhealthy status + return errorResponse(c, "Service Unavailable", 503); + } }); // log hono errors to console diff --git a/apps/ensapi/src/lib/ensdb-client/drizzle.ts b/apps/ensapi/src/lib/ensdb-client/drizzle.ts new file mode 100644 index 000000000..b5cc9b5c7 --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/drizzle.ts @@ -0,0 +1,26 @@ +// This file is based on `packages/ponder-subgraph/src/drizzle.ts` file. +// We currently duplicate the makeDrizzle function, as we don't have +// a shared package for backend code yet. When we do, we can move +// this function to the shared package and import it in both places. +import { setDatabaseSchema } from "@ponder/client"; +import { drizzle } from "drizzle-orm/node-postgres"; + +type Schema = { [name: string]: unknown }; + +/** + * Makes a Drizzle DB object. + */ +export const makeDrizzle = ({ + schema, + databaseUrl, + databaseSchema, +}: { + schema: SCHEMA; + databaseUrl: string; + databaseSchema: string; +}) => { + // monkeypatch schema onto tables + setDatabaseSchema(schema, databaseSchema); + + return drizzle(databaseUrl, { schema, casing: "snake_case" }); +}; diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.mock.ts b/apps/ensapi/src/lib/ensdb-client/ensdb-client.mock.ts new file mode 100644 index 000000000..7387cc19f --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/ensdb-client.mock.ts @@ -0,0 +1,72 @@ +import { + type BlockRef, + ChainIndexingStatusIds, + CrossChainIndexingStrategyIds, + type EnsIndexerPublicConfig, + OmnichainIndexingStatusIds, + PluginName, + RangeTypeIds, + type SerializedCrossChainIndexingStatusSnapshot, +} from "@ensnode/ensnode-sdk"; + +export const earlierBlockRef = { + timestamp: 1672531199, + number: 1024, +} as const satisfies BlockRef; + +export const laterBlockRef = { + timestamp: 1672531200, + number: 1025, +} as const satisfies BlockRef; + +export const databaseUrl = "postgres://user:pass@localhost:5432/ensdb"; + +export const databaseSchemaName = "public"; + +export const publicConfig = { + databaseSchemaName, + ensRainbowPublicConfig: { + version: "0.32.0", + labelSet: { + labelSetId: "subgraph", + highestLabelSetVersion: 0, + }, + recordsCount: 100, + }, + labelSet: { + labelSetId: "subgraph", + labelSetVersion: 0, + }, + indexedChainIds: new Set([1]), + isSubgraphCompatible: true, + namespace: "mainnet", + plugins: [PluginName.Subgraph], + versionInfo: { + nodejs: "v22.10.12", + ponder: "0.11.25", + ensDb: "0.32.0", + ensIndexer: "0.32.0", + ensNormalize: "1.11.1", + }, +} satisfies EnsIndexerPublicConfig; + +export const serializedSnapshot = { + strategy: CrossChainIndexingStrategyIds.Omnichain, + slowestChainIndexingCursor: earlierBlockRef.timestamp, + snapshotTime: earlierBlockRef.timestamp + 20, + omnichainSnapshot: { + omnichainStatus: OmnichainIndexingStatusIds.Following, + chains: { + "1": { + chainStatus: ChainIndexingStatusIds.Following, + config: { + rangeType: RangeTypeIds.LeftBounded, + startBlock: earlierBlockRef, + }, + latestIndexedBlock: earlierBlockRef, + latestKnownBlock: laterBlockRef, + }, + }, + omnichainIndexingCursor: earlierBlockRef.timestamp, + }, +} satisfies SerializedCrossChainIndexingStatusSnapshot; diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.test.ts b/apps/ensapi/src/lib/ensdb-client/ensdb-client.test.ts new file mode 100644 index 000000000..69271d6b0 --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/ensdb-client.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ensNodeMetadata } from "@ensnode/ensnode-schema"; +import { + deserializeCrossChainIndexingStatusSnapshot, + serializeEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { makeDrizzle } from "./drizzle"; +import { EnsDbClient } from "./ensdb-client"; +import * as ensDbClientMock from "./ensdb-client.mock"; + +// Mock the config module to prevent it from trying to load actual environment variables during tests +vi.mock("@/config", () => ({ default: {} })); + +// Mock the makeDrizzle function to return a mock database instance +vi.mock("./drizzle", () => ({ makeDrizzle: vi.fn() })); + +describe("EnsDbClient", () => { + // Mock database query results and methods + const selectResult = { current: [] as Array<{ value: unknown }> }; + const whereMock = vi.fn(async () => selectResult.current); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn(() => ({ from: fromMock })); + const onConflictDoUpdateMock = vi.fn(async () => undefined); + const valuesMock = vi.fn(() => ({ onConflictDoUpdate: onConflictDoUpdateMock })); + const insertMock = vi.fn(() => ({ values: valuesMock })); + const executeMock = vi.fn(async () => undefined); + const dbMock = { select: selectMock, insert: insertMock }; + + beforeEach(() => { + selectResult.current = []; + whereMock.mockClear(); + fromMock.mockClear(); + selectMock.mockClear(); + onConflictDoUpdateMock.mockClear(); + valuesMock.mockClear(); + insertMock.mockClear(); + executeMock.mockClear(); + vi.mocked(makeDrizzle).mockReturnValue(dbMock as unknown as ReturnType); + }); + + describe("getEnsDbVersion", () => { + it("returns undefined when no record exists", async () => { + // arrange + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + + // act & assert + await expect(client.getEnsDbVersion()).resolves.toBeUndefined(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(fromMock).toHaveBeenCalledWith(ensNodeMetadata); + }); + + it("returns value when one record exists", async () => { + // arrange + selectResult.current = [{ value: "0.1.0" }]; + + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + + // act & assert + await expect(client.getEnsDbVersion()).resolves.toBe("0.1.0"); + }); + + // This scenario should be impossible due to the primary key constraint on + // the 'key' column of 'ensnode_metadata' table. + it("throws when multiple records exist", async () => { + // arrange + selectResult.current = [{ value: "0.1.0" }, { value: "0.1.1" }]; + + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + + // act & assert + await expect(client.getEnsDbVersion()).rejects.toThrowError(/ensdb_version/i); + }); + }); + + describe("getEnsIndexerPublicConfig", () => { + it("returns undefined when no record exists", async () => { + // arrange + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + + // act & assert + await expect(client.getEnsIndexerPublicConfig()).resolves.toBeUndefined(); + }); + + it("deserializes the stored config", async () => { + // arrange + const serializedConfig = serializeEnsIndexerPublicConfig(ensDbClientMock.publicConfig); + selectResult.current = [{ value: serializedConfig }]; + + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + + // act & assert + await expect(client.getEnsIndexerPublicConfig()).resolves.toStrictEqual( + ensDbClientMock.publicConfig, + ); + }); + }); + + describe("getIndexingStatusSnapshot", () => { + it("deserializes the stored indexing status snapshot", async () => { + // arrange + selectResult.current = [{ value: ensDbClientMock.serializedSnapshot }]; + + const client = new EnsDbClient( + ensDbClientMock.databaseUrl, + ensDbClientMock.databaseSchemaName, + ); + const expected = deserializeCrossChainIndexingStatusSnapshot( + ensDbClientMock.serializedSnapshot, + ); + + // act & assert + await expect(client.getIndexingStatusSnapshot()).resolves.toStrictEqual(expected); + }); + }); +}); diff --git a/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts b/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts new file mode 100644 index 000000000..e6e28a772 --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/ensdb-client.ts @@ -0,0 +1,133 @@ +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm/sql"; + +import { ensNodeMetadata } from "@ensnode/ensnode-schema"; +import { + type CrossChainIndexingStatusSnapshot, + deserializeCrossChainIndexingStatusSnapshot, + deserializeEnsIndexerPublicConfig, + type EnsDbClientQuery, + type EnsIndexerPublicConfig, + EnsNodeMetadataKeys, + type SerializedEnsNodeMetadata, + type SerializedEnsNodeMetadataEnsDbVersion, + type SerializedEnsNodeMetadataEnsIndexerIndexingStatus, + type SerializedEnsNodeMetadataEnsIndexerPublicConfig, +} from "@ensnode/ensnode-sdk"; + +import { makeDrizzle } from "./drizzle"; + +/** + * ENSDb Client Schema + * + * Includes schema definitions for {@link EnsDbClient} queries and mutations. + */ +const schema = { + ensNodeMetadata, +}; + +/** + * Drizzle database + * + * Allows interacting with Postgres database for ENSDb, using Drizzle ORM. + */ +interface DrizzleDb extends NodePgDatabase {} + +/** + * ENSDb Client + * + * This client exists to provide an abstraction layer for interacting with ENSDb. + * It enables ENSIndexer and ENSApi to decouple from each other, and use + * ENSDb as the integration point between the two (via ENSDb Client). + * + * Enables querying ENSDb data, such as: + * - ENSDb version + * - ENSIndexer Public Config, and Indexing Status Snapshot and CrossChainIndexingStatusSnapshot. + */ +export class EnsDbClient implements EnsDbClientQuery { + /** + * Drizzle database instance for ENSDb. + */ + private db: DrizzleDb; + + /** + * @param databaseUrl connection string for ENSDb Postgres database + * @param databaseSchemaName Postgres schema name for ENSDb tables + */ + constructor(databaseUrl: string, databaseSchemaName: string) { + this.db = makeDrizzle({ + databaseSchema: databaseSchemaName, + databaseUrl, + schema, + }); + } + + /** + * @inheritdoc + */ + async getEnsDbVersion(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsDbVersion, + }); + + return record; + } + + /** + * @inheritdoc + */ + async getEnsIndexerPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, + }); + + if (!record) { + return undefined; + } + + return deserializeEnsIndexerPublicConfig(record); + } + + /** + * @inheritdoc + */ + async getIndexingStatusSnapshot(): Promise { + const record = await this.getEnsNodeMetadata( + { + key: EnsNodeMetadataKeys.EnsIndexerIndexingStatus, + }, + ); + + if (!record) { + return undefined; + } + + return deserializeCrossChainIndexingStatusSnapshot(record); + } + + /** + * Get ENSNode metadata record + * + * @returns selected record in ENSDb. + * @throws when more than one matching metadata record is found + * (should be impossible given the PK constraint on 'key') + */ + private async getEnsNodeMetadata( + metadata: Pick, + ): Promise { + const result = await this.db + .select() + .from(ensNodeMetadata) + .where(eq(ensNodeMetadata.key, metadata.key)); + + if (result.length === 0) { + return undefined; + } + + if (result.length === 1 && result[0]) { + return result[0].value as EnsNodeMetadataType["value"]; + } + + throw new Error(`There must be exactly one ENSNodeMetadata record for '${metadata.key}' key`); + } +} diff --git a/apps/ensapi/src/lib/ensdb-client/singleton.ts b/apps/ensapi/src/lib/ensdb-client/singleton.ts new file mode 100644 index 000000000..5b2d74df3 --- /dev/null +++ b/apps/ensapi/src/lib/ensdb-client/singleton.ts @@ -0,0 +1,8 @@ +import config from "@/config"; + +import { EnsDbClient } from "./ensdb-client"; + +/** + * Singleton instance of {@link EnsDbClient} for use in ENSApi. + */ +export const ensDbClient = new EnsDbClient(config.databaseUrl, config.databaseSchemaName); diff --git a/apps/ensapi/src/lib/public-config-builder/index.ts b/apps/ensapi/src/lib/public-config-builder/index.ts new file mode 100644 index 000000000..645a128e5 --- /dev/null +++ b/apps/ensapi/src/lib/public-config-builder/index.ts @@ -0,0 +1 @@ +export * from "./public-config-builder"; diff --git a/apps/ensapi/src/lib/public-config-builder/public-config-builder.test.ts b/apps/ensapi/src/lib/public-config-builder/public-config-builder.test.ts new file mode 100644 index 000000000..2ea7a6153 --- /dev/null +++ b/apps/ensapi/src/lib/public-config-builder/public-config-builder.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ENSNamespaceIds, + type EnsApiPublicConfig, + type EnsDbClientQuery, + type EnsIndexerPublicConfig, + type EnsIndexerVersionInfo, + type EnsRainbowPublicConfig, + PluginName, + type TheGraphFallback, +} from "@ensnode/ensnode-sdk"; + +import { PublicConfigBuilder } from "./public-config-builder"; + +// Mock the config module +vi.mock("@/config", () => ({ + default: { + version: "1.0.0", + namespace: ENSNamespaceIds.Mainnet, + theGraphApiKey: "test-api-key", + }, +})); + +// Mock the SDK validation functions and canFallbackToTheGraph +vi.mock("@ensnode/ensnode-sdk", async () => { + const actual = + await vi.importActual("@ensnode/ensnode-sdk"); + return { + ...actual, + validateEnsApiPublicConfig: vi.fn(), + }; +}); + +vi.mock("@ensnode/ensnode-sdk/internal", async () => { + const actual = await vi.importActual( + "@ensnode/ensnode-sdk/internal", + ); + return { + ...actual, + canFallbackToTheGraph: vi.fn(), + }; +}); + +import config from "@/config"; + +import { validateEnsApiPublicConfig } from "@ensnode/ensnode-sdk"; +import { canFallbackToTheGraph } from "@ensnode/ensnode-sdk/internal"; + +// Test fixtures +const mockEnsRainbowConfig: EnsRainbowPublicConfig = { + version: "1.0.0", + labelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 }, + recordsCount: 1000, +}; + +const mockVersionInfo: EnsIndexerVersionInfo = { + nodejs: "v20.0.0", + ponder: "0.9.0", + ensDb: "1.0.0", + ensIndexer: "1.0.0", + ensNormalize: "1.10.0", +}; + +const mockEnsIndexerPublicConfig: EnsIndexerPublicConfig = { + databaseSchemaName: "public", + labelSet: { labelSetId: "subgraph", labelSetVersion: 0 }, + ensRainbowPublicConfig: mockEnsRainbowConfig, + indexedChainIds: new Set([1, 8453]), + isSubgraphCompatible: true, + namespace: ENSNamespaceIds.Mainnet, + plugins: [PluginName.Subgraph], + versionInfo: mockVersionInfo, +}; + +const theGraphFallback: TheGraphFallback = { + canFallback: true, + url: "https://gateway.thegraph.com/api/wellhereisthekey/subgraphs/id/5XqPmWe6gjyrJtFn9cLy237i4cWw2j9HcUJEXsP5qGtH", +}; + +const mockEnsApiPublicConfig: EnsApiPublicConfig = { + version: "1.0.0", + theGraphFallback, + ensIndexerPublicConfig: mockEnsIndexerPublicConfig, +}; + +describe("PublicConfigBuilder", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getPublicConfig()", () => { + let ensDbClientMock: EnsDbClientQuery; + + beforeEach(() => { + ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + } as unknown as EnsDbClientQuery; + + vi.mocked(canFallbackToTheGraph).mockReturnValue(theGraphFallback); + vi.mocked(validateEnsApiPublicConfig).mockReturnValue(mockEnsApiPublicConfig); + }); + + it("builds public config on first call", async () => { + const builder = new PublicConfigBuilder(ensDbClientMock); + + const result = await builder.getPublicConfig(); + + expect(ensDbClientMock.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + expect(canFallbackToTheGraph).toHaveBeenCalledWith({ + namespace: config.namespace, + theGraphApiKey: "", + isSubgraphCompatible: mockEnsIndexerPublicConfig.isSubgraphCompatible, + }); + expect(validateEnsApiPublicConfig).toHaveBeenCalledWith({ + version: config.version, + theGraphFallback, + ensIndexerPublicConfig: mockEnsIndexerPublicConfig, + }); + expect(result).toBe(mockEnsApiPublicConfig); + }); + + it("caches config and returns same reference on subsequent calls", async () => { + const builder = new PublicConfigBuilder(ensDbClientMock); + + const result1 = await builder.getPublicConfig(); + const result2 = await builder.getPublicConfig(); + + expect(ensDbClientMock.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(1); + expect(canFallbackToTheGraph).toHaveBeenCalledTimes(1); + expect(validateEnsApiPublicConfig).toHaveBeenCalledTimes(1); + expect(result1).toBe(result2); + }); + + it("masks the API key in theGraphFallback", async () => { + await new PublicConfigBuilder(ensDbClientMock).getPublicConfig(); + + expect(canFallbackToTheGraph).toHaveBeenCalledWith( + expect.objectContaining({ + theGraphApiKey: "", + }), + ); + }); + + it("passes isSubgraphCompatible from ENSIndexer config", async () => { + const nonSubgraphConfig: EnsIndexerPublicConfig = { + ...mockEnsIndexerPublicConfig, + isSubgraphCompatible: false, + }; + vi.mocked(ensDbClientMock.getEnsIndexerPublicConfig).mockResolvedValue(nonSubgraphConfig); + + const builder = new PublicConfigBuilder(ensDbClientMock); + await builder.getPublicConfig(); + + expect(canFallbackToTheGraph).toHaveBeenCalledWith( + expect.objectContaining({ + isSubgraphCompatible: false, + }), + ); + }); + + describe("error handling", () => { + it("throws when ENSIndexer config is undefined", async () => { + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(undefined), + } as unknown as EnsDbClientQuery; + + const builder = new PublicConfigBuilder(ensDbClientMock); + + await expect(builder.getPublicConfig()).rejects.toThrow( + "ENSDb must contain an ENSIndexer Public Config", + ); + expect(canFallbackToTheGraph).not.toHaveBeenCalled(); + expect(validateEnsApiPublicConfig).not.toHaveBeenCalled(); + }); + + it("propagates ENSDb client errors", async () => { + const dbError = new Error("Database connection failed"); + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockRejectedValue(dbError), + } as unknown as EnsDbClientQuery; + + const builder = new PublicConfigBuilder(ensDbClientMock); + + await expect(builder.getPublicConfig()).rejects.toThrow(dbError); + }); + + it("propagates canFallbackToTheGraph errors", async () => { + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + } as unknown as EnsDbClientQuery; + + const fallbackError = new Error("Invalid namespace"); + vi.mocked(canFallbackToTheGraph).mockImplementation(() => { + throw fallbackError; + }); + + const builder = new PublicConfigBuilder(ensDbClientMock); + + await expect(builder.getPublicConfig()).rejects.toThrow(fallbackError); + expect(validateEnsApiPublicConfig).not.toHaveBeenCalled(); + }); + + it("propagates validation errors", async () => { + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + } as unknown as EnsDbClientQuery; + + vi.mocked(canFallbackToTheGraph).mockReturnValue(theGraphFallback); + + const validationError = new Error("Invalid config"); + vi.mocked(validateEnsApiPublicConfig).mockImplementation(() => { + throw validationError; + }); + + const builder = new PublicConfigBuilder(ensDbClientMock); + + await expect(builder.getPublicConfig()).rejects.toThrow(validationError); + }); + }); + }); + + describe("caching", () => { + it("each instance has independent cache", async () => { + const config1: EnsApiPublicConfig = { ...mockEnsApiPublicConfig, version: "1.0.0" }; + const config2: EnsApiPublicConfig = { ...mockEnsApiPublicConfig, version: "2.0.0" }; + + let callCount = 0; + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi.fn().mockResolvedValue(mockEnsIndexerPublicConfig), + } as unknown as EnsDbClientQuery; + + vi.mocked(validateEnsApiPublicConfig).mockImplementation(() => { + return ++callCount === 1 ? config1 : config2; + }); + + const builder1 = new PublicConfigBuilder(ensDbClientMock); + const result1 = await builder1.getPublicConfig(); + + const builder2 = new PublicConfigBuilder(ensDbClientMock); + const result2 = await builder2.getPublicConfig(); + + expect(ensDbClientMock.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(2); + expect(result1).toBe(config1); + expect(result2).toBe(config2); + }); + + it("retries after failure", async () => { + const ensDbClientMock = { + getEnsIndexerPublicConfig: vi + .fn() + .mockRejectedValueOnce(new Error("DB down")) + .mockResolvedValueOnce(mockEnsIndexerPublicConfig), + } as unknown as EnsDbClientQuery; + + vi.mocked(canFallbackToTheGraph).mockReturnValue(theGraphFallback); + vi.mocked(validateEnsApiPublicConfig).mockReturnValue(mockEnsApiPublicConfig); + + const builder = new PublicConfigBuilder(ensDbClientMock); + + await expect(builder.getPublicConfig()).rejects.toThrow("DB down"); + + const result = await builder.getPublicConfig(); + + expect(ensDbClientMock.getEnsIndexerPublicConfig).toHaveBeenCalledTimes(2); + expect(result).toBe(mockEnsApiPublicConfig); + }); + }); +}); diff --git a/apps/ensapi/src/lib/public-config-builder/public-config-builder.ts b/apps/ensapi/src/lib/public-config-builder/public-config-builder.ts new file mode 100644 index 000000000..75a3f5f56 --- /dev/null +++ b/apps/ensapi/src/lib/public-config-builder/public-config-builder.ts @@ -0,0 +1,67 @@ +import config from "@/config"; + +import { + type EnsApiPublicConfig, + type EnsDbClientQuery, + validateEnsApiPublicConfig, +} from "@ensnode/ensnode-sdk"; +import { canFallbackToTheGraph } from "@ensnode/ensnode-sdk/internal"; + +export class PublicConfigBuilder { + /** + * ENSDb Client + * + * Used to fetch the ENSIndexer Public Config from ENSDb. + */ + private ensDbClient: EnsDbClientQuery; + + /** + * Immutable ENSApi Public Config + * + * The cached ENSApi Public Config object, which is built and validated + * on the first call to `getPublicConfig()`, and returned as-is on subsequent calls. + */ + private immutablePublicConfig: EnsApiPublicConfig | undefined; + + /** + * @param ensDbClient ENSDb Client instance used to fetch ENSDb Public Config + */ + constructor(ensDbClient: EnsDbClientQuery) { + this.ensDbClient = ensDbClient; + } + + /** + * Get ENSApi Public Config + * + * Note: ENSApi Public Config is cached after the first call, so + * subsequent calls will return the cached version without rebuilding it. + * + * @throws if the built ENSApi Public Config does not conform to + * the expected schema + */ + async getPublicConfig(): Promise { + if (typeof this.immutablePublicConfig === "undefined") { + const ensIndexerPublicConfig = await this.ensDbClient.getEnsIndexerPublicConfig(); + + // Invariant: the ENSIndexer Public Config is guaranteed to be available in ENSDb + if (typeof ensIndexerPublicConfig === "undefined") { + throw new Error("ENSDb must contain an ENSIndexer Public Config"); + } + + this.immutablePublicConfig = validateEnsApiPublicConfig({ + version: config.version, + theGraphFallback: canFallbackToTheGraph({ + namespace: config.namespace, + // 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 ? "" : undefined, + isSubgraphCompatible: ensIndexerPublicConfig.isSubgraphCompatible, + }), + ensIndexerPublicConfig, + }); + } + + return this.immutablePublicConfig; + } +} diff --git a/apps/ensapi/src/lib/public-config-builder/singleton.ts b/apps/ensapi/src/lib/public-config-builder/singleton.ts new file mode 100644 index 000000000..dfa7820c6 --- /dev/null +++ b/apps/ensapi/src/lib/public-config-builder/singleton.ts @@ -0,0 +1,4 @@ +import { ensDbClient } from "@/lib/ensdb-client/singleton"; +import { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; + +export const publicConfigBuilder = new PublicConfigBuilder(ensDbClient); diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index e305f597a..a912b5ec9 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -34,6 +34,7 @@ import { getENSIP19ReverseNameRecordFromIndex } from "@/lib/protocol-acceleratio import { getRecordsFromIndex } from "@/lib/protocol-acceleration/get-records-from-index"; import { areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId } from "@/lib/protocol-acceleration/resolver-records-indexed-on-chain"; import { getPublicClient } from "@/lib/public-client"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { makeEmptyResolverRecordsResponse, makeRecordsResponseFromIndexedRecords, @@ -163,12 +164,13 @@ async function _resolveForward( } const publicClient = getPublicClient(chainId); + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); //////////////////////////// /// Temporary ENSv2 Bailout //////////////////////////// // TODO: re-enable protocol acceleration for ENSv2 - if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + if (ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { // execute each record's call against the UniversalResolverV2 const rawResults = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, diff --git a/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts b/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts index 40323fcc6..f1aeec04b 100644 --- a/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts +++ b/apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { ChainIndexingStatusIds, getENSRootChainId } from "@ensnode/ensnode-sdk"; import type { SubgraphMeta } from "@ensnode/ponder-subgraph"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; /** @@ -16,9 +17,9 @@ import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-st * @param indexingStatus - The indexing context from the indexing status middleware * @returns SubgraphMeta object or null if conversion is not possible */ -export function indexingContextToSubgraphMeta( +export async function indexingContextToSubgraphMeta( indexingStatus: IndexingStatusMiddlewareVariables["indexingStatus"], -): SubgraphMeta { +): Promise { // indexing status middleware has never successfully fetched (and cached) an indexing status snapshot // for the lifetime of this service instance. if (indexingStatus instanceof Error) return null; @@ -35,8 +36,9 @@ export function indexingContextToSubgraphMeta( case ChainIndexingStatusIds.Completed: case ChainIndexingStatusIds.Backfill: case ChainIndexingStatusIds.Following: { + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); return { - deployment: config.ensIndexerPublicConfig.versionInfo.ensIndexer, + deployment: ensIndexerPublicConfig.versionInfo.ensIndexer, hasIndexingErrors: false, block: { hash: null, diff --git a/apps/ensapi/src/middleware/can-accelerate.middleware.ts b/apps/ensapi/src/middleware/can-accelerate.middleware.ts index 9aee7188b..b4bfb0206 100644 --- a/apps/ensapi/src/middleware/can-accelerate.middleware.ts +++ b/apps/ensapi/src/middleware/can-accelerate.middleware.ts @@ -1,9 +1,8 @@ -import config from "@/config"; - import { PluginName } from "@ensnode/ensnode-sdk"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; const logger = makeLogger("can-accelerate.middleware"); @@ -29,11 +28,13 @@ export const canAccelerateMiddleware = factory.createMiddleware(async (c, next) throw new Error(`Invariant(canAccelerateMiddleware): isRealtime middleware required`); } + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + //////////////////////////// /// Temporary ENSv2 Bailout //////////////////////////// // TODO: re-enable acceleration for ensv2 once implemented - if (config.ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { + if (ensIndexerPublicConfig.plugins.includes(PluginName.ENSv2)) { if (!didWarnCannotAccelerateENSv2) { logger.warn( `ENSApi is currently unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, @@ -50,7 +51,7 @@ export const canAccelerateMiddleware = factory.createMiddleware(async (c, next) /// Protocol Acceleration Plugin Availability ////////////////////////////////////////////// - const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( + const hasProtocolAccelerationPlugin = ensIndexerPublicConfig.plugins.includes( PluginName.ProtocolAcceleration, ); diff --git a/apps/ensapi/src/middleware/name-tokens.middleware.ts b/apps/ensapi/src/middleware/name-tokens.middleware.ts index 8f78643e7..7dc3fb5bf 100644 --- a/apps/ensapi/src/middleware/name-tokens.middleware.ts +++ b/apps/ensapi/src/middleware/name-tokens.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { NameTokensResponseCodes, NameTokensResponseErrorCodes, @@ -9,6 +7,7 @@ import { import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; const logger = makeLogger("name-tokens.middleware"); @@ -36,7 +35,9 @@ export const nameTokensApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(name-tokens.middleware): indexingStatusMiddleware required`); } - if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + + if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(ensIndexerPublicConfig)) { return c.json( serializeNameTokensResponse({ responseCode: NameTokensResponseCodes.Error, diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index fc43df3d3..9b4ed820f 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasRegistrarActionsConfigSupport, hasRegistrarActionsIndexingStatusSupport, @@ -9,6 +7,7 @@ import { import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; const logger = makeLogger("registrar-actions.middleware"); @@ -35,7 +34,9 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(registrar-actions.middleware): indexingStatusMiddleware required`); } - const configSupport = hasRegistrarActionsConfigSupport(config.ensIndexerPublicConfig); + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + + const configSupport = hasRegistrarActionsConfigSupport(ensIndexerPublicConfig); if (!configSupport.supported) { return c.json( serializeRegistrarActionsResponse({ diff --git a/apps/ensapi/src/middleware/require-core-plugin.middleware.ts b/apps/ensapi/src/middleware/require-core-plugin.middleware.ts index 7b066646e..997763db7 100644 --- a/apps/ensapi/src/middleware/require-core-plugin.middleware.ts +++ b/apps/ensapi/src/middleware/require-core-plugin.middleware.ts @@ -1,8 +1,7 @@ -import config from "@/config"; - import { hasGraphqlApiConfigSupport, hasSubgraphApiConfigSupport } from "@ensnode/ensnode-sdk"; import { factory } from "@/lib/hono-factory"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; /** * Creates middleware that requires a specific core plugin to be enabled in ENSIndexer. @@ -14,12 +13,14 @@ import { factory } from "@/lib/hono-factory"; */ export const requireCorePluginMiddleware = (core: "subgraph" | "ensv2") => factory.createMiddleware(async (c, next) => { - const subgraph = hasSubgraphApiConfigSupport(config.ensIndexerPublicConfig); + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + + const subgraph = hasSubgraphApiConfigSupport(ensIndexerPublicConfig); if (core === "subgraph" && !subgraph.supported) { return c.text(`Service Unavailable: ${subgraph.reason}`, 503); } - const graphql = hasGraphqlApiConfigSupport(config.ensIndexerPublicConfig); + const graphql = hasGraphqlApiConfigSupport(ensIndexerPublicConfig); if (core === "ensv2" && !graphql.supported) { return c.text(`Service Unavailable: ${graphql.reason}`, 503); } diff --git a/apps/ensapi/src/middleware/subgraph-meta.middleware.ts b/apps/ensapi/src/middleware/subgraph-meta.middleware.ts index 0d60d4864..726b74db3 100644 --- a/apps/ensapi/src/middleware/subgraph-meta.middleware.ts +++ b/apps/ensapi/src/middleware/subgraph-meta.middleware.ts @@ -20,6 +20,6 @@ export const subgraphMetaMiddleware = createMiddleware<{ throw new Error(`Invariant(subgraphMetaMiddleware): indexingStatusMiddleware required`); } - c.set("_meta", indexingContextToSubgraphMeta(c.var.indexingStatus)); + c.set("_meta", await indexingContextToSubgraphMeta(c.var.indexingStatus)); await next(); }); diff --git a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts index 0cac9478a..3ff173772 100644 --- a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts +++ b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts @@ -6,6 +6,7 @@ import { canFallbackToTheGraph } from "@ensnode/ensnode-sdk/internal"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; const logger = makeLogger("thegraph-fallback.middleware"); @@ -25,10 +26,12 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex throw new Error(`Invariant(thegraphFallbackMiddleware): isRealtimeMiddleware expected`); } + const { ensIndexerPublicConfig } = await publicConfigBuilder.getPublicConfig(); + const fallback = canFallbackToTheGraph({ namespace: config.namespace, theGraphApiKey: config.theGraphApiKey, - isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible, + isSubgraphCompatible: ensIndexerPublicConfig.isSubgraphCompatible, }); // log one warning to the console if !canFallback diff --git a/apps/ensindexer/src/lib/version-info.ts b/apps/ensindexer/src/lib/version-info.ts index c13100735..8f47997d7 100644 --- a/apps/ensindexer/src/lib/version-info.ts +++ b/apps/ensindexer/src/lib/version-info.ts @@ -4,11 +4,6 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { prettifyError } from "zod/v4"; - -import type { ENSIndexerVersionInfo, SerializedENSIndexerVersionInfo } from "@ensnode/ensnode-sdk"; -import { makeENSIndexerVersionInfoSchema } from "@ensnode/ensnode-sdk/internal"; - /** * Get ENSIndexer version */ diff --git a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts index ae15afb0c..e9e34b4d5 100644 --- a/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts +++ b/packages/ensnode-sdk/src/ensapi/config/conversions.test.ts @@ -84,11 +84,11 @@ describe("ENSApi Config Serialization/Deserialization", () => { it("handles validation errors with custom value label", () => { const invalidConfig = { ...MOCK_SERIALIZED_ENSAPI_PUBLIC_CONFIG, - version: "", // Invalid: empty string + ensIndexerPublicConfig: {}, }; expect(() => deserializeEnsApiPublicConfig(invalidConfig, "testConfig")).toThrow( - /testConfig.version/, + /testConfig.ensIndexerPublicConfig.databaseSchemaName/, ); }); }); diff --git a/packages/ensnode-sdk/src/ensapi/config/index.ts b/packages/ensnode-sdk/src/ensapi/config/index.ts index bff2897b5..0d2f07add 100644 --- a/packages/ensnode-sdk/src/ensapi/config/index.ts +++ b/packages/ensnode-sdk/src/ensapi/config/index.ts @@ -2,4 +2,5 @@ export * from "./deserialize"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; +export * from "./validate/ensapi-public-config"; export * from "./zod-schemas"; diff --git a/packages/ensnode-sdk/src/ensapi/config/validate/ensapi-public-config.ts b/packages/ensnode-sdk/src/ensapi/config/validate/ensapi-public-config.ts new file mode 100644 index 000000000..a26270c3b --- /dev/null +++ b/packages/ensnode-sdk/src/ensapi/config/validate/ensapi-public-config.ts @@ -0,0 +1,25 @@ +import { prettifyError } from "zod/v4"; + +import type { Unvalidated } from "../../../shared/types"; +import type { EnsApiPublicConfig } from "../types"; +import { makeEnsApiPublicConfigSchema } from "../zod-schemas"; + +/** + * Validates an unvalidated representation of + * {@link EnsApiPublicConfig} object. + * + * @throws Error if the provided object is not + * a valid {@link EnsApiPublicConfig}. + */ +export function validateEnsApiPublicConfig( + unvalidatedConfig: Unvalidated, +): EnsApiPublicConfig { + const schema = makeEnsApiPublicConfigSchema(); + const result = schema.safeParse(unvalidatedConfig); + + if (!result.success) { + throw new Error(`Invalid EnsApiPublicConfig: ${prettifyError(result.error)}`); + } + + return result.data; +} diff --git a/packages/ensnode-sdk/src/ensapi/config/zod-schemas.ts b/packages/ensnode-sdk/src/ensapi/config/zod-schemas.ts index 7ed65cfcc..e438d51ae 100644 --- a/packages/ensnode-sdk/src/ensapi/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensapi/config/zod-schemas.ts @@ -11,6 +11,9 @@ import { export { TheGraphCannotFallbackReasonSchema, TheGraphFallbackSchema }; +export const makeEnsApiVersionSchema = () => + z.string().min(1, `ENSApi version must be a non-empty string`); + /** * Create a Zod schema for validating ENSApiPublicConfig. * @@ -20,7 +23,7 @@ export function makeEnsApiPublicConfigSchema(valueLabel?: string) { const label = valueLabel ?? "ENSApiPublicConfig"; return z.object({ - version: z.string().min(1, `${label}.version must be a non-empty string`), + version: makeEnsApiVersionSchema(), theGraphFallback: TheGraphFallbackSchema, ensIndexerPublicConfig: makeEnsIndexerPublicConfigSchema(`${label}.ensIndexerPublicConfig`), }); @@ -37,7 +40,7 @@ export function makeSerializedEnsApiPublicConfigSchema(valueLabel?: string) { const label = valueLabel ?? "ENSApiPublicConfig"; return z.object({ - version: z.string().min(1, `${label}.version must be a non-empty string`), + version: makeEnsApiVersionSchema(), theGraphFallback: TheGraphFallbackSchema, ensIndexerPublicConfig: makeSerializedEnsIndexerPublicConfigSchema( `${label}.ensIndexerPublicConfig`, diff --git a/terraform/modules/ensindexer/main.tf b/terraform/modules/ensindexer/main.tf index 1c5d81204..43740ff76 100644 --- a/terraform/modules/ensindexer/main.tf +++ b/terraform/modules/ensindexer/main.tf @@ -2,6 +2,8 @@ locals { common_variables = { # Common configuration "DATABASE_URL" = { value = var.ensdb_url }, + "DATABASE_SCHEMA" = { value = var.database_schema }, + "NAMESPACE" = { value = var.namespace }, "ALCHEMY_API_KEY" = { value = var.alchemy_api_key } "QUICKNODE_API_KEY" = { value = var.quicknode_api_key } "QUICKNODE_ENDPOINT_NAME" = { value = var.quicknode_endpoint_name } @@ -28,12 +30,10 @@ resource "render_web_service" "ensindexer" { } env_vars = merge(local.common_variables, { - "DATABASE_SCHEMA" = { value = var.database_schema }, "ENSRAINBOW_URL" = { value = var.ensrainbow_url }, "LABEL_SET_ID" = { value = var.ensindexer_label_set_id }, "LABEL_SET_VERSION" = { value = var.ensindexer_label_set_version }, "PLUGINS" = { value = var.plugins }, - "NAMESPACE" = { value = var.namespace }, "SUBGRAPH_COMPAT" = { value = var.subgraph_compat }, "ENSINDEXER_URL" = { value = "http://ensindexer-${var.ensnode_indexer_type}:10000" } })