From 95f2e2ab206609e74a9ac266b4fc8a8a365d5c3f Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 4 May 2026 09:28:56 -0500 Subject: [PATCH 1/8] feat: update abis to latest --- .../datasources/src/abis/ensv2/Registry.ts | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/packages/datasources/src/abis/ensv2/Registry.ts b/packages/datasources/src/abis/ensv2/Registry.ts index 8e76e49b0..1ecfa2e20 100644 --- a/packages/datasources/src/abis/ensv2/Registry.ts +++ b/packages/datasources/src/abis/ensv2/Registry.ts @@ -550,7 +550,7 @@ export const Registry = [ ], outputs: [ { - name: "tokenId", + name: "", type: "uint256", internalType: "uint256", }, @@ -860,31 +860,6 @@ export const Registry = [ ], stateMutability: "view", }, - { - type: "event", - name: "Approval", - inputs: [ - { - name: "owner", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "approved", - type: "address", - indexed: true, - internalType: "address", - }, - { - name: "tokenId", - type: "uint256", - indexed: true, - internalType: "uint256", - }, - ], - anonymous: false, - }, { type: "event", name: "ApprovalForAll", From c2404ef5f7de234c66ae7f2668188a6e921e83fe Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 15:24:39 -0500 Subject: [PATCH 2/8] checkpoint: initial canonicality implementation --- .../src/lib/resolution/forward-resolution.ts | 10 +- .../find-domains/canonical-registries-cte.ts | 69 ------ .../find-domains/layers/base-domain-set.ts | 32 ++- .../layers/filter-by-canonical.ts | 8 +- .../lib/find-domains/layers/filter-by-name.ts | 26 +-- .../omnigraph-api/lib/get-canonical-path.ts | 52 ++--- .../lib/get-domain-by-interpreted-name.ts | 68 ++---- .../schema/domain.integration.test.ts | 4 - .../src/omnigraph-api/schema/resolver.ts | 8 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 202 ++++++++++++++++++ .../src/lib/ensv2/registry-db-helpers.ts | 30 +++ .../ensv2/handlers/ensv1/ENSv1Registry.ts | 61 +++--- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 140 ++++++++++-- .../src/ensindexer-abstract/ensv2.schema.ts | 32 ++- .../is-bridged-resolver.ts | 64 ++++-- .../ensnode-sdk/src/shared/root-registry.ts | 66 +----- 16 files changed, 525 insertions(+), 347 deletions(-) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts create mode 100644 apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts create mode 100644 apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts 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..0bed1d683 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -1,10 +1,6 @@ -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; @@ -12,18 +8,20 @@ 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 +35,19 @@ 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. + -- MAX_DEPTH guards against corrupted state. 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 + ON pd.id = r.canonical_domain_id WHERE upward.depth < ${MAX_DEPTH} - AND upward.registry_id <> ALL(${rootRegistryIdsArray}) ) SELECT * FROM upward @@ -60,15 +56,5 @@ export async function getCanonicalPath(domainId: DomainId): Promise 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/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index c08a2b34e..f224cb17f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -91,10 +91,6 @@ describe("Domain.path", () => { }); 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", }); 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/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts new file mode 100644 index 000000000..486b24710 --- /dev/null +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -0,0 +1,202 @@ +import config from "@/config"; + +import type { 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 }); + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ canonical: reg?.canonical ?? false }); +} + +/** + * Removes a Domain from a Registry's child list. + */ +export async function removeDomainFromRegistry( + context: IndexingEngineContext, + registryId: RegistryId, + domainId: DomainId, +): Promise { + const existing = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); + if (!existing) return; + + const domainIds = existing.domainIds.filter((id) => id !== domainId); + if (domainIds.length === existing.domainIds.length) return; + + if (domainIds.length === 0) { + await context.ensDb.delete(ensIndexerSchema.registryDomains, { registryId }); + } else { + await context.ensDb.update(ensIndexerSchema.registryDomains, { registryId }).set({ domainIds }); + } +} + +/** + * 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 canonical namegraph is a tree (each Registry has at most one canonical + * parent Domain, edge-authenticated by the bidirectional invariant), so no cycle guard is needed. + */ +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) { + await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ canonical }); + + const child = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + 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). + * + * Runs after Protocol Acceleration's NewResolver/ResolverUpdated handlers, which have already + * overwritten the Domain-Resolver Relation — so the prior bridged target is recovered from + * `Domain.canonicalSubregistryId` (which only the bridged-attach path writes for ENSv1 originating + * Domains) rather than the DRR. + */ +export async function handleBridgedResolverChange( + context: IndexingEngineContext, + domainId: DomainId, + originatingNode: Node, + newResolver: NormalizedAddress, +): Promise { + const next = isBridgedResolver( + config.namespace, + { chainId: context.chain.id, address: newResolver }, + originatingNode, + ); + + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + const prev: RegistryId | null = domain?.canonicalSubregistryId ?? null; + + if (prev && (!next || prev !== next.id)) { + await setRegistryCanonicalDomain(context, prev, null); + } + + if (next) { + await ensureRegistry(context, next.id, { + type: next.type, + chainId: next.chainId, + address: next.address, + ...(next.type === "ENSv1VirtualRegistry" ? { node: next.node } : {}), + }); + + await setRegistryCanonicalDomain(context, next.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..0c529ec09 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). This + // runs after Protocol Acceleration's NewResolver handler has overwritten the DRR. + await handleBridgedResolverChange(context, 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..78090b7da 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,106 @@ 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); + await ensureEvent(context, event, senderId); + }, + ); + + /** + * Wire/unwire the canonical edge for known Bridged Resolvers when the Resolver changes. Runs + * after Protocol Acceleration's ResolverUpdated handler has overwritten the DRR. 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: bigint; resolver: NormalizedAddress }>; + }) => { + const { tokenId, resolver } = event.args; + const registry = getThisAccountId(context, event); + const storageId = makeStorageId(tokenId as never); + 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 as never); + await handleBridgedResolverChange(context, domainId, originatingNode, resolver); + }, + ); + addOnchainEventListener( namespaceContract(pluginName, "ENSv2Registry:TokenRegenerated"), async ({ diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 92456c4ba..934414921 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -61,7 +61,9 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * `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 `Registry.canonical`/`canonicalDomainId` ↔ `Domain.canonical`/`canonicalSubregistryId`, + * which materialize the canonical subgraph for PK-keyed query-time access. * * 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: @@ -207,6 +209,12 @@ export const registry = onchainTable( // If this is an ENSv1VirtualRegistry, `node` is the namehash of the parent ENSv1 domain that // owns it, otherwise null. node: t.hex().$type(), + + // 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); -}; From 3b8eeca3036786d834f61006a511c393a0d3734e Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 17:00:46 -0500 Subject: [PATCH 3/8] feat: expose canonicality in omnigraph --- .../ensapi/src/omnigraph-api/schema/domain.ts | 9 +++ .../src/omnigraph-api/schema/registry.ts | 9 +++ .../enskit-react-example/src/SearchView.tsx | 13 +--- .../src/omnigraph/generated/introspection.ts | 63 +++++++++++++++++++ .../src/omnigraph/generated/schema.graphql | 21 +++++++ 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 915ec9c1b..231649cee 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -103,6 +103,15 @@ DomainInterfaceRef.implement({ resolve: (parent) => parent.label, }), + //////////////////// + // Domain.canonical + //////////////////// + canonical: t.field({ + description: "Whether the Domain is Canonical.", + type: "Boolean", + resolve: (parent) => parent.canonical, + }), + /////////////// // Domain.name /////////////// diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index eb9b87be0..f2ec59f0a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -76,6 +76,15 @@ RegistryInterfaceRef.implement({ resolve: (parent) => parent.id, }), + ////////////////////// + // Registry.canonical + ////////////////////// + canonical: t.field({ + description: "Whether the Registry is Canonical.", + type: "Boolean", + resolve: (parent) => parent.canonical, + }), + /////////////////// // Registry.contract /////////////////// 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=.

