diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md new file mode 100644 index 000000000..e7f485b00 --- /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 namegraph. diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 116b83a45..17f7768a4 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -5,6 +5,7 @@ import { type AccountId, asInterpretedName, ENS_ROOT_NAME, + ENS_ROOT_NODE, type InterpretedName, isNormalizedName, type Node, @@ -239,14 +240,19 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - const bridgesTo = isBridgedResolver(config.namespace, resolver); + // Forward Resolution recurses with the bridged target's AccountId; `originatingNode` + // doesn't affect that projection, so a sentinel suffices. + const bridgesTo = isBridgedResolver(config.namespace, resolver, ENS_ROOT_NODE); if (bridgesTo) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, () => - _resolveForward(name, selection, { ...options, registry: bridgesTo.registry }), + _resolveForward(name, selection, { + ...options, + registry: { chainId: bridgesTo.chainId, address: bridgesTo.address }, + }), ); } 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 381e4f283..000000000 --- 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 49650dab2..f8848ae08 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 @@ -1,5 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; +import { eq, sql } from "drizzle-orm"; import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -12,41 +11,33 @@ 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 }`. * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ export function domainsBase() { - const parentDomain = alias(ensIndexerSchema.domain, "parentDomain"); - return ( ensDb .select({ domainId: sql`${ensIndexerSchema.domain.id}`.as("domainId"), ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), - parentId: sql`${parentDomain.id}`.as("parentId"), + parentId: sql`${ensIndexerSchema.registry.canonicalDomainId}`.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. + // parent: materialized via `registry.canonicalDomainId`. The bidirectional invariant + // (`Domain.canonicalSubregistryId` ↔ `Registry.canonicalDomainId`) guarantees consistency, + // so no edge-auth join is required. .leftJoin( - ensIndexerSchema.registryCanonicalDomain, - eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), - ) - .leftJoin( - parentDomain, - and( - eq(parentDomain.id, ensIndexerSchema.registryCanonicalDomain.domainId), - eq(parentDomain.subregistryId, ensIndexerSchema.domain.registryId), - ), + ensIndexerSchema.registry, + eq(ensIndexerSchema.registry.id, ensIndexerSchema.domain.registryId), ) // join label for labelHash/sortableLabel .leftJoin( @@ -67,6 +58,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 72918f826..156a2e15c 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,18 @@ 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. + * + * Reads the materialized `domain.canonical` flag, which is maintained at index time by the + * canonicality db helpers (Registry/Domain bidirectional pointers + cascading flips). */ 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 d950569c8..ba0ed672f 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,9 +47,9 @@ 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 registry.canonicalDomainId. // 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 materialized canonical edge, 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 @@ -64,23 +64,23 @@ 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 registry.canonical_domain_id. The bidirectional invariant guarantees the edge + -- is consistent without a separate edge-auth join. 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} r + ON r.id = d.registry_id JOIN ${ensIndexerSchema.domain} parent - ON parent.id = rcd.domain_id AND parent.subregistry_id = d.registry_id + ON parent.id = r.canonical_domain_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 registry.canonical_domain_id, verifying each + -- ancestor's labelHash. SELECT upward_check.leaf_id, np.id AS current_id, @@ -88,10 +88,10 @@ 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} pr + ON pr.id = pd.registry_id JOIN ${ensIndexerSchema.domain} np - ON np.id = rcd.domain_id AND np.subregistry_id = pd.registry_id + ON np.id = pr.canonical_domain_id WHERE upward_check.depth < ${pathLength} AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] ) 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 62848873f..496aac4bc 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,33 @@ -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"; +/** + * Maximum depth to walk before throwing. ENS names have no formal depth limit, but at the + * Omnigraph API boundary we cap traversal to fail loudly rather than risk an unbounded + * recursive CTE if the canonical-tree invariant is ever violated. The cap is detected via an + * extra row beyond `MAX_DEPTH`; if that row is produced we throw rather than silently truncate. + */ const MAX_DEPTH = 16; /** * 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 via the materialized canonical + * edge until the registry has no canonical parent (root). Returns `null` when the input Domain is + * not itself canonical (`domain.canonical = false`). */ export async function getCanonicalPath(domainId: DomainId): Promise { - const rootRegistryIds = getRootRegistryIds(config.namespace); - - // 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[]`; + // Short-circuit non-canonical Domains via the materialized flag. + 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}' did not exist.`); + } + if (!domain.canonical) return null; const result = await ensDb.execute(sql` WITH RECURSIVE upward AS ( @@ -37,21 +41,20 @@ 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. + -- The bidirectional invariant guarantees consistency, so no edge-auth is needed. + -- 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} r + ON r.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 = r.canonical_domain_id + WHERE upward.depth <= ${MAX_DEPTH} ) SELECT * FROM upward @@ -60,15 +63,19 @@ export async function getCanonicalPath(domainId: DomainId): Promise MAX_DEPTH) { + throw new Error( + `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_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 883eb7204..82eddf3bd 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,7 +11,6 @@ import { interpretedLabelsToLabelHashPath, interpretedNameToInterpretedLabels, type LabelHashPath, - makeConcreteRegistryId, type RegistryId, } from "enssdk"; @@ -23,7 +22,6 @@ import { maybeGetDatasourceContract, 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"; @@ -94,34 +92,21 @@ export async function getDomainIdByInterpretedName( } /** - * 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. + * Bridged Resolver attachments are wired into the canonical namegraph at index time (the bridged + * (shadow)Registry becomes the originating Domain's `canonicalSubregistryId`), so the walk follows + * them as ordinary canonical edges without a path-slice. The remaining hop logic preserves the + * ENSv1 fallback for ENSv1Resolver. * - * 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`. - * - * 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`. - * - * 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. + * For Domains with Bridged Resolvers the origin Domain is the correct result — i.e. "linea.eth" + * resolves to the ENS Root Chain's "linea.eth", not the Linea Chain's shadowed linea.eth. Not only + * do users want the origin chain's entry the existence of the shadowed linea.eth is an implementation + * detail of Shadow Registries, and not relevant for traversal/resolution. */ async function resolveCanonicalDomainId( 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}`, @@ -157,24 +142,6 @@ async function resolveCanonicalDomainId( } // 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); - } } // finally, return the exact match if it was the leaf @@ -185,8 +152,8 @@ async function resolveCanonicalDomainId( * Walks the Canonical 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, when a Domain is non-canonical, or + * when `canonical_subregistry_id` becomes NULL (leaf canonical domain). */ async function walkCanonicalNamegraph(registryId: RegistryId, path: LabelHashPath) { if (path.length === 0) return []; @@ -197,27 +164,28 @@ 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", + d.canonical_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 d.canonical = TRUE AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) AND path.depth < ${MAX_WALK_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 460fdc34c..11ee38f96 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 c08a2b34e..968775a22 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -1,6 +1,15 @@ -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 { DatasourceNames } from "@ensnode/datasources"; +import { getDatasourceContract } from "@ensnode/ensnode-sdk"; + import { DEVNET_ETH_LABELS } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, @@ -74,7 +83,7 @@ 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", + name: "wallet.sub1.sub2.parent.eth", }); expect(result.domain).not.toBeNull(); @@ -83,35 +92,65 @@ describe("Domain.path", () => { const pathNames = (path ?? []).map((d) => d.name); expect(pathNames).toEqual([ - "wallet.linked.parent.eth", - "linked.parent.eth", + "wallet.sub1.sub2.parent.eth", + "sub1.sub2.parent.eth", + "sub2.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. + it("does not resolve non-canonical alias paths", async () => { + // The wallet Registry's `ParentUpdated` claims `sub1.sub2.parent.eth` as its parent; + // `linked.parent.eth.subregistry` was later re-pointed to the same Registry, but no + // corresponding `ParentUpdated` was emitted, so `linked.parent.eth` has no canonical + // edge into the wallet Registry. Looking up the alias path returns null. const aliasResult = await request(DomainPath, { - name: "wallet.sub1.sub2.parent.eth", - }); - const canonicalResult = await request(DomainPath, { name: "wallet.linked.parent.eth", }); + expect(aliasResult.domain).toBeNull(); + }); +}); - expect(aliasResult.domain?.id).toBe(canonicalResult.domain?.id); +describe("Domain.canonical", () => { + type DomainCanonicalResult = { + domain: { id: DomainId; canonical: boolean } | null; + }; - 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"); + 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("is true for v2-rooted domains", async () => { + await expect( + request(DomainCanonicalByName, { + name: "parent.eth" as InterpretedName, + }), + ).resolves.toMatchObject({ domain: { canonical: true } }); + }); + + it("is false for ENSv1 addr.reverse", async () => { + // addr.reverse only exists on the ENSv1 namegraph and the v1 root is non-canonical in + // ens-test-env (the ENSv2 root is the namespace's canonical root). We query by id + // because the canonical-name walk only finds canonical domains. + 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: false } }); }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 915ec9c1b..2ea09b5bf 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 /////////////// 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 137e40e1e..046ffaacc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -127,14 +127,19 @@ describe("Query.domains", () => { // there's at least a v2 'eth' domain expect(domains.length).toBeGreaterThanOrEqual(1); - const v1EthDomain = domains.find((d) => d.__typename === "ENSv1Domain" && d.name === "eth"); - const v2EthDomain = domains.find((d) => d.__typename === "ENSv2Domain" && d.name === "eth"); + const v1EthDomain = domains.find( + (d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID, + ); + const v2EthDomain = domains.find( + (d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID, + ); + // v1 root is non-canonical in ens-test-env (v2 is the namespace's canonical root), so the + // v1 'eth' Domain has a null canonical name. Future PRs may surface a fallback name. expect(v1EthDomain).toMatchObject({ id: V1_ETH_DOMAIN_ID, - name: "eth", + name: null, label: { interpreted: "eth" }, - // ENSv1Domain exposes `node` — the namehash of the canonical name node: ETH_NODE, }); 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 e63dfb842..c89fb48e3 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 ENSv2 root registry", async () => { + await expect(request(RegistryCanonical, { contract: V2_ROOT_REGISTRY })).resolves.toMatchObject( + { registry: { canonical: true } }, + ); + }); + + it("is false for the ENSv1 root registry (in ens-test-env, v2 is the canonical root)", async () => { + await expect(request(RegistryCanonical, { contract: V1_ROOT_REGISTRY })).resolves.toMatchObject( + { registry: { canonical: false } }, + ); + }); +}); + 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 eb9b87be0..278807d80 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 30c23117e..95e960298 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay"; import { and, eq } from "drizzle-orm"; import { + ENS_ROOT_NODE, makePermissionsId, makeResolverRecordsId, namehashInterpretedName, @@ -126,7 +127,12 @@ ResolverRef.implement({ "If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.", type: AccountIdRef, nullable: true, - resolve: (parent) => isBridgedResolver(config.namespace, parent)?.registry ?? null, + resolve: (parent) => { + // The Resolver row isn't tied to a specific name, so pass ENS_ROOT_NODE as a sentinel — + // only `chainId, address` are projected here, and those are name-independent. + const bridged = isBridgedResolver(config.namespace, parent, ENS_ROOT_NODE); + return bridged ? { chainId: bridged.chainId, address: bridged.address } : null; + }, }), //////////////////////// diff --git a/apps/ensapi/src/test/integration/devnet-names.ts b/apps/ensapi/src/test/integration/devnet-names.ts index 184c8840e..cbd42e1de 100644 --- a/apps/ensapi/src/test/integration/devnet-names.ts +++ b/apps/ensapi/src/test/integration/devnet-names.ts @@ -11,10 +11,11 @@ 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" }, + // The wallet Registry's `ParentUpdated` claims `sub1.sub2.parent.eth` as its canonical parent; + // `linked.parent.eth.subregistry` was re-pointed to the same Registry but emitted no + // `ParentUpdated`, so `wallet.linked.parent.eth` is a non-canonical alias and resolves to null. + { 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 diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 20a398fdc..92de86d7e 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 alone — 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 000000000..005c25c17 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -0,0 +1,204 @@ +import config from "@/config"; + +import type { AccountId, DomainId, Node, NormalizedAddress, RegistryId } from "enssdk"; + +import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; + +import { ensureRegistry } from "@/lib/ensv2/registry-db-helpers"; +import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; + +/** + * Canonicality db helpers. + * + * Maintain the bidirectional invariant `Registry.canonicalDomainId` ↔ `Domain.canonicalSubregistryId`, + * and a per-Registry list of child Domains in `registryDomains` so canonicality flips can walk + * only the affected Registry's children rather than the global `domain` table. + * + * NOTE(child-list): we store the child set as a single `DomainId[]` keyed by `registryId` because + * Ponder prefetches whole rows by PK, so the cascade reads the entire list in one round-trip. + * For very-large registries (e.g. the steady-state `.eth` virtual registry), append rewrites the + * full array per child — at sufficient N a doubly-linked-list (one row per edge) becomes the + * better trade. Revisit when registry sizes warrant. + */ + +/** + * Idempotently link `domainId` into `registryId`'s child list and inherit `canonical` from the + * Registry. If the Domain is already linked, no-op (the cascade in + * {@link updateRegistryCanonicality} keeps existing children's `canonical` consistent). + */ +export async function ensureDomainInRegistry( + context: IndexingEngineContext, + registryId: RegistryId, + domainId: DomainId, +): Promise { + const existing = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); + if (existing?.domainIds.includes(domainId)) return; + + const domainIds = existing ? [...existing.domainIds, domainId] : [domainId]; + await context.ensDb + .insert(ensIndexerSchema.registryDomains) + .values({ registryId, domainIds }) + .onConflictDoUpdate({ domainIds }); + + const reg = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!reg) { + throw new Error( + `Invariant(ensureDomainInRegistry): Registry '${registryId}' must exist before linking Domain '${domainId}'. Call ensureRegistry first.`, + ); + } + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ canonical: reg.canonical }); +} + +/** + * Set `registryId`'s canonical parent Domain (or unset if null), maintaining the bidirectional + * invariant and cascading canonicality to the registry's subtree. Returns the resulting + * `Registry.canonical`. + */ +export async function setRegistryCanonicalDomain( + context: IndexingEngineContext, + registryId: RegistryId, + newCanonicalDomainId: DomainId | null, +): Promise { + const reg = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!reg) { + throw new Error( + `Invariant(setRegistryCanonicalDomain): Registry '${registryId}' must exist before being canonicalized.`, + ); + } + + const prevCanonicalDomainId = reg.canonicalDomainId ?? null; + + // Read the new canonical Domain once; reused for both dislodge and shouldBeCanonical. + const newDomain = newCanonicalDomainId + ? await context.ensDb.find(ensIndexerSchema.domain, { id: newCanonicalDomainId }) + : null; + if (newCanonicalDomainId && !newDomain) { + throw new Error( + `Invariant(setRegistryCanonicalDomain): Domain '${newCanonicalDomainId}' must exist before being made canonical parent of '${registryId}'.`, + ); + } + + // Idempotent fast-path: edge already wired and canonicality consistent. + const shouldBeCanonical = newDomain?.canonical ?? false; + if (prevCanonicalDomainId === newCanonicalDomainId && reg.canonical === shouldBeCanonical) { + return reg.canonical; + } + + if (prevCanonicalDomainId && prevCanonicalDomainId !== newCanonicalDomainId) { + await context.ensDb + .update(ensIndexerSchema.domain, { id: prevCanonicalDomainId }) + .set({ canonicalSubregistryId: null }); + } + + if (newDomain) { + const prevRegistryUnderNewDomain = newDomain.canonicalSubregistryId ?? null; + if (prevRegistryUnderNewDomain && prevRegistryUnderNewDomain !== registryId) { + await context.ensDb + .update(ensIndexerSchema.registry, { id: prevRegistryUnderNewDomain }) + .set({ canonicalDomainId: null }); + await updateRegistryCanonicality(context, prevRegistryUnderNewDomain, false); + } + } + + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ canonicalDomainId: newCanonicalDomainId }); + + if (newCanonicalDomainId) { + await context.ensDb + .update(ensIndexerSchema.domain, { id: newCanonicalDomainId }) + .set({ canonicalSubregistryId: registryId }); + } + + if (reg.canonical !== shouldBeCanonical) { + await updateRegistryCanonicality(context, registryId, shouldBeCanonical); + } + + return shouldBeCanonical; +} + +/** + * Recursively flip `canonical` on `registryId` and every Domain in its child list (and their + * canonical subtrees). + * + * The recursion is unbounded by design. ENS names have no formal depth limit, so a fixed cap + * would abort indexing on legitimately deep namegraphs. Termination relies on the canonical + * namegraph being a tree (each Registry has at most one canonical parent Domain, enforced by + * the bidirectional invariant `Registry.canonicalDomainId` ↔ `Domain.canonicalSubregistryId`). + * If that invariant is ever violated and a cycle is introduced, this function could recurse + * indefinitely — that is an accepted trade-off for correctness on legitimately deep names. + */ +export async function updateRegistryCanonicality( + context: IndexingEngineContext, + registryId: RegistryId, + canonical: boolean, +): Promise { + await context.ensDb.update(ensIndexerSchema.registry, { id: registryId }).set({ canonical }); + + const children = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); + if (!children) return; + + for (const domainId of children.domainIds) { + // Read child once to capture its `canonicalSubregistryId` for the recursion (the field is + // independent of the `canonical` flag we're about to write, so a single PK read suffices). + const child = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ canonical }); + + const childSubregistry = child?.canonicalSubregistryId ?? null; + if (childSubregistry) { + await updateRegistryCanonicality(context, childSubregistry, canonical); + } + } +} + +/** + * 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. + */ +export async function handleBridgedResolverChange( + context: IndexingEngineContext, + registry: AccountId, + domainId: DomainId, + originatingNode: Node, + newResolver: NormalizedAddress, +): Promise { + const prevDRR = await context.ensDb.find(ensIndexerSchema.domainResolverRelation, { + chainId: registry.chainId, + address: registry.address, + domainId, + }); + const prevBridge = prevDRR + ? isBridgedResolver( + config.namespace, + { chainId: prevDRR.chainId, address: prevDRR.resolver }, + originatingNode, + ) + : null; + + const nextBridge = isBridgedResolver( + config.namespace, + { chainId: context.chain.id, address: newResolver }, + originatingNode, + ); + + if (prevBridge && (!nextBridge || prevBridge.id !== nextBridge.id)) { + await setRegistryCanonicalDomain(context, prevBridge.id, null); + } + + if (nextBridge) { + await ensureRegistry(context, nextBridge.id, { + type: nextBridge.type, + chainId: nextBridge.chainId, + address: nextBridge.address, + ...(nextBridge.type === "ENSv1VirtualRegistry" ? { node: nextBridge.node } : {}), + }); + + await setRegistryCanonicalDomain(context, nextBridge.id, domainId); + } +} 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 000000000..f26cc48ee --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts @@ -0,0 +1,30 @@ +import config from "@/config"; + +import type { RegistryId } from "enssdk"; + +import { getRootRegistryId } from "@ensnode/ensnode-sdk"; + +import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; + +/** + * Idempotently insert a Registry row, seeding `canonical = true` only if it is the namespace's + * primary Root Registry. All other Registries become canonical via ParentUpdated or Bridged + * Resolver attach. + */ +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: id === getRootRegistryId(config.namespace), + }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index a000fbaf1..263d73e59 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -22,8 +22,14 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { + ensureDomainInRegistry, + handleBridgedResolverChange, + setRegistryCanonicalDomain, +} 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 +88,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); - - // 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 setRegistryCanonicalDomain(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 +121,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 +219,9 @@ export default function () { event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ node: Node }>; + event: EventWithArgs<{ node: Node; resolver: NormalizedAddress }>; }) { - const { node } = event.args; + const { node, resolver } = event.args; // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; @@ -247,6 +232,10 @@ export default function () { // NOTE: Domain-Resolver relations are handled by the protocol-acceleration plugin and are not // directly indexed here + // Wire/unwire the canonical edge for known Bridged Resolvers (Basenames, Lineanames). Runs + // BEFORE Protocol Acceleration overwrites the DRR — the previous resolver is read from there. + await handleBridgedResolverChange(context, registry, domainId, node, 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 480091007..0a2ed0269 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -1,6 +1,7 @@ import { type AccountId, asLiteralLabel, + interpretTokenIdAsNode, type LabelHash, labelhashLiteralLabel, makeENSv2DomainId, @@ -20,12 +21,18 @@ import { } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; +import { + ensureDomainInRegistry, + handleBridgedResolverChange, + setRegistryCanonicalDomain, +} 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); @@ -269,32 +275,20 @@ export default function () { const storageId = makeStorageId(tokenId); const domainId = makeENSv2DomainId(registryAccountId, storageId); - // update domain's subregistry + // SubregistryUpdated is the on-chain forward pointer; canonicality is driven by ParentUpdated + // (which the child Registry emits). Set the raw `subregistryId` here, ensure the referenced + // Registry row exists for ParentUpdated to find, and leave canonicality to the dedicated path. 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 ensureRegistry(context, subregistryId, { + type: "ENSv2Registry", + ...subregistryAccountId, + }); await context.ensDb .update(ensIndexerSchema.domain, { id: domainId }) @@ -308,6 +302,110 @@ 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`, so we unconditionally ensure the parent + * Registry and parent Domain rows exist before wiring the canonical edge. + */ + addOnchainEventListener( + namespaceContract(pluginName, "ENSv2Registry:ParentUpdated"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ + parent: NormalizedAddress; + label: string; + sender: NormalizedAddress; + }>; + }) => { + const { parent: _parent, sender } = event.args; + const label = asLiteralLabel(event.args.label); + const parent = interpretAddress(_parent); + + const thisRegistryAccountId = getThisAccountId(context, event); + const thisRegistryId = makeENSv2RegistryId(thisRegistryAccountId); + // ParentUpdated MAY fire before any other event on `thisRegistry` — ensure the row exists. + await ensureRegistry(context, thisRegistryId, { + type: "ENSv2Registry", + ...thisRegistryAccountId, + }); + + if (parent === null) { + await setRegistryCanonicalDomain(context, thisRegistryId, null); + } else { + const parentRegistryAccountId: AccountId = { + chainId: context.chain.id, + address: parent, + }; + const parentRegistryId = makeENSv2RegistryId(parentRegistryAccountId); + const labelHash = labelhashLiteralLabel(label); + const parentTokenId = hexToBigInt(labelHash) as TokenId; + const parentDomainId = makeENSv2DomainId( + parentRegistryAccountId, + makeStorageId(parentTokenId), + ); + + await ensureLabel(context, label); + await ensureRegistry(context, parentRegistryId, { + type: "ENSv2Registry", + ...parentRegistryAccountId, + }); + + // Parent Domain row must exist for `Domain.canonicalSubregistryId` to point at; the + // parent Registry's LabelRegistered may not have arrived yet, so we insert a stub. + await context.ensDb + .insert(ensIndexerSchema.domain) + .values({ + id: parentDomainId, + type: "ENSv2Domain", + tokenId: parentTokenId, + registryId: parentRegistryId, + labelHash, + }) + .onConflictDoNothing(); + + await ensureDomainInRegistry(context, parentRegistryId, parentDomainId); + await setRegistryCanonicalDomain(context, thisRegistryId, parentDomainId); + } + + const senderId = await ensureAccount(context, sender); + // `ParentUpdated` is recorded as a registry-level event only; intentionally not linked to + // domain history via `ensureDomainEvent` for now. + // TODO: maybe ParentUpdated also belongs in the domain event history? + await ensureEvent(context, event, senderId); + }, + ); + + /** + * Wire/unwire the canonical edge for known Bridged Resolvers when the Resolver changes. Runs + * BEFORE Protocol Acceleration's ResolverUpdated handler overwrites the DRR — see + * `apps/ensindexer/ponder/src/register-handlers.ts` for the ordering contract. ENSv2 bridges + * are not yet defined in `isBridgedResolver`, so attach is currently unreachable via this path — + * but detach must still run if a previously-attached bridge gets replaced. + */ + addOnchainEventListener( + namespaceContract(pluginName, "ENSv2Registry:ResolverUpdated"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ tokenId: TokenId; resolver: NormalizedAddress }>; + }) => { + const { tokenId, resolver } = event.args; + const registry = getThisAccountId(context, event); + const storageId = makeStorageId(tokenId); + const domainId = makeENSv2DomainId(registry, storageId); + // For ENSv2 originators, `originatingNode` only feeds ENSv1VirtualRegistryId construction + // inside `isBridgedResolver`; the tokenId-derived value is forward-compatible. + const originatingNode = interpretTokenIdAsNode(tokenId); + await handleBridgedResolverChange(context, registry, domainId, originatingNode, 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 875e90737..b1778d411 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 000000000..b699e26ec --- /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/examples/enskit-react-example/src/SearchView.tsx b/examples/enskit-react-example/src/SearchView.tsx index ad7f18053..6d78dd9a8 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=.

(), + + // Whether this Registry is part of the canonical namegraph. See canonicality-db-helpers.ts. + canonical: t.boolean().notNull().default(false), + + // Reciprocal of `Domain.canonicalSubregistryId`. The parent Domain in the canonical namegraph. + canonicalDomainId: t.text().$type(), }), (t) => ({ // NOTE: non-unique index because multiple rows can share (chainId, address) across virtual registries @@ -263,8 +271,15 @@ 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 namegraph. Mirrors the parent Registry's flag. + canonical: t.boolean().notNull().default(false), + + // The Subregistry of this Domain that participates in the canonical namegraph (i.e. the + // Registry whose `canonicalDomainId` points back to this Domain). May differ from + // `subregistryId` when a Bridged Resolver attaches a different Registry under this Domain. + canonicalSubregistryId: t.text().$type(), + // 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), @@ -595,13 +610,10 @@ export const label_relations = relations(label, ({ many }) => ({ // 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) => ({ +// Children of each Registry, used by the canonicality cascade in canonicality-db-helpers.ts to +// walk a Registry's children without scanning `domain`. Keyed by `registryId` so a single PK read +// pulls the whole list — Ponder's row-level prefetch covers the iteration in one round-trip. +export const registryDomains = onchainTable("registry_domains", (t) => ({ registryId: t.text().primaryKey().$type(), - domainId: t.text().notNull().$type(), + domainIds: t.text().array().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 cc406d067..0c4aca0a4 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,4 +1,11 @@ -import type { AccountId } from "enssdk"; +import { + type AccountId, + type ChainId, + makeENSv1VirtualRegistryId, + type Node, + type NormalizedAddress, + type RegistryId, +} from "enssdk"; import { DatasourceNames } from "@ensnode/datasources"; import { @@ -8,23 +15,33 @@ import { } from "@ensnode/ensnode-sdk"; /** - * 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`). + * Rich description of a Bridged Resolver's target (shadow)Registry. Provides both: + * - canonicality wiring at index time (`{ id, type, chainId, address, node }` for upserting), and + * - canonical-namegraph forward traversal at query time (`id` alone), and + * - the bridged target as an AccountId (`{ chainId, address }`) for Forward Resolution recursion. * - * 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. + * Currently only ENSv1VirtualRegistry shadow registries (Basenames, Lineanames) are supported; + * future ENSv2 sub-registry bridges will use `type: "ENSv2Registry"`. */ -export interface BridgedResolverTarget { - registry: AccountId; - shadow: boolean; -} +export type BridgedResolverRegistry = + | { + id: RegistryId; + type: "ENSv1VirtualRegistry"; + chainId: ChainId; + address: NormalizedAddress; + node: Node; + } + | { + id: RegistryId; + type: "ENSv2Registry"; + chainId: ChainId; + address: NormalizedAddress; + }; /** - * 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. + * For a given `resolver`, if it is a known Bridged Resolver, return the (shadow)Registry it defers + * resolution to. The `originatingNode` is the Node of the Domain whose Resolver is being inspected, + * required for shadow-virtual-registry id construction. * * These Bridged Resolvers must abide the following pattern: * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, @@ -48,22 +65,31 @@ export interface BridgedResolverTarget { export function isBridgedResolver( namespace: ENSNamespaceId, resolver: AccountId, -): BridgedResolverTarget | null { + originatingNode: Node, +): BridgedResolverRegistry | null { const resolverEq = makeContractMatcher(namespace, resolver); // the ENSRoot's BasenamesL1Resolver bridges to the Basenames (shadow)Registry if (resolverEq(DatasourceNames.ENSRoot, "BasenamesL1Resolver")) { + const target = getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"); return { - registry: getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"), - shadow: true, + id: makeENSv1VirtualRegistryId(target, originatingNode), + type: "ENSv1VirtualRegistry", + chainId: target.chainId, + address: target.address, + node: originatingNode, }; } // the ENSRoot's LineanamesL1Resolver bridges to the Lineanames (shadow)Registry if (resolverEq(DatasourceNames.ENSRoot, "LineanamesL1Resolver")) { + const target = getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); return { - registry: getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"), - shadow: true, + id: makeENSv1VirtualRegistryId(target, originatingNode), + type: "ENSv1VirtualRegistry", + chainId: target.chainId, + address: target.address, + node: originatingNode, }; } diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index d97e67e5f..7dd53cc51 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 } 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 @@ -88,61 +81,8 @@ 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 canonical Root Registry for the selected `namespace`: the + * ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. */ 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. - */ -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); -}; diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b018ad6b8..394022e2f 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": { @@ -1625,6 +1637,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 +1901,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 +2048,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 +2207,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 +2608,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 +5157,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": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index dcb12e7d1..840bb4ac9 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 @@ -334,6 +337,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 @@ -392,6 +398,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 +423,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 +451,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 @@ -512,6 +527,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. """ @@ -1058,6 +1076,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. """