diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md new file mode 100644 index 0000000000..770533ff2a --- /dev/null +++ b/.changeset/canonical-fields-omnigraph.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph**: expose `Domain.canonical` and `Registry.canonical` on the Omnigraph schema. Both are non-null `Boolean!` fields indicating whether the entity participates in the Canonical Nametree. diff --git a/.changeset/drop-domains-where-canonical.md b/.changeset/drop-domains-where-canonical.md new file mode 100644 index 0000000000..de39b9d08c --- /dev/null +++ b/.changeset/drop-domains-where-canonical.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: drop the `canonical: Boolean = false` field from `DomainsWhereInput` (used by `Query.domains`). Every nameable Domain is canonical by definition, so the filter was redundant; the query now always scopes to Canonical Domains. Consumers passing `where: { canonical: true }` should drop the field; consumers relying on `canonical: false` (or default) to surface non-canonical Domains via this query no longer can — read `Domain.canonical` directly or scope by `Account.domains` / `Registry.domains` instead. diff --git a/.changeset/ensnode-sdk-root-registry-bridged-target.md b/.changeset/ensnode-sdk-root-registry-bridged-target.md new file mode 100644 index 0000000000..a638370557 --- /dev/null +++ b/.changeset/ensnode-sdk-root-registry-bridged-target.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +**Breaking (`@ensnode/ensnode-sdk`)**: `getRootRegistryIds` is removed; use the new `isRootRegistryId(namespace, registryId)` predicate to test root membership instead. `getRootRegistryId` (singular, "preferred root") is unchanged. diff --git a/.changeset/resolver-bridged-registry-type.md b/.changeset/resolver-bridged-registry-type.md new file mode 100644 index 0000000000..53b533ddcd --- /dev/null +++ b/.changeset/resolver-bridged-registry-type.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: `Resolver.bridged` is no longer an `AccountId` scalar; it now returns the bridged target `Registry` interface. Consumers should change their selection from `bridged` (scalar) to `bridged { ... }` (Registry interface) — the new shape exposes the full `Registry` and allows navigation into the bridged sub-registry's canonical Domain etc. diff --git a/AGENTS.md b/AGENTS.md index fdf796ddb4..fa468412af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ Fail fast and loudly on invalid inputs. - Schema changes never require a migration step. Ponder only runs fully-compatible indexes against existing schemas; otherwise the index is dropped and rebuilt from scratch. Do not propose, plan, or write migration code for the ensindexer drizzle schema. - Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar. - Access entities by primary key only. Ponder's cache layer keys on PK; filters or complex selects force a flush to Postgres and are extremely unperformant in the hot path. If you need a non-PK lookup at index time, design the schema so the lookup key is the primary key. +- TOCTOU is never a concern inside Ponder event handlers. Ponder serializes events per chain and runs each handler in a transaction, so a read-modify-write against the same row from two handlers cannot interleave. Bot reviewers will sometimes flag this — dismiss those comments. (Note: TOCTOU IS a concern in ENSApi when reading `ensIndexerSchema` — both parallel and serial reads can see different snapshots of the indexer's writes.) ## Workflow diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 116b83a453..ffea3405da 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -17,7 +17,7 @@ import { ForwardResolutionProtocolStep, type ForwardResolutionResult, getDatasourceContract, - getENSv1Registry, + getENSv1RootRegistry, maybeGetDatasourceContract, PluginName, type ResolverRecordsSelection, @@ -99,7 +99,7 @@ export async function resolveForward // initially be ENS Root Registry: see `_resolveForward` for additional context. return _resolveForward(interpretedName, selection, { ...options, - registry: getENSv1Registry(config.namespace), + registry: getENSv1RootRegistry(config.namespace), }); } @@ -239,14 +239,17 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - const bridgesTo = isBridgedResolver(config.namespace, resolver); - if (bridgesTo) { + const bridged = isBridgedResolver(config.namespace, resolver); + if (bridged) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, () => - _resolveForward(name, selection, { ...options, registry: bridgesTo.registry }), + _resolveForward(name, selection, { + ...options, + registry: bridged.targetRegistry, + }), ); } diff --git a/apps/ensapi/src/omnigraph-api/lib/constants.ts b/apps/ensapi/src/omnigraph-api/lib/constants.ts new file mode 100644 index 0000000000..59ad453f44 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/constants.ts @@ -0,0 +1,8 @@ +/** + * The maximum depth of a name supported by Omnigraph traversal libs, to avoid runaway walks across + * the namegraph. ENS names have no formal depth limit, but we cap traversal to fail loudly rather + * than risk an unbounded recursive CTE. + * + * If this depth turns out to be problematic in practice, we can increase it as necessary. + */ +export const MAX_SUPPORTED_NAME_DEPTH = 16; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts deleted file mode 100644 index 381e4f2832..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts +++ /dev/null @@ -1,69 +0,0 @@ -import config from "@/config"; - -import { sql } from "drizzle-orm"; - -import { getRootRegistryIds } from "@ensnode/ensnode-sdk"; - -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; - -/** - * The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries. - * - * The CTE walks `domain.subregistryId` forward from every Root Registry. `subregistryId` is the - * source-of-truth forward pointer, so no separate edge-authentication is needed — a Registry is - * canonical iff it is reachable via a chain of live forward pointers from a Root. - * - * The reachable set is a DAG, not a tree: aliased subregistries let multiple parent Domains - * declare the same child Registry, so the same row can appear at multiple depths during recursion. - * The outer projection dedupes via `SELECT DISTINCT`; `MAX_DEPTH` bounds runaway recursion if the - * graph is corrupted. - */ -const CANONICAL_REGISTRIES_MAX_DEPTH = 16; - -/** - * Builds a recursive CTE that traverses forward from every top-level Root Registry configured for - * the namespace (the ENSv1 Root Registry, the Basenames and Lineanames ENSv1VirtualRegistries when - * configured, and the ENSv2 Root Registry when defined — see {@link getRootRegistryIds}) to - * construct a set of all Canonical Registries. - * - * A Canonical Registry is one whose Domains are resolvable under the primary resolution pipeline. - * This includes both the ENSv2 subtree and every ENSv1 subtree: Universal Resolver v2 falls back - * to ENSv1 at resolution time for names not (yet) present in ENSv2, so ENSv1 Domains remain - * canonical from a resolution perspective. - * - * TODO: could this be optimized further, perhaps as a materialized view? - */ -export const getCanonicalRegistriesCTE = () => { - const roots = getRootRegistryIds(config.namespace); - - const rootsUnion = roots - .map((root) => sql`SELECT ${root}::text AS registry_id, 0 AS depth`) - .reduce((acc, part, i) => (i === 0 ? part : sql`${acc} UNION ALL ${part}`)); - - return ensDb - .select({ - // NOTE: using `id` here to avoid clobbering `registryId` in consuming queries, which would - // result in '_ is ambiguous' error messages from postgres because drizzle isn't scoping the - // selection properly. a bit fragile but works for now. - id: sql`registry_id`.as("id"), - }) - .from( - sql` - ( - WITH RECURSIVE canonical_registries AS ( - ${rootsUnion} - UNION ALL - SELECT d.subregistry_id AS registry_id, cr.depth + 1 - FROM canonical_registries cr - JOIN ${ensIndexerSchema.domain} d ON d.registry_id = cr.registry_id - - -- Filter nulls at the recursive step so Domains without a subregistry don't - -- emit null rows into the CTE and don't spawn dead-end recursion branches. - WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} - AND d.subregistry_id IS NOT NULL - ) - SELECT DISTINCT registry_id FROM canonical_registries - ) AS canonical_registries_cte`, - ) - .as("canonical_registries"); -}; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts index 49650dab29..97c655bfad 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -12,16 +12,21 @@ export type BaseDomainSet = ReturnType; /** * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * - * Returns `{ domainId, ownerId, registryId, parentId, labelHash, sortableLabel }` where `parentId` - * is derived via the domain's registry → canonical domain link (`registryCanonicalDomain`) - * and `sortableLabel` is the domain's own interpreted label, used for NAME ordering, and can be - * overridden by later layers. + * Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. + * - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of + * this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by + * `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to + * the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree. + * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering + * - all other values are directly sourced from Domain * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ export function domainsBase() { + // alias for parent Registry / parent Domain joins so we can reference them distinctly from + // the base Domain's own `registryId` column. + const parentRegistry = alias(ensIndexerSchema.registry, "parentRegistry"); const parentDomain = alias(ensIndexerSchema.domain, "parentDomain"); - return ( ensDb .select({ @@ -29,23 +34,22 @@ export function domainsBase() { ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), parentId: sql`${parentDomain.id}`.as("parentId"), + canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( "sortableLabel", ), }) .from(ensIndexerSchema.domain) - // parentId derivation: domain.registryId → canonical parent domain via registryCanonicalDomain. - // The `parentDomain.subregistryId = domain.registryId` clause performs edge authentication. - .leftJoin( - ensIndexerSchema.registryCanonicalDomain, - eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), - ) + // walk up to the parent Registry by this Domain's `registryId`, then to the parent Domain + // it points at, requiring `parentDomain.subregistryId` to agree back. The two joins + + // agreement predicate are the bidirectional canonical-edge check. + .leftJoin(parentRegistry, eq(parentRegistry.id, ensIndexerSchema.domain.registryId)) .leftJoin( parentDomain, and( - eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), - eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId), + eq(parentDomain.id, parentRegistry.canonicalDomainId), + eq(parentDomain.subregistryId, parentRegistry.id), ), ) // join label for labelHash/sortableLabel @@ -67,6 +71,7 @@ export function selectBase(base: BaseDomainSet) { ownerId: base.ownerId, registryId: base.registryId, parentId: base.parentId, + canonical: base.canonical, labelHash: base.labelHash, sortableLabel: base.sortableLabel, }; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts index 72918f8264..88f0300c2e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts @@ -2,18 +2,15 @@ import { eq } from "drizzle-orm"; import { ensDb } from "@/lib/ensdb/singleton"; -import { getCanonicalRegistriesCTE } from "../canonical-registries-cte"; import { type BaseDomainSet, selectBase } from "./base-domain-set"; /** * Filter a base domain set to only include Canonical Domains. */ export function filterByCanonical(base: BaseDomainSet) { - const canonicalRegistries = getCanonicalRegistriesCTE(); - return ensDb .select(selectBase(base)) .from(base) - .innerJoin(canonicalRegistries, eq(canonicalRegistries.id, base.registryId)) + .where(eq(base.canonical, true)) .as("baseDomains"); } diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts index d950569c88..a1138a3cd5 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts @@ -28,7 +28,7 @@ const FILTER_BY_NAME_MAX_DEPTH = 8; * - leafId: the deepest child (label "sub1") — the autocomplete result, for ownership check * - headId: the parent of the path (whose label should match partial "paren") * - * Algorithm: Start from the deepest child (leaf) and traverse UP via {@link registryCanonicalDomain}. + * Algorithm: Start from the deepest child (leaf) and traverse UP via `registry.canonicalDomainId`. */ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { // If no concrete path, return all domains (leaf = head = self) @@ -47,13 +47,15 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { const rawLabelHashPathArray = sql`${new Param(labelHashPath)}::text[]`; const pathLength = sql`array_length(${rawLabelHashPathArray}, 1)`; - // Recursive CTE starting from the deepest child and traversing UP via registryCanonicalDomain. + // Recursive CTE starting from the deepest child and traversing UP via the bidirectional + // canonical-edge agreement (`registries.canonical_domain_id = domains.id` AND + // `domains.subregistry_id = registries.id`). // 1. Start with domains matching the leaf labelHash (deepest child) - // 2. Recursively join parents via rcd, verifying each ancestor's labelHash + // 2. Recursively join parents via the agreement check, verifying each ancestor's labelHash // 3. Return both the leaf (for result/ownership) and head (for partial match) // // NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete - // canonical path to the searched FQDN. + // canonical path to the searched `labelHashPath`. return ensDb .select({ // https://github.com/drizzle-team/drizzle-orm/issues/1242 @@ -64,23 +66,24 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { sql`( WITH RECURSIVE upward_check AS ( -- Base case: find the deepest children (leaves of the concrete path) and walk one step - -- up via registryCanonicalDomain. The parent.subregistry_id = d.registry_id clause - -- performs edge authentication. + -- up via the agreement check (parent_registry.canonical_domain_id = parent.id AND + -- parent.subregistry_id = parent_registry.id). SELECT d.id AS leaf_id, parent.id AS current_id, 1 AS depth FROM ${ensIndexerSchema.domain} d - JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd - ON rcd.registry_id = d.registry_id + JOIN ${ensIndexerSchema.registry} parent_registry + ON parent_registry.id = d.registry_id JOIN ${ensIndexerSchema.domain} parent - ON parent.id = rcd.domain_id AND parent.subregistry_id = d.registry_id + ON parent.id = parent_registry.canonical_domain_id + AND parent.subregistry_id = parent_registry.id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL - -- Recursive step: traverse UP via registryCanonicalDomain, verifying each ancestor's - -- labelHash. The np.subregistry_id = pd.registry_id clause performs edge authentication. + -- Recursive step: traverse UP via the agreement check, verifying each ancestor's + -- labelHash. SELECT upward_check.leaf_id, np.id AS current_id, @@ -88,10 +91,11 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { FROM upward_check JOIN ${ensIndexerSchema.domain} pd ON pd.id = upward_check.current_id - JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd - ON rcd.registry_id = pd.registry_id + JOIN ${ensIndexerSchema.registry} pdr + ON pdr.id = pd.registry_id JOIN ${ensIndexerSchema.domain} np - ON np.id = rcd.domain_id AND np.subregistry_id = pd.registry_id + ON np.id = pdr.canonical_domain_id + AND np.subregistry_id = pdr.id WHERE upward_check.depth < ${pathLength} AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] ) @@ -124,7 +128,7 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { } if (concrete.length === 0) { - // No path traversal — sortableLabel is already the domain's own label from the base set + // no path traversal — sortableLabel is already the domain's own label from the base set return ensDb .select(selectBase(base)) .from(base) @@ -136,20 +140,20 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { .as("baseDomains"); } - // Build path traversal CTE over the unified `domain` table. + // build path traversal CTE over the unified `domain` table. const labelHashPath = interpretedLabelsToLabelHashPath(concrete); const pathResults = domainsByLabelHashPath(labelHashPath); - // Alias for head domain lookup (to get headLabelHash for label join) + // alias for head domain lookup (to get headLabelHash for label join) const headDomain = alias(ensIndexerSchema.domain, "headDomain"); const headLabel = alias(ensIndexerSchema.label, "headLabel"); - // Join base set with path results, look up head domain's label, override sortableLabel. - // The inner join on pathResults scopes results to domains matching the concrete path. + // join base set with path results, look up head domain's label, override sortableLabel + // the INNER JOIN on pathResults scopes results to domains matching the concrete path return ensDb .select({ ...selectBase(base), - // Override sortableLabel with head domain's label for NAME ordering + // override sortableLabel with head domain's label for NAME ordering sortableLabel: sql`${headLabel.interpreted}`.as("sortableLabel"), }) .from(base) diff --git a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts index 62848873fb..04e99febae 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -1,29 +1,25 @@ -import config from "@/config"; - -import { Param, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; -import { getRootRegistryIds } from "@ensnode/ensnode-sdk"; - import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; - -const MAX_DEPTH = 16; +import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; /** * Provide the canonical parents for a Domain via reverse traversal of the namegraph. * - * Traversal walks `domain → registry → canonical parent domain` via the - * {@link registryCanonicalDomain} table and terminates at any top-level Root Registry configured - * for the namespace (all concrete ENSv1Registries plus the ENSv2 Root when defined). Returns - * `null` when the resulting path does not terminate at a Root Registry (i.e. the Domain is not - * canonical). + * Walks `domain → registry → registry.canonicalDomainId` upward until the registry has no canonical + * parent (root). Returns `null` when the input Domain is not itself canonical. */ export async function getCanonicalPath(domainId: DomainId): Promise { - const rootRegistryIds = getRootRegistryIds(config.namespace); + // Short-circuit for non-canonical Domains + const domain = await ensDb.query.domain.findFirst({ + where: (t, { eq }) => eq(t.id, domainId), + columns: { canonical: true }, + }); + if (!domain) throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' expected.`); - // NOTE: using new Param to bind the array as a single text[] parameter, per - // https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 - const rootRegistryIdsArray = sql`${new Param(rootRegistryIds)}::text[]`; + // if the Domain is not Canonical, there's no path, so we can short-circuit with null + if (!domain.canonical) return null; const result = await ensDb.execute(sql` WITH RECURSIVE upward AS ( @@ -37,21 +33,22 @@ export async function getCanonicalPath(domainId: DomainId): Promise current registry's canonical domain (parent). - -- 1. Recursion stops as soon as we reach a Root Registry or there is no parent to traverse. - -- 2. MAX_DEPTH guards against corrupted state. - -- 3. The pd.subregistry_id = upward.registry_id clause performs edge authentication. + -- Step upward: domain → current registry's canonical parent domain via the bidirectional + -- canonical-edge agreement (registries.canonical_domain_id = domains.id AND + -- domains.subregistry_id = registries.id). + -- We allow recursion to one row beyond MAX_DEPTH so we can detect (and throw on) a + -- legitimate path that exceeds the cap, rather than silently truncating it. SELECT pd.id AS domain_id, pd.registry_id, upward.depth + 1 FROM upward - JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd - ON rcd.registry_id = upward.registry_id + JOIN ${ensIndexerSchema.registry} ur + ON ur.id = upward.registry_id JOIN ${ensIndexerSchema.domain} pd - ON pd.id = rcd.domain_id AND pd.subregistry_id = upward.registry_id - WHERE upward.depth < ${MAX_DEPTH} - AND upward.registry_id <> ALL(${rootRegistryIdsArray}) + ON pd.id = ur.canonical_domain_id + AND pd.subregistry_id = ur.id + WHERE upward.depth <= ${MAX_SUPPORTED_NAME_DEPTH} ) SELECT * FROM upward @@ -60,15 +57,19 @@ export async function getCanonicalPath(domainId: DomainId): Promise MAX_SUPPORTED_NAME_DEPTH) { + throw new Error( + `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_SUPPORTED_NAME_DEPTH}.`, + ); + } return rows.map((row) => row.domain_id); } 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 883eb72043..9e98c61ed5 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 @@ -11,41 +11,36 @@ import { interpretedLabelsToLabelHashPath, interpretedNameToInterpretedLabels, type LabelHashPath, - makeConcreteRegistryId, type RegistryId, } from "enssdk"; import { DatasourceNames } from "@ensnode/datasources"; import { - accountIdEqual, getENSv1RootRegistryId, + getENSv2RootRegistryId, getRootRegistryId, - maybeGetDatasourceContract, + makeContractMatcher, type RequiredAndNotNull, } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; +import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("get-domain-by-interpreted-name"); /** * The maximum number of times to hop between disjoint namegraphs, as a defense against infinite loops. - * i.e. how many times to follow a Bridged Resolver or fall back from ENSv2 to ENSv1 or vise-versa. + * i.e. how many times to follow a Bridged Resolver or fall back from ENSv2 to ENSv1 or vice versa. */ const MAX_HOP_DEPTH = 3; -/** - * The maximum depth to walk the namegraph from any Registry. - */ -const MAX_WALK_DEPTH = 16; - interface WalkResultRow { domainId: DomainId; + depth: number; address: Address | null; chainId: ChainId | null; - depth: number; } /** @@ -84,51 +79,41 @@ export async function getDomainIdByInterpretedName( throw new Error(`Invariant: ${name} generated 0 labelHashPath segments.`); } - if (path.length > MAX_WALK_DEPTH) { - throw new Error(`Invariant: Name '${name}' exceeds maximum depth ${MAX_WALK_DEPTH}.`); + if (path.length > MAX_SUPPORTED_NAME_DEPTH) { + throw new Error(`Invariant: Name '${name}' exceeds maximum depth ${MAX_SUPPORTED_NAME_DEPTH}.`); } return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - resolveCanonicalDomainId(getRootRegistryId(config.namespace), path), + forwardWalkNamegraph(getRootRegistryId(config.namespace), path), ); } /** - * Recursively walks the namegraph from `registryId` through `path`, joining each Domain with its - * Resolver via Domain-Resolver Relations. If there's an exact match, we return the identified domain, - * otherwise if the deepest defined resolver is a Bridged Resolver, we recurse to - * that target's Registry and continue walking, otherwise we were unable to identify the Domain. - * - * For ENSv1 Shadow Registries the bridged Registry's namegraph shadows that of the Root Chain's, - * meaning that it is represented as (shadow)RootRegistry -> "eth" -> ENSv1VirtualRegistry for eth - * -> "linea" ... etc. So when we recurse into a Shadow Registry we must pass the full `path`. + * Walks `path` from `registryId` to identify a leaf `domainId`, hopping between disjoint namegraphs + * as necessary to implement Resolution logic (Bridged Resolver, ENSv1Resolver, ENSv2Resolver). * - * In contrast, all ENSv2 Registries are rooted at the name being Bridged (they're relative to their - * parent); when recusing we must walk from the _remaining_ segments of `path`. + * This function prefers the leaf Domain within the origin Registry. i.e. if there's an ENSv2 Domain + * like example.eth that has as its Resolver the ENSv1Resolver (which sources records from ENSv1's + * example.eth's Resolver) this function preferentially returns the ENSv2 example.eth, which is more + * correctly the 'resolvable' Domain; the ENSv1 example.eth is more vestigial and not the source of + * truth. * - * Note that for Domains with Bridged Resolvers, we prefer the origin Domain, not the Domain within - * the target (shadow) Registry; in practice this means when someone asks for "linea.eth" they'll get - * the ENS Root Chain's "linea.eth", NOT the Linea Chain's "linea.eth" in the Linea Shadow Registry. - * This makes sense because: - * a) users probably want the ENS Root Chain's "linea.eth" regardless, and - * b) in non-shadow Registries, there's no "linea.eth" to address. + * This same logic also encodes the preference that, for a Domain with a Bridged Resolver, the Domain + * in the origin Registry (ex: the ENS Root Chain's 'linea.eth' [either ENSv1 or ENSv2]) we return + * the ENS Root Chain's linea.eth instead of the Linea Chain's shadowed linea.eth (which, formally, + * doesn't exist in the eyes of Resolution). */ -async function resolveCanonicalDomainId( +async function forwardWalkNamegraph( registryId: RegistryId, path: LabelHashPath, depth = 0, ): Promise { - // Sanity Check: maximum recursion depth of 3. technically only 2 is necessary because we know we - // need to support aonly - // have well-known Bridged Resolvers that bridge from the Root chain to an L2 chain, without - // further hops. if (depth > MAX_HOP_DEPTH) { - throw new Error( - `Invariant(resolveCanonicalDomainId): Bridged Resolver depth exceeded: ${depth}`, - ); + throw new Error(`Invariant(forwardWalkNamegraph): Hop depth exceeded: ${depth}`); } - const rows = await walkCanonicalNamegraph(registryId, path); + // walk the disjoint namegraph by indicated by `registryId` through `path` + const rows = await forwardWalkDisjointNamegraph(registryId, path); if (rows.length === 0) return null; // rows are ORDER BY depth DESC, so deepest element is rows[0] @@ -144,36 +129,35 @@ async function resolveCanonicalDomainId( // otherwise, identify the deepest element with a Resolver const deepestResolver = rows.find(hasResolver); if (deepestResolver) { + const resolverEq = makeContractMatcher(config.namespace, deepestResolver); + // Bridged Resolvers + // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry + const bridged = isBridgedResolver(config.namespace, deepestResolver); + if (bridged) { + // to follow a Bridged Resolver, continue walking the namegraph from the target `registryId` + // with the remaining portion of `path` + + // NOTE: we blindly return after bridging, which correctly implements the Forward Resolution + // behavior in that the origin Domain, even if there is one, is invisible to resolution + // (due to the ancestor Bridged Resolver) and therefore not addressable + return forwardWalkNamegraph( + bridged.targetRegistryId, + path.slice(deepestResolver.depth), + depth + 1, + ); + } + // ENSv1Resolver (ENSv1 Fallback) // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 - const ENSv1Resolver = maybeGetDatasourceContract( - config.namespace, - DatasourceNames.ENSv2Root, - "ENSv1Resolver", - ); - if (ENSv1Resolver && accountIdEqual(deepestResolver, ENSv1Resolver)) { - // fallback to ENSv1 using the full path - return resolveCanonicalDomainId(getENSv1RootRegistryId(config.namespace), path, depth + 1); + 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); } - // TODO: ENSv2Resolver - - // Bridged Resolver - // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry - const bridgesTo = isBridgedResolver(config.namespace, deepestResolver); - if (bridgesTo) { - // slice the path according to whether target Registry is a Shadow Registry or not - const targetPath = bridgesTo.shadow ? path : path.slice(deepestResolver.depth); - - // Bridged Resolvers only bridge to a Concrete Registry contract, an ENSv1Registry or an - // ENSv2Registry, so we can safely construct its id here - const targetRegistryId = makeConcreteRegistryId(bridgesTo.registry); - - // then recurse - // NOTE: we blindly return after bridging, which correctly implements the Forward Resolution - // behavior which is that the origin Domain, even if there is one, is invisible to resolution - // (due to the ancestor Bridged Resolver) and therefore not Canonical - return resolveCanonicalDomainId(targetRegistryId, targetPath, 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); } } @@ -182,13 +166,12 @@ async function resolveCanonicalDomainId( } /** - * Walks the Canonical namegraph from `registryId` through `path` to identify each ancestor Domain, + * 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 or `subregistry_id` becomes NULL (leaf - * domain). + * to NULL. Recursion terminates when the path is exhausted. */ -async function walkCanonicalNamegraph(registryId: RegistryId, path: LabelHashPath) { +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 @@ -197,27 +180,29 @@ async function walkCanonicalNamegraph(registryId: RegistryId, path: LabelHashPat const result = await ensDb.execute(sql` WITH RECURSIVE path AS ( SELECT - ${registryId}::text AS next_registry_id, - NULL::text AS "domainId", - 0 AS depth + ${registryId}::text AS next_registry_id, + NULL::text AS "domainId", + 0 AS depth UNION ALL SELECT - d.subregistry_id AS next_registry_id, - d.id AS "domainId", + -- 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_WALK_DEPTH} + AND path.depth < ${MAX_SUPPORTED_NAME_DEPTH} ) SELECT path."domainId", - drr.resolver AS "address", - drr.chain_id AS "chainId", + drr.resolver AS "address", + drr.chain_id AS "chainId", path.depth FROM path LEFT JOIN ${ensIndexerSchema.domainResolverRelation} drr diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index 460fdc34c3..11ee38f968 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -54,7 +54,7 @@ describe("Account.domains", () => { "sub1.sub2.parent.eth", "sub2.parent.eth", "test.eth", - "wallet.linked.parent.eth", + "wallet.sub1.sub2.parent.eth", ]; for (const name of expected) { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index c08a2b34e3..da448e72fa 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,7 +1,16 @@ -import type { DomainId, InterpretedLabel, InterpretedName } from "enssdk"; +import { + ADDR_REVERSE_NODE, + type DomainId, + type InterpretedLabel, + type InterpretedName, + makeENSv1DomainId, +} from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; -import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names"; +import { DatasourceNames } from "@ensnode/datasources"; +import { getDatasourceContract } from "@ensnode/ensnode-sdk"; + +import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, type PaginatedDomainResult, @@ -73,45 +82,76 @@ describe("Domain.path", () => { `; it("returns the full canonical path (leaf → root) for a deep name", async () => { - const result = await request(DomainPath, { - name: "wallet.linked.parent.eth", + await expect( + request(DomainPath, { name: "wallet.sub1.sub2.parent.eth" }), + ).resolves.toMatchObject({ + domain: { + path: [ + { name: "wallet.sub1.sub2.parent.eth" }, + { name: "sub1.sub2.parent.eth" }, + { name: "sub2.parent.eth" }, + { name: "parent.eth" }, + { name: "eth" }, + ], + }, }); - - expect(result.domain).not.toBeNull(); - const path = result.domain?.path; - expect(path).not.toBeNull(); - - const pathNames = (path ?? []).map((d) => d.name); - expect(pathNames).toEqual([ - "wallet.linked.parent.eth", - "linked.parent.eth", - "parent.eth", - "eth", - ]); }); - it("collapses aliases to their canonical path", async () => { - // `wallet.sub1.sub2.parent.eth` is an alias: `sub1.sub2.parent.eth`'s subregistry was - // re-pointed to the registry managed by `linked.parent.eth`. The canonical path must - // walk through `linked.parent.eth`, NOT `sub1.sub2.parent.eth` — edge-authentication - // in the reverse walk must reject the stale `registryCanonicalDomain` edge. - const aliasResult = await request(DomainPath, { - name: "wallet.sub1.sub2.parent.eth", - }); - const canonicalResult = await request(DomainPath, { - name: "wallet.linked.parent.eth", + it("returns the canonical path for a linked Name", async () => { + // The wallet Registry's `ParentUpdated` claims `sub1.sub2.parent.eth` as its canonical parent. + // `linked.parent.eth.subregistry` was later re-pointed to the same Registry without a + // corresponding `ParentUpdated`, so `wallet.linked.parent.eth` is an addressable alias whose + // canonical lineage walks through `sub1.sub2.parent.eth` + await expect( + request(DomainPath, { name: "wallet.linked.parent.eth" }), + ).resolves.toMatchObject({ + domain: { + path: [ + { name: "wallet.sub1.sub2.parent.eth" }, + { name: "sub1.sub2.parent.eth" }, + { name: "sub2.parent.eth" }, + { name: "parent.eth" }, + { name: "eth" }, + ], + }, }); + }); +}); - expect(aliasResult.domain?.id).toBe(canonicalResult.domain?.id); +describe("Domain.canonical", () => { + type DomainCanonicalResult = { + domain: { id: DomainId; canonical: boolean } | null; + }; + + const DomainCanonicalByName = gql` + query DomainCanonicalByName($name: InterpretedName!) { + domain(by: { name: $name }) { id canonical } + } + `; + + const DomainCanonicalById = gql` + query DomainCanonicalById($id: DomainId!) { + domain(by: { id: $id }) { id canonical } + } + `; + + it.each(DEVNET_NAMES)("is true for ENSv2 Domain '$name'", async ({ name }) => { + await expect( + request(DomainCanonicalByName, { name }), + ).resolves.toMatchObject({ domain: { canonical: true } }); + }); - const aliasPathNames = (aliasResult.domain?.path ?? []).map((d) => d.name); - expect(aliasPathNames).toEqual([ - "wallet.linked.parent.eth", - "linked.parent.eth", - "parent.eth", - "eth", - ]); - expect(aliasPathNames).not.toContain("sub1.sub2.parent.eth"); + it("is true for ENSv1 addr.reverse", async () => { + const v1RootRegistry = getDatasourceContract( + "ens-test-env", + DatasourceNames.ENSRoot, + "ENSv1Registry", + ); + const id = makeENSv1DomainId(v1RootRegistry, ADDR_REVERSE_NODE); + + await expect( + request(DomainCanonicalById, { id }), + ).resolves.toMatchObject({ domain: { id, canonical: true } }); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 915ec9c1bc..0c804c25a5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -103,6 +103,16 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.label, }), + //////////////////// + // Domain.canonical + //////////////////// + canonical: t.field({ + description: "Whether the Domain is Canonical.", + type: "Boolean", + nullable: false, + resolve: (parent) => parent.canonical, + }), + /////////////// // Domain.name /////////////// @@ -162,7 +172,7 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical.", + "The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical.", type: DomainInterfaceRef, nullable: true, resolve: async (domain, _args, context) => { @@ -429,11 +439,6 @@ export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { description: "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", }), - canonical: t.boolean({ - description: - "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). If false, the set of Domains is not filtered, and may include ENSv2 Domains not reachable by ENS Forward Resolution.", - defaultValue: false, - }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts index 137e40e1e7..bee8461e7a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -91,8 +91,8 @@ describe("Query.domains", () => { }; const QueryDomains = gql` - query QueryDomains($name: String!, $canonical: Boolean, $order: DomainsOrderInput) { - domains(where: { name: $name, canonical: $canonical }, order: $order) { + query QueryDomains($name: String!, $order: DomainsOrderInput) { + domains(where: { name: $name }, order: $order) { edges { node { __typename @@ -124,32 +124,29 @@ describe("Query.domains", () => { const domains = flattenConnection(result.domains); - // there's at least a v2 'eth' domain - expect(domains.length).toBeGreaterThanOrEqual(1); + // there's at least a v1 and a v2 'eth' domain + expect(domains.length).toBeGreaterThanOrEqual(2); - const v1EthDomain = domains.find((d) => d.__typename === "ENSv1Domain" && d.name === "eth"); - const v2EthDomain = domains.find((d) => d.__typename === "ENSv2Domain" && d.name === "eth"); - - expect(v1EthDomain).toMatchObject({ + expect( + domains.find((d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID), + ).toMatchObject({ id: V1_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, - // ENSv1Domain exposes `node` — the namehash of the canonical name node: ETH_NODE, }); - expect(v2EthDomain).toMatchObject({ + expect( + domains.find((d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID), + ).toMatchObject({ id: V2_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, }); }); - it("filters by canonical", async () => { - const result = await request(QueryDomains, { - name: "parent", - canonical: true, - }); + it("returns only canonical domains", async () => { + const result = await request(QueryDomains, { name: "parent" }); const domains = flattenConnection(result.domains); @@ -164,7 +161,7 @@ describe("Query.domains", () => { }); // TODO: devnet fixture needs a known non-canonical Domain to assert exclusion against. - it.todo("excludes non-canonical domains when `canonical: true` is set"); + it.todo("excludes non-canonical domains from the result set"); }); describe("Query.domain", () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 4e5df509f7..e3a9204fc8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -110,7 +110,8 @@ builder.queryType({ // Find Domains //////////////// domains: t.connection({ - description: "Find Domains by Name.", + description: + "Find Canonical Domains by Name. Results are always scoped to Canonical Domains — every nameable Domain is canonical by definition, so a non-canonical filter would be empty.", type: DomainInterfaceRef, args: { where: t.arg({ type: DomainsWhereInput, required: true }), @@ -119,7 +120,7 @@ builder.queryType({ resolve: (_, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); const named = filterByName(base, where.name); - const canonical = where.canonical === true ? filterByCanonical(named) : named; + const canonical = filterByCanonical(named); const domains = withOrderingMetadata(canonical); return resolveFindDomains(context, { domains, order, ...connectionArgs }); diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts index e63dfb8428..26338af4ea 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts @@ -21,6 +21,12 @@ import { gql } from "@/test/integration/omnigraph-api-client"; const namespace = "ens-test-env"; const V2_ETH_REGISTRY = getDatasourceContract(namespace, DatasourceNames.ENSv2Root, "ETHRegistry"); +const V2_ROOT_REGISTRY = getDatasourceContract( + namespace, + DatasourceNames.ENSv2Root, + "RootRegistry", +); +const V1_ROOT_REGISTRY = getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); describe("Registry.domains", () => { type RegistryDomainsResult = { @@ -59,6 +65,26 @@ describe("Registry.domains", () => { }); }); +describe("Registry.canonical", () => { + const RegistryCanonical = gql` + query RegistryCanonical($contract: AccountIdInput!) { + registry(by: { contract: $contract }) { canonical } + } + `; + + it("is true for the ENSv1 root registry", async () => { + await expect(request(RegistryCanonical, { contract: V1_ROOT_REGISTRY })).resolves.toMatchObject( + { registry: { canonical: true } }, + ); + }); + + it("is true for the ENSv2 root registry", async () => { + await expect(request(RegistryCanonical, { contract: V2_ROOT_REGISTRY })).resolves.toMatchObject( + { registry: { canonical: true } }, + ); + }); +}); + describe("Registry.domains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index eb9b87be0c..278807d804 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -76,6 +76,16 @@ RegistryInterfaceRef.implement({ resolve: (parent) => parent.id, }), + ////////////////////// + // Registry.canonical + ////////////////////// + canonical: t.field({ + description: "Whether the Registry is Canonical.", + type: "Boolean", + nullable: false, + resolve: (parent) => parent.canonical, + }), + /////////////////// // Registry.contract /////////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 30c23117e0..e181a86b9f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -22,6 +22,7 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { EventRef, EventsWhereInput } from "@/omnigraph-api/schema/event"; import { NameOrNodeInput } from "@/omnigraph-api/schema/name-or-node"; import { PermissionsRef } from "@/omnigraph-api/schema/permissions"; +import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; import { ResolverRecordsRef } from "@/omnigraph-api/schema/resolver-records"; /** @@ -124,9 +125,12 @@ ResolverRef.implement({ bridged: t.field({ description: "If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.", - type: AccountIdRef, + type: RegistryInterfaceRef, nullable: true, - resolve: (parent) => isBridgedResolver(config.namespace, parent)?.registry ?? null, + resolve: (parent) => { + const bridged = isBridgedResolver(config.namespace, parent); + return bridged?.targetRegistryId ?? null; + }, }), //////////////////////// diff --git a/apps/ensapi/src/test/integration/devnet-names.ts b/apps/ensapi/src/test/integration/devnet-names.ts index 184c8840e1..09cb3e2416 100644 --- a/apps/ensapi/src/test/integration/devnet-names.ts +++ b/apps/ensapi/src/test/integration/devnet-names.ts @@ -11,13 +11,15 @@ export const DEVNET_NAMES = [ { name: "sub2.parent.eth", canonical: "sub2.parent.eth" }, { name: "sub1.sub2.parent.eth", canonical: "sub1.sub2.parent.eth" }, { name: "linked.parent.eth", canonical: "linked.parent.eth" }, - { name: "wallet.linked.parent.eth", canonical: "wallet.linked.parent.eth" }, - // this name is actually correctly aliased - { name: "wallet.sub1.sub2.parent.eth", canonical: "wallet.linked.parent.eth" }, + // this name is incorrectly linked + // { name: "wallet.linked.parent.eth", canonical: "wallet.linked.parent.eth" }, + + // this name is correctly linked + { name: "wallet.sub1.sub2.parent.eth", canonical: "wallet.sub1.sub2.parent.eth" }, // NOTE: devnet says these are names but neither test.eth or alias.eth declare a subregistry - // so their subnames aren't resolvable + // so their subnames aren't addressable // { name: "sub.alias.eth", canonical: "sub.alias.eth" }, // { name: "sub.test.eth", canonical: "sub.alias.eth" }, ]; @@ -26,7 +28,6 @@ export const DEVNET_NAMES = [ export const DEVNET_ETH_LABELS = DEVNET_NAMES.map(({ name, canonical }) => { const isCanonical = name === canonical; if (!isCanonical) return null; - if (!name.endsWith(".eth")) return null; const segments = name.split("."); diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 20a398fdc4..4a12b90e4d 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -9,6 +9,7 @@ import { PluginName } from "@ensnode/ensnode-sdk"; import attach_ENSv2Handlers from "@/plugins/ensv2/event-handlers"; import attach_protocolAccelerationHandlers from "@/plugins/protocol-acceleration/event-handlers"; +import attach_NodeMigrationHandlers from "@/plugins/protocol-acceleration/handlers/node-migration"; import attach_RegistrarsHandlers from "@/plugins/registrars/event-handlers"; import attach_BasenamesHandlers from "@/plugins/subgraph/plugins/basenames/event-handlers"; import attach_LineanamesHandlers from "@/plugins/subgraph/plugins/lineanames/event-handlers"; @@ -36,11 +37,6 @@ if (config.plugins.includes(PluginName.ThreeDNS)) { attach_ThreeDNSHandlers(); } -// Protocol Acceleration Plugin -if (config.plugins.includes(PluginName.ProtocolAcceleration)) { - attach_protocolAccelerationHandlers(); -} - // Registrars Plugin if (config.plugins.includes(PluginName.Registrars)) { attach_RegistrarsHandlers(); @@ -51,11 +47,27 @@ if (config.plugins.includes(PluginName.TokenScope)) { attach_TokenscopeHandlers(); } -// ENSv2 Plugin -// NOTE: Because the ENSv2 plugin depends on node migration logic in the ProtocolAcceleration plugin, -// it's important that ENSv2 handlers are registered _after_ Protocol Acceleration handlers. This -// ensures that the Protocol Acceleration handlers are executed first and the results of their node -// migration indexing is available for the identical handlers in the ENSv2 plugin. +// REQUIRED ORDER: NodeMigration → ENSv2 → ProtocolAcceleration +// +// 1. NodeMigration runs first so that `nodeIsMigrated` is populated before either plugin's +// Old-registry guards consult it. +// 2. ENSv2 runs before ProtocolAcceleration so its `handleBridgedResolverChange` can read the +// PREVIOUS Domain-Resolver Relation from the index — ProtocolAcceleration's NewResolver / +// ResolverUpdated handlers overwrite that row, so reading MUST happen first. +// 3. ProtocolAcceleration's resolver handlers then write the new DRR. +// +// Note: NodeMigration is gated on ProtocolAcceleration but the ENSv2 plugin has +// ProtocolAcceleration as a hard requirement, so checking ProtocolAcceleration is sufficient +// to cover both plugins' needs. + +if (config.plugins.includes(PluginName.ProtocolAcceleration)) { + attach_NodeMigrationHandlers(); +} + if (config.plugins.includes(PluginName.ENSv2)) { attach_ENSv2Handlers(); } + +if (config.plugins.includes(PluginName.ProtocolAcceleration)) { + attach_protocolAccelerationHandlers(); +} diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts new file mode 100644 index 0000000000..9048944227 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -0,0 +1,334 @@ +import config from "@/config"; + +import { sql } from "drizzle-orm"; +import type { AccountId, DomainId, NormalizedAddress, RegistryId } from "enssdk"; + +import { isRootRegistryId } from "@ensnode/ensnode-sdk"; +import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; + +import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; + +/** + * Canonicality db helpers. + * + * A Registry is canonical iff it can be traced back to a Root Registry via bi-directionally agreed + * upon canonical edges. A canonical edge between Registry R and parent Domain D requires both + * unidirectional pointers to agree (`R.canonicalDomainId = D.id` ↔ `D.subregistryId = R.id`) + * AND for D itself to be canonical. Root Registries (ENSv1 root, ENSv2 root) are canonical by + * definition and seeded as Canonical at `ensureRegistry`-time. + * + * Concretely, this means a v1 Domain with a Bridged Resolver leaves its v1 children as non-canonical. + * Example: mainnet `linea.eth` has a Bridged Resolver pointing at the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry. `bridge.linea.eth` exists on both mainnet (as a v1 child of mainnet's + * `linea.eth` ENSv1VirtualRegistry) and Linea (as a child of the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry); but because mainnet's `linea.eth`'s Subregistry points at the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry agreement fails for `bridge.linea.eth` and it stays non-canonical (and so do + * its theoretical children). Only the Linea-side `bridge.linea.eth` (the resolution-visible one) is + * canonical. This mirrors the ENSv2 definition of Canonicality (nameability is derived from having + * the Root Registry as the oldest ancestor). + * + * The unidirectional pointers `Registry.canonicalDomainId` and `Domain.subregistryId` are written + * blindly when their onchain events fire, to store the on-chain state as-is. Edge authentication + * is done on-demand at query time. The boolean flags `Registry.canonical` and `Domain.canonical` + * are materialized from membership in the canonical nametree, and are kept up-to-date by reconciling + * during updates to the uni-directional pointers. + * + * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade + * the change through the canonical subgraph beneath it. Two paths: + * - If the Registry has no descendants (`Registry.__hasChildren = false`, the dominant case + * for fresh ENSv1 virtual registries on first wire-up), the cascade is a single-row flag + * flip done via an in-memory PK update. No raw SQL, no flush. This optimization no-ops + * the expensive reconciliation CTE for all ENSv1 Domains. + * - Otherwise, a single recursive-CTE batch UPDATE walks the canonical subgraph via the + * unidirectional pointers + inline agreement check, batch-updating every visited Registry + * and its child Domains. This goes through `context.ensDb.sql`, which forces a Ponder cache + * flush + invalidate. We accept that cost because it's bounded to Registries that have + * children AND whose canonicality actually flips — i.e. bridged-resolver attach/detach and + * ENSv2 reparenting on already-populated subtrees. + * + * `__hasChildren` is a monotonic sentinel on `Registry` (false → true on the first child Domain + * registered under it; never reset). See `ensureDomainInRegistry` for where it is flipped. + */ + +/** + * Idempotently inherit `canonical` on `domainId` from its parent `registryId`, and mark the + * parent Registry as having children (`__hasChildren = true`). + * + * Because of the invariant that Registry exist before Domain (thankfully true in both protocols), + * when a Domain is added to a Registry, the Registry's `canonical` flag has already been reconciled. + * So the new Domain trivially inherits the parent's current flag with no cascade required, since a + * brand-new Domain has no children of its own (thanks again to the above invariant). + * + * The `__hasChildren` write flips the parent Registry's sentinel from false to true on the + * first child Domain ever registered under it. The sentinel is monotonic (Domain rows aren't + * deleted and `domain.registryId` is set at creation and never mutated), so once flipped it + * stays true forever. `cascadeCanonicality` reads it to skip the SQL flush when there are + * provably no descendants to update. + */ +export async function ensureDomainInRegistry( + context: IndexingEngineContext, + registryId: RegistryId, + domainId: DomainId, +): Promise { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { + throw new Error( + `Invariant(ensureDomainInRegistry): Registry '${registryId}' must exist before linking Domain '${domainId}'.`, + ); + } + + // inherit the parent Registry's current canonical flag + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ canonical: registry.canonical }); + + // flip the parent Registry's __hasChildren sentinel on the first child (idempotent thereafter) + if (!registry.__hasChildren) { + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ __hasChildren: true }); + } +} + +/** + * Set `registryId`'s canonical parent Domain (or unset if null) by writing the unidirectional + * `Registry.canonicalDomainId` pointer, then reconciling this Registry's canonicality flag + * (which cascades through its descendants if it flips). + * + * The new canonical Domain need not exist yet — `Registry.canonicalDomainId` is set blindly. The + * canonical edge becomes "real" only when `Domain.subregistryId` agrees, which may happen later + * via `handleSubregistryUpdated`. + */ +export async function handleRegistryCanonicalDomainUpdated( + context: IndexingEngineContext, + registryId: RegistryId, + nextCanonicalDomainId: DomainId | null, +): Promise { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { + throw new Error( + `Invariant(handleRegistryCanonicalDomainUpdated): Registry ${registryId} does not yet exist.`, + ); + } + + const prevCanonicalDomainId = registry.canonicalDomainId ?? null; + + // if this Registry's Canonical Domain isn't changing, no-op + if (prevCanonicalDomainId === nextCanonicalDomainId) return; + + // set/unset the Registry's Canonical Domain (uni-directional Registry → Domain link) + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ canonicalDomainId: nextCanonicalDomainId }); + + // the registry's pointer changed, so its canonical-edge agreement may have changed too — + // reconcile the registry's flag (which cascades through its descendants if it flips) + await reconcileRegistryCanonicality(context, registryId); +} + +/** + * Handles canonicality when a Domain updates its Subregistry. Similar to + * `handleRegistryCanonicalDomainUpdated`, the new Subregistry need not exist yet — `Domain.subregistryId` + * is set blindly. The canonical edge becomes "real" only when `Registry.canonicalDomainId` agrees, + * which may happen later via `handleRegistryCanonicalDomainUpdated`. + */ +export async function handleSubregistryUpdated( + context: IndexingEngineContext, + domainId: DomainId, + nextSubregistryId: RegistryId | null, +) { + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + if (!domain) { + throw new Error(`Invariant(handleSubregistryUpdated): Domain ${domainId} does not yet exist.`); + } + + const prevSubregistryId = domain.subregistryId; + + // if the Subregistry isn't changing, no-op + if (prevSubregistryId === nextSubregistryId) return; + + // set/unset the Domain's Subregistry (uni-directional Domain → Registry link) + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ subregistryId: nextSubregistryId }); + + // both the previous and the next subregistry may have had their canonical-edge agreement + // change (the previous lost an agreeing back-pointer; the next may have gained one), so + // reconcile each + if (prevSubregistryId) await reconcileRegistryCanonicality(context, prevSubregistryId); + if (nextSubregistryId) await reconcileRegistryCanonicality(context, nextSubregistryId); +} + +/** + * Reconciles the canonical edge for a Domain whose Resolver just changed. Detaches any prior + * bridged target and attaches the new one (when the new resolver is a known Bridged Resolver). + * + * Reads the previous resolver from the Domain-Resolver Relation. This requires that this helper + * runs BEFORE Protocol Acceleration's NewResolver/ResolverUpdated handlers, which overwrite the + * DRR row — see `apps/ensindexer/ponder/src/register-handlers.ts` for the ordering. + * + * We allow any originating Domain to set the Bridged Resolver's target Registry as its Subregistry + * (which is correct for aliased forward walks [i.e. domains are correctly addressable by + * "example.fakebase.eth"]) but only set the target Registry's Canonical Domain iff this is the + * expected originating Domain. + * + * Implied invariant: Bridged target Registry is indexed before its originating Domain's Bridged + * Resolver event. `handleRegistryCanonicalDomainUpdated` throws when the registry row is missing, + * so a Bridged Resolver event firing on the originating Domain before any subname on the + * bridged target chain is indexed (which is what creates the bridged Registry row) would + * crash the indexer. If a future Bridged Resolver violates this, we should mirror the logic + * above and have two uni-directional pointer update helpers (one on ResolverChange [SubregistryUpdated] + * and one on Bridged Registry creation [CanonicalDomainUpdated]). + */ +export async function handleBridgedResolverChange( + context: IndexingEngineContext, + registry: AccountId, + domainId: DomainId, + nextResolver: NormalizedAddress | null, +): Promise { + const prev = await context.ensDb.find(ensIndexerSchema.domainResolverRelation, { + chainId: registry.chainId, + address: registry.address, + domainId, + }); + + const prevResolver = prev?.resolver; + + const prevBridged = prevResolver + ? isBridgedResolver(config.namespace, { chainId: registry.chainId, address: prevResolver }) + : null; + + const nextBridged = nextResolver + ? isBridgedResolver(config.namespace, { chainId: registry.chainId, address: nextResolver }) + : null; + + // the previous and the next are identical, no-op + // NOTE: this also covers the "neither are bridged resolvers" case (null === null) + if (prevBridged?.targetRegistryId === nextBridged?.targetRegistryId) return; + + // if the previous resolver was a Bridged Resolver, we need to disconnect both links + if (prevBridged) { + // update the domain's indicated subregistry + await handleSubregistryUpdated(context, domainId, null); + + // only update the Registry's Canonical Domain iff this is the correct originating Domain + if (prevBridged.originDomainId === domainId) { + await handleRegistryCanonicalDomainUpdated(context, prevBridged.targetRegistryId, null); + } + } + + // if the next resolver is a Bridged Resolver, we need to update the Domain's Subregistry + if (nextBridged) { + // update the domain's indicated subregistry + await handleSubregistryUpdated(context, domainId, nextBridged.targetRegistryId); + + // only update the Registry's Canonical Domain iff this is the correct originating Domain + if (nextBridged.originDomainId === domainId) { + await handleRegistryCanonicalDomainUpdated(context, nextBridged.targetRegistryId, domainId); + } + } +} + +/** + * Recompute `Registry.canonical` from its current canonical-edge agreement and, if the flag + * flips, cascade the new value through the canonical subgraph beneath this Registry via a + * single recursive-CTE batch UPDATE. + * + * Canonicality rule: + * - Root Registries (ENSv1 root, ENSv2 root) are canonical by axiom. + * - For any non-root Registry R, R.canonical iff + * ∃ Domain P: + * P.id = R.canonicalDomainId // R points up to P + * AND P.subregistryId = R.id // P points down to R (bidirectional agreement) + * AND P.canonical // P itself is in the canonical nametree + * + * Termination of the cascade walk relies on the canonical subgraph being a tree: each Registry + * has at most one canonical parent Domain (enforced by the bidirectional agreement check), so + * the recursive CTE cannot revisit a node. If that invariant is ever violated and a cycle is + * introduced, the CTE's `UNION` (not `UNION ALL`) prunes duplicates and termination is preserved. + */ +async function reconcileRegistryCanonicality( + context: IndexingEngineContext, + registryId: RegistryId, +): Promise { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) return; + + // determine the new canonicality from the current pointer state + let nextCanonical: boolean; + if (isRootRegistryId(config.namespace, registryId)) { + // root registries are canonical by axiom (no parent edge to derive canonicality from); guard + // against any pointer-write event spuriously flipping the flag off + nextCanonical = true; + } else if (!registry.canonicalDomainId) { + nextCanonical = false; + } else { + const parentDomain = await context.ensDb.find(ensIndexerSchema.domain, { + id: registry.canonicalDomainId, + }); + nextCanonical = + parentDomain != null && parentDomain.subregistryId === registryId && parentDomain.canonical; + } + + // if the canonicality flag isn't changing, no-op (no cascade, no flush) + if (registry.canonical === nextCanonical) return; + + if (registry.__hasChildren) { + // if the Registry has children, we use the CTE to bulk-update canonicality for this entire subtree + await cascadeCanonicality(context, registryId, nextCanonical); + } else { + // if Registry has no descendants, we can just update its own canonicality using ponder cache + // (this is the ENSv1 fast-path) + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ canonical: nextCanonical }); + } +} + +/** + * Walk the canonical subgraph rooted at `registryId` and set `canonical = nextCanonical` on + * every Registry and Domain it visits. + * + * Uses one recursive CTE that enumerates the canonical subgraph by following unidirectional + * pointers + agreement check, then two data-modifying CTEs that batch-update Registries and their + * child Domains. The walk visits exactly the rows whose canonicality flag must flip (the + * `IS DISTINCT FROM` filter skips rows already at the target value, which matters for the start + * registry — its flag is set in the same statement — and for any descendants that happen to already + * be consistent). + */ +async function cascadeCanonicality( + context: IndexingEngineContext, + registryId: RegistryId, + nextCanonical: boolean, +): Promise { + await context.ensDb.sql.execute(sql` + WITH RECURSIVE walk(registry_id) AS ( + SELECT ${registryId}::text + + UNION + + -- step downward: from a registry on the canonical subgraph, find each child Domain + -- (rows whose registry_id equals the current registry id), then follow that Domain's + -- subregistry_id if and only if the child registry agrees back via + -- canonical_domain_id = domain.id. + SELECT child_reg.id + FROM walk w + JOIN ${ensIndexerSchema.domain} d + ON d.registry_id = w.registry_id + JOIN ${ensIndexerSchema.registry} child_reg + ON child_reg.id = d.subregistry_id + WHERE child_reg.canonical_domain_id = d.id + ), + upd_reg AS ( + UPDATE ${ensIndexerSchema.registry} + SET canonical = ${nextCanonical} + WHERE id IN (SELECT registry_id FROM walk) + AND canonical IS DISTINCT FROM ${nextCanonical} + RETURNING id + ) + UPDATE ${ensIndexerSchema.domain} + SET canonical = ${nextCanonical} + WHERE registry_id IN (SELECT registry_id FROM walk) + AND canonical IS DISTINCT FROM ${nextCanonical}; + `); +} diff --git a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts new file mode 100644 index 0000000000..9ed8f76ca7 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts @@ -0,0 +1,33 @@ +import config from "@/config"; + +import type { RegistryId } from "enssdk"; + +import { isRootRegistryId } from "@ensnode/ensnode-sdk"; + +import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; + +/** + * Idempotently insert a Registry row, seeding `canonical = true` only for the namespace's Root + * Registries (ENSv1 root, and ENSv2 root when defined). All other Registries — including ENSv1 + * concrete and virtual registries — default to `canonical = false` and earn canonicality through + * the natural reconcile + cascade flow in `canonicality-db-helpers.ts` once the bidirectional + * canonical edge agrees back to a canonical parent Domain. v1 and v2 share the same canonicality + * definition: a Registry is canonical iff it can be traced back to a Root via canonical edges. + */ +export async function ensureRegistry( + context: IndexingEngineContext, + id: RegistryId, + args: Pick< + typeof ensIndexerSchema.registry.$inferInsert, + "type" | "chainId" | "address" | "node" + >, +) { + await context.ensDb + .insert(ensIndexerSchema.registry) + .values({ + id, + ...args, + canonical: isRootRegistryId(config.namespace, id), + }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index a000fbaf16..242da6cf27 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -22,8 +22,15 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { + ensureDomainInRegistry, + handleBridgedResolverChange, + handleRegistryCanonicalDomainUpdated, + handleSubregistryUpdated, +} from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel, ensureUnknownLabel, labelExists } from "@/lib/ensv2/label-db-helpers"; +import { ensureRegistry } from "@/lib/ensv2/registry-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { healAddrReverseSubnameLabel } from "@/lib/heal-addr-reverse-subname-label"; import { @@ -82,43 +89,31 @@ export default function () { // b) the ENSv1VirtualRegistry identified by (chainId, address, parentNode) let parentRegistryId: RegistryId; if (isTLD) { - // if this is a TLD, upsert the (concrete) ENSv1Registry representing this Registry contract parentRegistryId = makeENSv1RegistryId(registry); - await context.ensDb - .insert(ensIndexerSchema.registry) - .values({ id: parentRegistryId, type: "ENSv1Registry", ...registry }) - .onConflictDoNothing(); + await ensureRegistry(context, parentRegistryId, { type: "ENSv1Registry", ...registry }); } else { - // otherwise, ensure this ENSv1 Domain's parent Domain receives a virtual registry w/ Canonical Domain reference parentRegistryId = makeENSv1VirtualRegistryId(registry, parentNode); - await context.ensDb - .insert(ensIndexerSchema.registry) - .values({ - id: parentRegistryId, - type: "ENSv1VirtualRegistry", - ...registry, - node: parentNode, - }) - .onConflictDoNothing(); + await ensureRegistry(context, parentRegistryId, { + type: "ENSv1VirtualRegistry", + ...registry, + node: parentNode, + }); const parentDomainId = makeENSv1DomainId(registry, parentNode); + // route through handleSubregistryUpdated so any prior subregistry edge (e.g. a bridged + // attachment) is properly reconciled instead of orphaned by a blind overwrite. + await handleSubregistryUpdated(context, parentDomainId, parentRegistryId); - // set the parent Domain's subregistry to said registry - await context.ensDb - .update(ensIndexerSchema.domain, { id: parentDomainId }) - .set({ subregistryId: parentRegistryId }); - - // ensure Canonical Domain reference - await context.ensDb - .insert(ensIndexerSchema.registryCanonicalDomain) - .values({ registryId: parentRegistryId, domainId: parentDomainId }) - .onConflictDoNothing(); + await handleRegistryCanonicalDomainUpdated(context, parentRegistryId, parentDomainId); } const ownerId = interpretAddress(owner); await ensureAccount(context, owner); - // upsert domain + // ownerId/rootRegistryOwnerId are always set here despite being materialized from Registrars + // (BaseRegistrar, NameWrapper) because (a) the root Registry is the source of truth even when + // no Registrar is in use, and (b) Registrar events fire _after_ Registry events, so they + // re-materialize over the value we set here. await context.ensDb .insert(ensIndexerSchema.domain) .values({ @@ -127,22 +122,13 @@ export default function () { registryId: parentRegistryId, node, labelHash, - // NOTE: the inclusion of ownerId here 'inlines' the logic of `materializeENSv1DomainEffectiveOwner`, - // saving a single db op in a hot path (lots of NewOwner events, unsurprisingly!) - // - // NOTE: despite Domain.ownerId being materialized from other sources of truth (i.e. Registrars - // like BaseRegistrars & NameWrapper) it's ok (and necessary!) to always set it here because: - // a) the Root Registry is the source of truth, and other contracts (Registrars, RegistrarControllers) - // may not be in use, and - // b) the Registrar-emitted events occur _after_ the Registry events. So when a name is - // wrapped by the NameWrapper, for example, the Registry's owner is updated here to that - // of the NameWrapper, but then the NameWrapper emits NameWrapped, and this plugin - // re-materializes the Domain.ownerId to the NameWrapper-emitted value. ownerId, rootRegistryOwnerId: ownerId, }) .onConflictDoUpdate({ ownerId, rootRegistryOwnerId: ownerId }); + await ensureDomainInRegistry(context, parentRegistryId, domainId); + // Label Healing // // only attempt to heal label if it doesn't already exist @@ -234,9 +220,10 @@ export default function () { event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ node: Node }>; + event: EventWithArgs<{ node: Node; resolver: NormalizedAddress }>; }) { const { node } = event.args; + const resolver = interpretAddress(event.args.resolver); // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; @@ -247,6 +234,9 @@ export default function () { // NOTE: Domain-Resolver relations are handled by the protocol-acceleration plugin and are not // directly indexed here + // handle changes in resolver that could affect Bridged Resolver Canonical Domain edges + await handleBridgedResolverChange(context, registry, domainId, resolver); + // push event to domain history const eventId = await ensureEvent(context, event); await ensureDomainEvent(context, domainId, eventId); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 4800910070..234b5b9ad4 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -20,12 +20,19 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { + ensureDomainInRegistry, + handleBridgedResolverChange, + handleRegistryCanonicalDomainUpdated, + handleSubregistryUpdated, +} from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; import { getLatestRegistration, insertLatestRegistration, } from "@/lib/ensv2/registration-db-helpers"; +import { ensureRegistry } from "@/lib/ensv2/registry-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { addOnchainEventListener, @@ -82,10 +89,7 @@ export default function () { // ensure Registry // TODO(signals) — move to NewRegistry and add invariant here - await context.ensDb - .insert(ensIndexerSchema.registry) - .values({ id: registryId, type: "ENSv2Registry", ...registry }) - .onConflictDoNothing(); + await ensureRegistry(context, registryId, { type: "ENSv2Registry", ...registry }); // ensure discovered Label await ensureLabel(context, label); @@ -127,6 +131,8 @@ export default function () { // if the domain exists, this is a re-register after expiration and tokenId will have changed .onConflictDoUpdate({ tokenId }); + await ensureDomainInRegistry(context, registryId, domainId); + // insert Registration const registrantId = await ensureAccount(context, registrant); const eventId = await ensureEvent(context, event, registrantId); @@ -262,44 +268,18 @@ export default function () { sender: NormalizedAddress; }>; }) => { - const { tokenId, subregistry: _subregistry, sender } = event.args; - const subregistry = interpretAddress(_subregistry); + const { tokenId, sender } = event.args; + const subregistry = interpretAddress(event.args.subregistry); - const registryAccountId = getThisAccountId(context, event); + const registry = getThisAccountId(context, event); const storageId = makeStorageId(tokenId); - const domainId = makeENSv2DomainId(registryAccountId, storageId); + const domainId = makeENSv2DomainId(registry, storageId); - // update domain's subregistry - if (subregistry === null) { - // TODO(canonical-names): this last-write-wins heuristic breaks if a domain ever unsets its - // subregistry. i.e. the (sub)Registry's Canonical Domain becomes null, making it disjoint because - // we don't track other domains who have set it as a Subregistry. This is acceptable for now, - // and obviously isn't an issue once ENS Team implements Canonical Names - const previous = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); - if (previous?.subregistryId) { - await context.ensDb.delete(ensIndexerSchema.registryCanonicalDomain, { - registryId: previous.subregistryId, - }); - } - - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ subregistryId: null }); - } else { - const subregistryAccountId: AccountId = { chainId: context.chain.id, address: subregistry }; - const subregistryId = makeENSv2RegistryId(subregistryAccountId); - - // TODO(canonical-names): this implements last-write-wins heuristic for a Registry's canonical name, - // replace with real logic once ENS Team implements Canonical Names - await context.ensDb - .insert(ensIndexerSchema.registryCanonicalDomain) - .values({ registryId: subregistryId, domainId }) - .onConflictDoUpdate({ domainId }); - - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ subregistryId }); - } + const subregistryId = subregistry + ? makeENSv2RegistryId({ chainId: registry.chainId, address: subregistry }) + : null; + + await handleSubregistryUpdated(context, domainId, subregistryId); // push event to domain history const senderId = await ensureAccount(context, sender); @@ -308,6 +288,77 @@ export default function () { }, ); + /** + * `ParentUpdated(parent, label, sender)` is emitted by the _child_ Registry to claim its + * canonical parent Domain in the namegraph. It may fire in either order relative to the parent + * Registry's `SubregistryUpdated`/`LabelRegistered`. + * + * The `parent` address is interpreted as living on the same chain as the emitting (child) + * Registry — ENSv2 hierarchical registries are same-chain. Cross-chain parentage is expressed + * via Bridged Resolvers (CCIP-Read), not `ParentUpdated`. + */ + addOnchainEventListener( + namespaceContract(pluginName, "ENSv2Registry:ParentUpdated"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ + parent: NormalizedAddress; + label: string; + sender: NormalizedAddress; + }>; + }) => { + const label = asLiteralLabel(event.args.label); + const parent = interpretAddress(event.args.parent); + + const registry = getThisAccountId(context, event); + const registryId = makeENSv2RegistryId(registry); + + // TODO(signals): not necessary when signals + await ensureRegistry(context, registryId, { type: "ENSv2Registry", ...registry }); + + if (parent) { + // update the Canonical Domain, cascading the canonicality update to this registry's domains + const parentRegistry: AccountId = { chainId: registry.chainId, address: parent }; + const labelHash = labelhashLiteralLabel(label); + const domainId = makeENSv2DomainId(parentRegistry, makeStorageId(labelHash)); + + await handleRegistryCanonicalDomainUpdated(context, registryId, domainId); + } else { + // unset the Canonical Domain, cascading the canonicality update to this registry's domains + await handleRegistryCanonicalDomainUpdated(context, registryId, null); + } + + // TODO: push event to registry history + // const senderId = await ensureAccount(context, event.args.sender); + // const eventId = await ensureEvent(context, event, senderId); + // await ensureRegistryEvent(context, registryId, eventId); + }, + ); + + addOnchainEventListener( + namespaceContract(pluginName, "ENSv2Registry:ResolverUpdated"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ tokenId: TokenId; resolver: NormalizedAddress }>; + }) => { + const { tokenId } = event.args; + const resolver = interpretAddress(event.args.resolver); + + const registry = getThisAccountId(context, event); + const storageId = makeStorageId(tokenId); + const domainId = makeENSv2DomainId(registry, storageId); + + // handle changes in resolver that could affect Bridged Resolver Canonical Domain edges + await handleBridgedResolverChange(context, registry, domainId, resolver); + }, + ); + addOnchainEventListener( namespaceContract(pluginName, "ENSv2Registry:TokenRegenerated"), async ({ diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts index 875e907379..b1778d4114 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts @@ -1,8 +1,5 @@ -import config from "@/config"; +import { makeENSv1DomainId, type Node, type NormalizedAddress } from "enssdk"; -import { type LabelHash, makeENSv1DomainId, type Node, type NormalizedAddress } from "enssdk"; - -import { getENSRootChainId } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; import { getThisAccountId } from "@/lib/get-this-account-id"; @@ -11,16 +8,15 @@ import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; -import { migrateNode, nodeIsMigrated } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; - -const ensRootChainId = getENSRootChainId(config.namespace); +import { nodeIsMigrated } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; /** * Handler functions for Registry contracts in the Protocol Acceleration plugin. - * - indexes ENS Root Chain Registry migration status * - indexes Node-Resolver Relationships for all Registry contracts * - * Note that this registry migration status tracking is isolated to the protocol + * Note: ENS Root Chain Registry node-migration status is tracked separately in `node-migration.ts`, + * registered before both this plugin and the ENSv2 plugin so its results are available to the + * Old-registry guards in either plugin. */ export default function () { async function handleNewResolver({ @@ -40,33 +36,6 @@ export default function () { await ensureDomainResolverRelation(context, registry, domainId, resolver); } - /** - * Handles Registry#NewOwner for: - * - ENS Root Chain's (new) Registry - */ - addOnchainEventListener( - namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewOwner"), - async ({ - context, - event, - }: { - context: IndexingEngineContext; - event: EventWithArgs<{ - // NOTE: `node` event arg represents a `Node` that is the _parent_ of the node the NewOwner event is about - node: Node; - // NOTE: `label` event arg represents a `LabelHash` for the sub-node under `node` - label: LabelHash; - owner: NormalizedAddress; - }>; - }) => { - // no-op because we only track registry migration status on ENS Root Chain - if (context.chain.id !== ensRootChainId) return; - - const { label: labelHash, node: parentNode } = event.args; - await migrateNode(context, parentNode, labelHash); - }, - ); - /** * Handles Registry#NewResolver for: * - ENS Root Chain's ENSv1RegistryOld diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts new file mode 100644 index 0000000000..b699e26ec2 --- /dev/null +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts @@ -0,0 +1,43 @@ +import config from "@/config"; + +import type { LabelHash, Node, NormalizedAddress } from "enssdk"; + +import { getENSRootChainId } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { addOnchainEventListener, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; +import { namespaceContract } from "@/lib/plugin-helpers"; +import type { EventWithArgs } from "@/lib/ponder-helpers"; +import { migrateNode } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; + +const ensRootChainId = getENSRootChainId(config.namespace); + +/** + * Node migration handler — tracks ENSv1RegistryOld → ENSv1Registry migration on the ENS Root Chain. + * + * Extracted from the ProtocolAcceleration plugin so it can be registered before both the ENSv2 and + * ProtocolAcceleration plugins. This guarantees `nodeIsMigrated` reads from a populated table when + * those plugins' Old-registry guards run. + */ +export default function () { + addOnchainEventListener( + namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewOwner"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ + node: Node; + label: LabelHash; + owner: NormalizedAddress; + }>; + }) => { + // no-op because we only track registry migration status on ENS Root Chain + if (context.chain.id !== ensRootChainId) return; + + const { label: labelHash, node: parentNode } = event.args; + await migrateNode(context, parentNode, labelHash); + }, + ); +} diff --git a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx index 8a8bad5c85..e4a57219e0 100644 --- a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx @@ -96,7 +96,11 @@ For ENSv1, each domain that has children implicitly owns a "virtual" Registry (a `registryId` at the virtual registry. Concrete `ENSv1Registry` rows (e.g. the mainnet ENS Registry, the Basenames Registry, the Lineanames Registry) sit at the top. ENSv2 namegraphs are rooted in a single `ENSv2Registry` RootRegistry on the ENS Root Chain and are possibly circular directed -graphs. The canonical namegraph is never materialized, only _navigated_ at resolution-time. +graphs. The full namegraph is never materialized, only _navigated_ at resolution-time, with the +exception of the canonical subgraph, which is reflected via `Registry.canonical` / +`Domain.canonical` boolean flags on the rows themselves. The bidirectional canonical edge is NOT +materialized in a parallel table; it is derived on demand by checking that the two unidirectional +pointers agree (`Registry.canonicalDomainId = Domain.id` ↔ `Domain.subregistryId = Registry.id`). Note also that the Protocol Acceleration plugin is a hard requirement for the ENSv2 plugin. This allows us to rely on the shared logic for indexing: diff --git a/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index ad7f18053a..6d78dd9a89 100644 --- a/examples/enskit-react-example/src/SearchView.tsx +++ b/examples/enskit-react-example/src/SearchView.tsx @@ -66,17 +66,10 @@ export function SearchView() {

Domain Search

-
- Heads up! We return both ENSv1 and ENSv2 names due to a small bug in our Canonical Name - derivation, which will be fixed in the near future. -
-

- Showcases live querying via Query.domains(where: {"{ name }"}). Input is - debounced by {DEBOUNCE_MS}ms and synced to the URL as ?query=. + Showcases live querying via Query.domains(where: {"{ name }"}). Only{" "} + Canonical Domains are rendered. Input is debounced by {DEBOUNCE_MS}ms and synced to + the URL as ?query=.

(), + + // the Registry's declared Canonical Domain (uni-directional) + canonicalDomainId: t.text().$type(), + + // Whether this Registry is part of the canonical nametree. See canonicality-db-helpers.ts. + canonical: t.boolean().notNull().default(false), + + // Synthetic monotonic sentinel: flipped to true the first time a child Domain is registered + // under this Registry (see `ensureDomainInRegistry`). Read by `cascadeCanonicality` to skip + // the raw-SQL recursive-CTE walk (and its associated Ponder cache flush) when the start + // registry provably has no descendants — the dominant case for fresh ENSv1 virtual + // registries on first wire-up. Double-underscore prefix marks it as an internal-only + // bookkeeping field, not part of the on-chain protocol surface. + __hasChildren: t.boolean().notNull().default(false), }), (t) => ({ // NOTE: non-unique index because multiple rows can share (chainId, address) across virtual registries @@ -244,7 +266,7 @@ export const domain = onchainTable( // belongs to a registry registryId: t.text().notNull().$type(), - // may have a subregistry + // the Domain's declared Subregistry (uni-directional) subregistryId: t.text().$type(), // If this is an ENSv2Domain, the TokenId within the ENSv2Registry, otherwise null. @@ -263,8 +285,10 @@ export const domain = onchainTable( // If this is an ENSv1Domain, may have a `rootRegistryOwner`, otherwise null. rootRegistryOwnerId: t.hex().$type(), + // Whether this Domain is part of the canonical nametree. Mirrors the parent Registry's flag. + canonical: t.boolean().notNull().default(false), + // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin - // NOTE: parent is derived via registryCanonicalDomain, not stored on the domain row }), (t) => ({ byType: index().on(t.type), @@ -590,18 +614,3 @@ export const label = onchainTable( export const label_relations = relations(label, ({ many }) => ({ domains: many(domain), })); - -/////////////////// -// Canonical Names -/////////////////// - -// TODO(canonical-names): this table will be refactored away once Canonical Names are implemented in -// ENSv2, and we'll be able to store this information directly on the Registry entity, but until -// then we need a place to track canonical domain references without requiring that a Registry contract -// has emitted an event (and therefore is indexed) -// TODO(canonical-names): this table can also disappear once the Signal pattern is implemented for -// Registry contracts, ensuring that they are indexed during construction and are available for storage. -export const registryCanonicalDomain = onchainTable("registry_canonical_domains", (t) => ({ - registryId: t.text().primaryKey().$type(), - domainId: t.text().notNull().$type(), -})); diff --git a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts index cc406d0672..70892a28a1 100644 --- a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts @@ -1,30 +1,56 @@ -import type { AccountId } from "enssdk"; +import { + type AccountId, + type DomainId, + makeENSv1DomainId, + makeENSv1VirtualRegistryId, + type RegistryId, +} from "enssdk"; -import { DatasourceNames } from "@ensnode/datasources"; +import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { + accountIdEqual, type ENSNamespaceId, getDatasourceContract, - makeContractMatcher, + getENSv1RootRegistry, + getManagedName, } from "@ensnode/ensnode-sdk"; +// simple cache of BridgedResolverConfig by namespace because it's in an indexing hot-path +const cache = new Map(); + /** - * Result of a Bridged Resolver detection: the AccountId of the (shadow)Registry the resolver - * defers to, plus whether that Registry indexes the namegraph from-root (`shadow: true`) or is - * rooted at the resolver's name (`shadow: false`). - * - * For ENSv1 Shadow Registries (Basenames, Lineanames) the L2 contract mirrors the full namegraph - * from the ENS root. For any future ENSv2 sub-Registry bridges the bridged Registry is rooted at - * the resolver's name. + * Describes a Bridged Resolver's origin Domain and target Registry. */ -export interface BridgedResolverTarget { - registry: AccountId; - shadow: boolean; +export interface BridgedResolverConfig { + /** + * The Bridged Resolver connecting the origin Domain to the target Registry. + */ + resolver: AccountId; + + /** + * The DomainId of the _legitimate_ originating Domain on the ENS Root chain whose + * Bridged Resolver attachment canonicalizes the bridged target Registry. Anyone can set a + * known Bridged Resolver (e.g. `BasenamesL1Resolver`) as their resolver, but only the + * originating Domain (e.g. mainnet `base.eth`) is the canonical parent of the bridged + * target Registry. + */ + originDomainId: DomainId; + + /** + * The RegistryId of the _specific_ (Concrete or Virtual) Registry to which the Bridged Resolver defers. + */ + targetRegistryId: RegistryId; + + /** + * The AccountId of the Concrete Registry to which the Bridged Resolver defers, necessary for + * current ENSv1 Protocol Acceleration implementation. + * TODO: refactor Protocol Acceleration to operate over RegistryId instead of AccountId. + */ + targetRegistry: AccountId; } /** - * For a given `resolver`, if it is a known Bridged Resolver, return the AccountId describing the - * (shadow)Registry it defers resolution to and a flag indicating whether that Registry indexes - * the namegraph from-root. + * Constructs the known Bridged Resolver Configurations for the provided `namespace`. * * These Bridged Resolvers must abide the following pattern: * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, @@ -44,30 +70,63 @@ export interface BridgedResolverTarget { * against the (shadow)Registry in question. * * TODO: these relationships could/should be encoded in an ENSIP + * TODO: once Forward Resolution is updated for ENSv2, this likely just returns RegistryId */ -export function isBridgedResolver( - namespace: ENSNamespaceId, - resolver: AccountId, -): BridgedResolverTarget | null { - const resolverEq = makeContractMatcher(namespace, resolver); +const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { + const cached = cache.get(namespace); + if (cached) return cached; + + const configs: BridgedResolverConfig[] = []; - // the ENSRoot's BasenamesL1Resolver bridges to the Basenames (shadow)Registry - if (resolverEq(DatasourceNames.ENSRoot, "BasenamesL1Resolver")) { - return { - registry: getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"), - shadow: true, - }; + const basenames = maybeGetDatasource(namespace, DatasourceNames.Basenames); + if (basenames) { + const resolver = getDatasourceContract( + namespace, + DatasourceNames.ENSRoot, + "BasenamesL1Resolver", + ); + const registry = getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"); + const { node } = getManagedName(namespace, registry); + configs.push({ + resolver, + originDomainId: makeENSv1DomainId(getENSv1RootRegistry(namespace), node), + targetRegistry: registry, + targetRegistryId: makeENSv1VirtualRegistryId(registry, node), + }); } - // the ENSRoot's LineanamesL1Resolver bridges to the Lineanames (shadow)Registry - if (resolverEq(DatasourceNames.ENSRoot, "LineanamesL1Resolver")) { - return { - registry: getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"), - shadow: true, - }; + const lineanames = maybeGetDatasource(namespace, DatasourceNames.Lineanames); + if (lineanames) { + const resolver = getDatasourceContract( + namespace, + DatasourceNames.ENSRoot, + "LineanamesL1Resolver", + ); + const registry = getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); + const { node } = getManagedName(namespace, registry); + configs.push({ + resolver, + originDomainId: makeENSv1DomainId(getENSv1RootRegistry(namespace), node), + targetRegistry: registry, + targetRegistryId: makeENSv1VirtualRegistryId(registry, node), + }); } - // TODO: ThreeDNS + cache.set(namespace, configs); - return null; + return configs; +}; + +/** + * For a given `resolver`, if it is a known Bridged Resolver, return its Bridged Resolver Config. + */ +export function isBridgedResolver( + namespace: ENSNamespaceId, + resolver: AccountId, +): BridgedResolverConfig | null { + return ( + getBridgedResolverConfigs(namespace).find((config) => + accountIdEqual(config.resolver, resolver), + ) ?? null + ); } diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index d97e67e5ff..0a3a846441 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -1,16 +1,9 @@ -import { - type AccountId, - makeENSv1RegistryId, - makeENSv1VirtualRegistryId, - makeENSv2RegistryId, - type RegistryId, -} from "enssdk"; +import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId, type RegistryId } from "enssdk"; import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; import { accountIdEqual } from "./account-id"; import { getDatasourceContract, maybeGetDatasourceContract } from "./datasource-contract"; -import { getManagedName } from "./managed-names"; ////////////// // ENSv1 @@ -19,20 +12,20 @@ import { getManagedName } from "./managed-names"; /** * Gets the AccountId representing the ENSv1 Registry in the selected `namespace`. */ -export const getENSv1Registry = (namespace: ENSNamespaceId) => +export const getENSv1RootRegistry = (namespace: ENSNamespaceId) => getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); /** * Gets the ENSv1RegistryId representing the ENSv1 Root Registry in the selected `namespace`. */ export const getENSv1RootRegistryId = (namespace: ENSNamespaceId) => - makeENSv1RegistryId(getENSv1Registry(namespace)); + makeENSv1RegistryId(getENSv1RootRegistry(namespace)); /** * Determines whether `contract` is the ENSv1 Registry in `namespace`. */ export const isENSv1Registry = (namespace: ENSNamespaceId, contract: AccountId) => - accountIdEqual(getENSv1Registry(namespace), contract); + accountIdEqual(getENSv1RootRegistry(namespace), contract); ////////////// // ENSv2 @@ -88,61 +81,17 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { ////////////// /** - * Gets the RegistryId representing the primary Root Registry for the selected `namespace`: the - * ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. Matches ENS Forward - * Resolution preference (v2 over v1) for display/resolution purposes. - * - * Not to be confused with the canonical-registries tree in the API layer, which is a union of - * both ENSv1 and ENSv2 subtrees because ENSv1 Domains remain resolvable via Universal Resolver - * v2's ENSv1 fallback. + * 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); /** - * Gets every top-level Root Registry configured for the namespace: all ENSv1Registries - * (ENSRoot ENSv1Registry, Basenames base.eth ENSv1VirtualRegistry, Lineanames linea.eth ENSv1VirtualRegistry) - * plus the ENSv2 Root Registry when defined. Used by consumers that need to walk the full set of - * canonical namegraph roots (forward traversal, canonical-set construction) rather than the single - * "primary" root returned by {@link getRootRegistryId}. Note that for the Lineanames and Basenames - * Shadow Registries, we consider the Managed Name's ENSv1VirtualRegistry as the root, which - * negates canonicality for any names managed by said Shadow Registry under a different Managed Name. - * - * Each Registry roots its own on-chain subtree (the mainnet ENSv1Registry, Basenames/Lineanames - * shadow Registries on their own chains) — they are not linked together at the indexed-namegraph - * level, so a traversal that starts from a single root cannot reach them all. - * - * TODO(ensv2-shadow): when well-known CCIP-read ENSv2 Registries are introduced, extend this helper to - * enumerate them. + * Determines whether `registryId` is a Root Registry (ENSv1 Root or, when defined, ENSv2 Root) + * for the selected `namespace`. */ -export const getRootRegistryIds = (namespace: ENSNamespaceId): RegistryId[] => { - const v1RootRegistryId = getENSv1RootRegistryId(namespace); - const v2RootRegistryId = maybeGetENSv2RootRegistryId(namespace); - - const basenamesRegistry = maybeGetDatasourceContract( - namespace, - DatasourceNames.Basenames, - "Registry", - ); - - const lineanamesRegistry = maybeGetDatasourceContract( - namespace, - DatasourceNames.Lineanames, - "Registry", - ); - - return [ - v1RootRegistryId, - basenamesRegistry && - makeENSv1VirtualRegistryId( - basenamesRegistry, - getManagedName(namespace, basenamesRegistry).node, - ), - lineanamesRegistry && - makeENSv1VirtualRegistryId( - lineanamesRegistry, - getManagedName(namespace, lineanamesRegistry).node, - ), - v2RootRegistryId, - ].filter((id): id is RegistryId => !!id); -}; +export const isRootRegistryId = (namespace: ENSNamespaceId, registryId: RegistryId): boolean => + registryId === getENSv1RootRegistryId(namespace) || + registryId === maybeGetENSv2RootRegistryId(namespace); diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b018ad6b8a..bf0f6a6889 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1042,6 +1042,18 @@ const introspection = { "kind": "INTERFACE", "name": "Domain", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "events", "type": { @@ -1600,14 +1612,6 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "DomainsWhereInput", "inputFields": [ - { - "name": "canonical", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - }, { "name": "name", "type": { @@ -1625,6 +1629,18 @@ const introspection = { "kind": "OBJECT", "name": "ENSv1Domain", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "events", "type": { @@ -1877,6 +1893,18 @@ const introspection = { "kind": "OBJECT", "name": "ENSv1Registry", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "contract", "type": { @@ -2012,6 +2040,18 @@ const introspection = { "kind": "OBJECT", "name": "ENSv1VirtualRegistry", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "contract", "type": { @@ -2159,6 +2199,18 @@ const introspection = { "kind": "OBJECT", "name": "ENSv2Domain", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "events", "type": { @@ -2548,6 +2600,18 @@ const introspection = { "kind": "OBJECT", "name": "ENSv2Registry", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "contract", "type": { @@ -5085,6 +5149,18 @@ const introspection = { "kind": "INTERFACE", "name": "Registry", "fields": [ + { + "name": "canonical", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "contract", "type": { @@ -5572,8 +5648,8 @@ const introspection = { { "name": "bridged", "type": { - "kind": "OBJECT", - "name": "AccountId" + "kind": "INTERFACE", + "name": "Registry" }, "args": [], "isDeprecated": false diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index dcb12e7d14..ffd7b2345c 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -211,6 +211,9 @@ scalar CoinType A Domain represents an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ interface Domain { + """Whether the Domain is Canonical.""" + canonical: Boolean! + """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -231,7 +234,7 @@ interface Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain @@ -321,11 +324,6 @@ input DomainsOrderInput { """Filter for the top-level domains query.""" input DomainsWhereInput { - """ - Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). If false, the set of Domains is not filtered, and may include ENSv2 Domains not reachable by ENS Forward Resolution. - """ - canonical: Boolean = false - """ A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'. """ @@ -334,6 +332,9 @@ input DomainsWhereInput { """An ENSv1Domain represents an ENSv1 Domain.""" type ENSv1Domain implements Domain { + """Whether the Domain is Canonical.""" + canonical: Boolean! + """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -357,7 +358,7 @@ type ENSv1Domain implements Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain @@ -392,6 +393,9 @@ type ENSv1Domain implements Domain { An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry). """ type ENSv1Registry implements Registry { + """Whether the Registry is Canonical.""" + canonical: Boolean! + """ Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. """ @@ -414,6 +418,9 @@ type ENSv1Registry implements Registry { An ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash. """ type ENSv1VirtualRegistry implements Registry { + """Whether the Registry is Canonical.""" + canonical: Boolean! + """ Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. """ @@ -439,6 +446,9 @@ type ENSv1VirtualRegistry implements Registry { """An ENSv2Domain represents an ENSv2 Domain.""" type ENSv2Domain implements Domain { + """Whether the Domain is Canonical.""" + canonical: Boolean! + """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -459,7 +469,7 @@ type ENSv2Domain implements Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain @@ -512,6 +522,9 @@ type ENSv2DomainPermissionsConnectionEdge { """An ENSv2Registry represents an ENSv2 Registry contract.""" type ENSv2Registry implements Registry { + """Whether the Registry is Canonical.""" + canonical: Boolean! + """ Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. """ @@ -924,7 +937,9 @@ type Query { """Identify a Domain by Name or DomainId""" domain(by: DomainIdInput!): Domain - """Find Domains by Name.""" + """ + Find Canonical Domains by Name. Results are always scoped to Canonical Domains — every nameable Domain is canonical by definition, so a non-canonical filter would be empty. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection """Identify Permissions by ID or AccountId.""" @@ -1058,6 +1073,9 @@ type RegistrationRenewalsConnectionEdge { A Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry. """ interface Registry { + """Whether the Registry is Canonical.""" + canonical: Boolean! + """ Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists. """ @@ -1163,7 +1181,7 @@ type Resolver { """ If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution. """ - bridged: AccountId + bridged: Registry """Contract metadata for this Resolver.""" contract: AccountId!