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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/domain-resolver-effective.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": patch
---

**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).
112 changes: 93 additions & 19 deletions apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-disjoint-namegraph";

type FindResolverResult =
| {
Expand Down Expand Up @@ -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
*/
async function findResolverWithIndex(
export async function findResolverWithIndex(
registry: AccountId,
name: InterpretedName,
): Promise<FindResolverResult> {
// 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<FindResolverResult> {
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,
);
Expand Down Expand Up @@ -234,18 +257,18 @@ 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.`,
);
}

// map the relation's `domainId` back to its name in `names`
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}.`,
);
}

Expand All @@ -258,3 +281,54 @@ 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<FindResolverResult> {
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,
};
},
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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-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
// omnigraph-api/lib/ alongside its other consumer (get-domain-by-interpreted-name.ts).
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<WalkResultRow, "address" | "chainId"> =>
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 [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
shrugs marked this conversation as resolved.

// 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
Comment thread
shrugs marked this conversation as resolved.
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[];
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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-disjoint-namegraph";
import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants";

const tracer = trace.getTracer("get-domain-by-interpreted-name");
Expand All @@ -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<WalkResultRow, "address" | "chainId"> =>
row.address !== null && row.chainId !== null;

/**
* Domain lookup by Interpreted Name by traversing the namegraph.
*
Expand Down Expand Up @@ -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[];
}
Loading
Loading