From aee85b60af4471ffe35d1607e7130f19e190474f Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 10:24:25 -0500 Subject: [PATCH 1/6] feat(ensapi): add DomainResolver.effective to Omnigraph API Adds `DomainResolver.effective`, the Resolver that ENS Forward Resolution (ENSIP-10) lands on for a Domain, identified from indexed data via findResolverWithIndex. Renames getDomainResolver -> getDomainAssignedResolver to disambiguate from the new effective-resolver lookup. --- .changeset/domain-resolver-effective.md | 5 +++ .../protocol-acceleration/find-resolver.ts | 2 +- .../omnigraph-api/lib/get-domain-resolver.ts | 40 ++++++++++++++++++- .../omnigraph-api/schema/domain-resolver.ts | 18 ++++++++- .../src/omnigraph/generated/schema.graphql | 5 +++ 5 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 .changeset/domain-resolver-effective.md diff --git a/.changeset/domain-resolver-effective.md b/.changeset/domain-resolver-effective.md new file mode 100644 index 0000000000..f909e53bbd --- /dev/null +++ b/.changeset/domain-resolver-effective.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +**Omnigraph API:** Adds `DomainResolver.effective`, the Resolver that ENS Forward Resolution (ENSIP-10) lands on for a Domain — identified from indexed data by walking the name hierarchy within the Domain's Registry. Complements the existing `DomainResolver.assigned` (the Domain's directly-assigned Resolver). diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index ad9cee0283..d9b72dd541 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -164,7 +164,7 @@ async function findResolverWithUniversalResolver( * // Returns: "0x123..." or null if no resolver found * ``` */ -async function findResolverWithIndex( +export async function findResolverWithIndex( registry: AccountId, name: InterpretedName, ): Promise { diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts index 346c229cde..0e7c7daca5 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts @@ -1,8 +1,15 @@ -import type { DomainId } from "enssdk"; +import { type DomainId, makeResolverId } from "enssdk"; import di from "@/di"; +import { findResolverWithIndex } from "@/lib/protocol-acceleration/find-resolver"; -export async function getDomainResolver(domainId: DomainId) { +/** + * Identifies the Resolver that this Domain has _assigned_, if any. + * + * NOTE: this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver. See + * {@link getDomainEffectiveResolver}. + */ +export async function getDomainAssignedResolver(domainId: DomainId) { const { ensDb } = di.context; const drr = await ensDb.query.domainResolverRelation.findFirst({ where: (t, { eq }) => eq(t.domainId, domainId), @@ -11,3 +18,32 @@ export async function getDomainResolver(domainId: DomainId) { return drr?.resolver; } + +/** + * Identifies the Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. + * its _effective_ Resolver — by walking the name hierarchy within the Domain's Registry via indexed + * data ({@link findResolverWithIndex}). + * + * Returns null when the Domain is not in the canonical nametree (no name to resolve against) or when + * no active Resolver exists for it. + */ +export async function getDomainEffectiveResolver(domainId: DomainId) { + const { ensDb } = di.context; + + const domain = await ensDb.query.domain.findFirst({ + where: (t, { eq }) => eq(t.id, domainId), + with: { registry: true }, + }); + + // a Domain outside the canonical nametree has no name to perform Forward Resolution against + if (!domain?.canonicalName) return null; + + const registry = { chainId: domain.registry.chainId, address: domain.registry.address }; + + const { activeResolver } = await findResolverWithIndex(registry, domain.canonicalName); + + if (!activeResolver) return null; + + // the effective Resolver lives on the Registry's chain + return makeResolverId({ chainId: registry.chainId, address: activeResolver }); +} diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts index 031570b5f7..7ccd3c7d64 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts @@ -1,7 +1,10 @@ import type { DomainId } from "enssdk"; import { builder } from "@/omnigraph-api/builder"; -import { getDomainResolver } from "@/omnigraph-api/lib/get-domain-resolver"; +import { + getDomainAssignedResolver, + getDomainEffectiveResolver, +} from "@/omnigraph-api/lib/get-domain-resolver"; import { ResolverRef } from "@/omnigraph-api/schema/resolver"; //////////////////////////////// @@ -20,7 +23,18 @@ DomainResolverRef.implement({ "The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.", type: ResolverRef, nullable: true, - resolve: (domainId) => getDomainResolver(domainId), + resolve: (domainId) => getDomainAssignedResolver(domainId), + }), + + //////////////////////////// + // DomainResolver.effective + //////////////////////////// + effective: t.field({ + description: + "The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree.", + type: ResolverRef, + nullable: true, + resolve: (domainId) => getDomainEffectiveResolver(domainId), }), }), }); diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 81bc4ece1e..46ed689361 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -462,6 +462,11 @@ type DomainResolver { The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. """ assigned: Resolver + + """ + The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree. + """ + effective: Resolver } type DomainSubdomainsConnection { From 1cbb34f02a71a38c267337eda17da8a8d7159a64 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 10:27:39 -0500 Subject: [PATCH 2/6] docs: trim DomainResolver.effective descriptions (loop 1) --- .changeset/domain-resolver-effective.md | 2 +- apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts | 2 +- packages/enssdk/src/omnigraph/generated/introspection.ts | 9 +++++++++ packages/enssdk/src/omnigraph/generated/schema.graphql | 2 +- packages/ensskills/skills/omnigraph/SKILL.md | 1 + 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/domain-resolver-effective.md b/.changeset/domain-resolver-effective.md index f909e53bbd..e76f7c1868 100644 --- a/.changeset/domain-resolver-effective.md +++ b/.changeset/domain-resolver-effective.md @@ -2,4 +2,4 @@ "ensapi": patch --- -**Omnigraph API:** Adds `DomainResolver.effective`, the Resolver that ENS Forward Resolution (ENSIP-10) lands on for a Domain — identified from indexed data by walking the name hierarchy within the Domain's Registry. Complements the existing `DomainResolver.assigned` (the Domain's directly-assigned Resolver). +**Omnigraph API:** Adds `DomainResolver.effective`, the Resolver that ENS Forward Resolution (ENSIP-10) lands on for a Domain. Complements the existing `DomainResolver.assigned` (the Domain's directly-assigned Resolver). diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts index 7ccd3c7d64..79322fa0bc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.ts @@ -31,7 +31,7 @@ DomainResolverRef.implement({ //////////////////////////// effective: t.field({ description: - "The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree.", + "The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree.", type: ResolverRef, nullable: true, resolve: (domainId) => getDomainEffectiveResolver(domainId), diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 37377f6f86..62ce6ea0c9 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1829,6 +1829,15 @@ const introspection = { }, "args": [], "isDeprecated": false + }, + { + "name": "effective", + "type": { + "kind": "OBJECT", + "name": "Resolver" + }, + "args": [], + "isDeprecated": false } ], "interfaces": [] diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 46ed689361..aa0bc709e7 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -464,7 +464,7 @@ type DomainResolver { assigned: Resolver """ - The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree. + The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree. """ effective: Resolver } diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md index b7dc798f6d..1acd087ee9 100644 --- a/packages/ensskills/skills/omnigraph/SKILL.md +++ b/packages/ensskills/skills/omnigraph/SKILL.md @@ -139,6 +139,7 @@ _A Resolver represents a Resolver contract on-chain._ _Metadata describing this Domain's relationship to its Resolver(s)._ - assigned: Resolver — The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution. +- effective: Resolver — The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver, identified by walking the name hierarchy within the Domain's Registry. Null when no active Resolver exists or the Domain is not in the canonical nametree. #### Registry From d68631efd31a78c718118eae838cc1fcff73c1db Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 11:35:54 -0500 Subject: [PATCH 3/6] feat(ensapi): resolve DomainResolver.effective for ENSv1 and ENSv2 Make findResolverWithIndex ENSv2-aware so DomainResolver.effective works across both data models. It now forks by namespace: ENSv1-only namespaces keep the namehash + Domain-Resolver-Relation lookup (Protocol Acceleration data only), while ENSv2 namespaces walk the namegraph by labelHash and return the deepest ancestor Domain with an assigned Resolver. - Extract the disjoint namegraph walk into a shared forward-walk-namegraph module, reused by findResolverWithIndex and getDomainIdByInterpretedName. - Add getRootRegistry (AccountId) and DRY getRootRegistryId through it. - Seed noresolver.parent.eth (no Resolver under a Resolver-bearing parent) to exercise effective-Resolver fallback, plus an integration test covering assigned===effective and the fallback case. The ENSv2 walk depends on the Unigraph-maintained domain table; the fork preserves protocol-acceleration-in-isolation for ENSv1 until the plugins are folded (TODO(fold-protocol-acceleration) markers added). --- .../protocol-acceleration/find-resolver.ts | 110 +++++++++++++++--- .../forward-walk-namegraph.ts | 79 +++++++++++++ .../lib/get-domain-by-interpreted-name.ts | 78 +------------ .../omnigraph-api/lib/get-domain-resolver.ts | 19 +-- .../domain-resolver.integration.test.ts | 51 ++++++++ .../ensnode-sdk/src/shared/root-registry.ts | 18 ++- .../src/devnet/fixtures.ts | 13 +++ .../src/seed/effective-resolver-fallback.ts | 48 ++++++++ .../integration-test-env/src/seed/index.ts | 2 + 9 files changed, 317 insertions(+), 101 deletions(-) create mode 100644 apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts create mode 100644 apps/ensapi/src/omnigraph-api/schema/domain-resolver.integration.test.ts create mode 100644 packages/integration-test-env/src/seed/effective-resolver-fallback.ts diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index d9b72dd541..5620351afc 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -7,17 +7,24 @@ import { type DomainId, getNameHierarchy, type InterpretedName, + interpretedLabelsToLabelHashPath, + interpretedNameToInterpretedLabels, + makeConcreteRegistryId, makeENSv1DomainId, namehashInterpretedName, } from "enssdk"; import { isAddressEqual, type PublicClient, toHex, zeroAddress } from "viem"; import { packetToBytes } from "viem/ens"; -import { DatasourceNames, getDatasource } from "@ensnode/datasources"; +import { DatasourceNames, getDatasource, maybeGetDatasource } from "@ensnode/datasources"; import { accountIdEqual, isENSv1Registry } from "@ensnode/ensnode-sdk"; import di from "@/di"; import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { + forwardWalkDisjointNamegraph, + hasResolver, +} from "@/lib/protocol-acceleration/forward-walk-namegraph"; type FindResolverResult = | { @@ -154,37 +161,53 @@ async function findResolverWithUniversalResolver( * Identifies the active resolver for a given ENS name, using indexed data, following ENSIP-10. * This function parallels UniversalResolver#findResolver. * - * @param registry — the AccountId of the Registry / Shadow Registry to use - * @param name - The ENS name to find the Resolver for - * @returns The resolver ID if found, null otherwise + * Forks by namespace data model: + * - ENSv1-only namespaces depend solely on Protocol Acceleration data + * - ENSv2 must walk the namegraph, so it has a dependency on the unigraph plugin * - * @example - * ```ts - * const resolverId = await identifyActiveResolver("sub.example.eth") - * // Returns: "0x123..." or null if no resolver found - * ``` + * @param registry — the AccountId of the Registry / Shadow Registry to begin from + * @param name - The ENS name to find the active Resolver for */ export async function findResolverWithIndex( registry: AccountId, name: InterpretedName, +): Promise { + // TODO(fold-protocol-acceleration): once the Protocol Acceleration plugin is folded into the + // Unigraph plugin, the `domain` table is guaranteed wherever Domain-Resolver Relations exist, so + // this ENSv1/ENSv2 fork collapses — delete findResolverWithIndexENSv1 and always walk the namegraph + // (findResolverWithIndexENSv2), which handles both data models. + return maybeGetDatasource(di.context.namespace, DatasourceNames.ENSv2Root) + ? findResolverWithIndexENSv2(registry, name) + : findResolverWithIndexENSv1(registry, name); +} + +/** + * ENSv1 active-resolver identification: computes the namehash-keyed DomainId of each ancestor and + * reads the Domain-Resolver Relations directly. Depends only on Protocol Acceleration data. + * + * TODO(fold-protocol-acceleration): remove this function once Protocol Acceleration is folded into + * Unigraph — the namegraph walk (findResolverWithIndexENSv2) then handles ENSv1 too. + */ +async function findResolverWithIndexENSv1( + registry: AccountId, + name: InterpretedName, ): Promise { return withActiveSpanAsync( tracer, - "findResolverWithIndex", + "findResolverWithIndexENSv1", { chainId: registry.chainId, registry: registry.address, name }, async () => { - // TODO: all of this logic needs to be updated for ENSv2 Datamodel, need to reference new UR - // 1. construct a hierarchy of names. i.e. sub.example.eth -> [sub.example.eth, example.eth, eth] const names = getNameHierarchy(name); // Invariant: there is at least 1 name in the hierarchy if (names.length === 0) { - throw new Error(`Invariant(findResolverWithIndex): received an invalid name: '${name}'`); + throw new Error( + `Invariant(findResolverWithIndexENSv1): received an invalid name: '${name}'`, + ); } - // 2. compute domainId of each node - // NOTE: this is currently ENSv1-specific + // 2. compute the namehash-keyed domainId of each node in the hierarchy const domainIds = names.map( (name) => makeENSv1DomainId(registry, namehashInterpretedName(name)) as DomainId, ); @@ -234,7 +257,7 @@ export async function findResolverWithIndex( // should never be zeroAddress. if (isAddressEqual(resolver, zeroAddress)) { throw new Error( - `Invariant(findResolverWithIndex): Encountered a zeroAddress resolverAddress for ${domainId}, which should be impossible: check ProtocolAcceleration Domain-Resolver Relation indexing logic.`, + `Invariant(findResolverWithIndexENSv1): Encountered a zeroAddress resolverAddress for ${domainId}, which should be impossible: check ProtocolAcceleration Domain-Resolver Relation indexing logic.`, ); } @@ -242,10 +265,10 @@ export async function findResolverWithIndex( const indexInHierarchy = domainIds.indexOf(domainId); const activeName = names[indexInHierarchy]; - // will never occur, exlusively for typechecking + // will never occur, exclusively for typechecking if (!activeName) { throw new Error( - `Invariant(findResolverWithIndex): activeName could not be determined. names = ${JSON.stringify(names)} domains = ${JSON.stringify(domainIds)} active resolver's domainId: ${domainId}.`, + `Invariant(findResolverWithIndexENSv1): activeName could not be determined. names = ${JSON.stringify(names)} domains = ${JSON.stringify(domainIds)} active resolver's domainId: ${domainId}.`, ); } @@ -258,3 +281,54 @@ export async function findResolverWithIndex( }, ); } + +/** + * ENSv2 active-resolver identification: walks the namegraph by labelHash from `registry` and returns + * the deepest ancestor Domain that has an assigned Resolver. Reads the `domain` table (the Registry + * hierarchy), maintained by the Unigraph plugin. + */ +async function findResolverWithIndexENSv2( + registry: AccountId, + name: InterpretedName, +): Promise { + return withActiveSpanAsync( + tracer, + "findResolverWithIndexENSv2", + { chainId: registry.chainId, registry: registry.address, name }, + async () => { + const path = interpretedLabelsToLabelHashPath(interpretedNameToInterpretedLabels(name)); + + // Invariant: there is at least 1 labelhash in the path + if (path.length === 0) { + throw new Error( + `Invariant(findResolverWithIndexENSv2): received an invalid name: '${name}'`, + ); + } + + // walk the namegraph from `registry`, joining each ancestor Domain to its Resolver + const rows = await forwardWalkDisjointNamegraph(makeConcreteRegistryId(registry), path); + + // the deepest Domain with an assigned Resolver is the active Resolver (ENSIP-10) + const active = rows.find(hasResolver); + if (!active) return NULL_RESULT; + + // map `active.depth` back to its name: getNameHierarchy is ordered leaf-first, while `depth` + // counts from the Root (depth 1 = TLD, depth = path.length = leaf) + const activeName = getNameHierarchy(name)[path.length - active.depth]; + + // will never occur, exclusively for typechecking + if (!activeName) { + throw new Error( + `Invariant(findResolverWithIndexENSv2): activeName could not be determined for '${name}' at depth ${active.depth}.`, + ); + } + + return { + activeName, + activeResolver: active.address, + // this resolver requires wildcard support if it was set above the leaf Domain + requiresWildcardSupport: active.depth < path.length, + }; + }, + ); +} diff --git a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts new file mode 100644 index 0000000000..4c70786087 --- /dev/null +++ b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts @@ -0,0 +1,79 @@ +import { trace } from "@opentelemetry/api"; +import { Param, sql } from "drizzle-orm"; +import type { Address, ChainId, DomainId, LabelHashPath, RegistryId } from "enssdk"; + +import type { RequiredAndNotNull } from "@ensnode/ensnode-sdk"; + +import di from "@/di"; +import { withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; + +const tracer = trace.getTracer("forward-walk-namegraph"); + +export interface WalkResultRow { + domainId: DomainId; + depth: number; + address: Address | null; + chainId: ChainId | null; +} + +/** + * Determines whether the WalkResultRow has a resolver set. + */ +export const hasResolver = ( + row: WalkResultRow, +): row is RequiredAndNotNull => + row.address !== null && row.chainId !== null; + +/** + * Walks a disjoint namegraph from `registryId` through `path` to identify each ancestor Domain, + * then LEFT JOINs each Domain to its Resolver via DRR and returns the full path ordered by depth + * DESC (deepest first). Resolver-less Domains are kept in the result with `resolver`/`chainId` set + * to NULL. Recursion terminates when the path is exhausted. + */ +export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) { + if (path.length === 0) return []; + + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 + const rawLabelHashPathArray = sql`${new Param(path)}::text[]`; + + const { ensDb, ensIndexerSchema } = di.context; + + const result = await withSpanAsync(tracer, "forward-walk", { registryId, path }, () => + ensDb.execute(sql` + WITH RECURSIVE path AS ( + SELECT + ${registryId}::text AS next_registry_id, + NULL::text AS "domainId", + 0 AS depth + + UNION ALL + + SELECT + -- NOTE: this walk specifically addresses non-canonical Domains as well, so it follows the + -- raw on-chain forward pointer domain.subregistry_id directly, without canonical edge authentication + d.subregistry_id AS next_registry_id, + d.id AS "domainId", + path.depth + 1 + FROM path + JOIN ${ensIndexerSchema.domain} d + ON d.registry_id = path.next_registry_id + WHERE d.label_hash = (${rawLabelHashPathArray})[path.depth + 1] + AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) + AND path.depth < ${MAX_SUPPORTED_NAME_DEPTH} + ) + SELECT + path."domainId", + drr.resolver AS "address", + drr.chain_id AS "chainId", + path.depth + FROM path + LEFT JOIN ${ensIndexerSchema.domainResolverRelation} drr + ON drr.domain_id = path."domainId" + WHERE path."domainId" IS NOT NULL + ORDER BY path.depth DESC; + `), + ); + + return result.rows as unknown as WalkResultRow[]; +} 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 aad86518bf..17e0a44f52 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,8 +1,5 @@ import { trace } from "@opentelemetry/api"; -import { Param, sql } from "drizzle-orm"; import { - type Address, - type ChainId, type DomainId, ENS_ROOT_NAME, type InterpretedName, @@ -18,12 +15,15 @@ import { getENSv2RootRegistryId, getRootRegistryId, makeContractMatcher, - type RequiredAndNotNull, } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import di from "@/di"; -import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-span"; +import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; +import { + forwardWalkDisjointNamegraph, + hasResolver, +} from "@/lib/protocol-acceleration/forward-walk-namegraph"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("get-domain-by-interpreted-name"); @@ -34,21 +34,6 @@ const tracer = trace.getTracer("get-domain-by-interpreted-name"); */ const MAX_HOP_DEPTH = 3; -interface WalkResultRow { - domainId: DomainId; - depth: number; - address: Address | null; - chainId: ChainId | null; -} - -/** - * Determines whether the WalkResultRow has a resolver set. - */ -const hasResolver = ( - row: WalkResultRow, -): row is RequiredAndNotNull => - row.address !== null && row.chainId !== null; - /** * Domain lookup by Interpreted Name by traversing the namegraph. * @@ -162,56 +147,3 @@ async function forwardWalkNamegraph( // finally, return the exact match if it was the leaf return exact ? deepest.domainId : null; } - -/** - * Walks a disjoint namegraph from `registryId` through `path` to identify each ancestor Domain, - * then LEFT JOINs each Domain to its Resolver via DRR and returns the full path ordered by depth - * DESC (deepest first). Resolver-less Domains are kept in the result with `resolver`/`chainId` set - * to NULL. Recursion terminates when the path is exhausted. - */ -async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) { - if (path.length === 0) return []; - - // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 - const rawLabelHashPathArray = sql`${new Param(path)}::text[]`; - - const { ensDb, ensIndexerSchema } = di.context; - - const result = await withSpanAsync(tracer, "forward-walk", { registryId, path }, () => - ensDb.execute(sql` - WITH RECURSIVE path AS ( - SELECT - ${registryId}::text AS next_registry_id, - NULL::text AS "domainId", - 0 AS depth - - UNION ALL - - SELECT - -- NOTE: this walk specifically addresses non-canonical Domains as well, so it follows the - -- raw on-chain forward pointer domain.subregistry_id directly, without canonical edge authentication - d.subregistry_id AS next_registry_id, - d.id AS "domainId", - path.depth + 1 - FROM path - JOIN ${ensIndexerSchema.domain} d - ON d.registry_id = path.next_registry_id - WHERE d.label_hash = (${rawLabelHashPathArray})[path.depth + 1] - AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) - AND path.depth < ${MAX_SUPPORTED_NAME_DEPTH} - ) - SELECT - path."domainId", - drr.resolver AS "address", - drr.chain_id AS "chainId", - path.depth - FROM path - LEFT JOIN ${ensIndexerSchema.domainResolverRelation} drr - ON drr.domain_id = path."domainId" - WHERE path."domainId" IS NOT NULL - ORDER BY path.depth DESC; - `), - ); - - return result.rows as unknown as WalkResultRow[]; -} diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts index 0e7c7daca5..894ba0ee7d 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-resolver.ts @@ -1,5 +1,7 @@ import { type DomainId, makeResolverId } from "enssdk"; +import { getRootRegistry } from "@ensnode/ensnode-sdk"; + import di from "@/di"; import { findResolverWithIndex } from "@/lib/protocol-acceleration/find-resolver"; @@ -21,29 +23,30 @@ export async function getDomainAssignedResolver(domainId: DomainId) { /** * Identifies the Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. - * its _effective_ Resolver — by walking the name hierarchy within the Domain's Registry via indexed - * data ({@link findResolverWithIndex}). + * its _effective_ Resolver — by walking the Domain's name hierarchy via indexed data + * ({@link findResolverWithIndex}), beginning from the Root Registry (the entry point for Forward + * Resolution). * - * Returns null when the Domain is not in the canonical nametree (no name to resolve against) or when + * Returns null when the Domain is not in the Canonical Nametree (no name to resolve against) or when * no active Resolver exists for it. */ export async function getDomainEffectiveResolver(domainId: DomainId) { - const { ensDb } = di.context; + const { ensDb, namespace } = di.context; const domain = await ensDb.query.domain.findFirst({ where: (t, { eq }) => eq(t.id, domainId), - with: { registry: true }, }); - // a Domain outside the canonical nametree has no name to perform Forward Resolution against + // a Domain outside the Canonical Nametree has no name to perform Forward Resolution against if (!domain?.canonicalName) return null; - const registry = { chainId: domain.registry.chainId, address: domain.registry.address }; + // Forward Resolution always begins at the Root Registry + const registry = getRootRegistry(namespace); const { activeResolver } = await findResolverWithIndex(registry, domain.canonicalName); if (!activeResolver) return null; - // the effective Resolver lives on the Registry's chain + // the effective Resolver lives on the Root Registry's chain return makeResolverId({ chainId: registry.chainId, address: activeResolver }); } diff --git a/apps/ensapi/src/omnigraph-api/schema/domain-resolver.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.integration.test.ts new file mode 100644 index 0000000000..7d70145f4d --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/schema/domain-resolver.integration.test.ts @@ -0,0 +1,51 @@ +import { asInterpretedName } from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { effectiveResolverFallback } from "@ensnode/integration-test-env/devnet"; + +import { request } from "@/test/integration/graphql-utils"; +import { gql } from "@/test/integration/omnigraph-api-client"; + +type ResolverContract = { contract: { chainId: number; address: string } } | null; +type DomainResolverResult = { + domain: { resolver: { assigned: ResolverContract; effective: ResolverContract } } | null; +}; + +const DomainResolvers = gql` + query DomainResolvers($name: InterpretedName!) { + domain(by: { name: $name }) { + resolver { + assigned { contract { chainId address } } + effective { contract { chainId address } } + } + } + } +`; + +const queryResolvers = (name: string) => + request(DomainResolvers, { name: asInterpretedName(name) }); + +describe("DomainResolver.effective", () => { + it("equals the assigned Resolver when the Domain has its own Resolver", async () => { + const { domain } = await queryResolvers("test.eth"); + const resolver = domain?.resolver; + + expect(resolver?.assigned?.contract.address).toBeTruthy(); + expect(resolver?.effective?.contract).toEqual(resolver?.assigned?.contract); + }); + + it("falls back to an ancestor's Resolver when the Domain has none", async () => { + const { subname, parentName } = effectiveResolverFallback; + + const { domain } = await queryResolvers(subname); + const resolver = domain?.resolver; + + // the seeded subname has no Resolver of its own + expect(resolver?.assigned).toBeNull(); + + // but its effective Resolver is its parent's (ENSIP-10 fallback) + const { domain: parent } = await queryResolvers(parentName); + expect(parent?.resolver.assigned?.contract.address).toBeTruthy(); + expect(resolver?.effective?.contract).toEqual(parent?.resolver.assigned?.contract); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index 0a3a846441..1766ea8fb8 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -1,4 +1,10 @@ -import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId, type RegistryId } from "enssdk"; +import { + type AccountId, + makeConcreteRegistryId, + makeENSv1RegistryId, + makeENSv2RegistryId, + type RegistryId, +} from "enssdk"; import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; @@ -80,13 +86,21 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { // Root ////////////// +/** + * Gets the AccountId representing the preferred Root Registry for the selected `namespace` — + * the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. Used as the entry + * point for resolution-time namegraph traversal. + */ +export const getRootRegistry = (namespace: ENSNamespaceId) => + maybeGetENSv2RootRegistry(namespace) ?? getENSv1RootRegistry(namespace); + /** * Gets the RegistryId representing the preferred Root Registry for the selected `namespace` — * the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. Used as the entry * point for resolution-time namegraph traversal. */ export const getRootRegistryId = (namespace: ENSNamespaceId) => - maybeGetENSv2RootRegistryId(namespace) ?? getENSv1RootRegistryId(namespace); + makeConcreteRegistryId(getRootRegistry(namespace)); /** * Determines whether `registryId` is a Root Registry (ENSv1 Root or, when defined, ENSv2 Root) diff --git a/packages/integration-test-env/src/devnet/fixtures.ts b/packages/integration-test-env/src/devnet/fixtures.ts index dfc35c53d5..93fb17bf8c 100644 --- a/packages/integration-test-env/src/devnet/fixtures.ts +++ b/packages/integration-test-env/src/devnet/fixtures.ts @@ -76,6 +76,19 @@ const rawAddresses = { solana: getRawAddress("sol", "FncazAs6omJJjtLVzquzT9KoyXn6tFixr9kGjr42ktLj"), } as const satisfies Record; +/** + * An effective-Resolver fallback fixture: `noresolver.parent.eth` is seeded WITHOUT a Resolver under + * `parent.eth` (which has the PermissionedResolver), so its _effective_ Resolver resolves — via + * ENSIP-10 fallback — to `parent.eth`'s Resolver. + * @see packages/integration-test-env/src/seed/effective-resolver-fallback.ts + */ +export const effectiveResolverFallback = { + parentLabel: "parent", + parentName: "parent.eth", + subnameLabel: "noresolver", + subname: "noresolver.parent.eth", +} as const; + export const fixtures = { abiBytes: `0x${"01".repeat(32)}`, fourBytesInterface: "0x11100111", diff --git a/packages/integration-test-env/src/seed/effective-resolver-fallback.ts b/packages/integration-test-env/src/seed/effective-resolver-fallback.ts new file mode 100644 index 0000000000..ba63da718d --- /dev/null +++ b/packages/integration-test-env/src/seed/effective-resolver-fallback.ts @@ -0,0 +1,48 @@ +import { isAddressEqual, zeroAddress } from "viem"; + +import { RegistryABI } from "@ensnode/datasources"; +import { contracts } from "@ensnode/datasources/devnet"; + +import { effectiveResolverFallback } from "../devnet/fixtures"; +import type { DevnetWalletClients } from "./index"; +import { waitForTransactionReceipt } from "./index"; + +/** + * Registers `noresolver.parent.eth` WITHOUT a Resolver under `parent.eth` (which has one), so its + * _effective_ Resolver resolves — via ENSIP-10 fallback — to `parent.eth`'s Resolver. Exercises + * `DomainResolver.effective` fallback in the Omnigraph API integration tests. + */ +export async function seedEffectiveResolverFallback(clients: DevnetWalletClients): Promise { + const { parentLabel, subnameLabel } = effectiveResolverFallback; + const client = clients.owner; + + // resolve the parent's Subregistry (deployed during devnet bring-up, before seeding) + const subregistry = await client.readContract({ + address: contracts.ETHRegistry, + abi: RegistryABI, + functionName: "getSubregistry", + args: [parentLabel], + }); + + if (isAddressEqual(subregistry, zeroAddress)) { + throw new Error( + `[seed] expected ${parentLabel}.eth to have a Subregistry to register '${subnameLabel}' into.`, + ); + } + + // register the subname with resolver=zeroAddress, so it owns no Resolver of its own. + // NOTE: expiry must be <= the parent's expiry; the devnet registers 2LDs far enough out that 1 year fits. + const block = await client.getBlock(); + const expiry = block.timestamp + 365n * 24n * 60n * 60n; + + const hash = await client.writeContract({ + address: subregistry, + abi: RegistryABI, + functionName: "register", + args: [subnameLabel, client.account.address, zeroAddress, zeroAddress, 0n, expiry], + }); + await waitForTransactionReceipt(client, hash); + console.log( + `[seed] registered ${subnameLabel}.${parentLabel}.eth without a Resolver tx: ${hash}`, + ); +} diff --git a/packages/integration-test-env/src/seed/index.ts b/packages/integration-test-env/src/seed/index.ts index b40198f010..98ac5ea32d 100644 --- a/packages/integration-test-env/src/seed/index.ts +++ b/packages/integration-test-env/src/seed/index.ts @@ -13,6 +13,7 @@ import { import { ensTestEnvChain } from "@ensnode/datasources"; import { accounts } from "../devnet/fixtures"; +import { seedEffectiveResolverFallback } from "./effective-resolver-fallback"; import { seedPrimaryNameRecords } from "./primary-names"; import { seedResolverRecords } from "./resolver-records"; @@ -65,4 +66,5 @@ export async function seedDevnet(rpcUrl: string): Promise { const clients = createDevnetWalletClients(rpcUrl); await seedPrimaryNameRecords(clients); await seedResolverRecords(clients); + await seedEffectiveResolverFallback(clients); } From c4da2b2c2abd9730577df91c750cf8617c18d934 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 11:48:20 -0500 Subject: [PATCH 4/6] docs: note forward-walk-namegraph reads Unigraph data (fold marker) --- .../src/lib/protocol-acceleration/forward-walk-namegraph.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts index 4c70786087..82922a389c 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts @@ -10,6 +10,9 @@ import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("forward-walk-namegraph"); +// TODO(fold-protocol-acceleration): this walk reads the Unigraph-maintained `domain` table (the +// Registry hierarchy), not Protocol Acceleration tables. Once the plugins are folded it can move to +// omnigraph-api/lib/ alongside its other consumer (get-domain-by-interpreted-name.ts). export interface WalkResultRow { domainId: DomainId; depth: number; From f709cbcefaeca4fe38f6a39dd25ed992bcaeebdc Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 11:52:39 -0500 Subject: [PATCH 5/6] fix(ensapi): reject over-depth paths in forwardWalkDisjointNamegraph Guard against paths longer than MAX_SUPPORTED_NAME_DEPTH so an over-depth name throws rather than resolving against a CTE-truncated ancestor path. --- .../lib/protocol-acceleration/forward-walk-namegraph.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts index 82922a389c..e70ffed198 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts @@ -37,6 +37,14 @@ export const hasResolver = ( export async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) { if (path.length === 0) return []; + // Invariant: reject over-depth paths rather than silently truncating at MAX_SUPPORTED_NAME_DEPTH + // (the recursive CTE stops there), which would resolve against a truncated ancestor path. + if (path.length > MAX_SUPPORTED_NAME_DEPTH) { + throw new Error( + `Invariant(forwardWalkDisjointNamegraph): path length ${path.length} exceeds maximum depth ${MAX_SUPPORTED_NAME_DEPTH}.`, + ); + } + // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 const rawLabelHashPathArray = sql`${new Param(path)}::text[]`; From 277717d74ed81a4720d6eec31f6adde0a5a13e27 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 5 Jun 2026 12:04:14 -0500 Subject: [PATCH 6/6] refactor(ensapi): rename forward-walk-namegraph module to forward-walk-disjoint-namegraph Match the file name to its contents (forwardWalkDisjointNamegraph + helpers). --- apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts | 2 +- ...ard-walk-namegraph.ts => forward-walk-disjoint-namegraph.ts} | 2 +- .../src/omnigraph-api/lib/get-domain-by-interpreted-name.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename apps/ensapi/src/lib/protocol-acceleration/{forward-walk-namegraph.ts => forward-walk-disjoint-namegraph.ts} (98%) diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 5620351afc..ed9f2b0cf1 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -24,7 +24,7 @@ import { withActiveSpanAsync, withSpanAsync } from "@/lib/instrumentation/auto-s import { forwardWalkDisjointNamegraph, hasResolver, -} from "@/lib/protocol-acceleration/forward-walk-namegraph"; +} from "@/lib/protocol-acceleration/forward-walk-disjoint-namegraph"; type FindResolverResult = | { diff --git a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts similarity index 98% rename from apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts rename to apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts index e70ffed198..c9f1308c33 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/forward-walk-namegraph.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/forward-walk-disjoint-namegraph.ts @@ -8,7 +8,7 @@ import di from "@/di"; import { withSpanAsync } from "@/lib/instrumentation/auto-span"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; -const tracer = trace.getTracer("forward-walk-namegraph"); +const tracer = trace.getTracer("forward-walk-disjoint-namegraph"); // TODO(fold-protocol-acceleration): this walk reads the Unigraph-maintained `domain` table (the // Registry hierarchy), not Protocol Acceleration tables. Once the plugins are folded it can move to 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 17e0a44f52..979d0b7a14 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 @@ -23,7 +23,7 @@ import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; import { forwardWalkDisjointNamegraph, hasResolver, -} from "@/lib/protocol-acceleration/forward-walk-namegraph"; +} from "@/lib/protocol-acceleration/forward-walk-disjoint-namegraph"; import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("get-domain-by-interpreted-name");