Date: Tue, 5 May 2026 17:30:36 -0500 Subject: [PATCH 4/8] test: assert canonicality field on Domain and Registry Add integration tests covering Domain.canonical and Registry.canonical: canonical=true for v2-rooted entities, canonical=false for ENSv1 addr.reverse and the v1 root registry (which is non-canonical in ens-test-env where the v2 root is the namespace's canonical root). Add changeset for the omnigraph schema additions. --- .changeset/canonical-fields-omnigraph.md | 5 ++ .../schema/domain.integration.test.ts | 53 ++++++++++++++++++- .../schema/registry.integration.test.ts | 26 +++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/canonical-fields-omnigraph.md diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md new file mode 100644 index 000000000..daeb84276 --- /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 nullable `Boolean` fields indicating whether the entity participates in the canonical namegraph. 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 f224cb17f..d8acfcd2c 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, @@ -111,6 +120,48 @@ describe("Domain.path", () => { }); }); +describe("Domain.canonical", () => { + type DomainCanonicalResult = { + domain: { id: DomainId; canonical: boolean | null } | 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("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 } }); + }); +}); + describe("Domain.subdomains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ 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<{ From 1426de29a545a7c22e1ebb8336de9bc334ac00ad Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 19:59:34 -0500 Subject: [PATCH 5/8] fix: bot review feedback (greploop iter 1) - Reorder handler registration: NodeMigration -> ENSv2 -> ProtocolAcceleration. Lets handleBridgedResolverChange read the previous Domain-Resolver Relation before ProtocolAcceleration overwrites it, removing the brittle canonicalSubregistryId-as-provenance hack that detached normal canonical edges on non-bridged resolver updates. - Extract node migration into its own handlers/node-migration.ts so it can be registered ahead of both plugins. - handleBridgedResolverChange now takes the registry AccountId and reads prev via DRR PK lookup; isBridgedResolver determines bridge provenance. - Drop unused removeDomainFromRegistry. - ensureDomainInRegistry throws when registry row is missing (invariant). - updateRegistryCanonicality: read child once, add MAX_CASCADE_DEPTH=16 guard. - getCanonicalPath: throw on impossible zero-row result for defense in depth. - Domain.canonical and Registry.canonical are nullable: false on the omnigraph schema (matches notNull DB columns). Regenerate schema.graphql + introspection. - ParentUpdated: add TODO comment about whether it should also be a domain event. --- .../omnigraph-api/lib/get-canonical-path.ts | 8 ++ .../ensapi/src/omnigraph-api/schema/domain.ts | 1 + .../src/omnigraph-api/schema/registry.ts | 1 + .../ponder/src/register-handlers.ts | 31 ++++-- .../src/lib/ensv2/canonicality-db-helpers.ts | 98 +++++++++++-------- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 6 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 8 +- .../handlers/ENSv1Registry.ts | 41 +------- .../handlers/node-migration.ts | 43 ++++++++ .../src/omnigraph/generated/introspection.ts | 49 +++++++--- .../src/omnigraph/generated/schema.graphql | 14 +-- 11 files changed, 185 insertions(+), 115 deletions(-) create mode 100644 apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts 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 0bed1d683..4c58f3c51 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -56,5 +56,13 @@ export async function getCanonicalPath(domainId: DomainId): Promise row.domain_id); } diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 231649cee..2ea09b5bf 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -109,6 +109,7 @@ DomainInterfaceRef.implement({ canonical: t.field({ description: "Whether the Domain is Canonical.", type: "Boolean", + nullable: false, resolve: (parent) => parent.canonical, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.ts b/apps/ensapi/src/omnigraph-api/schema/registry.ts index f2ec59f0a..278807d80 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.ts @@ -82,6 +82,7 @@ RegistryInterfaceRef.implement({ canonical: t.field({ description: "Whether the Registry is Canonical.", type: "Boolean", + nullable: false, resolve: (parent) => parent.canonical, }), diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 20a398fdc..326cfbbea 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,26 @@ 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. + +if ( + config.plugins.includes(PluginName.ENSv2) || + 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 index 486b24710..33051a70f 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -1,6 +1,6 @@ import config from "@/config"; -import type { DomainId, Node, NormalizedAddress, RegistryId } from "enssdk"; +import type { AccountId, DomainId, Node, NormalizedAddress, RegistryId } from "enssdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; @@ -21,6 +21,13 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * better trade. Revisit when registry sizes warrant. */ +/** + * Maximum cascade depth in {@link updateRegistryCanonicality}. The canonical namegraph is a tree + * under correct bidirectional-invariant maintenance, so this only triggers if state has been + * corrupted (in which case we want to fail loudly rather than recurse indefinitely). + */ +const MAX_CASCADE_DEPTH = 16; + /** * 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 @@ -41,30 +48,14 @@ export async function ensureDomainInRegistry( .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 ?? false }); -} - -/** - * Removes a Domain from a Registry's child list. - */ -export async function removeDomainFromRegistry( - context: IndexingEngineContext, - registryId: RegistryId, - domainId: DomainId, -): Promise { - const existing = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); - if (!existing) return; - - const domainIds = existing.domainIds.filter((id) => id !== domainId); - if (domainIds.length === existing.domainIds.length) return; - - if (domainIds.length === 0) { - await context.ensDb.delete(ensIndexerSchema.registryDomains, { registryId }); - } else { - await context.ensDb.update(ensIndexerSchema.registryDomains, { registryId }).set({ domainIds }); - } + .set({ canonical: reg.canonical }); } /** @@ -138,25 +129,36 @@ export async function setRegistryCanonicalDomain( /** * Recursively flip `canonical` on `registryId` and every Domain in its child list (and their * canonical subtrees). The canonical namegraph is a tree (each Registry has at most one canonical - * parent Domain, edge-authenticated by the bidirectional invariant), so no cycle guard is needed. + * parent Domain, edge-authenticated by the bidirectional invariant), so cycles are unreachable + * under correct invariant maintenance — `MAX_CASCADE_DEPTH` exists purely to fail loudly on + * corrupted state rather than recurse indefinitely. */ export async function updateRegistryCanonicality( context: IndexingEngineContext, registryId: RegistryId, canonical: boolean, + depth = 0, ): Promise { + if (depth > MAX_CASCADE_DEPTH) { + throw new Error( + `Invariant(updateRegistryCanonicality): cascade depth exceeded ${MAX_CASCADE_DEPTH} starting at registry '${registryId}'. Bidirectional invariant likely corrupted.`, + ); + } + 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 child = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); const childSubregistry = child?.canonicalSubregistryId ?? null; if (childSubregistry) { - await updateRegistryCanonicality(context, childSubregistry, canonical); + await updateRegistryCanonicality(context, childSubregistry, canonical, depth + 1); } } } @@ -165,38 +167,48 @@ export async function updateRegistryCanonicality( * 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). * - * Runs after Protocol Acceleration's NewResolver/ResolverUpdated handlers, which have already - * overwritten the Domain-Resolver Relation — so the prior bridged target is recovered from - * `Domain.canonicalSubregistryId` (which only the bridged-attach path writes for ENSv1 originating - * Domains) rather than the DRR. + * 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 next = isBridgedResolver( + 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, ); - const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); - const prev: RegistryId | null = domain?.canonicalSubregistryId ?? null; - - if (prev && (!next || prev !== next.id)) { - await setRegistryCanonicalDomain(context, prev, null); + if (prevBridge && (!nextBridge || prevBridge.id !== nextBridge.id)) { + await setRegistryCanonicalDomain(context, prevBridge.id, null); } - if (next) { - await ensureRegistry(context, next.id, { - type: next.type, - chainId: next.chainId, - address: next.address, - ...(next.type === "ENSv1VirtualRegistry" ? { node: next.node } : {}), + 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, next.id, domainId); + await setRegistryCanonicalDomain(context, nextBridge.id, domainId); } } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 0c529ec09..263d73e59 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -232,9 +232,9 @@ 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). This - // runs after Protocol Acceleration's NewResolver handler has overwritten the DRR. - await handleBridgedResolverChange(context, domainId, node, resolver); + // 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); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 78090b7da..57788014e 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -372,13 +372,17 @@ export default function () { } 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 - * after Protocol Acceleration's ResolverUpdated handler has overwritten the DRR. ENSv2 bridges + * 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. */ @@ -398,7 +402,7 @@ export default function () { // For ENSv2 originators, `originatingNode` only feeds ENSv1VirtualRegistryId construction // inside `isBridgedResolver`; the tokenId-derived value is forward-compatible. const originatingNode = interpretTokenIdAsNode(tokenId as never); - await handleBridgedResolverChange(context, domainId, originatingNode, resolver); + await handleBridgedResolverChange(context, registry, domainId, originatingNode, resolver); }, ); 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/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 7741f2620..394022e2f 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1045,8 +1045,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -1637,8 +1640,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -1898,8 +1904,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -2042,8 +2051,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -2198,8 +2210,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -2596,8 +2611,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false @@ -5142,8 +5160,11 @@ const introspection = { { "name": "canonical", "type": { - "kind": "SCALAR", - "name": "Boolean" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } }, "args": [], "isDeprecated": false diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 0dcdccb3f..840bb4ac9 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -212,7 +212,7 @@ A Domain represents an individual Label within the ENS namegraph. It may or may """ interface Domain { """Whether the Domain is Canonical.""" - canonical: Boolean + canonical: Boolean! """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -338,7 +338,7 @@ input DomainsWhereInput { """An ENSv1Domain represents an ENSv1 Domain.""" type ENSv1Domain implements Domain { """Whether the Domain is Canonical.""" - canonical: Boolean + canonical: Boolean! """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -399,7 +399,7 @@ An ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry """ type ENSv1Registry implements Registry { """Whether the Registry is Canonical.""" - canonical: Boolean + 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. @@ -424,7 +424,7 @@ An ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that """ type ENSv1VirtualRegistry implements Registry { """Whether the Registry is Canonical.""" - canonical: Boolean + 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. @@ -452,7 +452,7 @@ type ENSv1VirtualRegistry implements Registry { """An ENSv2Domain represents an ENSv2 Domain.""" type ENSv2Domain implements Domain { """Whether the Domain is Canonical.""" - canonical: Boolean + canonical: Boolean! """All Events associated with this Domain.""" events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection @@ -528,7 +528,7 @@ type ENSv2DomainPermissionsConnectionEdge { """An ENSv2Registry represents an ENSv2 Registry contract.""" type ENSv2Registry implements Registry { """Whether the Registry is Canonical.""" - canonical: Boolean + 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. @@ -1077,7 +1077,7 @@ A Registry represents a Registry contract in the ENS namegraph. It may be an ENS """ interface Registry { """Whether the Registry is Canonical.""" - canonical: Boolean + 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. From 9e8b6faef3325c9c0f636908f3191a52c11fd3c9 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 20:30:09 -0500 Subject: [PATCH 6/8] test: align integration tests with materialized canonical model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 without a corresponding ParentUpdated, so wallet.linked.parent.eth is now a non-canonical alias that does not resolve via the canonical-name walk. Update Domain.path tests and DEVNET_NAMES to reflect this. Also: v1 'eth' Domain has a null canonical name in ens-test-env (v2 root is the namespace's canonical root), so update Query.domains > sees .eth domain to assert name: null on the v1 entity. Account.domains expected list flips wallet.linked.parent.eth → wallet.sub1.sub2.parent.eth. --- .../schema/account.integration.test.ts | 2 +- .../schema/domain.integration.test.ts | 28 +++++++------------ .../schema/query.integration.test.ts | 13 ++++++--- .../src/test/integration/devnet-names.ts | 7 +++-- 4 files changed, 24 insertions(+), 26 deletions(-) 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 d8acfcd2c..43da36fdf 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -83,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(); @@ -92,31 +92,23 @@ 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 () => { + 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?.id).toBe(canonicalResult.domain?.id); - - 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"); + expect(aliasResult.domain).toBeNull(); }); }); 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/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 From 5995fc2caef26f7e394118f985767a64e8e6ce0b Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 21:10:54 -0500 Subject: [PATCH 7/8] fix: bot review feedback (greploop iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changeset wording: "non-null Boolean!" instead of "nullable Boolean". - domain.integration.test.ts: tighten Domain.canonical test type to boolean. - ENSv2Registry.ts: replace `tokenId as never` with TokenId-typed event arg and direct usage, matching the rest of the file. - register-handlers.ts: gate node-migration on ProtocolAcceleration only; add comment noting that ProtocolAcceleration is a hard requirement of the ENSv2 plugin so the OR was redundant. - get-canonical-path.ts and updateRegistryCanonicality: drop the fixed depth caps. ENS names have no formal depth limit, so a fixed cap would silently truncate or abort indexing on legitimately deep namegraphs. Termination now relies on the canonical-namegraph-is-a-tree invariant; if that invariant is violated, both call sites would loop indefinitely — accepted trade-off, called out in inline comments. --- .changeset/canonical-fields-omnigraph.md | 2 +- .../omnigraph-api/lib/get-canonical-path.ts | 12 +++++--- .../schema/domain.integration.test.ts | 2 +- .../ponder/src/register-handlers.ts | 9 +++--- .../src/lib/ensv2/canonicality-db-helpers.ts | 28 ++++++------------- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 6 ++-- 6 files changed, 27 insertions(+), 32 deletions(-) diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md index daeb84276..e7f485b00 100644 --- a/.changeset/canonical-fields-omnigraph.md +++ b/.changeset/canonical-fields-omnigraph.md @@ -2,4 +2,4 @@ "ensapi": minor --- -**Omnigraph**: expose `Domain.canonical` and `Registry.canonical` on the Omnigraph schema. Both are nullable `Boolean` fields indicating whether the entity participates in the canonical namegraph. +**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/omnigraph-api/lib/get-canonical-path.ts b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts index 4c58f3c51..be7f7759f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -3,14 +3,19 @@ import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; -const MAX_DEPTH = 16; - /** * Provide the canonical parents for a Domain via reverse traversal of the namegraph. * * 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`). + * + * The recursion is unbounded by design. ENS names have no formal depth limit, so a fixed cap + * would silently truncate deep canonical paths. Termination relies on the canonical namegraph + * being a tree (the bidirectional invariant `Registry.canonicalDomainId` ↔ + * `Domain.canonicalSubregistryId` enforces this). If that invariant is ever violated and a + * cycle is introduced, this CTE could recurse indefinitely — that is an accepted trade-off + * for correctness on legitimately deep names. */ export async function getCanonicalPath(domainId: DomainId): Promise { // Short-circuit non-canonical Domains via the materialized flag. @@ -37,7 +42,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise { describe("Domain.canonical", () => { type DomainCanonicalResult = { - domain: { id: DomainId; canonical: boolean | null } | null; + domain: { id: DomainId; canonical: boolean } | null; }; const DomainCanonicalByName = gql` diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 326cfbbea..92de86d7e 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -55,11 +55,12 @@ if (config.plugins.includes(PluginName.TokenScope)) { // 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.ENSv2) || - config.plugins.includes(PluginName.ProtocolAcceleration) -) { +if (config.plugins.includes(PluginName.ProtocolAcceleration)) { attach_NodeMigrationHandlers(); } diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 33051a70f..005c25c17 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -21,13 +21,6 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * better trade. Revisit when registry sizes warrant. */ -/** - * Maximum cascade depth in {@link updateRegistryCanonicality}. The canonical namegraph is a tree - * under correct bidirectional-invariant maintenance, so this only triggers if state has been - * corrupted (in which case we want to fail loudly rather than recurse indefinitely). - */ -const MAX_CASCADE_DEPTH = 16; - /** * 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 @@ -128,23 +121,20 @@ export async function setRegistryCanonicalDomain( /** * Recursively flip `canonical` on `registryId` and every Domain in its child list (and their - * canonical subtrees). The canonical namegraph is a tree (each Registry has at most one canonical - * parent Domain, edge-authenticated by the bidirectional invariant), so cycles are unreachable - * under correct invariant maintenance — `MAX_CASCADE_DEPTH` exists purely to fail loudly on - * corrupted state rather than recurse indefinitely. + * 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, - depth = 0, ): Promise { - if (depth > MAX_CASCADE_DEPTH) { - throw new Error( - `Invariant(updateRegistryCanonicality): cascade depth exceeded ${MAX_CASCADE_DEPTH} starting at registry '${registryId}'. Bidirectional invariant likely corrupted.`, - ); - } - await context.ensDb.update(ensIndexerSchema.registry, { id: registryId }).set({ canonical }); const children = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); @@ -158,7 +148,7 @@ export async function updateRegistryCanonicality( const childSubregistry = child?.canonicalSubregistryId ?? null; if (childSubregistry) { - await updateRegistryCanonicality(context, childSubregistry, canonical, depth + 1); + await updateRegistryCanonicality(context, childSubregistry, canonical); } } } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 57788014e..0a2ed0269 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -393,15 +393,15 @@ export default function () { event, }: { context: IndexingEngineContext; - event: EventWithArgs<{ tokenId: bigint; resolver: NormalizedAddress }>; + event: EventWithArgs<{ tokenId: TokenId; resolver: NormalizedAddress }>; }) => { const { tokenId, resolver } = event.args; const registry = getThisAccountId(context, event); - const storageId = makeStorageId(tokenId as never); + 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 as never); + const originatingNode = interpretTokenIdAsNode(tokenId); await handleBridgedResolverChange(context, registry, domainId, originatingNode, resolver); }, ); From aad2f1c227d5f4717e2ce238e4cb2a6c484db9ee Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 5 May 2026 21:12:46 -0500 Subject: [PATCH 8/8] fix: re-add MAX_DEPTH guard to getCanonicalPath ensapi-layer code keeps depth caps so the API can fail loudly. The cap is detected by allowing the CTE to emit one row beyond MAX_DEPTH and throwing when that row appears, rather than silently truncating. --- .../omnigraph-api/lib/get-canonical-path.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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 be7f7759f..496aac4bc 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -3,19 +3,20 @@ import type { CanonicalPath, DomainId, RegistryId } from "enssdk"; 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. * * 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`). - * - * The recursion is unbounded by design. ENS names have no formal depth limit, so a fixed cap - * would silently truncate deep canonical paths. Termination relies on the canonical namegraph - * being a tree (the bidirectional invariant `Registry.canonicalDomainId` ↔ - * `Domain.canonicalSubregistryId` enforces this). If that invariant is ever violated and a - * cycle is introduced, this CTE could recurse indefinitely — that is an accepted trade-off - * for correctness on legitimately deep names. */ export async function getCanonicalPath(domainId: DomainId): Promise { // Short-circuit non-canonical Domains via the materialized flag. @@ -42,7 +43,8 @@ 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); }