From 94966d1abb8db8ab3f6201d141938ba97d0515b5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 18:42:14 +0200 Subject: [PATCH 01/18] Add `peek` method to `SWRCache` class Enables sync access to the currently cached value. --- .../src/shared/cache/swr-cache.test.ts | 51 +++++++++++++++++++ .../ensnode-sdk/src/shared/cache/swr-cache.ts | 25 +++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts index 3545ee8ee7..520eefd215 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.test.ts @@ -903,4 +903,55 @@ describe("SWRCache", () => { cache.destroy(); }); }); + + describe("peek", () => { + it("throws when cache is not initialized", () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + expect(() => cache.peek()).toThrow("Cache is not initialized yet"); + }); + + it("returns cached value without triggering revalidation", async () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + const result = cache.peek(); + expect(result).toBe("value1"); + expect(fn).toHaveBeenCalledTimes(1); // no revalidation triggered + }); + + it("returns cached error when result is an Error", async () => { + const error = new Error("Cached error"); + const fn = vi.fn(async () => { + throw error; + }); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + + const result = cache.peek(); + + expect(result).toBeInstanceOf(Error); + }); + + it("returns stale cached value without revalidating", async () => { + const fn = vi.fn(async () => "value1"); + const cache = new SWRCache({ fn, ttl: 1 }); + + await cache.read(); + expect(fn).toHaveBeenCalledTimes(1); + + // advance past TTL + vi.advanceTimersByTime(2000); + + // peek should return stale value synchronously without triggering revalidation + const result = cache.peek(); + expect(result).toBe("value1"); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts index a7be2064f4..82acb10454 100644 --- a/packages/ensnode-sdk/src/shared/cache/swr-cache.ts +++ b/packages/ensnode-sdk/src/shared/cache/swr-cache.ts @@ -173,6 +173,31 @@ export class SWRCache { return this.cache.result; } + /** + * Synchronously returns the most recently cached result without triggering revalidation. + * + * Unlike {@link read}, this method is synchronous and never causes any I/O — it simply + * returns whatever value is already in memory. Use this when you need a best-effort + * snapshot of the cached value and can tolerate stale data. + * + * @returns the most recently cached `ValueType`, or an `Error` if the last fetch failed. + * @throws if the cache has not been initialized yet (i.e. {@link read} has never been awaited + * and `proactivelyInitialize` was not set to `true`). + * + * @example + * ```ts + * // Ensure cache is initialized first + * await cache.read(); + * + * // Later, peek synchronously without triggering revalidation + * const result = cache.peek(); + * ``` + */ + public peek(): ValueType | Error { + if (!this.cache) throw new Error("Cache is not initialized yet"); + return this.cache.result; + } + /** * Destroys the background revalidation interval, if exists. */ From 3c22345736c7b3704edd37d23a61a924bd241a17 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 19:42:33 +0200 Subject: [PATCH 02/18] Create a global DI container to use across ENSApi codebase --- .../referral-program-edition-set.cache.ts | 2 +- apps/ensapi/src/di.ts | 233 ++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 apps/ensapi/src/di.ts diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index fd30202abc..3329579e7e 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -72,7 +72,7 @@ async function loadReferralProgramEditionConfigSet( } } -type ReferralProgramEditionConfigSetCache = SWRCache; +export type ReferralProgramEditionConfigSetCache = SWRCache; /** * SWR Cache for the referral program edition config set. diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts new file mode 100644 index 0000000000..8d08d3df3e --- /dev/null +++ b/apps/ensapi/src/di.ts @@ -0,0 +1,233 @@ +import ensApiConfig from "@/config"; + +import type { ChainId } from "enssdk"; +import { createPublicClient, fallback, http, type PublicClient } from "viem"; + +import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources"; +import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk"; +import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk"; +import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; +import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; + +import { type IndexingStatusCache, indexingStatusCache } from "@/cache/indexing-status.cache"; +import { + type ReferralProgramEditionConfigSetCache, + referralProgramEditionConfigSetCache, +} from "@/cache/referral-program-edition-set.cache"; +import type { EnsNodeStackInfoCache } from "@/cache/stack-info.cache"; +import { stackInfoCache } from "@/cache/stack-info.cache"; +import type { EnsApiConfig } from "@/config/config.schema"; +import ensDbConfig from "@/config/ensdb-config"; +import type { EnsApiEnvironment } from "@/config/environment"; +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; + +/** + * Dependency Injection Container for ENSApi. + */ +export interface EnsApiDiContext { + ensApiEnvironment: EnsApiEnvironment; + + ensApiConfig: EnsApiConfig; + + ensDbConfig: EnsDbConfig; + + ensDbClient: EnsDbReader; + + ensDb: EnsDbReader["ensDb"]; + + ensIndexerSchema: EnsDbReader["ensIndexerSchema"]; + + ensNamespaceId: ENSNamespaceId; + + rootChainId: ChainId; + + rootChainRpcConfig: RpcConfig; + + rootChainPublicClient: PublicClient; + + indexingStatusCache: IndexingStatusCache; + + referralProgramEditionConfigSetCache: ReferralProgramEditionConfigSetCache; + + stackInfoCache: EnsNodeStackInfoCache; + + stackInfo: EnsNodeStackInfo; + + subgraphApiGqlMiddleware: ReturnType; +} + +export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { + const instances = {} as EnsApiDiContext; + + const context = { + get ensApiEnvironment(): EnsApiEnvironment { + return env; + }, + + get ensApiConfig(): EnsApiConfig { + if (!instances.ensApiConfig) { + instances.ensApiConfig = ensApiConfig; + } + + return instances.ensApiConfig; + }, + + get ensDbConfig(): EnsDbConfig { + if (!instances.ensDbConfig) { + instances.ensDbConfig = ensDbConfig; + } + return instances.ensDbConfig; + }, + + get ensDbClient(): EnsDbReader { + return ensDbClient; + }, + + get ensDb(): EnsDbReader["ensDb"] { + return context.ensDbClient.ensDb; + }, + + get ensIndexerSchema(): EnsDbReader["ensIndexerSchema"] { + return context.ensDbClient.ensIndexerSchema; + }, + + get ensNamespaceId(): ENSNamespaceId { + return context.stackInfo.ensIndexer.namespace; + }, + + get rootChainId(): ChainId { + if (!instances.rootChainId) { + instances.rootChainId = getENSRootChainId(context.ensNamespaceId); + } + + return instances.rootChainId; + }, + + get rootChainRpcConfig(): RpcConfig { + if (!instances.rootChainRpcConfig) { + const rpcConfig = context.ensApiConfig.rpcConfigs.get(context.rootChainId); + + if (!rpcConfig) { + throw new Error( + `RPC configuration for root chain (chainId: ${context.rootChainId}) is required but was not found in the environment variables.`, + ); + } + + instances.rootChainRpcConfig = rpcConfig; + } + + return instances.rootChainRpcConfig; + }, + + get rootChainPublicClient(): PublicClient { + if (!instances.rootChainPublicClient) { + // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs + instances.rootChainPublicClient = createPublicClient({ + transport: fallback( + context.rootChainRpcConfig.httpRPCs.map((url) => http(url.toString())), + ), + }); + } + + return instances.rootChainPublicClient; + }, + + get indexingStatusCache(): IndexingStatusCache { + if (!instances.indexingStatusCache) { + instances.indexingStatusCache = indexingStatusCache; + } + + return instances.indexingStatusCache; + }, + + get referralProgramEditionConfigSetCache(): ReferralProgramEditionConfigSetCache { + if (!instances.referralProgramEditionConfigSetCache) { + instances.referralProgramEditionConfigSetCache = referralProgramEditionConfigSetCache; + } + + return instances.referralProgramEditionConfigSetCache; + }, + + get stackInfoCache(): EnsNodeStackInfoCache { + if (!instances.stackInfoCache) { + instances.stackInfoCache = stackInfoCache; + } + + return instances.stackInfoCache; + }, + + get stackInfo(): EnsNodeStackInfo { + const stackInfo = context.stackInfoCache.peek(); + + if (stackInfo instanceof Error) { + throw new Error("Stack info is not available in the stackInfoCache."); + } + + return stackInfo; + }, + + get subgraphApiGqlMiddleware(): ReturnType { + if (!instances.subgraphApiGqlMiddleware) { + // generate a subgraph-specific subset of the schema + const subgraphSchema = filterSchemaByPrefix("subgraph_", context.ensIndexerSchema); + + instances.subgraphApiGqlMiddleware = subgraphGraphQLMiddleware({ + databaseUrl: context.ensDbConfig.ensDbUrl, + databaseSchema: context.ensDbConfig.ensIndexerSchemaName, + schema: subgraphSchema, + // describes the polymorphic (interface) relationships in the schema + polymorphicConfig: { + types: { + DomainEvent: [ + subgraphSchema.transfer, + subgraphSchema.newOwner, + subgraphSchema.newResolver, + subgraphSchema.newTTL, + subgraphSchema.wrappedTransfer, + subgraphSchema.nameWrapped, + subgraphSchema.nameUnwrapped, + subgraphSchema.fusesSet, + subgraphSchema.expiryExtended, + ], + RegistrationEvent: [ + subgraphSchema.nameRegistered, + subgraphSchema.nameRenewed, + subgraphSchema.nameTransferred, + ], + ResolverEvent: [ + subgraphSchema.addrChanged, + subgraphSchema.multicoinAddrChanged, + subgraphSchema.nameChanged, + subgraphSchema.abiChanged, + subgraphSchema.pubkeyChanged, + subgraphSchema.textChanged, + subgraphSchema.contenthashChanged, + subgraphSchema.interfaceChanged, + subgraphSchema.authorisationChanged, + subgraphSchema.versionChanged, + ], + }, + fields: { + "Domain.events": "DomainEvent", + "Registration.events": "RegistrationEvent", + "Resolver.events": "ResolverEvent", + }, + }, + }); + } + + return instances.subgraphApiGqlMiddleware; + }, + } satisfies EnsApiDiContext; + + return context; +} + +const di = { + get context(): Readonly { + return Object.freeze(buildEnsApiDiContext(process.env)); + }, +}; + +export default di; From 91c6a4eff9ef996ec543eb42d457e6db81cf0865 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 19:43:34 +0200 Subject: [PATCH 03/18] Replace all direct imports from `import config from "@/config";` We use `import di from @/di;` now to allow for lazy initialization upon config access. --- .../cache/referral-edition-snapshots.cache.ts | 5 +- .../referral-program-edition-set.cache.ts | 11 ++-- apps/ensapi/src/cache/stack-info.cache.ts | 9 ++- .../handlers/api/explore/name-tokens-api.ts | 14 ++-- .../handlers/api/omnigraph/omnigraph-api.ts | 5 +- .../src/handlers/subgraph/subgraph-api.ts | 65 +------------------ apps/ensapi/src/index.ts | 10 ++- .../find-name-tokens-for-domain.ts | 5 +- .../protocol-acceleration/find-resolver.ts | 7 +- .../get-records-from-index.ts | 5 +- .../src/lib/resolution/forward-resolution.ts | 44 +++++++------ .../multichain-primary-name-resolution.ts | 43 ++++++------ .../indexing-status-to-subgraph-meta.ts | 11 ++-- .../middleware/can-accelerate.middleware.ts | 7 +- .../src/middleware/ensanalytics.middleware.ts | 5 +- .../src/middleware/name-tokens.middleware.ts | 5 +- .../registrar-actions.middleware.ts | 5 +- .../thegraph-fallback.middleware.ts | 11 ++-- .../lib/get-domain-by-interpreted-name.ts | 21 ++++-- apps/ensapi/src/omnigraph-api/schema/query.ts | 5 +- .../src/omnigraph-api/schema/resolver.ts | 5 +- 21 files changed, 119 insertions(+), 179 deletions(-) diff --git a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts index 2761b93f7a..f062774735 100644 --- a/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts +++ b/apps/ensapi/src/cache/referral-edition-snapshots.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasEnsAnalyticsConfigSupport, hasEnsAnalyticsIndexingStatusSupport, @@ -13,6 +11,7 @@ import { minutesToSeconds } from "date-fns"; import { type CachedResult, getLatestIndexedBlockRef, SWRCache } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/referrer-leaderboard/closeout"; import { getReferralEditionSnapshot } from "@/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot"; import { makeLogger } from "@/lib/logger"; @@ -73,7 +72,7 @@ function createEditionSnapshotBuilder( // the cache could capture a snapshot derived from a not-yet-final indexer state, or one with // silently dropped rows because a required namespace plugin is inactive, and serve it for the // rest of its (effectively infinite, for closed editions) TTL. - const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasEnsAnalyticsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { throw new Error( `Unable to generate edition snapshot for ${editionSlug}. ${configSupport.reason}`, diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index 3329579e7e..3161a4f262 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { buildReferralProgramEditionConfigSet, ENSReferralsClient, @@ -10,6 +8,7 @@ import { minutesToSeconds } from "date-fns"; import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; @@ -32,20 +31,22 @@ function partiallyRedactUrl(url: URL): string { async function loadReferralProgramEditionConfigSet( _cachedResult?: CachedResult, ): Promise { + const { referralProgramEditionConfigSetUrl } = di.context.ensApiConfig; + // If no URL is configured, treat the referral program as having zero editions. - if (!config.referralProgramEditionConfigSetUrl) { + if (!referralProgramEditionConfigSetUrl) { logger.info( "REFERRAL_PROGRAM_EDITIONS is not set; referral program edition config set is empty", ); return buildReferralProgramEditionConfigSet([]); } - const logSafeUrl = partiallyRedactUrl(config.referralProgramEditionConfigSetUrl); + const logSafeUrl = partiallyRedactUrl(referralProgramEditionConfigSetUrl); logger.info(`Loading referral program edition config set from: ${logSafeUrl}`); try { const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet( - config.referralProgramEditionConfigSetUrl, + referralProgramEditionConfigSetUrl, ); // Strip any unrecognized editions immediately — they are client-side forward-compatibility diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index 243f9738c8..74ada4f4f9 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { minutesToSeconds } from "date-fns"; import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk"; @@ -56,9 +54,14 @@ export const stackInfoCache = lazyProxy( throw new Error("Indexing Metadata Context was uninitialized in ENSDb."); } + // Async import `di` here to avoid circular dependency between this cache module and the DI container module. + // NOTE: It wil be not required soon, as we plan to create a factory function for this cache + // that accepts the necessary dependencies as parameters, instead of importing from the DI container. + const di = await import("@/di").then((mod) => mod.default); + const ensIndexerStackInfo = indexingMetadataContext.stackInfo; const ensNodeStackInfo = buildEnsNodeStackInfo( - buildEnsApiPublicConfig(config, ensIndexerStackInfo.ensIndexer), + buildEnsApiPublicConfig(di.context.ensApiConfig, ensIndexerStackInfo.ensIndexer), ensIndexerStackInfo.ensDb, ensIndexerStackInfo.ensIndexer, ensIndexerStackInfo.ensRainbow, diff --git a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts index 4c96521dcb..42b29da927 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { asInterpretedName, getParentInterpretedName, @@ -18,8 +16,8 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; -import { lazyProxy } from "@/lib/lazy"; import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain"; import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; @@ -29,12 +27,6 @@ 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[]), -); - /** * Factory function for creating a 404 Name Tokens Not Indexed error response */ @@ -85,6 +77,10 @@ app.openapi(getNameTokensRoute, async (c) => { ); } + const indexedSubregistries = getIndexedSubregistries( + di.context.ensNamespaceId, + di.context.stackInfo.ensIndexer.plugins as PluginName[], + ); const parentNode = namehashInterpretedName(parentName); const subregistry = indexedSubregistries.find((s) => s.node === parentNode); diff --git a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts index ec2d40362c..563e31c05e 100644 --- a/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts +++ b/apps/ensapi/src/handlers/api/omnigraph/omnigraph-api.ts @@ -1,17 +1,16 @@ -import config from "@/config"; - import { hasOmnigraphApiConfigSupport, hasOmnigraphApiIndexingStatusSupport, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; const app = createApp({ middlewares: [indexingStatusMiddleware] }); app.use(async (c, next) => { - const configPrerequisite = hasOmnigraphApiConfigSupport(config.ensIndexerPublicConfig); + const configPrerequisite = hasOmnigraphApiConfigSupport(di.context.stackInfo.ensIndexer); // 503 if Omnigraph API is not available due to config prerequisites not met if (!configPrerequisite.supported) { return c.text(`Service Unavailable: ${configPrerequisite.reason}`, 503); diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index 97fa5ed225..285e5e5137 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -1,23 +1,14 @@ -import config from "@/config"; - import type { Duration } from "enssdk"; import { createDocumentationMiddleware } from "ponder-enrich-gql-docs-middleware"; -// FIXME: use the import from: -// import { ensIndexerSchema } from "@/lib/ensdb/singleton"; -// Once the lazy proxy implemented for `ensIndexerSchema` export is improved -// to support Drizzle ORM in `ponder-subgraph` package. -import * as ensIndexerSchema from "@ensnode/ensdb-sdk/ensindexer-abstract"; import { hasSubgraphApiConfigSupport, hasSubgraphApiIndexingStatusSupport, } from "@ensnode/ensnode-sdk"; -import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; +import di from "@/di"; import { createApp } from "@/lib/hono-factory"; -import { lazy } from "@/lib/lazy"; import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation"; -import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; @@ -26,13 +17,10 @@ import { thegraphFallbackMiddleware } from "@/middleware/thegraph-fallback.middl const MAX_REALTIME_DISTANCE_TO_RESOLVE: Duration = 10 * 60; // 10 minutes in seconds -// generate a subgraph-specific subset of the schema -const subgraphSchema = filterSchemaByPrefix("subgraph_", ensIndexerSchema); - const app = createApp({ middlewares: [indexingStatusMiddleware] }); app.use(async (c, next) => { - const configPrerequisite = hasSubgraphApiConfigSupport(config.ensIndexerPublicConfig); + const configPrerequisite = hasSubgraphApiConfigSupport(di.context.stackInfo.ensIndexer); // 503 if Subgraph API is not available due to config prerequisites not met if (!configPrerequisite.supported) { return c.text(`Service Unavailable: ${configPrerequisite.reason}`, 503); @@ -70,53 +58,6 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); -// lazy() defers construction until first use so that this module can be -// imported without env vars being present (e.g. during OpenAPI generation). -const getSubgraphMiddleware = lazy(() => - subgraphGraphQLMiddleware({ - databaseUrl: config.ensDbUrl, - databaseSchema: config.ensIndexerSchemaName, - schema: subgraphSchema, - // describes the polymorphic (interface) relationships in the schema - polymorphicConfig: { - types: { - DomainEvent: [ - subgraphSchema.transfer, - subgraphSchema.newOwner, - subgraphSchema.newResolver, - subgraphSchema.newTTL, - subgraphSchema.wrappedTransfer, - subgraphSchema.nameWrapped, - subgraphSchema.nameUnwrapped, - subgraphSchema.fusesSet, - subgraphSchema.expiryExtended, - ], - RegistrationEvent: [ - subgraphSchema.nameRegistered, - subgraphSchema.nameRenewed, - subgraphSchema.nameTransferred, - ], - ResolverEvent: [ - subgraphSchema.addrChanged, - subgraphSchema.multicoinAddrChanged, - subgraphSchema.nameChanged, - subgraphSchema.abiChanged, - subgraphSchema.pubkeyChanged, - subgraphSchema.textChanged, - subgraphSchema.contenthashChanged, - subgraphSchema.interfaceChanged, - subgraphSchema.authorisationChanged, - subgraphSchema.versionChanged, - ], - }, - fields: { - "Domain.events": "DomainEvent", - "Registration.events": "RegistrationEvent", - "Resolver.events": "ResolverEvent", - }, - }, - }), -); -app.use((c, next) => getSubgraphMiddleware()(c, next)); +app.use(di.context.subgraphApiGqlMiddleware); export default app; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index ea8791ef28..043e41062a 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -1,4 +1,4 @@ -import config, { initEnvConfig } from "@/config"; +import { initEnvConfig } from "@/config"; import { serve } from "@hono/node-server"; @@ -6,6 +6,7 @@ import { indexingStatusCache } from "@/cache/indexing-status.cache"; import { getReferralEditionSnapshotsCaches } from "@/cache/referral-edition-snapshots.cache"; import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; import { redactEnsApiConfig } from "@/config/redact"; +import di from "@/di"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; import { writeGraphQLSchema } from "@/omnigraph-api/lib/write-graphql-schema"; @@ -21,10 +22,13 @@ sdk.start(); const server = serve( { fetch: app.fetch, - port: config.port, + port: di.context.ensApiConfig.port, }, async (info) => { - logger.info({ config: redactEnsApiConfig(config) }, `ENSApi listening on port ${info.port}`); + logger.info( + { config: redactEnsApiConfig(di.context.ensApiConfig) }, + `ENSApi listening on port ${info.port}`, + ); // Write the generated graphql schema in the background void writeGraphQLSchema(); diff --git a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts index 3b4b6ca956..5f50e9b20b 100644 --- a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts +++ b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { eq } from "drizzle-orm/sql"; import { type AccountId, asInterpretedName, type Node, type UnixTimestamp } from "enssdk"; @@ -13,6 +11,7 @@ import { type RegisteredNameTokens, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; interface FindRegisteredNameTokensForDomainRecord { @@ -103,7 +102,7 @@ function _recordsToRegisteredNameTokens( } satisfies AccountId; // biome-ignore lint/style/noNonNullAssertion: domain.name guaranteed to exist const name = asInterpretedName(record.domains.name!); - const ownership = getNameTokenOwnership(config.namespace, name, owner); + const ownership = getNameTokenOwnership(di.context.ensNamespaceId, name, owner); const token = _recordToNameToken(record, ownership); const expiresAt = bigIntToNumber(record.registrationLifecycles.expiresAt); diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 5511f804bd..fd40076d6b 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { bytesToPacket } from "@ensdomains/ensjs/utils"; import { SpanStatusCode, trace } from "@opentelemetry/api"; import { @@ -18,6 +16,7 @@ import { packetToBytes } from "viem/ens"; import { DatasourceNames, getDatasource } from "@ensnode/datasources"; import { accountIdEqual, isENSv1Registry } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; @@ -71,7 +70,7 @@ export async function findResolver({ } // Invariant: UniversalResolver#findResolver only works for ENS Root Registry - if (!isENSv1Registry(config.namespace, registry)) { + if (!isENSv1Registry(di.context.ensNamespaceId, registry)) { throw new Error( `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers agains the ENs Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, ); @@ -98,7 +97,7 @@ async function findResolverWithUniversalResolver( contracts: { UniversalResolver: { address, abi }, }, - } = getDatasource(config.namespace, DatasourceNames.ENSRoot); + } = getDatasource(di.context.ensNamespaceId, DatasourceNames.ENSRoot); // 2. Call UniversalResolver#findResolver via RPC const dnsEncodedNameBytes = packetToBytes(name); diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts index 43da88b1b5..2e2436993e 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts @@ -1,10 +1,9 @@ -import config from "@/config"; - import { type AccountId, DEFAULT_EVM_COIN_TYPE, type Node } from "enssdk"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { staticResolverImplementsAddressRecordDefaulting } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb } from "@/lib/ensdb/singleton"; const DEFAULT_EVM_COIN_TYPE_BIGINT = BigInt(DEFAULT_EVM_COIN_TYPE); @@ -41,7 +40,7 @@ export async function getRecordsFromIndex - getDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolver"), -); - -const getUniversalResolverV2 = lazy(() => - maybeGetDatasourceContract(config.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2"), -); - /** * Implements Forward Resolution of record values for a specified ENS Name. * @@ -98,7 +87,7 @@ export async function resolveForward // initially be ENS Root Registry: see `_resolveForward` for additional context. return _resolveForward(interpretedName, selection, { ...options, - registry: getENSv1RootRegistry(config.namespace), + registry: getENSv1RootRegistry(di.context.ensNamespaceId), }); } @@ -168,7 +157,7 @@ async function _resolveForward( // if no operations were generated, this was an empty selection; give them what they asked for if (operations.length === 0) return makeRecordsResponse(operations); - const publicClient = getPublicClient(chainId); + const publicClient = di.context.rootChainPublicClient; //////////////////////////// /// 0. Temporary ENSv2 Bailout @@ -177,9 +166,21 @@ async function _resolveForward( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(config.namespace, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ENSv2Root)) { + const universalResolverV1 = getDatasourceContract( + di.context.ensNamespaceId, + DatasourceNames.ENSRoot, + "UniversalResolver", + ); + + const universalResolverV2 = maybeGetDatasourceContract( + di.context.ensNamespaceId, + DatasourceNames.ENSRoot, + "UniversalResolverV2", + ); + const UniversalResolverAddress = - getUniversalResolverV2()?.address ?? getUniversalResolverV1().address; + universalResolverV2?.address ?? universalResolverV1.address; operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.ExecuteResolveCalls, @@ -241,7 +242,7 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - const bridged = isBridgedResolver(config.namespace, resolver); + const bridged = isBridgedResolver(di.context.ensNamespaceId, resolver); if (bridged) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, @@ -270,7 +271,7 @@ async function _resolveForward( const resolver = { chainId, address: activeResolver }; // Pass: ENSIP-19 Reverse Resolver - if (isKnownENSIP19ReverseResolver(config.namespace, resolver)) { + if (isKnownENSIP19ReverseResolver(di.context.ensNamespaceId, resolver)) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, @@ -282,10 +283,13 @@ async function _resolveForward( // Pass: Known On-Chain Static Resolver with indexed records const resolverRecordsAreIndexed = areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId( - config.namespace, + di.context.ensNamespaceId, chainId, ); - if (resolverRecordsAreIndexed && isStaticResolver(config.namespace, resolver)) { + if ( + resolverRecordsAreIndexed && + isStaticResolver(di.context.ensNamespaceId, resolver) + ) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index 930b0142e9..bba3b28d5e 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -1,7 +1,4 @@ -import config from "@/config"; - import { trace } from "@opentelemetry/api"; -import type { ChainId } from "enssdk"; import { mainnet } from "viem/chains"; import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; @@ -11,31 +8,33 @@ import { uniq, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; -import { lazy } from "@/lib/lazy"; import { resolveReverse } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); -const getENSIP19SupportedChainIds = lazy(() => [ - // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, - // regardless of the current namespace - mainnet.id, +const getENSIP19SupportedChainIds = () => { + return [ + // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, + // regardless of the current namespace + mainnet.id, - // then include any ENSIP-19 Supported Chains defined in this namespace - ...uniq( - [ - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverRoot), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverBase), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverLinea), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverOptimism), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverArbitrum), - maybeGetDatasource(config.namespace, DatasourceNames.ReverseResolverScroll), - ] - .filter((ds) => ds !== undefined) - .map((ds) => ds.chain.id), - ), -]); + // then include any ENSIP-19 Supported Chains defined in this namespace + ...uniq( + [ + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverRoot), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverBase), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverLinea), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverOptimism), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverArbitrum), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverScroll), + ] + .filter((ds) => ds !== undefined) + .map((ds) => ds.chain.id), + ), + ]; +}; /** * Implements batch resolution of an address' Primary Name across the provided `chainIds`. 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 40323fcc6b..3401c7c1e4 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 @@ -1,8 +1,7 @@ -import config from "@/config"; - -import { ChainIndexingStatusIds, getENSRootChainId } from "@ensnode/ensnode-sdk"; +import { ChainIndexingStatusIds } from "@ensnode/ensnode-sdk"; import type { SubgraphMeta } from "@ensnode/ponder-subgraph"; +import di from "@/di"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; /** @@ -23,9 +22,7 @@ export function indexingContextToSubgraphMeta( // for the lifetime of this service instance. if (indexingStatus instanceof Error) return null; - const rootChain = indexingStatus.snapshot.omnichainSnapshot.chains.get( - getENSRootChainId(config.namespace), - ); + const rootChain = indexingStatus.snapshot.omnichainSnapshot.chains.get(di.context.rootChainId); if (!rootChain) return null; switch (rootChain.chainStatus) { @@ -36,7 +33,7 @@ export function indexingContextToSubgraphMeta( case ChainIndexingStatusIds.Backfill: case ChainIndexingStatusIds.Following: { return { - deployment: config.ensIndexerPublicConfig.versionInfo.ensIndexer, + deployment: di.context.stackInfo.ensIndexer.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 c795f9696a..4acb00ee87 100644 --- a/apps/ensapi/src/middleware/can-accelerate.middleware.ts +++ b/apps/ensapi/src/middleware/can-accelerate.middleware.ts @@ -1,8 +1,7 @@ -import config from "@/config"; - import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory, producing } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -39,7 +38,7 @@ export const canAccelerateMiddleware = producing( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(config.namespace, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ENSv2Root)) { if (!didWarnCannotAccelerateENSv2) { logger.warn( `ENSApi is temporarily unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, @@ -56,7 +55,7 @@ export const canAccelerateMiddleware = producing( /// Protocol Acceleration Plugin Availability ////////////////////////////////////////////// - const hasProtocolAccelerationPlugin = config.ensIndexerPublicConfig.plugins.includes( + const hasProtocolAccelerationPlugin = di.context.stackInfo.ensIndexer.plugins.includes( PluginName.ProtocolAcceleration, ); diff --git a/apps/ensapi/src/middleware/ensanalytics.middleware.ts b/apps/ensapi/src/middleware/ensanalytics.middleware.ts index c3ea70fe0f..8c938887af 100644 --- a/apps/ensapi/src/middleware/ensanalytics.middleware.ts +++ b/apps/ensapi/src/middleware/ensanalytics.middleware.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { hasEnsAnalyticsConfigSupport, hasEnsAnalyticsIndexingStatusSupport, @@ -7,6 +5,7 @@ import { import type { PrerequisiteResult } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory, producing } from "@/lib/hono-factory"; /** @@ -51,7 +50,7 @@ export const ensanalyticsApiMiddleware = producing( throw new Error(`Invariant(ensanalytics.middleware): indexingStatusMiddleware required`); } - const configSupport = hasEnsAnalyticsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasEnsAnalyticsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { c.set("ensAnalyticsPrerequisites", configSupport); return await next(); diff --git a/apps/ensapi/src/middleware/name-tokens.middleware.ts b/apps/ensapi/src/middleware/name-tokens.middleware.ts index 8f78643e76..ea74319ba8 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, @@ -7,6 +5,7 @@ import { serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -36,7 +35,7 @@ export const nameTokensApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(name-tokens.middleware): indexingStatusMiddleware required`); } - if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { + if (!nameTokensPrerequisites.hasEnsIndexerConfigSupport(di.context.stackInfo.ensIndexer)) { 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 fc43df3d39..096f61307b 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, @@ -7,6 +5,7 @@ import { serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -35,7 +34,7 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( throw new Error(`Invariant(registrar-actions.middleware): indexingStatusMiddleware required`); } - const configSupport = hasRegistrarActionsConfigSupport(config.ensIndexerPublicConfig); + const configSupport = hasRegistrarActionsConfigSupport(di.context.stackInfo.ensIndexer); if (!configSupport.supported) { return c.json( serializeRegistrarActionsResponse({ diff --git a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts index 0cac9478a5..21f3247088 100644 --- a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts +++ b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts @@ -1,9 +1,8 @@ -import config from "@/config"; - import { proxy } from "hono/proxy"; import { canFallbackToTheGraph } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -26,9 +25,9 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } const fallback = canFallbackToTheGraph({ - namespace: config.namespace, - theGraphApiKey: config.theGraphApiKey, - isSubgraphCompatible: config.ensIndexerPublicConfig.isSubgraphCompatible, + namespace: di.context.ensNamespaceId, + theGraphApiKey: di.context.ensApiConfig.theGraphApiKey, + isSubgraphCompatible: di.context.stackInfo.ensIndexer.isSubgraphCompatible, }); // log one warning to the console if !canFallback @@ -46,7 +45,7 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } case "no-subgraph-url": { logger.warn( - `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${config.namespace}') is not supported by The Graph.`, + `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${di.context.ensNamespaceId}') is not supported by The Graph.`, ); break; } diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index 8092e31d09..b54d416128 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { trace } from "@opentelemetry/api"; import { Param, sql } from "drizzle-orm"; import { @@ -24,6 +22,7 @@ import { } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; @@ -84,7 +83,7 @@ export async function getDomainIdByInterpretedName( } return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - forwardWalkNamegraph(getRootRegistryId(config.namespace), path), + forwardWalkNamegraph(getRootRegistryId(di.context.ensNamespaceId), path), ); } @@ -129,10 +128,10 @@ async function forwardWalkNamegraph( // otherwise, identify the deepest element with a Resolver const deepestResolver = rows.find(hasResolver); if (deepestResolver) { - const resolverEq = makeContractMatcher(config.namespace, deepestResolver); + const resolverEq = makeContractMatcher(di.context.ensNamespaceId, deepestResolver); // Bridged Resolvers // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry - const bridged = isBridgedResolver(config.namespace, deepestResolver); + const bridged = isBridgedResolver(di.context.ensNamespaceId, deepestResolver); if (bridged) { // to follow a Bridged Resolver, continue walking the namegraph from the target `registryId` // with the remaining portion of `path` @@ -151,13 +150,21 @@ async function forwardWalkNamegraph( // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 if (resolverEq(DatasourceNames.ENSv2Root, "ENSv1Resolver")) { // to implement the ENSv1Resolver, walk the ENSv1 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv1RootRegistryId(config.namespace), path, depth + 1); + return forwardWalkNamegraph( + getENSv1RootRegistryId(di.context.ensNamespaceId), + path, + depth + 1, + ); } // ENSv2Resolver (ENSv2 Fallback) if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path - return forwardWalkNamegraph(getENSv2RootRegistryId(config.namespace), path, depth + 1); + return forwardWalkNamegraph( + getENSv2RootRegistryId(di.context.ensNamespaceId), + path, + depth + 1, + ); } } diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 9eca56bfeb..dfa4c386cd 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -1,10 +1,9 @@ -import config from "@/config"; - import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { makeConcreteRegistryId, makePermissionsId, makeResolverId } from "enssdk"; import { getRootRegistryId } from "@ensnode/ensnode-sdk"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; @@ -184,7 +183,7 @@ builder.queryType({ "The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined or the ENSv1 Root Registry.", type: RegistryInterfaceRef, nullable: false, - resolve: () => getRootRegistryId(config.namespace), + resolve: () => getRootRegistryId(di.context.ensNamespaceId), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 07e8d65bea..47acd6c00c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -1,5 +1,3 @@ -import config from "@/config"; - import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, eq } from "drizzle-orm"; import { @@ -11,6 +9,7 @@ import { import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; +import di from "@/di"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { builder } from "@/omnigraph-api/builder"; import { orderPaginationBy, paginateBy } from "@/omnigraph-api/lib/connection-helpers"; @@ -129,7 +128,7 @@ ResolverRef.implement({ type: RegistryInterfaceRef, nullable: true, resolve: (parent) => { - const bridged = isBridgedResolver(config.namespace, parent); + const bridged = isBridgedResolver(di.context.ensNamespaceId, parent); return bridged?.targetRegistryId ?? null; }, }), From d1c5f4b965fd62f9ccdc9b9b2c5548b66a15de7d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 20:56:31 +0200 Subject: [PATCH 04/18] Simplify `EnsApiConfig` to only reference env-based config --- apps/ensapi/src/config/config.schema.ts | 75 +++---------------------- apps/ensapi/src/config/index.ts | 48 ---------------- apps/ensapi/src/config/redact.ts | 21 ------- apps/ensapi/src/config/validations.ts | 59 ------------------- apps/ensapi/src/di.ts | 35 +++++++----- apps/ensapi/src/index.ts | 22 ++------ 6 files changed, 34 insertions(+), 226 deletions(-) delete mode 100644 apps/ensapi/src/config/index.ts delete mode 100644 apps/ensapi/src/config/redact.ts delete mode 100644 apps/ensapi/src/config/validations.ts diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 733bf0ec04..2733fddd38 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,27 +1,14 @@ -import pRetry from "p-retry"; import { prettifyError, ZodError, z } from "zod/v4"; +import type { EnsApiPublicConfig, EnsIndexerPublicConfig } from "@ensnode/ensnode-sdk"; import { - type EnsApiPublicConfig, - type EnsIndexerPublicConfig, - IndexingMetadataContextStatusCodes, -} 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"; @@ -43,72 +30,26 @@ 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, - - // include the ENSDbConfig params in the EnsApiConfigSchema - ensDbUrl: z.string(), - ensIndexerSchemaName: z.string(), - }) - .check(invariant_rpcConfigsSpecifiedForRootChain) - .check(invariant_ensIndexerPublicConfigVersionInfo); +const EnsApiConfigSchema = z.object({ + port: OptionalPortNumberSchema.default(ENSApi_DEFAULT_PORT), + theGraphApiKey: TheGraphApiKeySchema, + referralProgramEditionConfigSetUrl: ReferralProgramEditionConfigSetUrlSchema, +}); 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 { - // 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 indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - - if ( - indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized - ) { - throw new Error( - "EnsIndexerPublicConfig could not be fetched, the IndexingMetadataContext record has not been initialized in ENSDb yet.", - ); - } - - return indexingMetadataContext.stackInfo.ensIndexer; - }, - { - 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, }); } catch (error) { if (error instanceof ZodError) { diff --git a/apps/ensapi/src/config/index.ts b/apps/ensapi/src/config/index.ts deleted file mode 100644 index f8eab6ac2c..0000000000 --- a/apps/ensapi/src/config/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnsApiConfig } from "@/config/config.schema"; -import { buildConfigFromEnvironment } from "@/config/config.schema"; - -let _config: EnsApiConfig | null = null; - -/** - * Initializes the global config from environment variables. - * Must be called before any config property is accessed (i.e. as the first - * statement in index.ts, before the server starts). - */ -export async function initEnvConfig(env: NodeJS.ProcessEnv): Promise { - _config = await buildConfigFromEnvironment(env); -} - -/** - * Lazy config proxy — defers access to the underlying config object until it - * has been initialized via initConfig(). - * - * This allows app.ts (and all route/handler modules it imports) to be loaded - * at module evaluation time without requiring env vars to be present. That - * property is essential for the OpenAPI generation script, which imports app.ts - * to introspect routes but never starts the server or calls initConfig(). - * - * Any attempt to read a config property before initConfig() is called will - * throw a descriptive error. Use @/lib/lazy to defer config-dependent - * initialization in modules that are evaluated at import time. - */ -export default new Proxy({} as EnsApiConfig, { - get(_, prop: string | symbol) { - if (_config === null) { - throw err(prop); - } - return _config[prop as keyof EnsApiConfig]; - }, - has(_, prop: string | symbol) { - if (_config === null) { - throw err(prop); - } - return prop in _config; - }, -}); - -const err = (prop: string | symbol) => { - throw new Error( - `Config not initialized — call initConfig() before accessing config.${String(prop)} - Probably you access config in top level of the module. Use @/lib/lazy for lazy loading dependencies.`, - ); -}; diff --git a/apps/ensapi/src/config/redact.ts b/apps/ensapi/src/config/redact.ts deleted file mode 100644 index f21d376d4c..0000000000 --- a/apps/ensapi/src/config/redact.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { redactRpcConfigs, redactString, redactUrl } from "@ensnode/ensnode-sdk/internal"; - -import type { EnsApiConfig } from "@/config/config.schema"; - -/** - * Redact sensitive values from ENSApi configuration. - */ -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, - }; -} diff --git a/apps/ensapi/src/config/validations.ts b/apps/ensapi/src/config/validations.ts deleted file mode 100644 index b091e21085..0000000000 --- a/apps/ensapi/src/config/validations.ts +++ /dev/null @@ -1,59 +0,0 @@ -import packageJson from "@/../package.json" with { type: "json" }; - -import type { ENSIndexerPublicConfig } from "@ensnode/ensnode-sdk"; -import type { ZodCheckFnInput } from "@ensnode/ensnode-sdk/internal"; - -import { ensApiVersionInfo } from "@/lib/version-info"; - -// Invariant: ENSIndexerPublicConfig VersionInfo must match ENSApi -export function invariant_ensIndexerPublicConfigVersionInfo( - ctx: ZodCheckFnInput<{ - ensIndexerPublicConfig: ENSIndexerPublicConfig; - }>, -) { - const { - value: { ensIndexerPublicConfig }, - } = ctx; - - // Invariant: ENSApi & ENSDB must match version numbers - if (ensIndexerPublicConfig.versionInfo.ensDb !== packageJson.version) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensDb"], - input: ensIndexerPublicConfig.versionInfo.ensDb, - message: `Version Mismatch: ENSDB@${ensIndexerPublicConfig.versionInfo.ensDb} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: ENSApi & ENSIndexer must match version numbers - if (ensIndexerPublicConfig.versionInfo.ensIndexer !== packageJson.version) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensIndexer"], - input: ensIndexerPublicConfig.versionInfo.ensIndexer, - message: `Version Mismatch: ENSIndexer@${ensIndexerPublicConfig.versionInfo.ensIndexer} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: ENSApi & ENSRainbow must match version numbers - if ( - ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow !== packageJson.version - ) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow"], - input: ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow, - message: `Version Mismatch: ENSRainbow@${ensIndexerPublicConfig.ensRainbowPublicConfig.versionInfo.ensRainbow} !== ENSApi@${packageJson.version}`, - }); - } - - // Invariant: `@adraffy/ens-normalize` package version must match between ENSApi & ENSIndexer - if (ensIndexerPublicConfig.versionInfo.ensNormalize !== ensApiVersionInfo.ensNormalize) { - ctx.issues.push({ - code: "custom", - path: ["ensIndexerPublicConfig.versionInfo.ensNormalize"], - input: ensIndexerPublicConfig.versionInfo.ensNormalize, - message: `Dependency Version Mismatch: '@adraffy/ens-normalize' version must be the same between ENSIndexer and ENSApi. Found ENSApi@${ensApiVersionInfo.ensNormalize} and ENSIndexer@${ensIndexerPublicConfig.versionInfo.ensNormalize}`, - }); - } -} diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index 8d08d3df3e..b601ad2d41 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -1,12 +1,14 @@ -import ensApiConfig from "@/config"; - import type { ChainId } from "enssdk"; import { createPublicClient, fallback, http, type PublicClient } from "viem"; import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources"; import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk"; import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk"; -import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; +import { + buildRpcConfigsFromEnv, + type RpcConfig, + RpcConfigsSchema, +} from "@ensnode/ensnode-sdk/internal"; import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; import { type IndexingStatusCache, indexingStatusCache } from "@/cache/indexing-status.cache"; @@ -17,6 +19,7 @@ import { import type { EnsNodeStackInfoCache } from "@/cache/stack-info.cache"; import { stackInfoCache } from "@/cache/stack-info.cache"; import type { EnsApiConfig } from "@/config/config.schema"; +import { buildConfigFromEnvironment } from "@/config/config.schema"; import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { ensDbClient } from "@/lib/ensdb/singleton"; @@ -67,7 +70,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { get ensApiConfig(): EnsApiConfig { if (!instances.ensApiConfig) { - instances.ensApiConfig = ensApiConfig; + instances.ensApiConfig = buildConfigFromEnvironment(env); } return instances.ensApiConfig; @@ -96,30 +99,32 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { return context.stackInfo.ensIndexer.namespace; }, - get rootChainId(): ChainId { - if (!instances.rootChainId) { - instances.rootChainId = getENSRootChainId(context.ensNamespaceId); - } - - return instances.rootChainId; - }, - get rootChainRpcConfig(): RpcConfig { if (!instances.rootChainRpcConfig) { - const rpcConfig = context.ensApiConfig.rpcConfigs.get(context.rootChainId); + const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, context.ensNamespaceId); + const rpcConfigs = RpcConfigsSchema.parse(unvalidatedRpcConfigs); + const rootChainRpcConfig = rpcConfigs.get(context.rootChainId); - if (!rpcConfig) { + if (!rootChainRpcConfig) { throw new Error( `RPC configuration for root chain (chainId: ${context.rootChainId}) is required but was not found in the environment variables.`, ); } - instances.rootChainRpcConfig = rpcConfig; + instances.rootChainRpcConfig = rootChainRpcConfig; } return instances.rootChainRpcConfig; }, + get rootChainId(): ChainId { + if (!instances.rootChainId) { + instances.rootChainId = getENSRootChainId(context.ensNamespaceId); + } + + return instances.rootChainId; + }, + get rootChainPublicClient(): PublicClient { if (!instances.rootChainPublicClient) { // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 043e41062a..a1b22f1aa4 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -1,11 +1,6 @@ -import { initEnvConfig } from "@/config"; - import { serve } from "@hono/node-server"; -import { indexingStatusCache } from "@/cache/indexing-status.cache"; import { getReferralEditionSnapshotsCaches } from "@/cache/referral-edition-snapshots.cache"; -import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; -import { redactEnsApiConfig } from "@/config/redact"; import di from "@/di"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; @@ -13,8 +8,6 @@ import { writeGraphQLSchema } from "@/omnigraph-api/lib/write-graphql-schema"; import app from "./app"; -await initEnvConfig(process.env); - // start ENSNode API OpenTelemetry SDK sdk.start(); @@ -25,16 +18,13 @@ const server = serve( port: di.context.ensApiConfig.port, }, async (info) => { - logger.info( - { config: redactEnsApiConfig(di.context.ensApiConfig) }, - `ENSApi listening on port ${info.port}`, - ); - // Write the generated graphql schema in the background void writeGraphQLSchema(); - // proactively read the indexing status to warm cache - void indexingStatusCache.read(); + // proactively warm up caches in the background + void Promise.all([di.context.indexingStatusCache.read(), di.context.stackInfoCache.read()]); + + logger.info(`ENSApi listening on port ${info.port}`); }, ); @@ -54,7 +44,7 @@ const gracefulShutdown = async () => { logger.info("Destroyed tracing instrumentation"); // Destroy referral program edition config set cache - referralProgramEditionConfigSetCache.destroy(); + di.context.referralProgramEditionConfigSetCache.destroy(); logger.info("Destroyed referralProgramEditionConfigSetCache"); // Destroy all edition caches (if initialized) @@ -66,7 +56,7 @@ const gracefulShutdown = async () => { } } - indexingStatusCache.destroy(); + di.context.indexingStatusCache.destroy(); logger.info("Destroyed indexingStatusCache"); await closeServer(); From 063732af0c3421370c0eaf35ec539fe590231ff9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 21:50:08 +0200 Subject: [PATCH 05/18] Add builder function for the Root Chain RPC Config --- apps/ensapi/src/config/config.schema.ts | 53 ++++++++++++++++++++++++- apps/ensapi/src/di.ts | 23 +++-------- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 2733fddd38..4224cf6df2 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -1,9 +1,17 @@ import { prettifyError, ZodError, z } from "zod/v4"; -import type { EnsApiPublicConfig, EnsIndexerPublicConfig } from "@ensnode/ensnode-sdk"; import { + type ENSNamespaceId, + type EnsApiPublicConfig, + type EnsIndexerPublicConfig, + getENSRootChainId, +} from "@ensnode/ensnode-sdk"; +import { + buildRpcConfigsFromEnv, canFallbackToTheGraph, OptionalPortNumberSchema, + type RpcConfig, + RpcConfigsSchema, TheGraphApiKeySchema, } from "@ensnode/ensnode-sdk/internal"; @@ -41,8 +49,10 @@ export type EnsApiConfig = z.infer; /** * Builds the EnsApiConfig from an EnsApiEnvironment object. * + * Note: If error occurs during parsing/validation, the error will be logged and the process + * will exit with code 1. + * * @returns A validated EnsApiConfig object - * @throws Error with formatted validation messages if environment parsing fails */ export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig { try { @@ -64,6 +74,45 @@ export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig } } +/** + * Builds the RPC config for the root chain based on the provided environment and ENS namespace ID. + * @param env - The environment variables for the ENSApi + * @param ensNamespaceId - The ENS namespace ID + * @returns The RPC config for the root chain + * + * Note: If error occurs during parsing/validation, the error will be logged and the process + * will exit with code 1. + */ +export function buildRootChainRpcConfig( + env: EnsApiEnvironment, + ensNamespaceId: ENSNamespaceId, +): RpcConfig { + try { + const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); + const rootChainId = getENSRootChainId(ensNamespaceId); + const rpcConfigs = RpcConfigsSchema.parse(unvalidatedRpcConfigs); + const rootChainRpcConfig = rpcConfigs.get(rootChainId); + + if (!rootChainRpcConfig) { + throw new Error( + `RPC configuration for root chain (chainId: ${rootChainId}) is required but was not found in the environment variables.`, + ); + } + + return rootChainRpcConfig; + } catch (error) { + if (error instanceof ZodError) { + logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); + } else if (error instanceof Error) { + logger.error(error, `Failed to build EnsApiConfig`); + } else { + logger.error(`Unknown Error`); + } + + process.exit(1); + } +} + /** * Builds the ENSApi public configuration from an EnsApiConfig object. * diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index b601ad2d41..c78202e357 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -4,11 +4,7 @@ import { createPublicClient, fallback, http, type PublicClient } from "viem"; import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources"; import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk"; import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk"; -import { - buildRpcConfigsFromEnv, - type RpcConfig, - RpcConfigsSchema, -} from "@ensnode/ensnode-sdk/internal"; +import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; import { type IndexingStatusCache, indexingStatusCache } from "@/cache/indexing-status.cache"; @@ -19,7 +15,7 @@ import { import type { EnsNodeStackInfoCache } from "@/cache/stack-info.cache"; import { stackInfoCache } from "@/cache/stack-info.cache"; import type { EnsApiConfig } from "@/config/config.schema"; -import { buildConfigFromEnvironment } from "@/config/config.schema"; +import { buildConfigFromEnvironment, buildRootChainRpcConfig } from "@/config/config.schema"; import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { ensDbClient } from "@/lib/ensdb/singleton"; @@ -101,17 +97,10 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { get rootChainRpcConfig(): RpcConfig { if (!instances.rootChainRpcConfig) { - const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, context.ensNamespaceId); - const rpcConfigs = RpcConfigsSchema.parse(unvalidatedRpcConfigs); - const rootChainRpcConfig = rpcConfigs.get(context.rootChainId); - - if (!rootChainRpcConfig) { - throw new Error( - `RPC configuration for root chain (chainId: ${context.rootChainId}) is required but was not found in the environment variables.`, - ); - } - - instances.rootChainRpcConfig = rootChainRpcConfig; + instances.rootChainRpcConfig = buildRootChainRpcConfig( + context.ensApiEnvironment, + context.ensNamespaceId, + ); } return instances.rootChainRpcConfig; From d7f44402573b959c85bca1e207aa593419b846ed Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 21:50:22 +0200 Subject: [PATCH 06/18] Update tests --- apps/ensapi/src/config/config.schema.test.ts | 74 ++++++++----------- .../ensanalytics/ensanalytics-api.test.ts | 13 ---- 2 files changed, 31 insertions(+), 56 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index a29ab2bf74..57d29d85bd 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -17,7 +17,13 @@ vi.mock("@/config/ensdb-config", () => ({ }, })); -import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema"; +import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; + +import { + buildConfigFromEnvironment, + buildEnsApiPublicConfig, + buildRootChainRpcConfig, +} from "@/config/config.schema"; import { BASE_ENV, indexingMetadataContextInitialized, @@ -42,29 +48,14 @@ describe("buildConfigFromEnvironment", () => { it("returns a valid config object using environment variables", async () => { const exitSpy = mockProcessExit(); - const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; - const config = await buildConfigFromEnvironment(BASE_ENV); + const config = buildConfigFromEnvironment(BASE_ENV); expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); expect(config).toStrictEqual({ port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, theGraphApiKey: undefined, - - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map([ - [ - 1, - { - httpRPCs: [new URL(BASE_ENV.RPC_URL_1)], - websocketRPC: undefined, - } satisfies RpcConfig, - ], - ]), referralProgramEditionConfigSetUrl: undefined, }); }); @@ -73,7 +64,7 @@ describe("buildConfigFromEnvironment", () => { const exitSpy = mockProcessExit(); const editionsUrl = "https://example.com/editions.json"; - const config = await buildConfigFromEnvironment({ + const config = buildConfigFromEnvironment({ ...BASE_ENV, REFERRAL_PROGRAM_EDITIONS: editionsUrl, }); @@ -87,7 +78,7 @@ describe("buildConfigFromEnvironment", () => { it("includes theGraphApiKey when provided", async () => { const exitSpy = mockProcessExit(); - const config = await buildConfigFromEnvironment({ + const config = buildConfigFromEnvironment({ ...BASE_ENV, THEGRAPH_API_KEY: "my-api-key", }); @@ -113,12 +104,12 @@ describe("buildConfigFromEnvironment", () => { it("logs error and exits when REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( + expect(() => buildConfigFromEnvironment({ ...testEnv, REFERRAL_PROGRAM_EDITIONS: "not-a-url", }), - ).rejects.toThrow("process.exit"); + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( expect.stringContaining("REFERRAL_PROGRAM_EDITIONS is not a valid URL: not-a-url"), @@ -129,12 +120,15 @@ describe("buildConfigFromEnvironment", () => { it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( - buildConfigFromEnvironment({ - ...testEnv, - QUICKNODE_API_KEY: "my-api-key", - }), - ).rejects.toThrow("process.exit"); + expect(() => + buildRootChainRpcConfig( + { + ...testEnv, + QUICKNODE_API_KEY: "my-api-key", + }, + ENSNamespaceIds.Mainnet, + ), + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( @@ -148,12 +142,15 @@ describe("buildConfigFromEnvironment", () => { it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => { const testEnv = structuredClone(BASE_ENV); - await expect( - buildConfigFromEnvironment({ - ...testEnv, - QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", - }), - ).rejects.toThrow("process.exit"); + expect(() => + buildRootChainRpcConfig( + { + ...testEnv, + QUICKNODE_ENDPOINT_NAME: "my-endpoint-name", + }, + ENSNamespaceIds.Mainnet, + ), + ).toThrow("process.exit"); expect(logger.error).toHaveBeenCalledExactlyOnceWith( new Error( @@ -203,11 +200,7 @@ describe("buildEnsApiPublicConfig", () => { const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), + theGraphApiKey: "my-api-key", referralProgramEditionConfigSetUrl: undefined, }; @@ -225,11 +218,6 @@ describe("buildEnsApiPublicConfig", () => { const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: "secret-api-key", }; diff --git a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts index 7b97784290..b2b4ab7750 100644 --- a/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics/ensanalytics-api.test.ts @@ -1,22 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ENSNamespaceIds } from "@ensnode/datasources"; - -import type { EnsApiConfig } from "@/config/config.schema"; import * as ensanalyticsMiddleware from "@/middleware/ensanalytics.middleware"; import * as editionsCachesMiddleware from "@/middleware/referral-edition-snapshots-caches.middleware"; import * as editionSetMiddleware from "@/middleware/referral-program-edition-set.middleware"; -vi.mock("@/config", () => ({ - get default() { - const mockedConfig: Pick = { - namespace: ENSNamespaceIds.Mainnet, - }; - - return mockedConfig; - }, -})); - vi.mock("@/middleware/referral-program-edition-set.middleware", () => ({ referralProgramEditionConfigSetMiddleware: vi.fn(), })); From 369bf0ff5d1c1a76f164caf1cef3fef308ebd0b8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 20 May 2026 21:50:41 +0200 Subject: [PATCH 07/18] Remove the generic `getPublicClient` helper --- apps/ensapi/src/lib/public-client.ts | 35 ------------------- .../execute-operations.integration.test.ts | 4 +-- 2 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 apps/ensapi/src/lib/public-client.ts diff --git a/apps/ensapi/src/lib/public-client.ts b/apps/ensapi/src/lib/public-client.ts deleted file mode 100644 index 8b51cf4ecf..0000000000 --- a/apps/ensapi/src/lib/public-client.ts +++ /dev/null @@ -1,35 +0,0 @@ -import config from "@/config"; - -import type { ChainId } from "enssdk"; -import { createPublicClient, fallback, http, type PublicClient } from "viem"; - -const _cache = new Map(); - -/** - * Gets a viem#PublicClient for the specified `chainId` using the ENSApiConfig's RPCConfig. Caches - * the instance itself to minimize unnecessary allocations. - */ -export function getPublicClient(chainId: ChainId): PublicClient { - // Invariant: ENSApi must have an rpcConfig for the requested `chainId` - const rpcConfig = config.rpcConfigs.get(chainId); - if (!rpcConfig) { - throw new Error(`Invariant: ENSApi does not have an RPC to chain id '${chainId}'.`); - } - - if (!_cache.has(chainId)) { - _cache.set( - chainId, - // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs - createPublicClient({ - transport: fallback(rpcConfig.httpRPCs.map((url) => http(url.toString()))), - }), - ); - } - - const publicClient = _cache.get(chainId); - - // publicClient guaranteed to exist due to cache-setting logic above - if (!publicClient) throw new Error("never"); - - return publicClient; -} diff --git a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts index 33ba525d6c..4abaf7723c 100644 --- a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts @@ -26,7 +26,7 @@ import { describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; -import { getPublicClient } from "@/lib/public-client"; +import di from "@/di"; import { executeOperations } from "@/lib/resolution/execute-operations"; import { makeOperations } from "@/lib/resolution/operations"; @@ -38,7 +38,7 @@ const NAME_WITH_ENCODED_LABELHASHES = interpretedLabelsToInterpretedName([ const EXPECTED_DESCRIPTION = "example.eth"; -const publicClient = getPublicClient(ensTestEnvChain.id); +const publicClient = di.context.rootChainPublicClient; const UniversalResolverV2 = getDatasourceContract( ENSNamespaceIds.EnsTestEnv, From 6a72955a3ba5ba391151caf10c3b5dca3cd69ed2 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 11:12:54 +0200 Subject: [PATCH 08/18] Fix integration testing suite --- .../execute-operations.integration.test.ts | 14 ++++++++++---- .../find-domains-resolver-helpers.test.ts | 4 +--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts index 4abaf7723c..9e80098553 100644 --- a/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts +++ b/apps/ensapi/src/lib/resolution/execute-operations.integration.test.ts @@ -1,14 +1,20 @@ +import { createPublicClient, http } from "viem"; import { vi } from "vitest"; -import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; +import { ENSNamespaceIds } from "@ensnode/datasources"; // we're testing a function specifically, not fetching through the running ensapi instance, so // we need to mock the config when this worker process attempts to import ./execute-operations // (and this is an integration test because we want to RPC fetch against the running devnet) -vi.mock("@/config", () => ({ +vi.mock("@/di", () => ({ default: { - namespace: ENSNamespaceIds.EnsTestEnv, - rpcConfigs: new Map([[ensTestEnvChain.id, { httpRPCs: [new URL("http://localhost:8545")] }]]), + context: { + get rootChainPublicClient() { + return createPublicClient({ + transport: http("http://localhost:8545"), + }); + }, + }, }, })); diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts index 7582acbaaf..84864f9aaf 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver-helpers.test.ts @@ -1,6 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/config", () => ({ default: { namespace: "mainnet" } })); +import { describe, expect, it } from "vitest"; import { isEffectiveDesc } from "./find-domains-resolver-helpers"; From f7e6a185f59b9fe0c41fe3f30a7effb3efd33a9d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 11:13:20 +0200 Subject: [PATCH 09/18] Fix OpenAPI spec generator script --- apps/ensapi/src/handlers/subgraph/subgraph-api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index 285e5e5137..367d4050e7 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -58,6 +58,9 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); -app.use(di.context.subgraphApiGqlMiddleware); +// inject the GraphQL middleware for the Subgraph API +// note: we wrap the middleware in a function to defer its construction until runtime, +// which allows lazy-loading of DI context dependencies +app.use(async (c, next) => di.context.subgraphApiGqlMiddleware(c, next)); export default app; From f0d4f92ba7abd1408828470c66601184c08e6a25 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 12:46:14 +0200 Subject: [PATCH 10/18] Apply AI PR feedback --- apps/ensapi/src/cache/stack-info.cache.ts | 2 +- apps/ensapi/src/config/config.schema.test.ts | 4 +-- apps/ensapi/src/config/config.schema.ts | 2 +- .../multichain-primary-name-resolution.ts | 26 +++++++++---------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/ensapi/src/cache/stack-info.cache.ts b/apps/ensapi/src/cache/stack-info.cache.ts index 74ada4f4f9..253be6e3cb 100644 --- a/apps/ensapi/src/cache/stack-info.cache.ts +++ b/apps/ensapi/src/cache/stack-info.cache.ts @@ -55,7 +55,7 @@ export const stackInfoCache = lazyProxy( } // Async import `di` here to avoid circular dependency between this cache module and the DI container module. - // NOTE: It wil be not required soon, as we plan to create a factory function for this cache + // NOTE: It will not be required soon, as we plan to create a factory function for this cache // that accepts the necessary dependencies as parameters, instead of importing from the DI container. const di = await import("@/di").then((mod) => mod.default); diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 57d29d85bd..7e2b541323 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -134,7 +134,7 @@ describe("buildConfigFromEnvironment", () => { new Error( "Use of the QUICKNODE_API_KEY environment variable requires use of the QUICKNODE_ENDPOINT_NAME environment variable as well.", ), - "Failed to build EnsApiConfig", + "Failed to build the root chain RPC config", ); expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); @@ -156,7 +156,7 @@ describe("buildConfigFromEnvironment", () => { new Error( "Use of the QUICKNODE_ENDPOINT_NAME environment variable requires use of the QUICKNODE_API_KEY environment variable as well.", ), - "Failed to build EnsApiConfig", + "Failed to build the root chain RPC config", ); expect(process.exit).toHaveBeenCalledExactlyOnceWith(1); }); diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index 4224cf6df2..e3413357a0 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -104,7 +104,7 @@ export function buildRootChainRpcConfig( if (error instanceof ZodError) { logger.error(`Failed to parse environment configuration: \n${prettifyError(error)}\n`); } else if (error instanceof Error) { - logger.error(error, `Failed to build EnsApiConfig`); + logger.error(error, `Failed to build the root chain RPC config`); } else { logger.error(`Unknown Error`); } diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index bba3b28d5e..b2ffaa6e0e 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -15,25 +15,23 @@ import { resolveReverse } from "@/lib/resolution/reverse-resolution"; const tracer = trace.getTracer("multichain-primary-name-resolution"); const getENSIP19SupportedChainIds = () => { - return [ + return uniq([ // always include Mainnet, because its chainId corresponds to the ENS Root Chain's coinType, // regardless of the current namespace mainnet.id, // then include any ENSIP-19 Supported Chains defined in this namespace - ...uniq( - [ - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverRoot), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverBase), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverLinea), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverOptimism), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverArbitrum), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverScroll), - ] - .filter((ds) => ds !== undefined) - .map((ds) => ds.chain.id), - ), - ]; + ...[ + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverRoot), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverBase), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverLinea), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverOptimism), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverArbitrum), + maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverScroll), + ] + .filter((ds) => ds !== undefined) + .map((ds) => ds.chain.id), + ]); }; /** From 9f38b5b1555ed0e147803686bf6a3cc1ba39fce0 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 13:54:40 +0200 Subject: [PATCH 11/18] Create the `EnsApiDiContainer` class This helps manage the DI container lifecycle more conveninently. --- apps/ensapi/src/di.ts | 170 +++++++++++++++++++++++++++++++++++++-- apps/ensapi/src/index.ts | 10 +-- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index c78202e357..90f4d959fa 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -19,38 +19,83 @@ import { buildConfigFromEnvironment, buildRootChainRpcConfig } from "@/config/co import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { ensDbClient } from "@/lib/ensdb/singleton"; +import { makeLogger } from "@/lib/logger"; import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; +const logger = makeLogger("di"); + /** * Dependency Injection Container for ENSApi. */ export interface EnsApiDiContext { + /** + * The environment variables for ENSApi. + */ ensApiEnvironment: EnsApiEnvironment; + /** + * The ENSApi config. + */ ensApiConfig: EnsApiConfig; + /** + * The ENSDb config to be used by ENSApi. + */ ensDbConfig: EnsDbConfig; + /** + * The ENSDb client to be used by ENSApi for ENSDb access. + */ ensDbClient: EnsDbReader; + /** + * Alias for {@link ensDbClient.ensDb} to simplify access to the actual database connection. + */ ensDb: EnsDbReader["ensDb"]; + /** + * Alias for {@link ensDbClient.ensIndexerSchema} to simplify access to the ENSIndexer Schema. + */ ensIndexerSchema: EnsDbReader["ensIndexerSchema"]; + /** + * The ENS Namespace ID used by the ENSApi instance. + */ ensNamespaceId: ENSNamespaceId; + /** + * Chain ID of the ENS Root Chain for the {@link ensNamespaceId}. + */ rootChainId: ChainId; + /** + * RPC config for the ENS Root Chain. + */ rootChainRpcConfig: RpcConfig; + /** + * A cached instance of viem's {@link PublicClient} for the ENS Root Chain. + */ rootChainPublicClient: PublicClient; + /** + * Singleton {@link IndexingStatusCache} instance to be used across ENSApi. + */ indexingStatusCache: IndexingStatusCache; + /** + * Singleton {@link ReferralProgramEditionConfigSetCache} instance to be used across ENSApi. + */ referralProgramEditionConfigSetCache: ReferralProgramEditionConfigSetCache; + /** + * Singleton {@link EnsNodeStackInfoCache} instance to be used across ENSApi. + */ stackInfoCache: EnsNodeStackInfoCache; + /** + * Synchronous getter for {@link EnsNodeStackInfo} that reads from the {@link stackInfoCache}. + */ stackInfo: EnsNodeStackInfo; subgraphApiGqlMiddleware: ReturnType; @@ -151,6 +196,12 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { return instances.stackInfoCache; }, + /** + * Synchronous getter for stack info that reads from the stackInfoCache. + * + * Note: This assumes that the stack info has already been loaded into the cache + * (e.g. by calling `di.context.stackInfoCache.read()` during ENSApi startup). + */ get stackInfo(): EnsNodeStackInfo { const stackInfo = context.stackInfoCache.peek(); @@ -218,10 +269,119 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { return context; } -const di = { - get context(): Readonly { - return Object.freeze(buildEnsApiDiContext(process.env)); - }, -}; +/** + * Dependency Injection Container class for ENSApi + * + * It allows for lazy loading of the DI context and provides methods to + * initialize and destroy resources as needed. + * + * The lifecycle of the DI container is managed manually by calling + * the `init()` and `destroy()` methods, which allows for flexibility + * in when resources are initialized and cleaned up, such as during application + * startup and shutdown. + * + * @example + * ```ts + * const di = new EnsApiDiContainer(); + * di.init(); // Initializes the DI context and any necessary resources + * const ensNamespaceId = di.context.ensNamespaceId; // Access a member of the DI context + * di.destroy(); // Clean up resources when they are no longer needed + * ``` + */ +class EnsApiDiContainer { + private _context: EnsApiDiContext | undefined; + /** + * The DI context for ENSApi, which is lazily loaded on first access. + * + * Note: the context can be re-loaded by calling {@link di.loadContext()}. + */ + get context(): EnsApiDiContext { + if (!this._context) { + throw new Error( + "DI context has not been loaded yet. Call `di.init()` to load the context and initialize necessary resources.", + ); + } + return this._context; + } + + /** + * Initializes the DI container by loading the context and initializing + * necessary resources. + */ + init(): void { + if (this._context) { + throw new Error( + "DI context has already been initialized. If you want to re-initialize, call `di.destroy()` first to clean up resources.", + ); + } + + // Load the DI context + this.loadContext(); + + logger.info("Initializing caches"); + void Promise.all([ + this.context.indexingStatusCache.read(), + this.context.stackInfoCache.read(), + this.context.referralProgramEditionConfigSetCache.read(), + ]).then(() => logger.info("Caches initialized")); + } + + /** + * Destroys any resources held by the DI container, such as caches, to + * allow for clean shutdown or re-initialization. + */ + destroy(): void { + if (!this._context) { + logger.warn( + "DI context is not loaded, so there are no resources to destroy. If you are trying to reload the context, call `di.init()` to load the context and initialize necessary resources.", + ); + + return; + } + + logger.info("Destroying caches"); + this.context.stackInfoCache.destroy(); + this.context.indexingStatusCache.destroy(); + this.context.referralProgramEditionConfigSetCache.destroy(); + logger.info("Caches destroyed"); + + this._context = undefined; + } + + /** + * Loads the DI context by building it from the environment variables and + * freezing it to prevent modification at runtime. + * + * Note: useful for testing purposes to reset the DI context between tests, + * or during hot-reloading in development to reload the context. + * + * @throws Error if the context has already been loaded to prevent accidental + * overwriting of the context. Call `di.destroy()` first to clean up + * resources if you want to reload the context. + */ + private loadContext(): void { + if (this._context) { + throw new Error( + "DI context has already been loaded. If you want to reload the context, call `di.destroy()` first to clean up resources.", + ); + } + + logger.info("Loading context"); + + // Load the current environment variables into the DI context + // and freeze the context to prevent modification at runtime + this._context = Object.freeze(buildEnsApiDiContext(process.env)); + + logger.info( + { context: Object.keys(this.context) }, + "Context loaded, available members at `di.context` are", + ); + } +} + +/** + * The singleton instance of the {@link EnsApiDiContainer} for ENSApi. + */ +const di = new EnsApiDiContainer(); export default di; diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 4577c11c07..4821c3f2f9 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -11,6 +11,8 @@ import app from "./app"; // start ENSNode API OpenTelemetry SDK sdk.start(); +// initialize DI container and its resources +di.init(); // start hono server const server = serve( @@ -47,10 +49,6 @@ const gracefulShutdown = async () => { await sdk.shutdown(); logger.info("Destroyed tracing instrumentation"); - // Destroy referral program edition config set cache - di.context.referralProgramEditionConfigSetCache.destroy(); - logger.info("Destroyed referralProgramEditionConfigSetCache"); - // Destroy all edition caches (if initialized) const editionsCaches = getReferralEditionSnapshotsCaches(); if (editionsCaches) { @@ -60,8 +58,8 @@ const gracefulShutdown = async () => { } } - di.context.indexingStatusCache.destroy(); - logger.info("Destroyed indexingStatusCache"); + // Destroy DI container resources + di.destroy(); await closeServer(); logger.info("Closed application server"); From 868ac54b7631f26c69df9ec8643e93ad8709c35a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 13:57:07 +0200 Subject: [PATCH 12/18] Wrap the GraphQL middleware for Subgraph API in a lazy-loaded module --- apps/ensapi/src/di.ts | 57 ------------------- .../src/handlers/subgraph/subgraph-api.ts | 15 ++++- .../ensapi/src/lib/subgraph/gql.middleware.ts | 51 +++++++++++++++++ 3 files changed, 63 insertions(+), 60 deletions(-) create mode 100644 apps/ensapi/src/lib/subgraph/gql.middleware.ts diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index 90f4d959fa..a6948748cb 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -5,7 +5,6 @@ import { type ENSNamespaceId, getENSRootChainId } from "@ensnode/datasources"; import type { EnsDbConfig, EnsDbReader } from "@ensnode/ensdb-sdk"; import type { EnsNodeStackInfo } from "@ensnode/ensnode-sdk"; import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; -import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; import { type IndexingStatusCache, indexingStatusCache } from "@/cache/indexing-status.cache"; import { @@ -20,7 +19,6 @@ import ensDbConfig from "@/config/ensdb-config"; import type { EnsApiEnvironment } from "@/config/environment"; import { ensDbClient } from "@/lib/ensdb/singleton"; import { makeLogger } from "@/lib/logger"; -import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; const logger = makeLogger("di"); @@ -97,8 +95,6 @@ export interface EnsApiDiContext { * Synchronous getter for {@link EnsNodeStackInfo} that reads from the {@link stackInfoCache}. */ stackInfo: EnsNodeStackInfo; - - subgraphApiGqlMiddleware: ReturnType; } export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { @@ -211,59 +207,6 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { return stackInfo; }, - - get subgraphApiGqlMiddleware(): ReturnType { - if (!instances.subgraphApiGqlMiddleware) { - // generate a subgraph-specific subset of the schema - const subgraphSchema = filterSchemaByPrefix("subgraph_", context.ensIndexerSchema); - - instances.subgraphApiGqlMiddleware = subgraphGraphQLMiddleware({ - databaseUrl: context.ensDbConfig.ensDbUrl, - databaseSchema: context.ensDbConfig.ensIndexerSchemaName, - schema: subgraphSchema, - // describes the polymorphic (interface) relationships in the schema - polymorphicConfig: { - types: { - DomainEvent: [ - subgraphSchema.transfer, - subgraphSchema.newOwner, - subgraphSchema.newResolver, - subgraphSchema.newTTL, - subgraphSchema.wrappedTransfer, - subgraphSchema.nameWrapped, - subgraphSchema.nameUnwrapped, - subgraphSchema.fusesSet, - subgraphSchema.expiryExtended, - ], - RegistrationEvent: [ - subgraphSchema.nameRegistered, - subgraphSchema.nameRenewed, - subgraphSchema.nameTransferred, - ], - ResolverEvent: [ - subgraphSchema.addrChanged, - subgraphSchema.multicoinAddrChanged, - subgraphSchema.nameChanged, - subgraphSchema.abiChanged, - subgraphSchema.pubkeyChanged, - subgraphSchema.textChanged, - subgraphSchema.contenthashChanged, - subgraphSchema.interfaceChanged, - subgraphSchema.authorisationChanged, - subgraphSchema.versionChanged, - ], - }, - fields: { - "Domain.events": "DomainEvent", - "Registration.events": "RegistrationEvent", - "Resolver.events": "ResolverEvent", - }, - }, - }); - } - - return instances.subgraphApiGqlMiddleware; - }, } satisfies EnsApiDiContext; return context; diff --git a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts index 367d4050e7..4b401c3300 100644 --- a/apps/ensapi/src/handlers/subgraph/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph/subgraph-api.ts @@ -59,8 +59,17 @@ app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/ app.use(subgraphMetaMiddleware); // inject the GraphQL middleware for the Subgraph API -// note: we wrap the middleware in a function to defer its construction until runtime, -// which allows lazy-loading of DI context dependencies -app.use(async (c, next) => di.context.subgraphApiGqlMiddleware(c, next)); +app.use(async (c, next) => { + // Note: we import the middleware with a dynamic import to defer its construction until runtime. + // This allows the middleware internal logic to only access the DI container at runtime, + // which prevents potential issues with eager evaluation of the DI container's dependencies. + // Thanks to the dynamic import, the `gql.middleware` module to be resolved just once, + // and reused for subsequent requests. + const subgraphApiGqlMiddleware = await import("@/lib/subgraph/gql.middleware").then( + (mod) => mod.default, + ); + + return subgraphApiGqlMiddleware(c, next); +}); export default app; diff --git a/apps/ensapi/src/lib/subgraph/gql.middleware.ts b/apps/ensapi/src/lib/subgraph/gql.middleware.ts new file mode 100644 index 0000000000..57ee2e4a0f --- /dev/null +++ b/apps/ensapi/src/lib/subgraph/gql.middleware.ts @@ -0,0 +1,51 @@ +import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; + +import di from "@/di"; +import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; + +// generate a subgraph-specific subset of the schema +const subgraphSchema = filterSchemaByPrefix("subgraph_", di.context.ensIndexerSchema); + +export default subgraphGraphQLMiddleware({ + databaseUrl: di.context.ensDbConfig.ensDbUrl, + databaseSchema: di.context.ensDbConfig.ensIndexerSchemaName, + schema: subgraphSchema, + // describes the polymorphic (interface) relationships in the schema + polymorphicConfig: { + types: { + DomainEvent: [ + subgraphSchema.transfer, + subgraphSchema.newOwner, + subgraphSchema.newResolver, + subgraphSchema.newTTL, + subgraphSchema.wrappedTransfer, + subgraphSchema.nameWrapped, + subgraphSchema.nameUnwrapped, + subgraphSchema.fusesSet, + subgraphSchema.expiryExtended, + ], + RegistrationEvent: [ + subgraphSchema.nameRegistered, + subgraphSchema.nameRenewed, + subgraphSchema.nameTransferred, + ], + ResolverEvent: [ + subgraphSchema.addrChanged, + subgraphSchema.multicoinAddrChanged, + subgraphSchema.nameChanged, + subgraphSchema.abiChanged, + subgraphSchema.pubkeyChanged, + subgraphSchema.textChanged, + subgraphSchema.contenthashChanged, + subgraphSchema.interfaceChanged, + subgraphSchema.authorisationChanged, + subgraphSchema.versionChanged, + ], + }, + fields: { + "Domain.events": "DomainEvent", + "Registration.events": "RegistrationEvent", + "Resolver.events": "ResolverEvent", + }, + }, +}); From 4d6ddf908836a7ffcdb08464e1605ec20a4fb644 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 14:12:19 +0200 Subject: [PATCH 13/18] docs(changeset): Added `peek` method to `SWRCache` class. --- .changeset/thirty-bees-divide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thirty-bees-divide.md diff --git a/.changeset/thirty-bees-divide.md b/.changeset/thirty-bees-divide.md new file mode 100644 index 0000000000..21a09b5390 --- /dev/null +++ b/.changeset/thirty-bees-divide.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Added `peek` method to `SWRCache` class. From 290b1e128566e9e80cac4cd90acfad9d5df068c8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 14:14:16 +0200 Subject: [PATCH 14/18] docs(changeset): Replaced all eagerly-evaluated reads from `import config from "@/config";` with lazy-evaluated reads from `import di from "@/di";`. This change allows more granual control over internal resources in ENSApi. --- .changeset/clever-coins-sell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-coins-sell.md diff --git a/.changeset/clever-coins-sell.md b/.changeset/clever-coins-sell.md new file mode 100644 index 0000000000..93a241ca0a --- /dev/null +++ b/.changeset/clever-coins-sell.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Replaced all eagerly-evaluated reads from `import config from "@/config";` with lazy-evaluated reads from `import di from "@/di";`. This change allows more granual control over internal resources in ENSApi. From a74c399826a0de09ff95aa465b9ac4512d236266 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 14:28:25 +0200 Subject: [PATCH 15/18] Apply AI PR feedback --- apps/ensapi/src/config/config.schema.test.ts | 25 +--------------- apps/ensapi/src/di.ts | 31 +++++++------------- apps/ensapi/src/index.ts | 11 ++++--- 3 files changed, 17 insertions(+), 50 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index 7e2b541323..cae54d7d6f 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RpcConfig } from "@ensnode/ensnode-sdk/internal"; - import { ensApiVersionInfo } from "@/lib/version-info"; vi.mock("@/lib/ensdb/singleton", () => ({ @@ -24,11 +22,7 @@ import { buildEnsApiPublicConfig, buildRootChainRpcConfig, } from "@/config/config.schema"; -import { - BASE_ENV, - indexingMetadataContextInitialized, - VALID_RPC_URL, -} from "@/config/config.schema.mock"; +import { BASE_ENV, indexingMetadataContextInitialized } from "@/config/config.schema.mock"; import { ENSApi_DEFAULT_PORT } from "@/config/defaults"; import logger from "@/lib/logger"; @@ -168,19 +162,6 @@ describe("buildEnsApiPublicConfig", () => { const { ensIndexer: ensIndexerPublicConfig } = indexingMetadataContextInitialized.stackInfo; const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map([ - [ - 1, - { - httpRPCs: [new URL(VALID_RPC_URL)], - websocketRPC: undefined, - } satisfies RpcConfig, - ], - ]), referralProgramEditionConfigSetUrl: undefined, }; @@ -243,10 +224,6 @@ describe("buildEnsApiPublicConfig", () => { const ensApiConfig = { port: ENSApi_DEFAULT_PORT, ensDbUrl: BASE_ENV.ENSDB_URL, - ensIndexerSchemaName: BASE_ENV.ENSINDEXER_SCHEMA_NAME, - ensIndexerPublicConfig, - namespace: ensIndexerPublicConfig.namespace, - rpcConfigs: new Map(), referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: undefined, }; diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index a6948748cb..0761d5961d 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -26,11 +26,6 @@ const logger = makeLogger("di"); * Dependency Injection Container for ENSApi. */ export interface EnsApiDiContext { - /** - * The environment variables for ENSApi. - */ - ensApiEnvironment: EnsApiEnvironment; - /** * The ENSApi config. */ @@ -97,24 +92,20 @@ export interface EnsApiDiContext { stackInfo: EnsNodeStackInfo; } -export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { +export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsApiDiContext { const instances = {} as EnsApiDiContext; const context = { - get ensApiEnvironment(): EnsApiEnvironment { - return env; - }, - get ensApiConfig(): EnsApiConfig { - if (!instances.ensApiConfig) { - instances.ensApiConfig = buildConfigFromEnvironment(env); + if (instances.ensApiConfig === undefined) { + instances.ensApiConfig = buildConfigFromEnvironment(ensApiEnvironment); } return instances.ensApiConfig; }, get ensDbConfig(): EnsDbConfig { - if (!instances.ensDbConfig) { + if (instances.ensDbConfig === undefined) { instances.ensDbConfig = ensDbConfig; } return instances.ensDbConfig; @@ -137,9 +128,9 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get rootChainRpcConfig(): RpcConfig { - if (!instances.rootChainRpcConfig) { + if (instances.rootChainRpcConfig === undefined) { instances.rootChainRpcConfig = buildRootChainRpcConfig( - context.ensApiEnvironment, + ensApiEnvironment, context.ensNamespaceId, ); } @@ -148,7 +139,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get rootChainId(): ChainId { - if (!instances.rootChainId) { + if (instances.rootChainId === undefined) { instances.rootChainId = getENSRootChainId(context.ensNamespaceId); } @@ -156,7 +147,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get rootChainPublicClient(): PublicClient { - if (!instances.rootChainPublicClient) { + if (instances.rootChainPublicClient === undefined) { // Create an viem#PublicClient that uses a fallback() transport with all specified HTTP RPCs instances.rootChainPublicClient = createPublicClient({ transport: fallback( @@ -169,7 +160,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get indexingStatusCache(): IndexingStatusCache { - if (!instances.indexingStatusCache) { + if (instances.indexingStatusCache === undefined) { instances.indexingStatusCache = indexingStatusCache; } @@ -177,7 +168,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get referralProgramEditionConfigSetCache(): ReferralProgramEditionConfigSetCache { - if (!instances.referralProgramEditionConfigSetCache) { + if (instances.referralProgramEditionConfigSetCache === undefined) { instances.referralProgramEditionConfigSetCache = referralProgramEditionConfigSetCache; } @@ -185,7 +176,7 @@ export function buildEnsApiDiContext(env: NodeJS.ProcessEnv): EnsApiDiContext { }, get stackInfoCache(): EnsNodeStackInfoCache { - if (!instances.stackInfoCache) { + if (instances.stackInfoCache === undefined) { instances.stackInfoCache = stackInfoCache; } diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 4821c3f2f9..d8e660ed8c 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -27,9 +27,6 @@ const server = serve( const shouldWriteSchema = !(process.env.NODE_ENV === "production") && !INCLUDE_DEV_METHODS; if (shouldWriteSchema) void writeGraphQLSchema(); - // proactively warm up caches in the background - void Promise.all([di.context.indexingStatusCache.read(), di.context.stackInfoCache.read()]); - logger.info(`ENSApi listening on port ${info.port}`); }, ); @@ -46,6 +43,11 @@ const closeServer = () => // perform graceful shutdown const gracefulShutdown = async () => { try { + // Close the server to stop accepting new requests + await closeServer(); + logger.info("Closed application server"); + + // Shutdown the OpenTelemetry SDK to flush any remaining spans await sdk.shutdown(); logger.info("Destroyed tracing instrumentation"); @@ -61,9 +63,6 @@ const gracefulShutdown = async () => { // Destroy DI container resources di.destroy(); - await closeServer(); - logger.info("Closed application server"); - process.exit(0); } catch (error) { logger.error(error); From 9bca675e12a50964a3ceed99d947d5f069284d4c Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 14:57:32 +0200 Subject: [PATCH 16/18] Apply AI PR feedback --- apps/ensapi/src/di.ts | 2 +- apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index 0761d5961d..160d21c3f3 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -193,7 +193,7 @@ export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsA const stackInfo = context.stackInfoCache.peek(); if (stackInfo instanceof Error) { - throw new Error("Stack info is not available in the stackInfoCache."); + throw stackInfo; } return stackInfo; diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index fd40076d6b..895e5e1559 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -72,7 +72,7 @@ export async function findResolver({ // Invariant: UniversalResolver#findResolver only works for ENS Root Registry if (!isENSv1Registry(di.context.ensNamespaceId, registry)) { throw new Error( - `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers agains the ENs Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, + `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers against the ENS Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, ); } From 1a3f8a1d2d0a1fcf0f4b952f4e5b1b2be2d5044c Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 19:48:08 +0200 Subject: [PATCH 17/18] Apply PR feedback --- apps/ensapi/src/config/config.schema.ts | 8 ++++---- apps/ensapi/src/di.ts | 12 ++++++------ .../handlers/api/explore/name-tokens-api.ts | 2 +- .../find-name-tokens-for-domain.ts | 2 +- .../protocol-acceleration/find-resolver.ts | 4 ++-- .../get-records-from-index.ts | 2 +- .../src/lib/resolution/forward-resolution.ts | 19 ++++++++----------- .../multichain-primary-name-resolution.ts | 12 ++++++------ .../middleware/can-accelerate.middleware.ts | 2 +- .../thegraph-fallback.middleware.ts | 4 ++-- .../lib/get-domain-by-interpreted-name.ts | 18 +++++------------- apps/ensapi/src/omnigraph-api/schema/query.ts | 2 +- .../src/omnigraph-api/schema/resolver.ts | 2 +- 13 files changed, 39 insertions(+), 50 deletions(-) diff --git a/apps/ensapi/src/config/config.schema.ts b/apps/ensapi/src/config/config.schema.ts index e3413357a0..01bd98ca0e 100644 --- a/apps/ensapi/src/config/config.schema.ts +++ b/apps/ensapi/src/config/config.schema.ts @@ -77,7 +77,7 @@ export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig /** * Builds the RPC config for the root chain based on the provided environment and ENS namespace ID. * @param env - The environment variables for the ENSApi - * @param ensNamespaceId - The ENS namespace ID + * @param namespace - The ENS namespace ID * @returns The RPC config for the root chain * * Note: If error occurs during parsing/validation, the error will be logged and the process @@ -85,11 +85,11 @@ export function buildConfigFromEnvironment(env: EnsApiEnvironment): EnsApiConfig */ export function buildRootChainRpcConfig( env: EnsApiEnvironment, - ensNamespaceId: ENSNamespaceId, + namespace: ENSNamespaceId, ): RpcConfig { try { - const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, ensNamespaceId); - const rootChainId = getENSRootChainId(ensNamespaceId); + const unvalidatedRpcConfigs = buildRpcConfigsFromEnv(env, namespace); + const rootChainId = getENSRootChainId(namespace); const rpcConfigs = RpcConfigsSchema.parse(unvalidatedRpcConfigs); const rootChainRpcConfig = rpcConfigs.get(rootChainId); diff --git a/apps/ensapi/src/di.ts b/apps/ensapi/src/di.ts index 160d21c3f3..8293f8fb33 100644 --- a/apps/ensapi/src/di.ts +++ b/apps/ensapi/src/di.ts @@ -54,10 +54,10 @@ export interface EnsApiDiContext { /** * The ENS Namespace ID used by the ENSApi instance. */ - ensNamespaceId: ENSNamespaceId; + namespace: ENSNamespaceId; /** - * Chain ID of the ENS Root Chain for the {@link ensNamespaceId}. + * Chain ID of the ENS Root Chain for the {@link namespace}. */ rootChainId: ChainId; @@ -123,7 +123,7 @@ export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsA return context.ensDbClient.ensIndexerSchema; }, - get ensNamespaceId(): ENSNamespaceId { + get namespace(): ENSNamespaceId { return context.stackInfo.ensIndexer.namespace; }, @@ -131,7 +131,7 @@ export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsA if (instances.rootChainRpcConfig === undefined) { instances.rootChainRpcConfig = buildRootChainRpcConfig( ensApiEnvironment, - context.ensNamespaceId, + context.namespace, ); } @@ -140,7 +140,7 @@ export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsA get rootChainId(): ChainId { if (instances.rootChainId === undefined) { - instances.rootChainId = getENSRootChainId(context.ensNamespaceId); + instances.rootChainId = getENSRootChainId(context.namespace); } return instances.rootChainId; @@ -218,7 +218,7 @@ export function buildEnsApiDiContext(ensApiEnvironment: EnsApiEnvironment): EnsA * ```ts * const di = new EnsApiDiContainer(); * di.init(); // Initializes the DI context and any necessary resources - * const ensNamespaceId = di.context.ensNamespaceId; // Access a member of the DI context + * const namespace = di.context.namespace; // Access a member of the DI context * di.destroy(); // Clean up resources when they are no longer needed * ``` */ diff --git a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts index 42b29da927..cb2b1d1f41 100644 --- a/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/api/explore/name-tokens-api.ts @@ -78,7 +78,7 @@ app.openapi(getNameTokensRoute, async (c) => { } const indexedSubregistries = getIndexedSubregistries( - di.context.ensNamespaceId, + di.context.namespace, di.context.stackInfo.ensIndexer.plugins as PluginName[], ); const parentNode = namehashInterpretedName(parentName); diff --git a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts index 5f50e9b20b..e56981302a 100644 --- a/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts +++ b/apps/ensapi/src/lib/name-tokens/find-name-tokens-for-domain.ts @@ -102,7 +102,7 @@ function _recordsToRegisteredNameTokens( } satisfies AccountId; // biome-ignore lint/style/noNonNullAssertion: domain.name guaranteed to exist const name = asInterpretedName(record.domains.name!); - const ownership = getNameTokenOwnership(di.context.ensNamespaceId, name, owner); + const ownership = getNameTokenOwnership(di.context.namespace, name, owner); const token = _recordToNameToken(record, ownership); const expiresAt = bigIntToNumber(record.registrationLifecycles.expiresAt); diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 895e5e1559..d009afa039 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -70,7 +70,7 @@ export async function findResolver({ } // Invariant: UniversalResolver#findResolver only works for ENS Root Registry - if (!isENSv1Registry(di.context.ensNamespaceId, registry)) { + if (!isENSv1Registry(di.context.namespace, registry)) { throw new Error( `Invariant(findResolver): UniversalResolver#findResolver only identifies active resolvers against the ENS Root Registry, but a different Registry contract was passed: ${JSON.stringify(registry)}.`, ); @@ -97,7 +97,7 @@ async function findResolverWithUniversalResolver( contracts: { UniversalResolver: { address, abi }, }, - } = getDatasource(di.context.ensNamespaceId, DatasourceNames.ENSRoot); + } = getDatasource(di.context.namespace, DatasourceNames.ENSRoot); // 2. Call UniversalResolver#findResolver via RPC const dnsEncodedNameBytes = packetToBytes(name); diff --git a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts index 2e2436993e..4c68cd4493 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/get-records-from-index.ts @@ -40,7 +40,7 @@ export async function getRecordsFromIndex // initially be ENS Root Registry: see `_resolveForward` for additional context. return _resolveForward(interpretedName, selection, { ...options, - registry: getENSv1RootRegistry(di.context.ensNamespaceId), + registry: getENSv1RootRegistry(di.context.namespace), }); } @@ -166,15 +166,15 @@ async function _resolveForward( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root)) { const universalResolverV1 = getDatasourceContract( - di.context.ensNamespaceId, + di.context.namespace, DatasourceNames.ENSRoot, "UniversalResolver", ); const universalResolverV2 = maybeGetDatasourceContract( - di.context.ensNamespaceId, + di.context.namespace, DatasourceNames.ENSRoot, "UniversalResolverV2", ); @@ -242,7 +242,7 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - const bridged = isBridgedResolver(di.context.ensNamespaceId, resolver); + const bridged = isBridgedResolver(di.context.namespace, resolver); if (bridged) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, @@ -271,7 +271,7 @@ async function _resolveForward( const resolver = { chainId, address: activeResolver }; // Pass: ENSIP-19 Reverse Resolver - if (isKnownENSIP19ReverseResolver(di.context.ensNamespaceId, resolver)) { + if (isKnownENSIP19ReverseResolver(di.context.namespace, resolver)) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateENSIP19ReverseResolver, @@ -283,13 +283,10 @@ async function _resolveForward( // Pass: Known On-Chain Static Resolver with indexed records const resolverRecordsAreIndexed = areResolverRecordsIndexedByProtocolAccelerationPluginOnChainId( - di.context.ensNamespaceId, + di.context.namespace, chainId, ); - if ( - resolverRecordsAreIndexed && - isStaticResolver(di.context.ensNamespaceId, resolver) - ) { + if (resolverRecordsAreIndexed && isStaticResolver(di.context.namespace, resolver)) { operations = await withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOnchainStaticResolver, diff --git a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts index b2ffaa6e0e..dc433dccbd 100644 --- a/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts +++ b/apps/ensapi/src/lib/resolution/multichain-primary-name-resolution.ts @@ -22,12 +22,12 @@ const getENSIP19SupportedChainIds = () => { // then include any ENSIP-19 Supported Chains defined in this namespace ...[ - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverRoot), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverBase), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverLinea), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverOptimism), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverArbitrum), - maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ReverseResolverScroll), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverRoot), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverBase), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverLinea), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverOptimism), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverArbitrum), + maybeGetDatasource(di.context.namespace, DatasourceNames.ReverseResolverScroll), ] .filter((ds) => ds !== undefined) .map((ds) => ds.chain.id), diff --git a/apps/ensapi/src/middleware/can-accelerate.middleware.ts b/apps/ensapi/src/middleware/can-accelerate.middleware.ts index 4acb00ee87..abc29d59ea 100644 --- a/apps/ensapi/src/middleware/can-accelerate.middleware.ts +++ b/apps/ensapi/src/middleware/can-accelerate.middleware.ts @@ -38,7 +38,7 @@ export const canAccelerateMiddleware = producing( // NOTE: gate on the namespace containing an ENSv2Root datasource rather than the ENSv2 // plugin being configured — a namespace may be ENSv1-only even when the Unigraph plugin is // defined, and forward resolution must follow the ENSv1 path in that case. - if (maybeGetDatasource(di.context.ensNamespaceId, DatasourceNames.ENSv2Root)) { + if (maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root)) { if (!didWarnCannotAccelerateENSv2) { logger.warn( `ENSApi is temporarily unable to accelerate Resolution API requests while indexing ENSv2. Protocol Acceleration is DISABLED.`, diff --git a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts index 21f3247088..c3f569a93f 100644 --- a/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts +++ b/apps/ensapi/src/middleware/thegraph-fallback.middleware.ts @@ -25,7 +25,7 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } const fallback = canFallbackToTheGraph({ - namespace: di.context.ensNamespaceId, + namespace: di.context.namespace, theGraphApiKey: di.context.ensApiConfig.theGraphApiKey, isSubgraphCompatible: di.context.stackInfo.ensIndexer.isSubgraphCompatible, }); @@ -45,7 +45,7 @@ export const thegraphFallbackMiddleware = factory.createMiddleware(async (c, nex } case "no-subgraph-url": { logger.warn( - `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${di.context.ensNamespaceId}') is not supported by The Graph.`, + `ENSApi can NOT fallback to The Graph: the connected ENSIndexer's namespace ('${di.context.namespace}') is not supported by The Graph.`, ); break; } diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts index b54d416128..745c1ec0cd 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts @@ -83,7 +83,7 @@ export async function getDomainIdByInterpretedName( } return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - forwardWalkNamegraph(getRootRegistryId(di.context.ensNamespaceId), path), + forwardWalkNamegraph(getRootRegistryId(di.context.namespace), path), ); } @@ -128,10 +128,10 @@ async function forwardWalkNamegraph( // otherwise, identify the deepest element with a Resolver const deepestResolver = rows.find(hasResolver); if (deepestResolver) { - const resolverEq = makeContractMatcher(di.context.ensNamespaceId, deepestResolver); + const resolverEq = makeContractMatcher(di.context.namespace, deepestResolver); // Bridged Resolvers // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry - const bridged = isBridgedResolver(di.context.ensNamespaceId, deepestResolver); + const bridged = isBridgedResolver(di.context.namespace, deepestResolver); if (bridged) { // to follow a Bridged Resolver, continue walking the namegraph from the target `registryId` // with the remaining portion of `path` @@ -150,21 +150,13 @@ async function forwardWalkNamegraph( // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 if (resolverEq(DatasourceNames.ENSv2Root, "ENSv1Resolver")) { // to implement the ENSv1Resolver, walk the ENSv1 disjoint namegraph with the full path - return forwardWalkNamegraph( - getENSv1RootRegistryId(di.context.ensNamespaceId), - path, - depth + 1, - ); + return forwardWalkNamegraph(getENSv1RootRegistryId(di.context.namespace), path, depth + 1); } // ENSv2Resolver (ENSv2 Fallback) if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path - return forwardWalkNamegraph( - getENSv2RootRegistryId(di.context.ensNamespaceId), - path, - depth + 1, - ); + return forwardWalkNamegraph(getENSv2RootRegistryId(di.context.namespace), path, depth + 1); } } diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 8081c387e8..56464f2914 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -181,7 +181,7 @@ builder.queryType({ "The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry.", type: RegistryInterfaceRef, nullable: false, - resolve: () => getRootRegistryId(di.context.ensNamespaceId), + resolve: () => getRootRegistryId(di.context.namespace), }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 47acd6c00c..f1db948a7d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -128,7 +128,7 @@ ResolverRef.implement({ type: RegistryInterfaceRef, nullable: true, resolve: (parent) => { - const bridged = isBridgedResolver(di.context.ensNamespaceId, parent); + const bridged = isBridgedResolver(di.context.namespace, parent); return bridged?.targetRegistryId ?? null; }, }), From e6f3518a360fb9ad4bc92d2e0787a2dd1f52ef43 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 21 May 2026 19:58:36 +0200 Subject: [PATCH 18/18] Apply PR feedback --- .changeset/clever-coins-sell.md | 5 ----- apps/ensapi/src/cache/referral-program-edition-set.cache.ts | 5 ++++- apps/ensapi/src/config/config.schema.test.ts | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) delete mode 100644 .changeset/clever-coins-sell.md diff --git a/.changeset/clever-coins-sell.md b/.changeset/clever-coins-sell.md deleted file mode 100644 index 93a241ca0a..0000000000 --- a/.changeset/clever-coins-sell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ensapi": minor ---- - -Replaced all eagerly-evaluated reads from `import config from "@/config";` with lazy-evaluated reads from `import di from "@/di";`. This change allows more granual control over internal resources in ENSApi. diff --git a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts index 3161a4f262..b4d7eec1df 100644 --- a/apps/ensapi/src/cache/referral-program-edition-set.cache.ts +++ b/apps/ensapi/src/cache/referral-program-edition-set.cache.ts @@ -8,7 +8,6 @@ import { minutesToSeconds } from "date-fns"; import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk"; -import di from "@/di"; import { lazyProxy } from "@/lib/lazy"; import { makeLogger } from "@/lib/logger"; @@ -31,6 +30,10 @@ function partiallyRedactUrl(url: URL): string { async function loadReferralProgramEditionConfigSet( _cachedResult?: CachedResult, ): Promise { + // Async import `di` here to avoid circular dependency between this cache module and the DI container module. + // NOTE: It will not be required soon, as we plan to create a factory function for this cache + // that accepts the necessary dependencies as parameters, instead of importing from the DI container. + const di = await import("@/di").then((mod) => mod.default); const { referralProgramEditionConfigSetUrl } = di.context.ensApiConfig; // If no URL is configured, treat the referral program as having zero editions. diff --git a/apps/ensapi/src/config/config.schema.test.ts b/apps/ensapi/src/config/config.schema.test.ts index cae54d7d6f..a56083d87b 100644 --- a/apps/ensapi/src/config/config.schema.test.ts +++ b/apps/ensapi/src/config/config.schema.test.ts @@ -223,7 +223,6 @@ describe("buildEnsApiPublicConfig", () => { const ensApiConfig = { port: ENSApi_DEFAULT_PORT, - ensDbUrl: BASE_ENV.ENSDB_URL, referralProgramEditionConfigSetUrl: undefined, theGraphApiKey: undefined, };