From 95f2e2ab206609e74a9ac266b4fc8a8a365d5c3f Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 4 May 2026 09:28:56 -0500 Subject: [PATCH 01/28] 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 8e76e49b0f..1ecfa2e207 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 02/28] 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 116b83a453..17f7768a4a 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 381e4f2832..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/canonical-registries-cte.ts +++ /dev/null @@ -1,69 +0,0 @@ -import config from "@/config"; - -import { sql } from "drizzle-orm"; - -import { getRootRegistryIds } from "@ensnode/ensnode-sdk"; - -import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; - -/** - * The maximum depth to traverse the namegraph in order to construct the set of Canonical Registries. - * - * The CTE walks `domain.subregistryId` forward from every Root Registry. `subregistryId` is the - * source-of-truth forward pointer, so no separate edge-authentication is needed — a Registry is - * canonical iff it is reachable via a chain of live forward pointers from a Root. - * - * The reachable set is a DAG, not a tree: aliased subregistries let multiple parent Domains - * declare the same child Registry, so the same row can appear at multiple depths during recursion. - * The outer projection dedupes via `SELECT DISTINCT`; `MAX_DEPTH` bounds runaway recursion if the - * graph is corrupted. - */ -const CANONICAL_REGISTRIES_MAX_DEPTH = 16; - -/** - * Builds a recursive CTE that traverses forward from every top-level Root Registry configured for - * the namespace (the ENSv1 Root Registry, the Basenames and Lineanames ENSv1VirtualRegistries when - * configured, and the ENSv2 Root Registry when defined — see {@link getRootRegistryIds}) to - * construct a set of all Canonical Registries. - * - * A Canonical Registry is one whose Domains are resolvable under the primary resolution pipeline. - * This includes both the ENSv2 subtree and every ENSv1 subtree: Universal Resolver v2 falls back - * to ENSv1 at resolution time for names not (yet) present in ENSv2, so ENSv1 Domains remain - * canonical from a resolution perspective. - * - * TODO: could this be optimized further, perhaps as a materialized view? - */ -export const getCanonicalRegistriesCTE = () => { - const roots = getRootRegistryIds(config.namespace); - - const rootsUnion = roots - .map((root) => sql`SELECT ${root}::text AS registry_id, 0 AS depth`) - .reduce((acc, part, i) => (i === 0 ? part : sql`${acc} UNION ALL ${part}`)); - - return ensDb - .select({ - // NOTE: using `id` here to avoid clobbering `registryId` in consuming queries, which would - // result in '_ is ambiguous' error messages from postgres because drizzle isn't scoping the - // selection properly. a bit fragile but works for now. - id: sql`registry_id`.as("id"), - }) - .from( - sql` - ( - WITH RECURSIVE canonical_registries AS ( - ${rootsUnion} - UNION ALL - SELECT d.subregistry_id AS registry_id, cr.depth + 1 - FROM canonical_registries cr - JOIN ${ensIndexerSchema.domain} d ON d.registry_id = cr.registry_id - - -- Filter nulls at the recursive step so Domains without a subregistry don't - -- emit null rows into the CTE and don't spawn dead-end recursion branches. - WHERE cr.depth < ${CANONICAL_REGISTRIES_MAX_DEPTH} - AND d.subregistry_id IS NOT NULL - ) - SELECT DISTINCT registry_id FROM canonical_registries - ) AS canonical_registries_cte`, - ) - .as("canonical_registries"); -}; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts index 49650dab29..f8848ae08c 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 72918f8264..156a2e15c1 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 d950569c88..ba0ed672fe 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 62848873fb..0bed1d6838 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 883eb72043..82eddf3bd3 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 c08a2b34e3..f224cb17f7 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 30c23117e0..95e9602989 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 0000000000..486b247107 --- /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 0000000000..f26cc48eec --- /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 a000fbaf16..0c529ec09d 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 4800910070..78090b7dab 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 92456c4bae..9344149214 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 cc406d0672..0c4aca0a46 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 d97e67e5ff..7dd53cc51c 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 03/28] 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 915ec9c1bc..231649cee8 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 eb9b87be0c..f2ec59f0a9 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 ad7f18053a..6d78dd9a89 100644 --- a/examples/enskit-react-example/src/SearchView.tsx +++ b/examples/enskit-react-example/src/SearchView.tsx @@ -66,17 +66,10 @@ export function SearchView() {

Domain Search

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

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

Date: Tue, 5 May 2026 17:30:36 -0500 Subject: [PATCH 04/28] 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 0000000000..daeb842763 --- /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 f224cb17f7..d8acfcd2c4 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 e63dfb8428..c89fb48e3c 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 05/28] 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 0bed1d6838..4c58f3c516 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 231649cee8..2ea09b5bfa 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 f2ec59f0a9..278807d804 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 20a398fdc4..326cfbbeaa 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 486b247107..33051a70f5 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 0c529ec09d..263d73e592 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 78090b7dab..57788014ec 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 875e907379..b1778d4114 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts @@ -1,8 +1,5 @@ -import config from "@/config"; +import { makeENSv1DomainId, type Node, type NormalizedAddress } from "enssdk"; -import { type LabelHash, makeENSv1DomainId, type Node, type NormalizedAddress } from "enssdk"; - -import { getENSRootChainId } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; import { getThisAccountId } from "@/lib/get-this-account-id"; @@ -11,16 +8,15 @@ import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; -import { migrateNode, nodeIsMigrated } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; - -const ensRootChainId = getENSRootChainId(config.namespace); +import { nodeIsMigrated } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; /** * Handler functions for Registry contracts in the Protocol Acceleration plugin. - * - indexes ENS Root Chain Registry migration status * - indexes Node-Resolver Relationships for all Registry contracts * - * Note that this registry migration status tracking is isolated to the protocol + * Note: ENS Root Chain Registry node-migration status is tracked separately in `node-migration.ts`, + * registered before both this plugin and the ENSv2 plugin so its results are available to the + * Old-registry guards in either plugin. */ export default function () { async function handleNewResolver({ @@ -40,33 +36,6 @@ export default function () { await ensureDomainResolverRelation(context, registry, domainId, resolver); } - /** - * Handles Registry#NewOwner for: - * - ENS Root Chain's (new) Registry - */ - addOnchainEventListener( - namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewOwner"), - async ({ - context, - event, - }: { - context: IndexingEngineContext; - event: EventWithArgs<{ - // NOTE: `node` event arg represents a `Node` that is the _parent_ of the node the NewOwner event is about - node: Node; - // NOTE: `label` event arg represents a `LabelHash` for the sub-node under `node` - label: LabelHash; - owner: NormalizedAddress; - }>; - }) => { - // no-op because we only track registry migration status on ENS Root Chain - if (context.chain.id !== ensRootChainId) return; - - const { label: labelHash, node: parentNode } = event.args; - await migrateNode(context, parentNode, labelHash); - }, - ); - /** * Handles Registry#NewResolver for: * - ENS Root Chain's ENSv1RegistryOld diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts new file mode 100644 index 0000000000..b699e26ec2 --- /dev/null +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/node-migration.ts @@ -0,0 +1,43 @@ +import config from "@/config"; + +import type { LabelHash, Node, NormalizedAddress } from "enssdk"; + +import { getENSRootChainId } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { addOnchainEventListener, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; +import { namespaceContract } from "@/lib/plugin-helpers"; +import type { EventWithArgs } from "@/lib/ponder-helpers"; +import { migrateNode } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; + +const ensRootChainId = getENSRootChainId(config.namespace); + +/** + * Node migration handler — tracks ENSv1RegistryOld → ENSv1Registry migration on the ENS Root Chain. + * + * Extracted from the ProtocolAcceleration plugin so it can be registered before both the ENSv2 and + * ProtocolAcceleration plugins. This guarantees `nodeIsMigrated` reads from a populated table when + * those plugins' Old-registry guards run. + */ +export default function () { + addOnchainEventListener( + namespaceContract(PluginName.ProtocolAcceleration, "ENSv1Registry:NewOwner"), + async ({ + context, + event, + }: { + context: IndexingEngineContext; + event: EventWithArgs<{ + node: Node; + label: LabelHash; + owner: NormalizedAddress; + }>; + }) => { + // no-op because we only track registry migration status on ENS Root Chain + if (context.chain.id !== ensRootChainId) return; + + const { label: labelHash, node: parentNode } = event.args; + await migrateNode(context, parentNode, labelHash); + }, + ); +} diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 7741f26201..394022e2f1 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 0dcdccb3f2..840bb4ac9b 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 06/28] 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 460fdc34c3..11ee38f968 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -54,7 +54,7 @@ describe("Account.domains", () => { "sub1.sub2.parent.eth", "sub2.parent.eth", "test.eth", - "wallet.linked.parent.eth", + "wallet.sub1.sub2.parent.eth", ]; for (const name of expected) { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index d8acfcd2c4..43da36fdff 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 137e40e1e7..046ffaacc0 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 184c8840e1..cbd42e1de0 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 07/28] 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 daeb842763..e7f485b006 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 4c58f3c516..be7f7759fa 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 326cfbbeaa..92de86d7e7 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 33051a70f5..005c25c178 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 57788014ec..0a2ed02696 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 08/28] 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 be7f7759fa..496aac4bc7 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); } From 499a2f45201cde173b15594e03a20a8e0aac6651 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 09:59:41 -0500 Subject: [PATCH 09/28] docs: note that TOCTOU is not a concern inside Ponder event handlers Bot reviewers have repeatedly flagged read-modify-write patterns in canonicality-db-helpers.ts as TOCTOU risks. They aren't, because Ponder serializes events per chain and runs each handler in a transaction. Document this so future contributors (and bots) have a single place to reference. Also note the contrast with ENSApi, where TOCTOU on ensIndexerSchema reads is real. --- AGENTS.md | 1 + apps/ensindexer/ponder/src/register-handlers.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdf796ddb4..fa468412af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ Fail fast and loudly on invalid inputs. - Schema changes never require a migration step. Ponder only runs fully-compatible indexes against existing schemas; otherwise the index is dropped and rebuilt from scratch. Do not propose, plan, or write migration code for the ensindexer drizzle schema. - Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar. - Access entities by primary key only. Ponder's cache layer keys on PK; filters or complex selects force a flush to Postgres and are extremely unperformant in the hot path. If you need a non-PK lookup at index time, design the schema so the lookup key is the primary key. +- TOCTOU is never a concern inside Ponder event handlers. Ponder serializes events per chain and runs each handler in a transaction, so a read-modify-write against the same row from two handlers cannot interleave. Bot reviewers will sometimes flag this — dismiss those comments. (Note: TOCTOU IS a concern in ENSApi when reading `ensIndexerSchema` — both parallel and serial reads can see different snapshots of the indexer's writes.) ## Workflow diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index 92de86d7e7..4a12b90e4d 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -53,10 +53,10 @@ if (config.plugins.includes(PluginName.TokenScope)) { // 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. +// 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 +// Note: NodeMigration is gated on ProtocolAcceleration but the ENSv2 plugin has // ProtocolAcceleration as a hard requirement, so checking ProtocolAcceleration is sufficient // to cover both plugins' needs. From 314992065118ed39657eff9334d81e0012699af0 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 12:11:16 -0500 Subject: [PATCH 10/28] checkpoint: pre-canonicality parallel table extraction --- .changeset/canonical-fields-omnigraph.md | 2 +- .../src/lib/resolution/forward-resolution.ts | 13 +-- .../ensapi/src/omnigraph-api/lib/constants.ts | 8 ++ .../find-domains/layers/base-domain-set.ts | 3 + .../layers/filter-by-canonical.ts | 3 - .../omnigraph-api/lib/get-canonical-path.ts | 17 +--- .../lib/get-domain-by-interpreted-name.ts | 93 +++++++++++-------- .../src/omnigraph-api/schema/resolver.ts | 10 +- .../src/test/integration/devnet-names.ts | 10 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 90 +++++++++--------- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 5 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 82 ++++------------ packages/datasources/src/ens-test-env.ts | 5 + .../is-bridged-resolver.ts | 70 +++++--------- .../src/omnigraph/generated/schema.graphql | 2 +- 15 files changed, 180 insertions(+), 233 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/constants.ts diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md index e7f485b006..770533ff2a 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 non-null `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 Nametree. diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 17f7768a4a..311121fb1c 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -5,7 +5,6 @@ import { type AccountId, asInterpretedName, ENS_ROOT_NAME, - ENS_ROOT_NODE, type InterpretedName, isNormalizedName, type Node, @@ -240,19 +239,13 @@ async function _resolveForward( ///////////////////////////////////// if (accelerate && canAccelerate) { const resolver = { chainId, address: activeResolver }; - // 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) { + const bridged = isBridgedResolver(config.namespace, resolver); + if (bridged) { return withEnsProtocolStep( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, - () => - _resolveForward(name, selection, { - ...options, - registry: { chainId: bridgesTo.chainId, address: bridgesTo.address }, - }), + () => _resolveForward(name, selection, { ...options, registry: bridged.registry }), ); } diff --git a/apps/ensapi/src/omnigraph-api/lib/constants.ts b/apps/ensapi/src/omnigraph-api/lib/constants.ts new file mode 100644 index 0000000000..59ad453f44 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/constants.ts @@ -0,0 +1,8 @@ +/** + * The maximum depth of a name supported by Omnigraph traversal libs, to avoid runaway walks across + * the namegraph. ENS names have no formal depth limit, but we cap traversal to fail loudly rather + * than risk an unbounded recursive CTE. + * + * If this depth turns out to be problematic in practice, we can increase it as necessary. + */ +export const MAX_SUPPORTED_NAME_DEPTH = 16; diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts index f8848ae08c..befa554a33 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -12,6 +12,9 @@ export type BaseDomainSet = ReturnType; * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * * Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. + * - parentId derived via Domain -> Registry -> canonicalDomainId + * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering + * - all other values are directly sourced from Domain * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ 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 156a2e15c1..88f0300c2e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-canonical.ts @@ -6,9 +6,6 @@ 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) { return ensDb 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 496aac4bc7..9d4bf700f2 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -2,14 +2,7 @@ import { sql } from "drizzle-orm"; 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; +import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; /** * Provide the canonical parents for a Domain via reverse traversal of the namegraph. @@ -19,7 +12,7 @@ const MAX_DEPTH = 16; * not itself canonical (`domain.canonical = false`). */ export async function getCanonicalPath(domainId: DomainId): Promise { - // Short-circuit non-canonical Domains via the materialized flag. + // Short-circuit for non-canonical Domains const domain = await ensDb.query.domain.findFirst({ where: (t, { eq }) => eq(t.id, domainId), columns: { canonical: true }, @@ -54,7 +47,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise MAX_DEPTH) { + if (rows.length > MAX_SUPPORTED_NAME_DEPTH) { throw new Error( - `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_DEPTH}.`, + `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_SUPPORTED_NAME_DEPTH}.`, ); } 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 82eddf3bd3..85c7530c25 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 @@ -16,15 +16,17 @@ import { import { DatasourceNames } from "@ensnode/datasources"; import { - accountIdEqual, getENSv1RootRegistryId, + getENSv2RootRegistryId, getRootRegistryId, - maybeGetDatasourceContract, + makeContractMatcher, type RequiredAndNotNull, } from "@ensnode/ensnode-sdk"; +import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span"; +import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; const tracer = trace.getTracer("get-domain-by-interpreted-name"); @@ -34,16 +36,11 @@ const tracer = trace.getTracer("get-domain-by-interpreted-name"); */ const MAX_HOP_DEPTH = 3; -/** - * The maximum depth to walk the namegraph from any Registry. - */ -const MAX_WALK_DEPTH = 16; - interface WalkResultRow { domainId: DomainId; + depth: number; address: Address | null; chainId: ChainId | null; - depth: number; } /** @@ -82,38 +79,41 @@ export async function getDomainIdByInterpretedName( throw new Error(`Invariant: ${name} generated 0 labelHashPath segments.`); } - if (path.length > MAX_WALK_DEPTH) { - throw new Error(`Invariant: Name '${name}' exceeds maximum depth ${MAX_WALK_DEPTH}.`); + if (path.length > MAX_SUPPORTED_NAME_DEPTH) { + throw new Error(`Invariant: Name '${name}' exceeds maximum depth ${MAX_SUPPORTED_NAME_DEPTH}.`); } return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, () => - resolveCanonicalDomainId(getRootRegistryId(config.namespace), path), + forwardWalkNamegraph(getRootRegistryId(config.namespace), path), ); } /** - * 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. + * Walks `path` from `registryId` to identify a leaf `domainId`, hopping between disjoint namegraphs + * as necessary to implement Resolution logic (Bridged Resolver, ENSv1Resolver, ENSv2Resolver). * - * 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. + * This function prefers the leaf Domain within the origin Registry. i.e. if there's an ENSv2 Domain + * like example.eth that has as its Resolver the ENSv1Resolver (which sources records from ENSv1's + * example.eth's Resolver) this function preferentially returns the ENSv2 example.eth, which is more + * correctly the 'resolvable' Domain; the ENSv1 example.eth is more vestigal and not the source of + * truth. + * + * This same logic also encodes the preference that, for a Domain with a Bridged Resolver, the Domain + * in the origin Registry (ex: the ENS Root Chain's 'linea.eth' [either ENSv1 or ENSv2]) we return + * the ENS Root Chain's linea.eth instead of the Linea Chain's shadowed linea.eth (which, formally, + * doesn't exist in the eyes of Resolution). */ -async function resolveCanonicalDomainId( +async function forwardWalkNamegraph( registryId: RegistryId, path: LabelHashPath, depth = 0, ): Promise { if (depth > MAX_HOP_DEPTH) { - throw new Error( - `Invariant(resolveCanonicalDomainId): Bridged Resolver depth exceeded: ${depth}`, - ); + throw new Error(`Invariant(forwardWalkNamegraph): Hop depth exceeded: ${depth}`); } - const rows = await walkCanonicalNamegraph(registryId, path); + // walk the disjoint namegraph by indicated by `registryId` through `path` + const rows = await forwardWalkDisjointNamegraph(registryId, path); if (rows.length === 0) return null; // rows are ORDER BY depth DESC, so deepest element is rows[0] @@ -129,19 +129,32 @@ async function resolveCanonicalDomainId( // otherwise, identify the deepest element with a Resolver const deepestResolver = rows.find(hasResolver); if (deepestResolver) { + const resolverEq = makeContractMatcher(config.namespace, deepestResolver); + // Bridged Resolvers + // if the deepest Resolver is a Bridged Resolver, recurse to the target Registry + const bridged = isBridgedResolver(config.namespace, deepestResolver); + if (bridged) { + // to follow a Bridged Resolver, continue walking the namegraph from the target `registryId` + // with the remaining portion of `path` + + // NOTE: we blindly return after bridging, which correctly implements the Forward Resolution + // behavior in that the origin Domain, even if there is one, is invisible to resolution + // (due to the ancestor Bridged Resolver) and therefore not addressable + return forwardWalkNamegraph(bridged.registryId, path.slice(deepestResolver.depth), depth + 1); + } + // ENSv1Resolver (ENSv1 Fallback) // if the deepest Resolver is the ENSv1Resolver, fallback to ENSv1 - const ENSv1Resolver = maybeGetDatasourceContract( - config.namespace, - DatasourceNames.ENSv2Root, - "ENSv1Resolver", - ); - if (ENSv1Resolver && accountIdEqual(deepestResolver, ENSv1Resolver)) { - // fallback to ENSv1 using the full path - return resolveCanonicalDomainId(getENSv1RootRegistryId(config.namespace), path, depth + 1); + if (resolverEq(DatasourceNames.ENSv2Root, "ENSv1Resolver")) { + // to implement the ENSv1Resolver, walk the ENSv1 disjoint namegraph with the full path + return forwardWalkNamegraph(getENSv1RootRegistryId(config.namespace), path, depth + 1); } - // TODO: ENSv2Resolver + // ENSv1Resolver (ENSv2 Fallback) + if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { + // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path + return forwardWalkNamegraph(getENSv2RootRegistryId(config.namespace), path, depth + 1); + } } // finally, return the exact match if it was the leaf @@ -149,13 +162,12 @@ async function resolveCanonicalDomainId( } /** - * Walks the Canonical namegraph from `registryId` through `path` to identify each ancestor Domain, + * Walks a disjoint namegraph from `registryId` through `path` to identify each ancestor Domain, * then LEFT JOINs each Domain to its Resolver via DRR and returns the full path ordered by depth * DESC (deepest first). Resolver-less Domains are kept in the result with `resolver`/`chainId` set - * to NULL. Recursion terminates when the path is exhausted, when a Domain is non-canonical, or - * when `canonical_subregistry_id` becomes NULL (leaf canonical domain). + * to NULL. Recursion terminates when the path is exhausted. */ -async function walkCanonicalNamegraph(registryId: RegistryId, path: LabelHashPath) { +async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelHashPath) { if (path.length === 0) return []; // NOTE: using new Param as per https://github.com/drizzle-team/drizzle-orm/issues/1289#issuecomment-2688581070 @@ -171,16 +183,17 @@ async function walkCanonicalNamegraph(registryId: RegistryId, path: LabelHashPat UNION ALL SELECT - d.canonical_subregistry_id AS next_registry_id, + -- NOTE: that here we recurse by domain.subregistry_id NOT domain.canonical_subregistry_id + -- as this walk allows addressing of non-canonical Domains + d.subregistry_id AS next_registry_id, d.id AS "domainId", path.depth + 1 FROM path JOIN ${ensIndexerSchema.domain} d ON d.registry_id = path.next_registry_id WHERE d.label_hash = (${rawLabelHashPathArray})[path.depth + 1] - AND d.canonical = TRUE AND path.depth + 1 <= array_length(${rawLabelHashPathArray}, 1) - AND path.depth < ${MAX_WALK_DEPTH} + AND path.depth < ${MAX_SUPPORTED_NAME_DEPTH} ) SELECT path."domainId", diff --git a/apps/ensapi/src/omnigraph-api/schema/resolver.ts b/apps/ensapi/src/omnigraph-api/schema/resolver.ts index 95e9602989..a9860ad94f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolver.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolver.ts @@ -3,7 +3,6 @@ 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, @@ -23,6 +22,7 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; import { EventRef, EventsWhereInput } from "@/omnigraph-api/schema/event"; import { NameOrNodeInput } from "@/omnigraph-api/schema/name-or-node"; import { PermissionsRef } from "@/omnigraph-api/schema/permissions"; +import { RegistryInterfaceRef } from "@/omnigraph-api/schema/registry"; import { ResolverRecordsRef } from "@/omnigraph-api/schema/resolver-records"; /** @@ -125,13 +125,11 @@ ResolverRef.implement({ bridged: t.field({ description: "If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.", - type: AccountIdRef, + type: RegistryInterfaceRef, nullable: true, resolve: (parent) => { - // 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; + const bridged = isBridgedResolver(config.namespace, parent); + return bridged?.registryId ?? null; }, }), diff --git a/apps/ensapi/src/test/integration/devnet-names.ts b/apps/ensapi/src/test/integration/devnet-names.ts index cbd42e1de0..09cb3e2416 100644 --- a/apps/ensapi/src/test/integration/devnet-names.ts +++ b/apps/ensapi/src/test/integration/devnet-names.ts @@ -12,13 +12,14 @@ export const DEVNET_NAMES = [ { name: "sub1.sub2.parent.eth", canonical: "sub1.sub2.parent.eth" }, { name: "linked.parent.eth", canonical: "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. + // this name is incorrectly linked + // { name: "wallet.linked.parent.eth", canonical: "wallet.linked.parent.eth" }, + + // this name is correctly linked { name: "wallet.sub1.sub2.parent.eth", canonical: "wallet.sub1.sub2.parent.eth" }, // NOTE: devnet says these are names but neither test.eth or alias.eth declare a subregistry - // so their subnames aren't resolvable + // so their subnames aren't addressable // { name: "sub.alias.eth", canonical: "sub.alias.eth" }, // { name: "sub.test.eth", canonical: "sub.alias.eth" }, ]; @@ -27,7 +28,6 @@ export const DEVNET_NAMES = [ export const DEVNET_ETH_LABELS = DEVNET_NAMES.map(({ name, canonical }) => { const isCanonical = name === canonical; if (!isCanonical) return null; - if (!name.endsWith(".eth")) return null; const segments = name.split("."); diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 005c25c178..d74f53e02b 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -1,10 +1,9 @@ import config from "@/config"; -import type { AccountId, DomainId, Node, NormalizedAddress, RegistryId } from "enssdk"; +import type { AccountId, DomainId, 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"; /** @@ -12,25 +11,34 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * * 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. + * by primary keys to abide by Ponder's in-memory cache requirements and avoid a flush to Postgres. * * 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. + * full array per child — at sufficient N either: + * a) a doubly-linked-list (one row per edge), or + * b) eating the flush cost and using custom sql to CTE-walk-and-update the relevant domains + * becomes the better trade. Revisit when necessary. */ /** * 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). + * 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 registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { + throw new Error( + `Invariant(ensureDomainInRegistry): Registry '${registryId}' must exist before linking Domain '${domainId}'. Call ensureRegistry first.`, + ); + } + const existing = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); if (existing?.domainIds.includes(domainId)) return; @@ -40,15 +48,9 @@ export async function ensureDomainInRegistry( .values({ registryId, domainIds }) .onConflictDoUpdate({ domainIds }); - const reg = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - if (!reg) { - throw new Error( - `Invariant(ensureDomainInRegistry): Registry '${registryId}' must exist before linking Domain '${domainId}'. Call ensureRegistry first.`, - ); - } await context.ensDb .update(ensIndexerSchema.domain, { id: domainId }) - .set({ canonical: reg.canonical }); + .set({ canonical: registry.canonical }); } /** @@ -61,14 +63,14 @@ export async function setRegistryCanonicalDomain( registryId: RegistryId, newCanonicalDomainId: DomainId | null, ): Promise { - const reg = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - if (!reg) { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { throw new Error( `Invariant(setRegistryCanonicalDomain): Registry '${registryId}' must exist before being canonicalized.`, ); } - const prevCanonicalDomainId = reg.canonicalDomainId ?? null; + const prevCanonicalDomainId = registry.canonicalDomainId ?? null; // Read the new canonical Domain once; reused for both dislodge and shouldBeCanonical. const newDomain = newCanonicalDomainId @@ -82,8 +84,8 @@ export async function setRegistryCanonicalDomain( // 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 === newCanonicalDomainId && registry.canonical === shouldBeCanonical) { + return registry.canonical; } if (prevCanonicalDomainId && prevCanonicalDomainId !== newCanonicalDomainId) { @@ -112,7 +114,7 @@ export async function setRegistryCanonicalDomain( .set({ canonicalSubregistryId: registryId }); } - if (reg.canonical !== shouldBeCanonical) { + if (registry.canonical !== shouldBeCanonical) { await updateRegistryCanonicality(context, registryId, shouldBeCanonical); } @@ -130,7 +132,7 @@ export async function setRegistryCanonicalDomain( * 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( +async function updateRegistryCanonicality( context: IndexingEngineContext, registryId: RegistryId, canonical: boolean, @@ -165,40 +167,38 @@ export async function handleBridgedResolverChange( context: IndexingEngineContext, registry: AccountId, domainId: DomainId, - originatingNode: Node, - newResolver: NormalizedAddress, + nextResolver: NormalizedAddress, ): Promise { - const prevDRR = await context.ensDb.find(ensIndexerSchema.domainResolverRelation, { + const prev = 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, - ) + + const prevBridged = prev + ? isBridgedResolver(config.namespace, { chainId: prev.chainId, address: prev.resolver }) : null; - const nextBridge = isBridgedResolver( - config.namespace, - { chainId: context.chain.id, address: newResolver }, - originatingNode, - ); + const nextBridge = isBridgedResolver(config.namespace, { + chainId: context.chain.id, + address: nextResolver, + }); + + // we need to unset the previous bridged resolver's target registry's canonical domain if: + const needsUnset = + prevBridged && // it was previously bridged AND + (!nextBridge || // we're unsetting the resolver entirely OR + prevBridged.registryId !== nextBridge.registryId); // we're setting it and it's being changed - if (prevBridge && (!nextBridge || prevBridge.id !== nextBridge.id)) { - await setRegistryCanonicalDomain(context, prevBridge.id, null); + if (needsUnset) { + await setRegistryCanonicalDomain(context, prevBridged.registryId, null); } + // if the new resolver is a Bridged Resolver, set the target registry's canonical domain if (nextBridge) { - await ensureRegistry(context, nextBridge.id, { - type: nextBridge.type, - chainId: nextBridge.chainId, - address: nextBridge.address, - ...(nextBridge.type === "ENSv1VirtualRegistry" ? { node: nextBridge.node } : {}), - }); - - await setRegistryCanonicalDomain(context, nextBridge.id, domainId); + // NOTE: this function doesn't ensureRegistry() for the target, so if the event setting a + // Bridged Resolver occurs before the target Registry exists (which does not occur for our existing + // Bridged Resolvers) then this setRegistryCanonicalDomain will throw with the appropriate invariant + await setRegistryCanonicalDomain(context, nextBridge.registryId, domainId); } } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 263d73e592..bc3facf40d 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -232,9 +232,8 @@ export default function () { // NOTE: Domain-Resolver relations are handled by the protocol-acceleration plugin and are not // directly indexed here - // Wire/unwire the canonical edge for known Bridged Resolvers (Basenames, Lineanames). Runs - // BEFORE Protocol Acceleration overwrites the DRR — the previous resolver is read from there. - await handleBridgedResolverChange(context, registry, domainId, node, resolver); + // handle changes in resolver that could affect Bridged Resolver Canonical Domain edges + await handleBridgedResolverChange(context, registry, domainId, resolver); // push event to domain history const eventId = await ensureEvent(context, event); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 0a2ed02696..6f6770d8b7 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -305,8 +305,7 @@ 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. + * Registry's `SubregistryUpdated`/`LabelRegistered`. */ addOnchainEventListener( namespaceContract(pluginName, "ENSv2Registry:ParentUpdated"), @@ -321,71 +320,31 @@ export default function () { 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, - }); + const parent = interpretAddress(event.args.parent); - 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), - ); + const registry = getThisAccountId(context, event); + const registryId = makeENSv2RegistryId(registry); - await ensureLabel(context, label); - await ensureRegistry(context, parentRegistryId, { - type: "ENSv2Registry", - ...parentRegistryAccountId, - }); + if (parent) { + // update the Canonical Domain, cascading the canonicality update to this registry's domains + const parentRegistry: AccountId = { chainId: registry.chainId, address: parent }; + const labelHash = labelhashLiteralLabel(label); + const domainId = makeENSv2DomainId(parentRegistry, makeStorageId(labelHash)); - // 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); + await setRegistryCanonicalDomain(context, registryId, domainId); + } else { + // unset the Canonical Domain, cascading the canonicality update to this registry's domains + await setRegistryCanonicalDomain(context, registryId, null); } - 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); + // TODO: push event to registry history + // const senderId = await ensureAccount(context, event.args.sender); + // const eventId = await ensureEvent(context, event, senderId); + // await ensureRegistryEvent(context, registryId, eventId); }, ); - /** - * Wire/unwire the canonical edge for known Bridged Resolvers when the Resolver changes. Runs - * BEFORE Protocol Acceleration's ResolverUpdated handler overwrites the DRR — see - * `apps/ensindexer/ponder/src/register-handlers.ts` for the ordering contract. ENSv2 bridges - * are not yet defined in `isBridgedResolver`, so attach is currently unreachable via this path — - * but detach must still run if a previously-attached bridge gets replaced. - */ addOnchainEventListener( namespaceContract(pluginName, "ENSv2Registry:ResolverUpdated"), async ({ @@ -399,10 +358,9 @@ export default function () { const registry = getThisAccountId(context, event); const storageId = makeStorageId(tokenId); const domainId = makeENSv2DomainId(registry, storageId); - // For ENSv2 originators, `originatingNode` only feeds ENSv1VirtualRegistryId construction - // inside `isBridgedResolver`; the tokenId-derived value is forward-compatible. - const originatingNode = interpretTokenIdAsNode(tokenId); - await handleBridgedResolverChange(context, registry, domainId, originatingNode, resolver); + + // handle changes in resolver that could affect Bridged Resolver Canonical Domain edges + await handleBridgedResolverChange(context, registry, domainId, resolver); }, ); diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/ens-test-env.ts index 2f178b80b3..249cf6dfa0 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/ens-test-env.ts @@ -124,6 +124,11 @@ export default { address: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", startBlock: 0, }, + ENSv2Resolver: { + abi: ResolverABI, + address: "0xc6e7df5e7b4f2a278906862b61205850344d4e7d", + startBlock: 0, + }, }, }, 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 0c4aca0a46..bb59767e9f 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,47 +1,31 @@ -import { - type AccountId, - type ChainId, - makeENSv1VirtualRegistryId, - type Node, - type NormalizedAddress, - type RegistryId, -} from "enssdk"; +import { type AccountId, makeENSv1VirtualRegistryId, type RegistryId } from "enssdk"; import { DatasourceNames } from "@ensnode/datasources"; import { type ENSNamespaceId, getDatasourceContract, + getManagedName, makeContractMatcher, } from "@ensnode/ensnode-sdk"; /** - * 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. - * - * Currently only ENSv1VirtualRegistry shadow registries (Basenames, Lineanames) are supported; - * future ENSv2 sub-registry bridges will use `type: "ENSv2Registry"`. + * Describes a Bridged Resolver's Target */ -export type BridgedResolverRegistry = - | { - id: RegistryId; - type: "ENSv1VirtualRegistry"; - chainId: ChainId; - address: NormalizedAddress; - node: Node; - } - | { - id: RegistryId; - type: "ENSv2Registry"; - chainId: ChainId; - address: NormalizedAddress; - }; +export interface BridgedResolverTarget { + /** + * The RegistryId of the _specific_ (Concrete or Virtual) Registry to which the Bridged Resolver defers. + */ + registryId: RegistryId; + + /** + * The AccountId of the Concrete Registry to which the Bridged Resolver defers. + */ + registry: AccountId; +} /** * 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. + * resolution to. * * These Bridged Resolvers must abide the following pattern: * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, @@ -61,35 +45,31 @@ export type BridgedResolverRegistry = * against the (shadow)Registry in question. * * TODO: these relationships could/should be encoded in an ENSIP + * TODO: once Forward Resolution is updated for ENSv2, this likely just returns RegistryId */ export function isBridgedResolver( namespace: ENSNamespaceId, resolver: AccountId, - originatingNode: Node, -): BridgedResolverRegistry | null { +): BridgedResolverTarget | 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"); + const registry = getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"); + const { node } = getManagedName(namespace, registry); return { - id: makeENSv1VirtualRegistryId(target, originatingNode), - type: "ENSv1VirtualRegistry", - chainId: target.chainId, - address: target.address, - node: originatingNode, + registryId: makeENSv1VirtualRegistryId(registry, node), + registry, }; } // the ENSRoot's LineanamesL1Resolver bridges to the Lineanames (shadow)Registry if (resolverEq(DatasourceNames.ENSRoot, "LineanamesL1Resolver")) { - const target = getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); + const registry = getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); + const { node } = getManagedName(namespace, registry); return { - id: makeENSv1VirtualRegistryId(target, originatingNode), - type: "ENSv1VirtualRegistry", - chainId: target.chainId, - address: target.address, - node: originatingNode, + registryId: makeENSv1VirtualRegistryId(registry, node), + registry, }; } diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 840bb4ac9b..1641730f3e 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -1184,7 +1184,7 @@ type Resolver { """ If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution. """ - bridged: AccountId + bridged: Registry """Contract metadata for this Resolver.""" contract: AccountId! From 856d30e51210c00196252519dbdbb928b0ae4406 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 13:23:13 -0500 Subject: [PATCH 11/28] checkpoint: post parallel refactor, pre-reconciliation --- .../find-domains/layers/base-domain-set.ts | 19 +- .../lib/find-domains/layers/filter-by-name.ts | 23 +-- .../omnigraph-api/lib/get-canonical-path.ts | 6 +- .../lib/get-domain-by-interpreted-name.ts | 5 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 169 +++++++++++------- .../src/lib/ensv2/registry-db-helpers.ts | 12 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 1 - .../src/ensindexer-abstract/ensv2.schema.ts | 35 ++-- 8 files changed, 163 insertions(+), 107 deletions(-) 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 befa554a33..6e15fffea2 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -12,7 +12,7 @@ export type BaseDomainSet = ReturnType; * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * * Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. - * - parentId derived via Domain -> Registry -> canonicalDomainId + * - parentId derived via Domain -> registryCanonicalDomain (parallel canonicality table) * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering * - all other values are directly sourced from Domain * @@ -25,9 +25,10 @@ export function domainsBase() { domainId: sql`${ensIndexerSchema.domain.id}`.as("domainId"), ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), - parentId: sql`${ensIndexerSchema.registry.canonicalDomainId}`.as( - "parentId", - ), + parentId: + sql`${ensIndexerSchema.registryCanonicalDomain.canonicalDomainId}`.as( + "parentId", + ), canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( @@ -35,12 +36,12 @@ export function domainsBase() { ), }) .from(ensIndexerSchema.domain) - // parent: materialized via `registry.canonicalDomainId`. The bidirectional invariant - // (`Domain.canonicalSubregistryId` ↔ `Registry.canonicalDomainId`) guarantees consistency, - // so no edge-auth join is required. + // parent: the canonical edge is materialized in the parallel `registryCanonicalDomain` table + // (keyed by registryId). The bidirectional invariant maintained against + // `domainCanonicalSubregistry` guarantees consistency, so no edge-auth join is required. .leftJoin( - ensIndexerSchema.registry, - eq(ensIndexerSchema.registry.id, ensIndexerSchema.domain.registryId), + ensIndexerSchema.registryCanonicalDomain, + eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), ) // join label for labelHash/sortableLabel .leftJoin( 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 ba0ed672fe..df94a12f99 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 @@ -47,7 +47,8 @@ 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 registry.canonicalDomainId. + // Recursive CTE starting from the deepest child and traversing UP via the parallel + // `registryCanonicalDomain` table. // 1. Start with domains matching the leaf labelHash (deepest child) // 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) @@ -64,23 +65,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 registry.canonical_domain_id. The bidirectional invariant guarantees the edge - -- is consistent without a separate edge-auth join. + -- up via registry_canonical_domains.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.registry} r - ON r.id = d.registry_id + JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd + ON rcd.registry_id = d.registry_id JOIN ${ensIndexerSchema.domain} parent - ON parent.id = r.canonical_domain_id + ON parent.id = rcd.canonical_domain_id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL - -- Recursive step: traverse UP via registry.canonical_domain_id, verifying each - -- ancestor's labelHash. + -- Recursive step: traverse UP via registry_canonical_domains.canonical_domain_id, + -- verifying each ancestor's labelHash. SELECT upward_check.leaf_id, np.id AS current_id, @@ -88,10 +89,10 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { FROM upward_check JOIN ${ensIndexerSchema.domain} pd ON pd.id = upward_check.current_id - JOIN ${ensIndexerSchema.registry} pr - ON pr.id = pd.registry_id + JOIN ${ensIndexerSchema.registryCanonicalDomain} prcd + ON prcd.registry_id = pd.registry_id JOIN ${ensIndexerSchema.domain} np - ON np.id = pr.canonical_domain_id + ON np.id = prcd.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 9d4bf700f2..4712716978 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -43,10 +43,10 @@ export async function getCanonicalPath(domainId: DomainId): Promise { - const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - if (!registry) { - throw new Error( - `Invariant(setRegistryCanonicalDomain): Registry '${registryId}' must exist before being canonicalized.`, - ); - } + nextCanonicalDomainId: DomainId | null, +): Promise { + // first, identfiy this Registry's Previous Canonical Domain + const prevEdge = await context.ensDb.find(ensIndexerSchema.registryCanonicalDomain, { + registryId, + }); + const prevCanonicalDomainId = prevEdge?.canonicalDomainId ?? null; - const prevCanonicalDomainId = registry.canonicalDomainId ?? null; + // if this Registry's Canonical Domain isn't changing, no-op (canonicality already consistent) + if (prevCanonicalDomainId === nextCanonicalDomainId) return; - // Read the new canonical Domain once; reused for both dislodge and shouldBeCanonical. - const newDomain = newCanonicalDomainId - ? await context.ensDb.find(ensIndexerSchema.domain, { id: newCanonicalDomainId }) + // determine whether this Registry was previously Canonical + // const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + // const prevCanonical = registry?.canonical ?? false; + + // fetch Domain if setting + const domain = nextCanonicalDomainId + ? await context.ensDb.find(ensIndexerSchema.domain, { id: nextCanonicalDomainId }) : 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 && registry.canonical === shouldBeCanonical) { - return registry.canonical; + // determine whether this Registry should be Canonical by checking the parent Domain. If the + // parent Domain doesn't exist yet, this Registry cannot (yet) be Canonical. + const nextCanonical = domain?.canonical ?? false; + + // clear the previous Canonical Domain's Canonical Subregistry, since it is no longer + // bidirectionally agreed upon (note that we maintain Domain.subregistryId, as that represents the + // uni-directional Domain->Registry link) + if (prevCanonicalDomainId) { + await context.ensDb.delete(ensIndexerSchema.domainCanonicalSubregistry, { + domainId: prevCanonicalDomainId, + }); } - if (prevCanonicalDomainId && prevCanonicalDomainId !== newCanonicalDomainId) { + // set/unset this Registry's Canonical Domain + // note that this is the uni-directional Registry->Domain link + if (nextCanonicalDomainId) { 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); - } + .insert(ensIndexerSchema.registryCanonicalDomain) + .values({ registryId, canonicalDomainId: nextCanonicalDomainId }) + .onConflictDoUpdate({ canonicalDomainId: nextCanonicalDomainId }); + } else { + await context.ensDb.delete(ensIndexerSchema.registryCanonicalDomain, { registryId }); } - await context.ensDb - .update(ensIndexerSchema.registry, { id: registryId }) - .set({ canonicalDomainId: newCanonicalDomainId }); - - if (newCanonicalDomainId) { + // iff `Registry → Domain` ↔ `Domain → Registry`, upsert the Domain's Canonical Subregistry + // note that this is the materialized bi-directional Domain->Registry link (edge-authenticated) + const registryPointsToDomain = domain && nextCanonicalDomainId === domain.id; + const domainPointsToSubregistry = domain && domain.subregistryId === registryId; + if (registryPointsToDomain && domainPointsToSubregistry) { await context.ensDb - .update(ensIndexerSchema.domain, { id: newCanonicalDomainId }) - .set({ canonicalSubregistryId: registryId }); + .insert(ensIndexerSchema.domainCanonicalSubregistry) + .values({ domainId: nextCanonicalDomainId, canonicalSubregistryId: registryId }) + .onConflictDoUpdate({ canonicalSubregistryId: registryId }); } - if (registry.canonical !== shouldBeCanonical) { - await updateRegistryCanonicality(context, registryId, shouldBeCanonical); - } - - return shouldBeCanonical; + // Cascade this Registry's canonicality + await updateRegistryCanonicality(context, registryId, nextCanonical); } /** @@ -128,33 +149,52 @@ export async function setRegistryCanonicalDomain( * 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. + * the bidirectional invariant maintained across `registryCanonicalDomain` ↔ + * `domainCanonicalSubregistry`). 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. */ async function updateRegistryCanonicality( context: IndexingEngineContext, registryId: RegistryId, canonical: boolean, ): Promise { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + + // if the Registry does not exist, no-op + if (!registry) return; + + // if the Registry's canonicality is already consistent, no-op + const prevCanonical = registry.canonical; + if (prevCanonical === canonical) return; + + // otherwise, update its canonicality await context.ensDb.update(ensIndexerSchema.registry, { id: registryId }).set({ canonical }); + // and cascade through its children 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 }); + // Invariant: Domain is guaranteed to exist if its id is in ensIndexerSchema.registryDomains await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ canonical }); - const childSubregistry = child?.canonicalSubregistryId ?? null; - if (childSubregistry) { - await updateRegistryCanonicality(context, childSubregistry, canonical); + // retrieve the child Domain's (bi-directional) Canonical Subregistry + const childEdge = await context.ensDb.find(ensIndexerSchema.domainCanonicalSubregistry, { + domainId, + }); + if (childEdge) { + // if exists, cascade canonicality + await updateRegistryCanonicality(context, childEdge.canonicalSubregistryId, canonical); } } } +// shouldn't there be a handleSubregistryUpdate here that needs to +// check if `Registry → Domain` ↔ `Domain → Registry` and upsert the Domain's Canonical Subregistry +// and then cascade? that way events on the other side (ENSv2 SubregistryUpdated) reconcile canonicality as well +// and for ENSv1 maybe we don't need to do anything because the creation order is guaranteed? + /** * 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). @@ -196,9 +236,6 @@ export async function handleBridgedResolverChange( // if the new resolver is a Bridged Resolver, set the target registry's canonical domain if (nextBridge) { - // NOTE: this function doesn't ensureRegistry() for the target, so if the event setting a - // Bridged Resolver occurs before the target Registry exists (which does not occur for our existing - // Bridged Resolvers) then this setRegistryCanonicalDomain will throw with the appropriate invariant await setRegistryCanonicalDomain(context, nextBridge.registryId, domainId); } } diff --git a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts index f26cc48eec..e51f945f06 100644 --- a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts @@ -2,14 +2,13 @@ import config from "@/config"; import type { RegistryId } from "enssdk"; -import { getRootRegistryId } from "@ensnode/ensnode-sdk"; +import { getENSv1RootRegistryId, getENSv2RootRegistryId } 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. + * Idempotently insert a Registry row, seeding `canonical = true` if it is the namespace's + * ENSv1 or ENSv2 Root Registry. */ export async function ensureRegistry( context: IndexingEngineContext, @@ -24,7 +23,10 @@ export async function ensureRegistry( .values({ id, ...args, - canonical: id === getRootRegistryId(config.namespace), + canonical: + // by default, only the ENSv1 and ENSv2 Root Registries are Canonical + id === getENSv1RootRegistryId(config.namespace) || + id === getENSv2RootRegistryId(config.namespace), }) .onConflictDoNothing(); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 6f6770d8b7..1d16c1eb1c 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -1,7 +1,6 @@ import { type AccountId, asLiteralLabel, - interpretTokenIdAsNode, type LabelHash, labelhashLiteralLabel, makeENSv2DomainId, diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 9344149214..d5586e9dfb 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -62,8 +62,11 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * 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 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. + * exception of the canonical subgraph, which is materialized for PK-keyed query-time access: + * `Registry.canonical` ↔ `Domain.canonical` flags on the rows themselves, and the bidirectional + * canonical edge in the parallel `registryCanonicalDomain` ↔ `domainCanonicalSubregistry` tables. + * The edge tables are parallel (rather than columns on `registry`/`domain`) so canonicality can be + * recorded before the corresponding Registry or Domain row exists. * * 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: @@ -212,9 +215,6 @@ export const registry = onchainTable( // 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 @@ -274,11 +274,6 @@ export const domain = onchainTable( // 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 }), (t) => ({ @@ -617,3 +612,23 @@ export const registryDomains = onchainTable("registry_domains", (t) => ({ registryId: t.text().primaryKey().$type(), domainIds: t.text().array().notNull().$type(), })); + +// One half of the bidirectional canonical edge: a Registry's canonical parent Domain. +// Stored in a parallel table (rather than a column on `registry`) so the edge can be recorded +// before the Registry row exists (e.g. ParentUpdated firing before any LabelRegistered for the +// child Registry, or a Bridged Resolver targeting a Registry not yet observed onchain). The +// reciprocal half lives in `domainCanonicalSubregistry`; both rows are written together by +// canonicality-db-helpers.ts to maintain the bidirectional invariant. +export const registryCanonicalDomain = onchainTable("registry_canonical_domains", (t) => ({ + registryId: t.text().primaryKey().$type(), + canonicalDomainId: t.text().notNull().$type(), +})); + +// The reciprocal half of `registryCanonicalDomain`: a Domain's canonical Subregistry (i.e. the +// Registry whose canonical parent Domain points back to this Domain). May differ from +// `Domain.subregistryId` when a Bridged Resolver attaches a different Registry under this Domain. +// Parallel-table for the same reason as `registryCanonicalDomain`. +export const domainCanonicalSubregistry = onchainTable("domain_canonical_subregistries", (t) => ({ + domainId: t.text().primaryKey().$type(), + canonicalSubregistryId: t.text().notNull().$type(), +})); From fd0bc685078297757c62d4ee1e39745069873fa9 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 14:23:38 -0500 Subject: [PATCH 12/28] checkpoint: canonicality refactor testing --- .../find-domains/layers/base-domain-set.ts | 23 +- .../lib/find-domains/layers/filter-by-name.ts | 24 +-- .../omnigraph-api/lib/get-canonical-path.ts | 11 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 202 +++++++++++------- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 3 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 35 ++- .../src/ensindexer-abstract/ensv2.schema.ts | 38 ++-- 7 files changed, 191 insertions(+), 145 deletions(-) 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 6e15fffea2..e6b5874a0a 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -12,7 +12,8 @@ export type BaseDomainSet = ReturnType; * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * * Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. - * - parentId derived via Domain -> registryCanonicalDomain (parallel canonicality table) + * - parentId is the canonical parent Domain via the edge-authenticated + * `domainCanonicalSubregistry` table (`canonicalSubregistryId = domain.registryId`) * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering * - all other values are directly sourced from Domain * @@ -25,10 +26,9 @@ export function domainsBase() { domainId: sql`${ensIndexerSchema.domain.id}`.as("domainId"), ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), - parentId: - sql`${ensIndexerSchema.registryCanonicalDomain.canonicalDomainId}`.as( - "parentId", - ), + parentId: sql`${ensIndexerSchema.domainCanonicalSubregistry.domainId}`.as( + "parentId", + ), canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( @@ -36,12 +36,15 @@ export function domainsBase() { ), }) .from(ensIndexerSchema.domain) - // parent: the canonical edge is materialized in the parallel `registryCanonicalDomain` table - // (keyed by registryId). The bidirectional invariant maintained against - // `domainCanonicalSubregistry` guarantees consistency, so no edge-auth join is required. + // canonical parent via the edge-authenticated bidirectional edge: the row in + // `domainCanonicalSubregistry` whose `canonicalSubregistryId` is this Domain's Registry + // names the parent Domain in `domainId`. .leftJoin( - ensIndexerSchema.registryCanonicalDomain, - eq(ensIndexerSchema.registryCanonicalDomain.registryId, ensIndexerSchema.domain.registryId), + ensIndexerSchema.domainCanonicalSubregistry, + eq( + ensIndexerSchema.domainCanonicalSubregistry.canonicalSubregistryId, + ensIndexerSchema.domain.registryId, + ), ) // join label for labelHash/sortableLabel .leftJoin( 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 df94a12f99..8cedb77a3f 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 @@ -47,8 +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 the parallel - // `registryCanonicalDomain` table. + // Recursive CTE starting from the deepest child and traversing UP via the edge-authenticated + // `domainCanonicalSubregistry` table (which only contains rows where both + // `Registry.canonicalDomainId` and `Domain.subregistryId` agree). // 1. Start with domains matching the leaf labelHash (deepest child) // 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) @@ -65,23 +66,22 @@ 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 registry_canonical_domains.canonical_domain_id. The bidirectional invariant - -- guarantees the edge is consistent without a separate edge-auth join. + -- up via domain_canonical_subregistries (canonical_subregistry_id → registry_id). SELECT d.id AS leaf_id, parent.id AS current_id, 1 AS depth FROM ${ensIndexerSchema.domain} d - JOIN ${ensIndexerSchema.registryCanonicalDomain} rcd - ON rcd.registry_id = d.registry_id + JOIN ${ensIndexerSchema.domainCanonicalSubregistry} dcs + ON dcs.canonical_subregistry_id = d.registry_id JOIN ${ensIndexerSchema.domain} parent - ON parent.id = rcd.canonical_domain_id + ON parent.id = dcs.domain_id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL - -- Recursive step: traverse UP via registry_canonical_domains.canonical_domain_id, - -- verifying each ancestor's labelHash. + -- Recursive step: traverse UP via domain_canonical_subregistries, verifying each + -- ancestor's labelHash. SELECT upward_check.leaf_id, np.id AS current_id, @@ -89,10 +89,10 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { FROM upward_check JOIN ${ensIndexerSchema.domain} pd ON pd.id = upward_check.current_id - JOIN ${ensIndexerSchema.registryCanonicalDomain} prcd - ON prcd.registry_id = pd.registry_id + JOIN ${ensIndexerSchema.domainCanonicalSubregistry} pdcs + ON pdcs.canonical_subregistry_id = pd.registry_id JOIN ${ensIndexerSchema.domain} np - ON np.id = prcd.canonical_domain_id + ON np.id = pdcs.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 4712716978..42abe1a555 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -34,8 +34,9 @@ export async function getCanonicalPath(domainId: DomainId): Promise { - // first, identfiy this Registry's Previous Canonical Domain - const prevEdge = await context.ensDb.find(ensIndexerSchema.registryCanonicalDomain, { - registryId, - }); - const prevCanonicalDomainId = prevEdge?.canonicalDomainId ?? null; + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { + throw new Error( + `Invariant(setRegistryCanonicalDomain): Registry ${registryId} does not yet exist.`, + ); + } + + // identfiy this Registry's Previous Canonical Domain + const prevCanonicalDomainId = registry?.canonicalDomainId ?? null; // if this Registry's Canonical Domain isn't changing, no-op (canonicality already consistent) if (prevCanonicalDomainId === nextCanonicalDomainId) return; - // determine whether this Registry was previously Canonical - // const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - // const prevCanonical = registry?.canonical ?? false; - - // fetch Domain if setting - const domain = nextCanonicalDomainId - ? await context.ensDb.find(ensIndexerSchema.domain, { id: nextCanonicalDomainId }) - : null; + // from here, we know that the Registry's Canonical parent Domain is changed... - // determine whether this Registry should be Canonical by checking the parent Domain. If the - // parent Domain doesn't exist yet, this Registry cannot (yet) be Canonical. - const nextCanonical = domain?.canonical ?? false; + // set/unset this Registry's Canonical Domain + // note that this is the uni-directional Registry->Domain link + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ canonicalDomainId: nextCanonicalDomainId }); - // clear the previous Canonical Domain's Canonical Subregistry, since it is no longer - // bidirectionally agreed upon (note that we maintain Domain.subregistryId, as that represents the - // uni-directional Domain->Registry link) + // if previous Canonical Domain, ensure that its canonicality is materialized if (prevCanonicalDomainId) { - await context.ensDb.delete(ensIndexerSchema.domainCanonicalSubregistry, { - domainId: prevCanonicalDomainId, - }); + await materializeAndCascade(context, registryId, prevCanonicalDomainId); } - // set/unset this Registry's Canonical Domain - // note that this is the uni-directional Registry->Domain link - if (nextCanonicalDomainId) { - await context.ensDb - .insert(ensIndexerSchema.registryCanonicalDomain) - .values({ registryId, canonicalDomainId: nextCanonicalDomainId }) - .onConflictDoUpdate({ canonicalDomainId: nextCanonicalDomainId }); - } else { - await context.ensDb.delete(ensIndexerSchema.registryCanonicalDomain, { registryId }); + // materialize and cascade this regitry's canonicality + await materializeAndCascade(context, registryId, nextCanonicalDomainId); +} + +async function materializeAndCascade( + context: IndexingEngineContext, + registryId: RegistryId, + domainId: DomainId | null, +) { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) { + throw new Error(`Invariant(materializeAndCascade): Registry ${registryId} does not yet exist.`); } - // iff `Registry → Domain` ↔ `Domain → Registry`, upsert the Domain's Canonical Subregistry + const domain = domainId + ? await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }) + : null; + + // iff `Registry → Domain` ↔ `Domain → Registry`, upsert the Domain's Canonical Subregistry; + // otherwise, if a stale row for this exact (Domain, Registry) pair exists, clear it. // note that this is the materialized bi-directional Domain->Registry link (edge-authenticated) - const registryPointsToDomain = domain && nextCanonicalDomainId === domain.id; + const registryPointsToDomain = registry.canonicalDomainId === domainId; const domainPointsToSubregistry = domain && domain.subregistryId === registryId; - if (registryPointsToDomain && domainPointsToSubregistry) { - await context.ensDb - .insert(ensIndexerSchema.domainCanonicalSubregistry) - .values({ domainId: nextCanonicalDomainId, canonicalSubregistryId: registryId }) - .onConflictDoUpdate({ canonicalSubregistryId: registryId }); + if (domainId) { + if (registryPointsToDomain && domainPointsToSubregistry) { + await context.ensDb + .insert(ensIndexerSchema.domainCanonicalSubregistry) + .values({ domainId, canonicalSubregistryId: registryId }) + .onConflictDoUpdate({ canonicalSubregistryId: registryId }); + } else { + const existing = await context.ensDb.find(ensIndexerSchema.domainCanonicalSubregistry, { + domainId, + }); + // unset Domain's existing Canonical Subregistry iff it was pointing at _this_ Registry + if (existing?.canonicalSubregistryId === registryId) { + await context.ensDb.delete(ensIndexerSchema.domainCanonicalSubregistry, { domainId }); + } + } } - // Cascade this Registry's canonicality - await updateRegistryCanonicality(context, registryId, nextCanonical); + // Registry is Canonical iff Domain is Canonical + const canonical = domain?.canonical ?? false; + + // cascade this Registry's canonicality + await updateRegistryCanonicality(context, registryId, canonical); } /** @@ -149,8 +164,8 @@ export async function setRegistryCanonicalDomain( * 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 maintained across `registryCanonicalDomain` ↔ - * `domainCanonicalSubregistry`). If that invariant is ever violated and a cycle is introduced, + * the bidirectional invariant materialized in `domainCanonicalSubregistry`). 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. */ @@ -194,6 +209,36 @@ async function updateRegistryCanonicality( // check if `Registry → Domain` ↔ `Domain → Registry` and upsert the Domain's Canonical Subregistry // and then cascade? that way events on the other side (ENSv2 SubregistryUpdated) reconcile canonicality as well // and for ENSv1 maybe we don't need to do anything because the creation order is guaranteed? +export async function handleSubregistryUpdated( + context: IndexingEngineContext, + domainId: DomainId, + nextSubregistryId: RegistryId | null, +) { + const domain = await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }); + if (!domain) { + throw new Error(`Invariant(handleSubregistryUpdated): Domain ${domainId} does not yet exist.`); + } + + // if the Subregistry isn't changing, no-op + const prevSubregistryId = domain.subregistryId; + if (prevSubregistryId === nextSubregistryId) return; + + // set/unset the Domain's Subregistry + // note that this is the uni-directional Domain->Registry link + if (nextSubregistryId) { + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ subregistryId: nextSubregistryId }); + } else { + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ subregistryId: null }); + } + + // materialize and cascade canonicality for the prevous and next subregistries + if (prevSubregistryId) await materializeAndCascade(context, prevSubregistryId, domainId); + if (nextSubregistryId) await materializeAndCascade(context, nextSubregistryId, domainId); +} /** * Reconciles the canonical edge for a Domain whose Resolver just changed. Detaches any prior @@ -207,7 +252,7 @@ export async function handleBridgedResolverChange( context: IndexingEngineContext, registry: AccountId, domainId: DomainId, - nextResolver: NormalizedAddress, + nextResolver: NormalizedAddress | null, ): Promise { const prev = await context.ensDb.find(ensIndexerSchema.domainResolverRelation, { chainId: registry.chainId, @@ -215,27 +260,36 @@ export async function handleBridgedResolverChange( domainId, }); - const prevBridged = prev - ? isBridgedResolver(config.namespace, { chainId: prev.chainId, address: prev.resolver }) + const prevResolver = prev?.resolver; + + const prevBridged = prevResolver + ? isBridgedResolver(config.namespace, { chainId: registry.chainId, address: prevResolver }) : null; - const nextBridge = isBridgedResolver(config.namespace, { - chainId: context.chain.id, - address: nextResolver, - }); + const nextBridged = nextResolver + ? isBridgedResolver(config.namespace, { chainId: registry.chainId, address: nextResolver }) + : null; - // we need to unset the previous bridged resolver's target registry's canonical domain if: - const needsUnset = - prevBridged && // it was previously bridged AND - (!nextBridge || // we're unsetting the resolver entirely OR - prevBridged.registryId !== nextBridge.registryId); // we're setting it and it's being changed + // the previous and the next are idential, no-op + if (prevBridged?.registryId === nextBridged?.registryId) return; - if (needsUnset) { + // if the prevous resolver was a Bridged Resolver, we need to disconnect both links + if (prevBridged) { + // update the Domain's Subregistry to null + await handleSubregistryUpdated(context, domainId, null); + + // update the Registry's Canonical Domain to null + // (which will also materialize and cascade canonicality) await setRegistryCanonicalDomain(context, prevBridged.registryId, null); } - // if the new resolver is a Bridged Resolver, set the target registry's canonical domain - if (nextBridge) { - await setRegistryCanonicalDomain(context, nextBridge.registryId, domainId); + // if the next resolver is a Bridged Resolver, we need to connect all links + if (nextBridged) { + // update the Domain's Subregistry + await handleSubregistryUpdated(context, domainId, nextBridged.registryId); + + // update the Registry's Canonical Domain + // (which will also materialize and cascade canonicality) + await setRegistryCanonicalDomain(context, nextBridged.registryId, domainId); } } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index bc3facf40d..ee19ed4d2a 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -221,7 +221,8 @@ export default function () { context: IndexingEngineContext; event: EventWithArgs<{ node: Node; resolver: NormalizedAddress }>; }) { - const { node, resolver } = event.args; + const { node } = event.args; + const resolver = interpretAddress(event.args.resolver); // ENSv2 model does not include root node, no-op if (node === ENS_ROOT_NODE) return; diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 1d16c1eb1c..2dd5acdf97 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -23,6 +23,7 @@ import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainInRegistry, handleBridgedResolverChange, + handleSubregistryUpdated, setRegistryCanonicalDomain, } from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; @@ -267,32 +268,18 @@ export default function () { sender: NormalizedAddress; }>; }) => { - const { tokenId, subregistry: _subregistry, sender } = event.args; - const subregistry = interpretAddress(_subregistry); + const { tokenId, sender } = event.args; + const subregistry = interpretAddress(event.args.subregistry); - const registryAccountId = getThisAccountId(context, event); + const registry = getThisAccountId(context, event); const storageId = makeStorageId(tokenId); - const domainId = makeENSv2DomainId(registryAccountId, storageId); + const domainId = makeENSv2DomainId(registry, storageId); - // 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) { - 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); - await ensureRegistry(context, subregistryId, { - type: "ENSv2Registry", - ...subregistryAccountId, - }); + const subregistryId = subregistry + ? makeENSv2RegistryId({ chainId: registry.chainId, address: subregistry }) + : null; - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ subregistryId }); - } + await handleSubregistryUpdated(context, domainId, subregistryId); // push event to domain history const senderId = await ensureAccount(context, sender); @@ -353,7 +340,9 @@ export default function () { context: IndexingEngineContext; event: EventWithArgs<{ tokenId: TokenId; resolver: NormalizedAddress }>; }) => { - const { tokenId, resolver } = event.args; + const { tokenId } = event.args; + const resolver = interpretAddress(event.args.resolver); + const registry = getThisAccountId(context, event); const storageId = makeStorageId(tokenId); const domainId = makeENSv2DomainId(registry, storageId); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index d5586e9dfb..9d8f3328df 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -213,6 +213,9 @@ export const registry = onchainTable( // owns it, otherwise null. node: t.hex().$type(), + // the Registry's declared Canonical Domain (uni-directional) + canonicalDomainId: t.text().$type(), + // Whether this Registry is part of the canonical namegraph. See canonicality-db-helpers.ts. canonical: t.boolean().notNull().default(false), }), @@ -252,7 +255,7 @@ export const domain = onchainTable( // belongs to a registry registryId: t.text().notNull().$type(), - // may have a subregistry + // the Domain's declared Subregistry (uni-directional) subregistryId: t.text().$type(), // If this is an ENSv2Domain, the TokenId within the ENSv2Registry, otherwise null. @@ -613,22 +616,17 @@ export const registryDomains = onchainTable("registry_domains", (t) => ({ domainIds: t.text().array().notNull().$type(), })); -// One half of the bidirectional canonical edge: a Registry's canonical parent Domain. -// Stored in a parallel table (rather than a column on `registry`) so the edge can be recorded -// before the Registry row exists (e.g. ParentUpdated firing before any LabelRegistered for the -// child Registry, or a Bridged Resolver targeting a Registry not yet observed onchain). The -// reciprocal half lives in `domainCanonicalSubregistry`; both rows are written together by -// canonicality-db-helpers.ts to maintain the bidirectional invariant. -export const registryCanonicalDomain = onchainTable("registry_canonical_domains", (t) => ({ - registryId: t.text().primaryKey().$type(), - canonicalDomainId: t.text().notNull().$type(), -})); - -// The reciprocal half of `registryCanonicalDomain`: a Domain's canonical Subregistry (i.e. the -// Registry whose canonical parent Domain points back to this Domain). May differ from -// `Domain.subregistryId` when a Bridged Resolver attaches a different Registry under this Domain. -// Parallel-table for the same reason as `registryCanonicalDomain`. -export const domainCanonicalSubregistry = onchainTable("domain_canonical_subregistries", (t) => ({ - domainId: t.text().primaryKey().$type(), - canonicalSubregistryId: t.text().notNull().$type(), -})); +// A bi-directionally edge-authenticated forward pointer from Domain -> Registry, only set if they +// both agree (`Registry → Domain` ↔ `Domain → Registry`) +export const domainCanonicalSubregistry = onchainTable( + "domain_canonical_subregistries", + (t) => ({ + domainId: t.text().primaryKey().$type(), + canonicalSubregistryId: t.text().notNull().$type(), + }), + // upward namegraph traversal joins on `canonicalSubregistryId` (the registry that points back + // up to a canonical parent Domain), so index it for the recursive CTEs in ENSApi. + (t) => ({ + byCanonicalSubregistry: index().on(t.canonicalSubregistryId), + }), +); From 11e582dced581187e9759193acd2f9da63dfc389 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 14:45:29 -0500 Subject: [PATCH 13/28] feat: fixup tests --- .../schema/domain.integration.test.ts | 67 ++++++++++--------- .../schema/query.integration.test.ts | 6 +- .../schema/registry.integration.test.ts | 10 +-- .../src/lib/ensv2/canonicality-db-helpers.ts | 12 ++-- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 3 + 5 files changed, 50 insertions(+), 48 deletions(-) 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 968775a224..18d06efadd 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -10,7 +10,7 @@ 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 { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, type PaginatedDomainResult, @@ -82,33 +82,39 @@ describe("Domain.path", () => { `; it("returns the full canonical path (leaf → root) for a deep name", async () => { - const result = await request(DomainPath, { - name: "wallet.sub1.sub2.parent.eth", + expect( + request(DomainPath, { name: "wallet.sub1.sub2.parent.eth" }), + ).resolves.toMatchObject({ + domain: { + path: [ + { name: "wallet.sub1.sub2.parent.eth" }, + { name: "sub1.sub2.parent.eth" }, + { name: "sub2.parent.eth" }, + { name: "parent.eth" }, + { name: "eth" }, + ], + }, }); - - expect(result.domain).not.toBeNull(); - const path = result.domain?.path; - expect(path).not.toBeNull(); - - const pathNames = (path ?? []).map((d) => d.name); - expect(pathNames).toEqual([ - "wallet.sub1.sub2.parent.eth", - "sub1.sub2.parent.eth", - "sub2.parent.eth", - "parent.eth", - "eth", - ]); }); - 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.linked.parent.eth", + it("returns the canonical path for a linked Name", async () => { + // The wallet Registry's `ParentUpdated` claims `sub1.sub2.parent.eth` as its canonical parent. + // `linked.parent.eth.subregistry` was later re-pointed to the same Registry without a + // corresponding `ParentUpdated`, so `wallet.linked.parent.eth` is an addressable alias whose + // canonical lineage walks through `sub1.sub2.parent.eth` + expect( + request(DomainPath, { name: "wallet.linked.parent.eth" }), + ).resolves.toMatchObject({ + domain: { + path: [ + { name: "wallet.sub1.sub2.parent.eth" }, + { name: "sub1.sub2.parent.eth" }, + { name: "sub2.parent.eth" }, + { name: "parent.eth" }, + { name: "eth" }, + ], + }, }); - expect(aliasResult.domain).toBeNull(); }); }); @@ -129,18 +135,13 @@ describe("Domain.canonical", () => { } `; - it("is true for v2-rooted domains", async () => { + it.each(DEVNET_NAMES)("is true for ENSv2 Domain '$name'", async ({ name }) => { await expect( - request(DomainCanonicalByName, { - name: "parent.eth" as InterpretedName, - }), + request(DomainCanonicalByName, { name }), ).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. + it("is true for ENSv1 addr.reverse", async () => { const v1RootRegistry = getDatasourceContract( "ens-test-env", DatasourceNames.ENSRoot, @@ -150,7 +151,7 @@ describe("Domain.canonical", () => { await expect( request(DomainCanonicalById, { id }), - ).resolves.toMatchObject({ domain: { id, canonical: false } }); + ).resolves.toMatchObject({ domain: { id, canonical: true } }); }); }); 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 046ffaacc0..6c6777e32a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -134,11 +134,11 @@ describe("Query.domains", () => { (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. + // both ENSv1 and ENSv2 root registries are canonical (canonicality is about nameability, + // not addressability/resolvability), so the v1 'eth' Domain has its own canonical name. expect(v1EthDomain).toMatchObject({ id: V1_ETH_DOMAIN_ID, - name: null, + name: "eth", label: { interpreted: "eth" }, node: ETH_NODE, }); diff --git a/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts index c89fb48e3c..26338af4ea 100644 --- a/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/registry.integration.test.ts @@ -72,15 +72,15 @@ describe("Registry.canonical", () => { } `; - it("is true for the ENSv2 root registry", async () => { - await expect(request(RegistryCanonical, { contract: V2_ROOT_REGISTRY })).resolves.toMatchObject( + it("is true for the ENSv1 root registry", async () => { + await expect(request(RegistryCanonical, { contract: V1_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 } }, + it("is true for the ENSv2 root registry", async () => { + await expect(request(RegistryCanonical, { contract: V2_ROOT_REGISTRY })).resolves.toMatchObject( + { registry: { canonical: true } }, ); }); }); diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 8f43a3d731..eaf5722670 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -120,9 +120,8 @@ async function materializeAndCascade( domainId: DomainId | null, ) { const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - if (!registry) { - throw new Error(`Invariant(materializeAndCascade): Registry ${registryId} does not yet exist.`); - } + // if the Registry doesn't exist yet, no-op + if (!registry) return; const domain = domainId ? await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }) @@ -205,10 +204,9 @@ async function updateRegistryCanonicality( } } -// shouldn't there be a handleSubregistryUpdate here that needs to -// check if `Registry → Domain` ↔ `Domain → Registry` and upsert the Domain's Canonical Subregistry -// and then cascade? that way events on the other side (ENSv2 SubregistryUpdated) reconcile canonicality as well -// and for ENSv1 maybe we don't need to do anything because the creation order is guaranteed? +/** + * Handles canonicality when a Domain updates its Subregistry. + */ export async function handleSubregistryUpdated( context: IndexingEngineContext, domainId: DomainId, diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index 2dd5acdf97..fdfdd54989 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -312,6 +312,9 @@ export default function () { const registry = getThisAccountId(context, event); const registryId = makeENSv2RegistryId(registry); + // TODO(signals): not necessary when signals + await ensureRegistry(context, registryId, { type: "ENSv2Registry", ...registry }); + if (parent) { // update the Canonical Domain, cascading the canonicality update to this registry's domains const parentRegistry: AccountId = { chainId: registry.chainId, address: parent }; From 2ad23ae5f0ba73aa640ae2c66ef0b2b9522b54a8 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 14:59:41 -0500 Subject: [PATCH 14/28] fix: regenerate gqlschema --- packages/enssdk/src/omnigraph/generated/introspection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 394022e2f1..8af494461a 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -5656,8 +5656,8 @@ const introspection = { { "name": "bridged", "type": { - "kind": "OBJECT", - "name": "AccountId" + "kind": "INTERFACE", + "name": "Registry" }, "args": [], "isDeprecated": false From 4bc1b4834272f21b8547c062dba76c6ffa8b07f2 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 15:31:02 -0500 Subject: [PATCH 15/28] fix: address bot review feedback (loop 1) - typo cleanup in canonicality-db-helpers.ts and get-domain-by-interpreted-name.ts - drop redundant optional chaining in setRegistryCanonicalDomain (registry already proven non-null) - update ensv2.schema.ts header comment to reference current canonicality tables (registryCanonicalDomain was removed) - add missing await on two Domain.path integration test assertions - fix mislabeled "ENSv1Resolver (ENSv2 Fallback)" comment to "ENSv2Resolver (ENSv2 Fallback)" - route ENSv1 parent virtual-registry assignment through handleSubregistryUpdated to reconcile any prior subregistry edge instead of overwriting blindly Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/get-domain-by-interpreted-name.ts | 6 +++--- .../omnigraph-api/schema/domain.integration.test.ts | 4 ++-- .../src/lib/ensv2/canonicality-db-helpers.ts | 12 ++++++------ .../plugins/ensv2/handlers/ensv1/ENSv1Registry.ts | 7 ++++--- .../src/ensindexer-abstract/ensv2.schema.ts | 8 ++++---- 5 files changed, 19 insertions(+), 18 deletions(-) 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 f1066d2a22..e536ead54c 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 @@ -32,7 +32,7 @@ const tracer = trace.getTracer("get-domain-by-interpreted-name"); /** * The maximum number of times to hop between disjoint namegraphs, as a defense against infinite loops. - * i.e. how many times to follow a Bridged Resolver or fall back from ENSv2 to ENSv1 or vise-versa. + * i.e. how many times to follow a Bridged Resolver or fall back from ENSv2 to ENSv1 or vice versa. */ const MAX_HOP_DEPTH = 3; @@ -95,7 +95,7 @@ export async function getDomainIdByInterpretedName( * This function prefers the leaf Domain within the origin Registry. i.e. if there's an ENSv2 Domain * like example.eth that has as its Resolver the ENSv1Resolver (which sources records from ENSv1's * example.eth's Resolver) this function preferentially returns the ENSv2 example.eth, which is more - * correctly the 'resolvable' Domain; the ENSv1 example.eth is more vestigal and not the source of + * correctly the 'resolvable' Domain; the ENSv1 example.eth is more vestigial and not the source of * truth. * * This same logic also encodes the preference that, for a Domain with a Bridged Resolver, the Domain @@ -150,7 +150,7 @@ async function forwardWalkNamegraph( return forwardWalkNamegraph(getENSv1RootRegistryId(config.namespace), path, depth + 1); } - // ENSv1Resolver (ENSv2 Fallback) + // ENSv2Resolver (ENSv2 Fallback) if (resolverEq(DatasourceNames.ENSv2Root, "ENSv2Resolver")) { // to implement the ENSv2Resolver, walk the ENSv2 disjoint namegraph with the full path return forwardWalkNamegraph(getENSv2RootRegistryId(config.namespace), path, depth + 1); 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 18d06efadd..da448e72fa 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -82,7 +82,7 @@ describe("Domain.path", () => { `; it("returns the full canonical path (leaf → root) for a deep name", async () => { - expect( + await expect( request(DomainPath, { name: "wallet.sub1.sub2.parent.eth" }), ).resolves.toMatchObject({ domain: { @@ -102,7 +102,7 @@ describe("Domain.path", () => { // `linked.parent.eth.subregistry` was later re-pointed to the same Registry without a // corresponding `ParentUpdated`, so `wallet.linked.parent.eth` is an addressable alias whose // canonical lineage walks through `sub1.sub2.parent.eth` - expect( + await expect( request(DomainPath, { name: "wallet.linked.parent.eth" }), ).resolves.toMatchObject({ domain: { diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index eaf5722670..638b95470f 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -91,8 +91,8 @@ export async function setRegistryCanonicalDomain( ); } - // identfiy this Registry's Previous Canonical Domain - const prevCanonicalDomainId = registry?.canonicalDomainId ?? null; + // identify this Registry's Previous Canonical Domain + const prevCanonicalDomainId = registry.canonicalDomainId ?? null; // if this Registry's Canonical Domain isn't changing, no-op (canonicality already consistent) if (prevCanonicalDomainId === nextCanonicalDomainId) return; @@ -110,7 +110,7 @@ export async function setRegistryCanonicalDomain( await materializeAndCascade(context, registryId, prevCanonicalDomainId); } - // materialize and cascade this regitry's canonicality + // materialize and cascade this registry's canonicality await materializeAndCascade(context, registryId, nextCanonicalDomainId); } @@ -233,7 +233,7 @@ export async function handleSubregistryUpdated( .set({ subregistryId: null }); } - // materialize and cascade canonicality for the prevous and next subregistries + // materialize and cascade canonicality for the previous and next subregistries if (prevSubregistryId) await materializeAndCascade(context, prevSubregistryId, domainId); if (nextSubregistryId) await materializeAndCascade(context, nextSubregistryId, domainId); } @@ -268,10 +268,10 @@ export async function handleBridgedResolverChange( ? isBridgedResolver(config.namespace, { chainId: registry.chainId, address: nextResolver }) : null; - // the previous and the next are idential, no-op + // the previous and the next are identical, no-op if (prevBridged?.registryId === nextBridged?.registryId) return; - // if the prevous resolver was a Bridged Resolver, we need to disconnect both links + // if the previous resolver was a Bridged Resolver, we need to disconnect both links if (prevBridged) { // update the Domain's Subregistry to null await handleSubregistryUpdated(context, domainId, null); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index ee19ed4d2a..5378d45434 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -25,6 +25,7 @@ import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainInRegistry, handleBridgedResolverChange, + handleSubregistryUpdated, setRegistryCanonicalDomain, } from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; @@ -99,9 +100,9 @@ export default function () { }); const parentDomainId = makeENSv1DomainId(registry, parentNode); - await context.ensDb - .update(ensIndexerSchema.domain, { id: parentDomainId }) - .set({ subregistryId: parentRegistryId }); + // route through handleSubregistryUpdated so any prior subregistry edge (e.g. a bridged + // attachment) is properly reconciled instead of orphaned by a blind overwrite. + await handleSubregistryUpdated(context, parentDomainId, parentRegistryId); await setRegistryCanonicalDomain(context, parentRegistryId, parentDomainId); } diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 9d8f3328df..59fe889171 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -63,10 +63,10 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * a single `ENSv2Registry` RootRegistry on the ENS Root Chain and are possibly circular directed * graphs. The full namegraph is never materialized, only _navigated_ at resolution-time, with the * exception of the canonical subgraph, which is materialized for PK-keyed query-time access: - * `Registry.canonical` ↔ `Domain.canonical` flags on the rows themselves, and the bidirectional - * canonical edge in the parallel `registryCanonicalDomain` ↔ `domainCanonicalSubregistry` tables. - * The edge tables are parallel (rather than columns on `registry`/`domain`) so canonicality can be - * recorded before the corresponding Registry or Domain row exists. + * `Registry.canonical` / `Domain.canonical` flags on the rows themselves, the bidirectional + * canonical edge in the parallel `domainCanonicalSubregistry` table, and a per-Registry child list + * in `registryDomains` used by the cascade walker. The edge table is parallel (rather than a column + * on `domain`) so canonicality can be recorded before the corresponding Domain row exists. * * 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: From daaf3ead280cbdbcbc52c887dc311c032f56f38f Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 16:00:55 -0500 Subject: [PATCH 16/28] fix: changeset --- .changeset/canonical-fields-omnigraph.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md index 770533ff2a..e34f1fc146 100644 --- a/.changeset/canonical-fields-omnigraph.md +++ b/.changeset/canonical-fields-omnigraph.md @@ -3,3 +3,5 @@ --- **Omnigraph**: expose `Domain.canonical` and `Registry.canonical` on the Omnigraph schema. Both are non-null `Boolean!` fields indicating whether the entity participates in the Canonical Nametree. + +Canonicality describes nameability, not addressability or resolvability — both the ENSv1 and ENSv2 Root Registries are canonical, so ENSv1 Domains remain canonical even after ENSv2 is deployed and continue to surface a `Domain.name` and `Domain.path`. Forward Resolution still prefers the ENSv2 namegraph when both exist. From 264af22423e717c80b0bffcada0876d6bafe972d Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 6 May 2026 17:15:51 -0500 Subject: [PATCH 17/28] fix: use maybeGetENSv2RootRegistryId for v1-only namespaces ensureRegistry threw on the first event when the namespace has no ENSv2Root datasource (e.g. combo sepolia), because the strict getENSv2RootRegistryId throws on missing datasources. Switch to the maybe variant so the comparison just yields false for v1-only namespaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts index e51f945f06..8109905d61 100644 --- a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts @@ -2,7 +2,7 @@ import config from "@/config"; import type { RegistryId } from "enssdk"; -import { getENSv1RootRegistryId, getENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; @@ -26,7 +26,7 @@ export async function ensureRegistry( canonical: // by default, only the ENSv1 and ENSv2 Root Registries are Canonical id === getENSv1RootRegistryId(config.namespace) || - id === getENSv2RootRegistryId(config.namespace), + id === maybeGetENSv2RootRegistryId(config.namespace), }) .onConflictDoNothing(); } From cc60281f82353632569acafdcfd48daa53e44c89 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 7 May 2026 17:05:19 -0500 Subject: [PATCH 18/28] checkpoint: custom sql --- .../find-domains/layers/base-domain-set.ts | 33 +- .../lib/find-domains/layers/filter-by-name.ts | 30 +- .../omnigraph-api/lib/get-canonical-path.ts | 17 +- .../lib/get-domain-by-interpreted-name.ts | 5 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 362 ++++++++++-------- .../src/lib/ensv2/registry-db-helpers.ts | 15 +- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 4 +- .../ensv2/handlers/ensv2/ENSv2Registry.ts | 6 +- .../src/ensindexer-abstract/ensv2.schema.ts | 46 +-- .../ensnode-sdk/src/shared/root-registry.ts | 11 +- 10 files changed, 278 insertions(+), 251 deletions(-) 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 e6b5874a0a..d06c3c3c51 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,4 +1,5 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { DomainId, NormalizedAddress, RegistryId } from "enssdk"; import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton"; @@ -12,23 +13,28 @@ export type BaseDomainSet = ReturnType; * Universal base domain set: all ENSv1 and ENSv2 Domains with consistent metadata. * * Returns `{ domainId, ownerId, registryId, parentId, canonical, labelHash, sortableLabel }`. - * - parentId is the canonical parent Domain via the edge-authenticated - * `domainCanonicalSubregistry` table (`canonicalSubregistryId = domain.registryId`) + * - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of + * this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by + * `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to + * the same Registry. This is the bidirectional canonical-edge agreement, computed on demand + * in lieu of a materialized parallel table. * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering * - all other values are directly sourced from Domain * * All downstream filters (owner, parent, registry, name, canonical) operate on this shape. */ export function domainsBase() { + // alias for parent Registry / parent Domain joins so we can reference them distinctly from + // the base Domain's own `registryId` column. + const parentRegistry = alias(ensIndexerSchema.registry, "parentRegistry"); + const parentDomain = alias(ensIndexerSchema.domain, "parentDomain"); return ( ensDb .select({ domainId: sql`${ensIndexerSchema.domain.id}`.as("domainId"), ownerId: sql`${ensIndexerSchema.domain.ownerId}`.as("ownerId"), registryId: sql`${ensIndexerSchema.domain.registryId}`.as("registryId"), - parentId: sql`${ensIndexerSchema.domainCanonicalSubregistry.domainId}`.as( - "parentId", - ), + parentId: sql`${parentDomain.id}`.as("parentId"), canonical: sql`${ensIndexerSchema.domain.canonical}`.as("canonical"), labelHash: sql`${ensIndexerSchema.domain.labelHash}`.as("labelHash"), sortableLabel: sql`${ensIndexerSchema.label.interpreted}`.as( @@ -36,14 +42,15 @@ export function domainsBase() { ), }) .from(ensIndexerSchema.domain) - // canonical parent via the edge-authenticated bidirectional edge: the row in - // `domainCanonicalSubregistry` whose `canonicalSubregistryId` is this Domain's Registry - // names the parent Domain in `domainId`. + // walk up to the parent Registry by this Domain's `registryId`, then to the parent Domain + // it points at, requiring `parentDomain.subregistryId` to agree back. The two joins + + // agreement predicate are the bidirectional canonical-edge check. + .leftJoin(parentRegistry, eq(parentRegistry.id, ensIndexerSchema.domain.registryId)) .leftJoin( - ensIndexerSchema.domainCanonicalSubregistry, - eq( - ensIndexerSchema.domainCanonicalSubregistry.canonicalSubregistryId, - ensIndexerSchema.domain.registryId, + parentDomain, + and( + eq(parentDomain.id, parentRegistry.canonicalDomainId), + eq(parentDomain.subregistryId, parentRegistry.id), ), ) // join label for labelHash/sortableLabel 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 8cedb77a3f..18276d0306 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 @@ -47,11 +47,12 @@ 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 the edge-authenticated - // `domainCanonicalSubregistry` table (which only contains rows where both - // `Registry.canonicalDomainId` and `Domain.subregistryId` agree). + // Recursive CTE starting from the deepest child and traversing UP via the bidirectional + // canonical-edge agreement (`registries.canonical_domain_id = domains.id` AND + // `domains.subregistry_id = registries.id`), computed on demand at each hop in lieu of a + // materialized parallel table. // 1. Start with domains matching the leaf labelHash (deepest child) - // 2. Recursively join parents via the materialized canonical edge, verifying each ancestor's labelHash + // 2. Recursively join parents via the agreement check, verifying each ancestor's labelHash // 3. Return both the leaf (for result/ownership) and head (for partial match) // // NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete @@ -66,22 +67,24 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { sql`( WITH RECURSIVE upward_check AS ( -- Base case: find the deepest children (leaves of the concrete path) and walk one step - -- up via domain_canonical_subregistries (canonical_subregistry_id → registry_id). + -- up via the agreement check (parent_registry.canonical_domain_id = parent.id AND + -- parent.subregistry_id = parent_registry.id). SELECT d.id AS leaf_id, parent.id AS current_id, 1 AS depth FROM ${ensIndexerSchema.domain} d - JOIN ${ensIndexerSchema.domainCanonicalSubregistry} dcs - ON dcs.canonical_subregistry_id = d.registry_id + JOIN ${ensIndexerSchema.registry} parent_registry + ON parent_registry.id = d.registry_id JOIN ${ensIndexerSchema.domain} parent - ON parent.id = dcs.domain_id + ON parent.id = parent_registry.canonical_domain_id + AND parent.subregistry_id = parent_registry.id WHERE d.label_hash = (${rawLabelHashPathArray})[${pathLength}] UNION ALL - -- Recursive step: traverse UP via domain_canonical_subregistries, verifying each - -- ancestor's labelHash. + -- Recursive step: traverse UP via the agreement check, verifying each ancestor's + -- labelHash. SELECT upward_check.leaf_id, np.id AS current_id, @@ -89,10 +92,11 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { FROM upward_check JOIN ${ensIndexerSchema.domain} pd ON pd.id = upward_check.current_id - JOIN ${ensIndexerSchema.domainCanonicalSubregistry} pdcs - ON pdcs.canonical_subregistry_id = pd.registry_id + JOIN ${ensIndexerSchema.registry} pdr + ON pdr.id = pd.registry_id JOIN ${ensIndexerSchema.domain} np - ON np.id = pdcs.domain_id + ON np.id = pdr.canonical_domain_id + AND np.subregistry_id = pdr.id WHERE upward_check.depth < ${pathLength} AND pd.label_hash = (${rawLabelHashPathArray})[${pathLength} - upward_check.depth] ) 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 42abe1a555..a9abc8917c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -34,9 +34,9 @@ export async function getCanonicalPath(domainId: DomainId): Promise MAX_SUPPORTED_NAME_DEPTH) { throw new Error( `Invariant(getCanonicalPath): DomainId '${domainId}' produced a canonical path deeper than ${MAX_SUPPORTED_NAME_DEPTH}.`, 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 e536ead54c..66f5bed156 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 @@ -183,9 +183,8 @@ async function forwardWalkDisjointNamegraph(registryId: RegistryId, path: LabelH UNION ALL SELECT - -- NOTE: that here we recurse by domain.subregistry_id (the on-chain forward pointer) - -- NOT domain_canonical_subregistries.canonical_subregistry_id. This walk addresses - -- non-canonical Domains too, so it must follow the raw subregistry edge. + -- NOTE: this walk specifically addresses non-canonical Domains as well, so it follows the + -- raw on-chain forward pointer domain.subregistry_id directly, without canonical edge authentication d.subregistry_id AS next_registry_id, d.id AS "domainId", path.depth + 1 diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 638b95470f..36c5e5a4bc 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -1,7 +1,9 @@ import config from "@/config"; +import { sql } from "drizzle-orm"; import type { AccountId, DomainId, NormalizedAddress, RegistryId } from "enssdk"; +import { isRootRegistryId } from "@ensnode/ensnode-sdk"; import { isBridgedResolver } from "@ensnode/ensnode-sdk/internal"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; @@ -9,37 +11,59 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng /** * Canonicality db helpers. * - * The two unidirectional pointers `Registry.canonicalDomainId` (Registry → Domain) and - * `Domain.subregistryId` (Domain → Registry) are written blindly when their respective onchain - * events fire. The bidirectional canonical edge is materialized — only when both pointers agree — - * into the parallel `domainCanonicalSubregistry` table (`domainId` PK → `canonicalSubregistryId`). - * That table is the single source of truth for "this Registry-Domain edge participates in the - * canonical namegraph", and the cascade walks it to flip `Registry.canonical` / `Domain.canonical`. + * v1 and v2 share a single canonicality definition: a Registry is canonical iff it can be traced + * back to a Root Registry via canonical edges. A canonical edge between Registry R and parent + * Domain D requires both unidirectional pointers to agree + * (`R.canonicalDomainId = D.id` ↔ `D.subregistryId = R.id`) AND D itself to be canonical. Root + * Registries (ENSv1 root, ENSv2 root) are canonical by definition and seeded as such at + * `ensureRegistry` time. * - * The parallel table is keyed by domain so the upward-walk SQL in ENSApi can JOIN on the - * canonical subregistry from the registry side; an index on `canonicalSubregistryId` covers that - * non-PK direction. + * Concretely, this means a v1 Domain whose Bridged Resolver claims its sub-registry leaves its + * v1 children non-canonical. Example: mainnet `linea.eth` has a Bridged Resolver pointing at the + * Linea Registry. `bridge.linea.eth` exists on both mainnet (as a v1 child of mainnet's + * `linea.eth` virtual registry) and Linea (as a child of the bridged Linea Registry); but + * mainnet `linea.eth.subregistryId` points at the Linea Registry, not the mainnet v1 virtual + * registry, so agreement fails for the v1 virtual registry — it stays non-canonical, and so do + * its children. Only the Linea-side `bridge.linea.eth` (the resolution-visible one) is canonical. * - * The `Registry.canonical` / `Domain.canonical` boolean flags remain on those rows and are - * updated by the cascade for entities that exist; entities created later inherit canonicality - * via `ensureDomainInRegistry`'s read of the parent Registry's flag. + * The unidirectional pointers `Registry.canonicalDomainId` and `Domain.subregistryId` are written + * blindly when their onchain events fire. Edge authentication is done on-demand at query time. + * The boolean flags `Registry.canonical` and `Domain.canonical` reflect membership in the canonical + * namegraph for PK-keyed query-time access, and are kept up-to-date by reconciling during updates + * to the uni-directional pointers. * - * A per-Registry list of child Domains in `registryDomains` lets canonicality flips walk by - * primary keys, abiding by Ponder's in-memory cache requirements and avoiding a flush to - * Postgres. + * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade + * the change through the canonical subgraph beneath it. Two paths: + * - If the Registry has no descendants (`Registry.__hasChildren = false`, the dominant case + * for fresh ENSv1 virtual registries on first wire-up), the cascade is a single-row flag + * flip done via an in-memory PK update. No raw SQL, no flush. + * - Otherwise, a single recursive-CTE batch UPDATE walks the canonical subgraph via the + * unidirectional pointers + inline agreement check, batch-updating every visited Registry + * and its child Domains. This goes through `context.ensDb.sql`, which forces a Ponder cache + * flush + invalidate. We accept that cost because it's bounded to Registries that have + * children AND whose canonicality actually flips — i.e. bridged-resolver attach/detach and + * ENSv2 reparenting on already-populated subtrees. * - * 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 either: - * a) a doubly-linked-list (one row per edge), or - * b) eating the flush cost and using custom sql to CTE-walk-and-update the relevant domains - * becomes the better trade. Revisit when necessary. + * `__hasChildren` is a synthetic monotonic sentinel on `Registry` (false → true on the first + * child Domain registered under it; never reset). See `ensureDomainInRegistry` for where it is + * flipped. */ /** - * Idempotently link `domainId` into `registryId`'s child list and inherit `canonical` from the - * Registry. + * Idempotently inherit `canonical` on `domainId` from its parent `registryId`, and mark the + * parent Registry as having children (`__hasChildren = true`). + * + * The parent Registry's `canonical` flag has already been reconciled via the bidirectional + * pointer dance before any child Domain is registered under it (the parent Registry's + * `handleRegistryCanonicalDomainUpdated` / `handleSubregistryUpdated` calls run earlier in the + * same handler). So the new Domain trivially inherits the parent's current flag — no cascade + * required, since a brand-new Domain has no children of its own. + * + * The `__hasChildren` write flips the parent Registry's sentinel from false to true on the + * first child Domain ever registered under it. The sentinel is monotonic (Domain rows aren't + * deleted and `domain.registryId` is set at creation and never mutated), so once flipped it + * stays true forever. `cascadeCanonicality` reads it to skip the SQL flush when there are + * provably no descendants to update. */ export async function ensureDomainInRegistry( context: IndexingEngineContext, @@ -53,33 +77,29 @@ export async function ensureDomainInRegistry( ); } - const existing = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); - // if Domain is already a child of Registry, no-op - if (existing?.domainIds.includes(domainId)) return; - - // append the new domainId - const domainIds = existing ? [...existing.domainIds, domainId] : [domainId]; - await context.ensDb - .insert(ensIndexerSchema.registryDomains) - .values({ registryId, domainIds }) - .onConflictDoUpdate({ domainIds }); - - // this Domain is Canonical if it is in a Canonical Registry + // inherit the parent Registry's current canonical flag await context.ensDb .update(ensIndexerSchema.domain, { id: domainId }) .set({ canonical: registry.canonical }); + + // flip the parent Registry's __hasChildren sentinel on the first child (idempotent thereafter) + if (!registry.__hasChildren) { + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ __hasChildren: true }); + } } /** * Set `registryId`'s canonical parent Domain (or unset if null) by writing the unidirectional - * `Registry.canonicalDomainId` pointer, then reconciling the bidirectional materialization in - * `domainCanonicalSubregistry` (and cascading canonicality) for both the previous and next edges. + * `Registry.canonicalDomainId` pointer, then reconciling canonicality for both the previous + * edge (if any) and the next edge. * * The new canonical Domain need not exist yet — `Registry.canonicalDomainId` is set blindly. The - * `domainCanonicalSubregistry` row only materializes once `Domain.subregistryId` agrees, which - * may happen later via `handleSubregistryUpdated`. + * canonical edge becomes "real" only when `Domain.subregistryId` agrees, which may happen later + * via `handleSubregistryUpdated`. */ -export async function setRegistryCanonicalDomain( +export async function handleRegistryCanonicalDomainUpdated( context: IndexingEngineContext, registryId: RegistryId, nextCanonicalDomainId: DomainId | null, @@ -91,117 +111,19 @@ export async function setRegistryCanonicalDomain( ); } - // identify this Registry's Previous Canonical Domain const prevCanonicalDomainId = registry.canonicalDomainId ?? null; - // if this Registry's Canonical Domain isn't changing, no-op (canonicality already consistent) + // if this Registry's Canonical Domain isn't changing, no-op if (prevCanonicalDomainId === nextCanonicalDomainId) return; - // from here, we know that the Registry's Canonical parent Domain is changed... - - // set/unset this Registry's Canonical Domain - // note that this is the uni-directional Registry->Domain link + // set/unset the Registry's Canonical Domain (uni-directional Registry → Domain link) await context.ensDb .update(ensIndexerSchema.registry, { id: registryId }) .set({ canonicalDomainId: nextCanonicalDomainId }); - // if previous Canonical Domain, ensure that its canonicality is materialized - if (prevCanonicalDomainId) { - await materializeAndCascade(context, registryId, prevCanonicalDomainId); - } - - // materialize and cascade this registry's canonicality - await materializeAndCascade(context, registryId, nextCanonicalDomainId); -} - -async function materializeAndCascade( - context: IndexingEngineContext, - registryId: RegistryId, - domainId: DomainId | null, -) { - const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - // if the Registry doesn't exist yet, no-op - if (!registry) return; - - const domain = domainId - ? await context.ensDb.find(ensIndexerSchema.domain, { id: domainId }) - : null; - - // iff `Registry → Domain` ↔ `Domain → Registry`, upsert the Domain's Canonical Subregistry; - // otherwise, if a stale row for this exact (Domain, Registry) pair exists, clear it. - // note that this is the materialized bi-directional Domain->Registry link (edge-authenticated) - const registryPointsToDomain = registry.canonicalDomainId === domainId; - const domainPointsToSubregistry = domain && domain.subregistryId === registryId; - if (domainId) { - if (registryPointsToDomain && domainPointsToSubregistry) { - await context.ensDb - .insert(ensIndexerSchema.domainCanonicalSubregistry) - .values({ domainId, canonicalSubregistryId: registryId }) - .onConflictDoUpdate({ canonicalSubregistryId: registryId }); - } else { - const existing = await context.ensDb.find(ensIndexerSchema.domainCanonicalSubregistry, { - domainId, - }); - // unset Domain's existing Canonical Subregistry iff it was pointing at _this_ Registry - if (existing?.canonicalSubregistryId === registryId) { - await context.ensDb.delete(ensIndexerSchema.domainCanonicalSubregistry, { domainId }); - } - } - } - - // Registry is Canonical iff Domain is Canonical - const canonical = domain?.canonical ?? false; - - // cascade this Registry's canonicality - await updateRegistryCanonicality(context, registryId, canonical); -} - -/** - * Recursively flip `canonical` on `registryId` and every Domain in its child list (and their - * canonical subtrees). - * - * The recursion is unbounded by design. ENS names have no formal depth limit, so a fixed cap - * would abort indexing on legitimately deep namegraphs. Termination relies on the canonical - * namegraph being a tree (each Registry has at most one canonical parent Domain, enforced by - * the bidirectional invariant materialized in `domainCanonicalSubregistry`). 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. - */ -async function updateRegistryCanonicality( - context: IndexingEngineContext, - registryId: RegistryId, - canonical: boolean, -): Promise { - const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); - - // if the Registry does not exist, no-op - if (!registry) return; - - // if the Registry's canonicality is already consistent, no-op - const prevCanonical = registry.canonical; - if (prevCanonical === canonical) return; - - // otherwise, update its canonicality - await context.ensDb.update(ensIndexerSchema.registry, { id: registryId }).set({ canonical }); - - // and cascade through its children - const children = await context.ensDb.find(ensIndexerSchema.registryDomains, { registryId }); - if (!children) return; - - for (const domainId of children.domainIds) { - // Invariant: Domain is guaranteed to exist if its id is in ensIndexerSchema.registryDomains - await context.ensDb.update(ensIndexerSchema.domain, { id: domainId }).set({ canonical }); - - // retrieve the child Domain's (bi-directional) Canonical Subregistry - const childEdge = await context.ensDb.find(ensIndexerSchema.domainCanonicalSubregistry, { - domainId, - }); - if (childEdge) { - // if exists, cascade canonicality - await updateRegistryCanonicality(context, childEdge.canonicalSubregistryId, canonical); - } - } + // the registry's pointer changed, so its canonical-edge agreement may have changed too — + // reconcile the registry's flag (which cascades through its descendants if it flips) + await reconcileRegistryCanonicality(context, registryId); } /** @@ -217,32 +139,28 @@ export async function handleSubregistryUpdated( throw new Error(`Invariant(handleSubregistryUpdated): Domain ${domainId} does not yet exist.`); } - // if the Subregistry isn't changing, no-op const prevSubregistryId = domain.subregistryId; + + // if the Subregistry isn't changing, no-op if (prevSubregistryId === nextSubregistryId) return; - // set/unset the Domain's Subregistry - // note that this is the uni-directional Domain->Registry link - if (nextSubregistryId) { - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ subregistryId: nextSubregistryId }); - } else { - await context.ensDb - .update(ensIndexerSchema.domain, { id: domainId }) - .set({ subregistryId: null }); - } + // set/unset the Domain's Subregistry (uni-directional Domain → Registry link) + await context.ensDb + .update(ensIndexerSchema.domain, { id: domainId }) + .set({ subregistryId: nextSubregistryId }); - // materialize and cascade canonicality for the previous and next subregistries - if (prevSubregistryId) await materializeAndCascade(context, prevSubregistryId, domainId); - if (nextSubregistryId) await materializeAndCascade(context, nextSubregistryId, domainId); + // both the previous and the next subregistry may have had their canonical-edge agreement + // change (the previous lost an agreeing back-pointer; the next may have gained one), so + // reconcile each + if (prevSubregistryId) await reconcileRegistryCanonicality(context, prevSubregistryId); + if (nextSubregistryId) await reconcileRegistryCanonicality(context, nextSubregistryId); } /** * Reconciles the canonical edge for a Domain whose Resolver just changed. Detaches any prior * bridged target and attaches the new one (when the new resolver is a known Bridged Resolver). * - * Reads the PREVIOUS resolver from the Domain-Resolver Relation. This requires that this helper + * 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. */ @@ -269,25 +187,131 @@ export async function handleBridgedResolverChange( : null; // the previous and the next are identical, no-op + // NOTE: this also covers the "neither are bridged resolvers" case (null === null) if (prevBridged?.registryId === nextBridged?.registryId) return; // if the previous resolver was a Bridged Resolver, we need to disconnect both links if (prevBridged) { - // update the Domain's Subregistry to null await handleSubregistryUpdated(context, domainId, null); - - // update the Registry's Canonical Domain to null - // (which will also materialize and cascade canonicality) - await setRegistryCanonicalDomain(context, prevBridged.registryId, null); + await handleRegistryCanonicalDomainUpdated(context, prevBridged.registryId, null); } // if the next resolver is a Bridged Resolver, we need to connect all links if (nextBridged) { - // update the Domain's Subregistry await handleSubregistryUpdated(context, domainId, nextBridged.registryId); + await handleRegistryCanonicalDomainUpdated(context, nextBridged.registryId, domainId); + } +} - // update the Registry's Canonical Domain - // (which will also materialize and cascade canonicality) - await setRegistryCanonicalDomain(context, nextBridged.registryId, domainId); +/** + * Recompute `Registry.canonical` from its current canonical-edge agreement and, if the flag + * flips, cascade the new value through the canonical subgraph beneath this Registry via a + * single recursive-CTE batch UPDATE. + * + * Canonicality rule: + * - Root Registries (ENSv1 root, ENSv2 root) are canonical by axiom. + * - For any non-root Registry R, R.canonical iff + * ∃ Domain P: + * P.id = R.canonicalDomainId // R points up to P + * AND P.subregistryId = R.id // P points down to R (bidirectional agreement) + * AND P.canonical // P itself is in the canonical namegraph + * + * Termination of the cascade walk relies on the canonical subgraph being a tree: each Registry + * has at most one canonical parent Domain (enforced by the bidirectional agreement check), so + * the recursive CTE cannot revisit a node. If that invariant is ever violated and a cycle is + * introduced, the CTE's `UNION` (not `UNION ALL`) prunes duplicates and termination is preserved. + */ +async function reconcileRegistryCanonicality( + context: IndexingEngineContext, + registryId: RegistryId, +): Promise { + const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); + if (!registry) return; + + // determine the new canonicality from the current pointer state + let nextCanonical: boolean; + if (isRootRegistryId(config.namespace, registryId)) { + // root registries are canonical by axiom (no parent edge to derive canonicality from); guard + // against any pointer-write event spuriously flipping the flag off + nextCanonical = true; + } else if (!registry.canonicalDomainId) { + nextCanonical = false; + } else { + const parentDomain = await context.ensDb.find(ensIndexerSchema.domain, { + id: registry.canonicalDomainId, + }); + nextCanonical = + parentDomain != null && parentDomain.subregistryId === registryId && parentDomain.canonical; } + + // if the canonicality flag isn't changing, no-op (no cascade, no flush) + if (registry.canonical === nextCanonical) return; + + // the flag is flipping — cascade through the canonical subgraph (or short-circuit to an + // in-memory single-row update if the start registry has no descendants). + await cascadeCanonicality(context, registryId, nextCanonical, registry.__hasChildren); +} + +/** + * Walk the canonical subgraph rooted at `startRegistryId` and set `canonical = newValue` on + * every Registry and Domain it visits. + * + * Optimistic short-circuit: if the start Registry's `__hasChildren` sentinel is false, we know + * structurally that there are no descendant rows for the cascade to update (no Domain has + * `registry_id = startRegistryId`, hence no descendant Registry can have an agreeing canonical + * edge into it either). In that case we flip just the start Registry's flag via an in-memory + * PK update — no raw SQL, no Ponder cache flush. This is the dominant case for fresh ENSv1 + * virtual registries on first wire-up. + * + * Otherwise we issue a single SQL statement: one recursive CTE that enumerates the canonical + * subgraph by following unidirectional pointers + agreement check, then two data-modifying CTEs + * that batch-update Registries and their child Domains. The walk visits exactly the rows whose + * canonicality flag must flip (the `IS DISTINCT FROM` filter skips rows already at the target + * value, which matters for the start registry — its flag is set in the same statement — and + * for any descendants that happen to already be consistent). + */ +async function cascadeCanonicality( + context: IndexingEngineContext, + startRegistryId: RegistryId, + newValue: boolean, + startHasChildren: boolean, +): Promise { + if (!startHasChildren) { + // no descendants → cascade is a single-row flag flip; do it in-memory + await context.ensDb + .update(ensIndexerSchema.registry, { id: startRegistryId }) + .set({ canonical: newValue }); + return; + } + + await context.ensDb.sql.execute(sql` + WITH RECURSIVE walk(registry_id) AS ( + SELECT ${startRegistryId}::text + + UNION + + -- step downward: from a registry on the canonical subgraph, find each child Domain + -- (rows whose registry_id equals the current registry id), then follow that Domain's + -- subregistry_id if and only if the child registry agrees back via + -- canonical_domain_id = domain.id. + SELECT child_reg.id + FROM walk w + JOIN ${ensIndexerSchema.domain} d + ON d.registry_id = w.registry_id + JOIN ${ensIndexerSchema.registry} child_reg + ON child_reg.id = d.subregistry_id + WHERE child_reg.canonical_domain_id = d.id + ), + upd_reg AS ( + UPDATE ${ensIndexerSchema.registry} + SET canonical = ${newValue} + WHERE id IN (SELECT registry_id FROM walk) + AND canonical IS DISTINCT FROM ${newValue} + RETURNING id + ) + UPDATE ${ensIndexerSchema.domain} + SET canonical = ${newValue} + WHERE registry_id IN (SELECT registry_id FROM walk) + AND canonical IS DISTINCT FROM ${newValue}; + `); } diff --git a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts index 8109905d61..9ed8f76ca7 100644 --- a/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/registry-db-helpers.ts @@ -2,13 +2,17 @@ import config from "@/config"; import type { RegistryId } from "enssdk"; -import { getENSv1RootRegistryId, maybeGetENSv2RootRegistryId } from "@ensnode/ensnode-sdk"; +import { isRootRegistryId } from "@ensnode/ensnode-sdk"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; /** - * Idempotently insert a Registry row, seeding `canonical = true` if it is the namespace's - * ENSv1 or ENSv2 Root Registry. + * Idempotently insert a Registry row, seeding `canonical = true` only for the namespace's Root + * Registries (ENSv1 root, and ENSv2 root when defined). All other Registries — including ENSv1 + * concrete and virtual registries — default to `canonical = false` and earn canonicality through + * the natural reconcile + cascade flow in `canonicality-db-helpers.ts` once the bidirectional + * canonical edge agrees back to a canonical parent Domain. v1 and v2 share the same canonicality + * definition: a Registry is canonical iff it can be traced back to a Root via canonical edges. */ export async function ensureRegistry( context: IndexingEngineContext, @@ -23,10 +27,7 @@ export async function ensureRegistry( .values({ id, ...args, - canonical: - // by default, only the ENSv1 and ENSv2 Root Registries are Canonical - id === getENSv1RootRegistryId(config.namespace) || - id === maybeGetENSv2RootRegistryId(config.namespace), + canonical: isRootRegistryId(config.namespace, id), }) .onConflictDoNothing(); } diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index 5378d45434..242da6cf27 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -25,8 +25,8 @@ import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainInRegistry, handleBridgedResolverChange, + handleRegistryCanonicalDomainUpdated, handleSubregistryUpdated, - 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"; @@ -104,7 +104,7 @@ export default function () { // attachment) is properly reconciled instead of orphaned by a blind overwrite. await handleSubregistryUpdated(context, parentDomainId, parentRegistryId); - await setRegistryCanonicalDomain(context, parentRegistryId, parentDomainId); + await handleRegistryCanonicalDomainUpdated(context, parentRegistryId, parentDomainId); } const ownerId = interpretAddress(owner); diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index fdfdd54989..d87210fabf 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -23,8 +23,8 @@ import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; import { ensureDomainInRegistry, handleBridgedResolverChange, + handleRegistryCanonicalDomainUpdated, handleSubregistryUpdated, - setRegistryCanonicalDomain, } from "@/lib/ensv2/canonicality-db-helpers"; import { ensureDomainEvent, ensureEvent } from "@/lib/ensv2/event-db-helpers"; import { ensureLabel } from "@/lib/ensv2/label-db-helpers"; @@ -321,10 +321,10 @@ export default function () { const labelHash = labelhashLiteralLabel(label); const domainId = makeENSv2DomainId(parentRegistry, makeStorageId(labelHash)); - await setRegistryCanonicalDomain(context, registryId, domainId); + await handleRegistryCanonicalDomainUpdated(context, registryId, domainId); } else { // unset the Canonical Domain, cascading the canonicality update to this registry's domains - await setRegistryCanonicalDomain(context, registryId, null); + await handleRegistryCanonicalDomainUpdated(context, registryId, null); } // TODO: push event to registry history diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 59fe889171..9dcb09320d 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -62,11 +62,12 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * 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 full namegraph is never materialized, only _navigated_ at resolution-time, with the - * exception of the canonical subgraph, which is materialized for PK-keyed query-time access: - * `Registry.canonical` / `Domain.canonical` flags on the rows themselves, the bidirectional - * canonical edge in the parallel `domainCanonicalSubregistry` table, and a per-Registry child list - * in `registryDomains` used by the cascade walker. The edge table is parallel (rather than a column - * on `domain`) so canonicality can be recorded before the corresponding Domain row exists. + * exception of the canonical subgraph, which is reflected via `Registry.canonical` / + * `Domain.canonical` boolean flags on the rows themselves. The bidirectional canonical edge is + * NOT materialized in a parallel table; it is derived on demand by checking that the two + * unidirectional pointers agree (`Registry.canonicalDomainId = Domain.id` + * ↔ `Domain.subregistryId = Registry.id`). Cascading canonicality flips through the subgraph + * run as a single recursive-CTE batch UPDATE (see `canonicality-db-helpers.ts`). * * 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: @@ -218,6 +219,14 @@ export const registry = onchainTable( // Whether this Registry is part of the canonical namegraph. See canonicality-db-helpers.ts. canonical: t.boolean().notNull().default(false), + + // Synthetic monotonic sentinel: flipped to true the first time a child Domain is registered + // under this Registry (see `ensureDomainInRegistry`). Read by `cascadeCanonicality` to skip + // the raw-SQL recursive-CTE walk (and its associated Ponder cache flush) when the start + // registry provably has no descendants — the dominant case for fresh ENSv1 virtual + // registries on first wire-up. Double-underscore prefix marks it as an internal-only + // bookkeeping field, not part of the on-chain protocol surface. + __hasChildren: t.boolean().notNull().default(false), }), (t) => ({ // NOTE: non-unique index because multiple rows can share (chainId, address) across virtual registries @@ -603,30 +612,3 @@ export const label = onchainTable( export const label_relations = relations(label, ({ many }) => ({ domains: many(domain), })); - -/////////////////// -// Canonical Names -/////////////////// - -// 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(), - domainIds: t.text().array().notNull().$type(), -})); - -// A bi-directionally edge-authenticated forward pointer from Domain -> Registry, only set if they -// both agree (`Registry → Domain` ↔ `Domain → Registry`) -export const domainCanonicalSubregistry = onchainTable( - "domain_canonical_subregistries", - (t) => ({ - domainId: t.text().primaryKey().$type(), - canonicalSubregistryId: t.text().notNull().$type(), - }), - // upward namegraph traversal joins on `canonicalSubregistryId` (the registry that points back - // up to a canonical parent Domain), so index it for the recursive CTEs in ENSApi. - (t) => ({ - byCanonicalSubregistry: index().on(t.canonicalSubregistryId), - }), -); diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index 7dd53cc51c..d5cc34ac78 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -1,4 +1,4 @@ -import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId } from "enssdk"; +import { type AccountId, makeENSv1RegistryId, makeENSv2RegistryId, type RegistryId } from "enssdk"; import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources"; @@ -86,3 +86,12 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { */ export const getRootRegistryId = (namespace: ENSNamespaceId) => maybeGetENSv2RootRegistryId(namespace) ?? getENSv1RootRegistryId(namespace); + +/** + * Determines whether `registryId` is a Root Registry (ENSv1 Root or, when defined, ENSv2 Root) + * for the selected `namespace`. Root Registries are canonical by axiom: they have no parent + * edge to derive canonicality from. + */ +export const isRootRegistryId = (namespace: ENSNamespaceId, registryId: RegistryId): boolean => + registryId === getENSv1RootRegistryId(namespace) || + registryId === maybeGetENSv2RootRegistryId(namespace); From bcf0ef8d3c60104449c989242335a40b899ca1e0 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 10:18:28 -0500 Subject: [PATCH 19/28] docs: address bot review feedback on canonicality docstrings - ensv2.schema header now describes both cascade paths (in-memory PK update when __hasChildren = false; recursive-CTE batch UPDATE otherwise) - getRootRegistryId docstring: clarify "preferred" (not "canonical"); both ENSv1 and ENSv2 Roots are canonical, use isRootRegistryId for membership Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts | 4 +++- packages/ensnode-sdk/src/shared/root-registry.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 9dcb09320d..efc2ae585d 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -67,7 +67,9 @@ import type { EncodedReferrer } from "@ensnode/ensnode-sdk"; * NOT materialized in a parallel table; it is derived on demand by checking that the two * unidirectional pointers agree (`Registry.canonicalDomainId = Domain.id` * ↔ `Domain.subregistryId = Registry.id`). Cascading canonicality flips through the subgraph - * run as a single recursive-CTE batch UPDATE (see `canonicality-db-helpers.ts`). + * run as either an in-memory PK update (when `Registry.__hasChildren = false`, the dominant case + * for fresh ENSv1 virtual registries on first wire-up) or a single recursive-CTE batch UPDATE + * otherwise (see `canonicality-db-helpers.ts`). * * Note also that the Protocol Acceleration plugin is a hard requirement for the ENSv2 plugin. This * allows us to rely on the shared logic for indexing: diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index d5cc34ac78..2a79fffbd6 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -81,8 +81,10 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { ////////////// /** - * Gets the RegistryId representing the canonical Root Registry for the selected `namespace`: the - * ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. + * Gets the RegistryId representing the preferred Root Registry for the selected `namespace` — + * the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. Used as the entry + * point for resolution-time namegraph traversal. Note that both ENSv1 and ENSv2 Roots are + * canonical by axiom in their respective namegraphs; use `isRootRegistryId` to test membership. */ export const getRootRegistryId = (namespace: ENSNamespaceId) => maybeGetENSv2RootRegistryId(namespace) ?? getENSv1RootRegistryId(namespace); From 56b17651d552316792f3d1082724ed4160291c98 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 10:39:18 -0500 Subject: [PATCH 20/28] docs: address remaining bot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ENSv2 ParentUpdated handler: note `parent` is interpreted same-chain; cross-chain parentage goes through Bridged Resolvers, not ParentUpdated - handleBridgedResolverChange: document the two implied invariants — 1:1 originating-Domain-per-bridged-Registry, and bridged target indexed before the Bridged Resolver event fires (with the fix sketch if a future Bridged Resolver violates it) - Add changeset for the breaking Resolver.bridged GraphQL type change (AccountId scalar → Registry interface) Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/resolver-bridged-registry-type.md | 5 +++++ .../src/lib/ensv2/canonicality-db-helpers.ts | 20 +++++++++++++++++++ .../ensv2/handlers/ensv2/ENSv2Registry.ts | 4 ++++ 3 files changed, 29 insertions(+) create mode 100644 .changeset/resolver-bridged-registry-type.md diff --git a/.changeset/resolver-bridged-registry-type.md b/.changeset/resolver-bridged-registry-type.md new file mode 100644 index 0000000000..c7fe7b0d3c --- /dev/null +++ b/.changeset/resolver-bridged-registry-type.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: `Resolver.bridged` now returns the bridged target `Registry` (resolved by id) instead of an `AccountId` scalar. Consumers selecting `bridged { ... }` now get the full `Registry` interface and can navigate into the bridged sub-registry's canonical Domain etc.; consumers reading `bridged` as an `AccountId` shape will need to update their selection. diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 36c5e5a4bc..351fbeb509 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -163,6 +163,26 @@ export async function handleSubregistryUpdated( * 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. + * + * Implied invariants — both currently true for every known Bridged Resolver and relied on by + * the helpers below: + * + * 1) **One originating Domain per bridged Registry.** Each bridged target Registry (e.g. the + * Basenames `ENSv1VirtualRegistry` for `base.eth`, the Lineanames equivalent for `linea.eth`) + * is the canonical sub-registry of exactly one originating mainnet Domain. If two mainnet + * Domains ever pointed at the same bridged Registry via Bridged Resolvers, detaching one + * would clear the canonical-domain pointer used by the other, orphaning the survivor's + * sub-tree. The 1:1 mapping is encoded in `isBridgedResolver`'s `(resolver address) → + * bridged registryId` table. + * + * 2) **Bridged target Registry is indexed before its originating Domain's Bridged Resolver + * event.** `handleRegistryCanonicalDomainUpdated` throws when the registry row is missing, + * so a Bridged Resolver event firing on the originating Domain before any subname on the + * bridged target chain is indexed (which is what creates the bridged Registry row) would + * crash the indexer. We rely on event ordering: every known Bridged Resolver targets a + * Registry whose first subname predates the Resolver attachment in block order. If a future + * Bridged Resolver violates this, the fix is to call `ensureRegistry(nextBridged.registryId, + * …)` here before the canonical-domain call (mirroring the v1 NewOwner non-TLD path). */ export async function handleBridgedResolverChange( context: IndexingEngineContext, diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts index d87210fabf..234b5b9ad4 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ENSv2Registry.ts @@ -292,6 +292,10 @@ export default function () { * `ParentUpdated(parent, label, sender)` is emitted by the _child_ Registry to claim its * canonical parent Domain in the namegraph. It may fire in either order relative to the parent * Registry's `SubregistryUpdated`/`LabelRegistered`. + * + * The `parent` address is interpreted as living on the same chain as the emitting (child) + * Registry — ENSv2 hierarchical registries are same-chain. Cross-chain parentage is expressed + * via Bridged Resolvers (CCIP-Read), not `ParentUpdated`. */ addOnchainEventListener( namespaceContract(pluginName, "ENSv2Registry:ParentUpdated"), From 57bc26553f6158c1630d9a901ff8d70a57f2db8f Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 10:42:51 -0500 Subject: [PATCH 21/28] docs: changeset for @ensnode/ensnode-sdk breaking changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRootRegistryIds removed (replaced by isRootRegistryId predicate) - BridgedResolverTarget shape: shadow → registryId (in /internal export) Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/ensnode-sdk-root-registry-bridged-target.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/ensnode-sdk-root-registry-bridged-target.md diff --git a/.changeset/ensnode-sdk-root-registry-bridged-target.md b/.changeset/ensnode-sdk-root-registry-bridged-target.md new file mode 100644 index 0000000000..8e54a8ca63 --- /dev/null +++ b/.changeset/ensnode-sdk-root-registry-bridged-target.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +**Breaking (`@ensnode/ensnode-sdk`)**: `getRootRegistryIds` is removed; use the new `isRootRegistryId(namespace, registryId)` predicate to test root membership instead. `getRootRegistryId` (singular, "preferred root") is unchanged. + +**Breaking (`@ensnode/ensnode-sdk/internal`)**: `BridgedResolverTarget` no longer has a `shadow: boolean` field; it now exposes `registryId: RegistryId` directly (the bridged target Registry's id), removing the need for downstream consumers to derive it. From ce477b0e8b48e2d56747c261f589fef603e4ab41 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 11:10:44 -0500 Subject: [PATCH 22/28] feat(ensapi)!: drop DomainsWhereInput.canonical filter every nameable Domain is canonical by definition, so the canonical filter on Query.domains was redundant. Query.domains now always scopes to Canonical Domains. consumers reading Domain.canonical directly are unaffected; consumers relying on canonical:false to surface non-canonical Domains via this query should switch to Account.domains / Registry.domains or read Domain.canonical directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/drop-domains-where-canonical.md | 5 +++ .../ensapi/src/omnigraph-api/schema/domain.ts | 5 --- .../schema/query.integration.test.ts | 34 +++++++------------ apps/ensapi/src/omnigraph-api/schema/query.ts | 5 +-- .../src/omnigraph/generated/schema.graphql | 9 ++--- 5 files changed, 24 insertions(+), 34 deletions(-) create mode 100644 .changeset/drop-domains-where-canonical.md diff --git a/.changeset/drop-domains-where-canonical.md b/.changeset/drop-domains-where-canonical.md new file mode 100644 index 0000000000..de39b9d08c --- /dev/null +++ b/.changeset/drop-domains-where-canonical.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +**Omnigraph (breaking)**: drop the `canonical: Boolean = false` field from `DomainsWhereInput` (used by `Query.domains`). Every nameable Domain is canonical by definition, so the filter was redundant; the query now always scopes to Canonical Domains. Consumers passing `where: { canonical: true }` should drop the field; consumers relying on `canonical: false` (or default) to surface non-canonical Domains via this query no longer can — read `Domain.canonical` directly or scope by `Account.domains` / `Registry.domains` instead. diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 2ea09b5bfa..72f8ce19cb 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -439,11 +439,6 @@ export const DomainsWhereInput = builder.inputType("DomainsWhereInput", { description: "A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'.", }), - canonical: t.boolean({ - description: - "Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). If false, the set of Domains is not filtered, and may include ENSv2 Domains not reachable by ENS Forward Resolution.", - defaultValue: false, - }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts index 6c6777e32a..bee8461e7a 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.integration.test.ts @@ -91,8 +91,8 @@ describe("Query.domains", () => { }; const QueryDomains = gql` - query QueryDomains($name: String!, $canonical: Boolean, $order: DomainsOrderInput) { - domains(where: { name: $name, canonical: $canonical }, order: $order) { + query QueryDomains($name: String!, $order: DomainsOrderInput) { + domains(where: { name: $name }, order: $order) { edges { node { __typename @@ -124,37 +124,29 @@ describe("Query.domains", () => { const domains = flattenConnection(result.domains); - // there's at least a v2 'eth' domain - expect(domains.length).toBeGreaterThanOrEqual(1); + // there's at least a v1 and a v2 'eth' domain + expect(domains.length).toBeGreaterThanOrEqual(2); - const v1EthDomain = domains.find( - (d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID, - ); - const v2EthDomain = domains.find( - (d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID, - ); - - // both ENSv1 and ENSv2 root registries are canonical (canonicality is about nameability, - // not addressability/resolvability), so the v1 'eth' Domain has its own canonical name. - expect(v1EthDomain).toMatchObject({ + expect( + domains.find((d) => d.__typename === "ENSv1Domain" && d.id === V1_ETH_DOMAIN_ID), + ).toMatchObject({ id: V1_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, node: ETH_NODE, }); - expect(v2EthDomain).toMatchObject({ + expect( + domains.find((d) => d.__typename === "ENSv2Domain" && d.id === V2_ETH_DOMAIN_ID), + ).toMatchObject({ id: V2_ETH_DOMAIN_ID, name: "eth", label: { interpreted: "eth" }, }); }); - it("filters by canonical", async () => { - const result = await request(QueryDomains, { - name: "parent", - canonical: true, - }); + it("returns only canonical domains", async () => { + const result = await request(QueryDomains, { name: "parent" }); const domains = flattenConnection(result.domains); @@ -169,7 +161,7 @@ describe("Query.domains", () => { }); // TODO: devnet fixture needs a known non-canonical Domain to assert exclusion against. - it.todo("excludes non-canonical domains when `canonical: true` is set"); + it.todo("excludes non-canonical domains from the result set"); }); describe("Query.domain", () => { diff --git a/apps/ensapi/src/omnigraph-api/schema/query.ts b/apps/ensapi/src/omnigraph-api/schema/query.ts index 4e5df509f7..e3a9204fc8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/query.ts +++ b/apps/ensapi/src/omnigraph-api/schema/query.ts @@ -110,7 +110,8 @@ builder.queryType({ // Find Domains //////////////// domains: t.connection({ - description: "Find Domains by Name.", + description: + "Find Canonical Domains by Name. Results are always scoped to Canonical Domains — every nameable Domain is canonical by definition, so a non-canonical filter would be empty.", type: DomainInterfaceRef, args: { where: t.arg({ type: DomainsWhereInput, required: true }), @@ -119,7 +120,7 @@ builder.queryType({ resolve: (_, { where, order, ...connectionArgs }, context) => { const base = domainsBase(); const named = filterByName(base, where.name); - const canonical = where.canonical === true ? filterByCanonical(named) : named; + const canonical = filterByCanonical(named); const domains = withOrderingMetadata(canonical); return resolveFindDomains(context, { domains, order, ...connectionArgs }); diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 1641730f3e..90e1f2f9a3 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -324,11 +324,6 @@ input DomainsOrderInput { """Filter for the top-level domains query.""" input DomainsWhereInput { - """ - Optional, defaults to false. If true, filters the set of Domains by those that are Canonical (i.e. reachable by ENS Forward Resolution). If false, the set of Domains is not filtered, and may include ENSv2 Domains not reachable by ENS Forward Resolution. - """ - canonical: Boolean = false - """ A partial Interpreted Name by which to search the set of Domains. ex: 'example', 'example.', 'example.et'. """ @@ -942,7 +937,9 @@ type Query { """Identify a Domain by Name or DomainId""" domain(by: DomainIdInput!): Domain - """Find Domains by Name.""" + """ + Find Canonical Domains by Name. Results are always scoped to Canonical Domains — every nameable Domain is canonical by definition, so a non-canonical filter would be empty. + """ domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection """Identify Permissions by ID or AccountId.""" From 7bec55dc55b9fad7bebd0d78645fdc3931b9facd Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 11:45:17 -0500 Subject: [PATCH 23/28] fix: update comments and gate bridged resolver canonicality --- .changeset/canonical-fields-omnigraph.md | 2 - .../src/lib/resolution/forward-resolution.ts | 10 +- .../find-domains/layers/base-domain-set.ts | 3 +- .../lib/find-domains/layers/filter-by-name.ts | 17 +- .../omnigraph-api/lib/get-canonical-path.ts | 13 +- .../lib/get-domain-by-interpreted-name.ts | 6 +- .../src/omnigraph-api/schema/resolver.ts | 2 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 163 +++++++++--------- .../is-bridged-resolver.ts | 103 +++++++---- .../ensnode-sdk/src/shared/root-registry.ts | 6 +- 10 files changed, 185 insertions(+), 140 deletions(-) diff --git a/.changeset/canonical-fields-omnigraph.md b/.changeset/canonical-fields-omnigraph.md index e34f1fc146..770533ff2a 100644 --- a/.changeset/canonical-fields-omnigraph.md +++ b/.changeset/canonical-fields-omnigraph.md @@ -3,5 +3,3 @@ --- **Omnigraph**: expose `Domain.canonical` and `Registry.canonical` on the Omnigraph schema. Both are non-null `Boolean!` fields indicating whether the entity participates in the Canonical Nametree. - -Canonicality describes nameability, not addressability or resolvability — both the ENSv1 and ENSv2 Root Registries are canonical, so ENSv1 Domains remain canonical even after ENSv2 is deployed and continue to surface a `Domain.name` and `Domain.path`. Forward Resolution still prefers the ENSv2 namegraph when both exist. diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 311121fb1c..ffea3405da 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -17,7 +17,7 @@ import { ForwardResolutionProtocolStep, type ForwardResolutionResult, getDatasourceContract, - getENSv1Registry, + getENSv1RootRegistry, maybeGetDatasourceContract, PluginName, type ResolverRecordsSelection, @@ -99,7 +99,7 @@ export async function resolveForward // initially be ENS Root Registry: see `_resolveForward` for additional context. return _resolveForward(interpretedName, selection, { ...options, - registry: getENSv1Registry(config.namespace), + registry: getENSv1RootRegistry(config.namespace), }); } @@ -245,7 +245,11 @@ async function _resolveForward( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, - () => _resolveForward(name, selection, { ...options, registry: bridged.registry }), + () => + _resolveForward(name, selection, { + ...options, + registry: bridged.targetRegistry, + }), ); } 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 d06c3c3c51..97c655bfad 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/base-domain-set.ts @@ -16,8 +16,7 @@ export type BaseDomainSet = ReturnType; * - parentId is the canonical parent Domain, derived inline by joining to the parent Registry of * this Domain (`registry.id = domain.registryId`) and then to the parent Domain named by * `registry.canonicalDomainId`, requiring that parent Domain's `subregistryId` agree back to - * the same Registry. This is the bidirectional canonical-edge agreement, computed on demand - * in lieu of a materialized parallel table. + * the same Registry. This is the bidirectional canonical-edge agreement that enforces a tree. * - sortableLabel is the Domain's own InterpretedLabel, used for NAME ordering * - all other values are directly sourced from Domain * 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 18276d0306..a1138a3cd5 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/layers/filter-by-name.ts @@ -49,14 +49,13 @@ function domainsByLabelHashPath(labelHashPath: LabelHashPath) { // Recursive CTE starting from the deepest child and traversing UP via the bidirectional // canonical-edge agreement (`registries.canonical_domain_id = domains.id` AND - // `domains.subregistry_id = registries.id`), computed on demand at each hop in lieu of a - // materialized parallel table. + // `domains.subregistry_id = registries.id`). // 1. Start with domains matching the leaf labelHash (deepest child) // 2. Recursively join parents via the agreement check, verifying each ancestor's labelHash // 3. Return both the leaf (for result/ownership) and head (for partial match) // // NOTE: JOIN (not LEFT JOIN) is intentional — we only match domains with a complete - // canonical path to the searched FQDN. + // canonical path to the searched `labelHashPath`. return ensDb .select({ // https://github.com/drizzle-team/drizzle-orm/issues/1242 @@ -129,7 +128,7 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { } if (concrete.length === 0) { - // No path traversal — sortableLabel is already the domain's own label from the base set + // no path traversal — sortableLabel is already the domain's own label from the base set return ensDb .select(selectBase(base)) .from(base) @@ -141,20 +140,20 @@ export function filterByName(base: BaseDomainSet, name?: string | null) { .as("baseDomains"); } - // Build path traversal CTE over the unified `domain` table. + // build path traversal CTE over the unified `domain` table. const labelHashPath = interpretedLabelsToLabelHashPath(concrete); const pathResults = domainsByLabelHashPath(labelHashPath); - // Alias for head domain lookup (to get headLabelHash for label join) + // alias for head domain lookup (to get headLabelHash for label join) const headDomain = alias(ensIndexerSchema.domain, "headDomain"); const headLabel = alias(ensIndexerSchema.label, "headLabel"); - // Join base set with path results, look up head domain's label, override sortableLabel. - // The inner join on pathResults scopes results to domains matching the concrete path. + // join base set with path results, look up head domain's label, override sortableLabel + // the INNER JOIN on pathResults scopes results to domains matching the concrete path return ensDb .select({ ...selectBase(base), - // Override sortableLabel with head domain's label for NAME ordering + // override sortableLabel with head domain's label for NAME ordering sortableLabel: sql`${headLabel.interpreted}`.as("sortableLabel"), }) .from(base) diff --git a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts index a9abc8917c..61a97e562a 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -7,9 +7,8 @@ import { MAX_SUPPORTED_NAME_DEPTH } from "@/omnigraph-api/lib/constants"; /** * 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`). + * Walks `domain → registry → registry.canonicalDomainId` upward until the registry has no canonical + * parent (root). Returns `null` when the input Domain is not itself canonical. */ export async function getCanonicalPath(domainId: DomainId): Promise { // Short-circuit for non-canonical Domains @@ -17,9 +16,9 @@ export async function getCanonicalPath(domainId: DomainId): Promise eq(t.id, domainId), columns: { canonical: true }, }); - if (!domain) { - throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' did not exist.`); - } + if (!domain) throw new Error(`Invariant(getCanonicalPath): DomainId '${domainId}' expected.`); + + // if the Domain is not Canonial, there's no path, so we can short-circuit with null if (!domain.canonical) return null; const result = await ensDb.execute(sql` @@ -36,7 +35,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise { const bridged = isBridgedResolver(config.namespace, parent); - return bridged?.registryId ?? null; + return bridged?.targetRegistryId ?? null; }, }), diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 351fbeb509..3a30c50dd9 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -11,32 +11,34 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng /** * Canonicality db helpers. * - * v1 and v2 share a single canonicality definition: a Registry is canonical iff it can be traced - * back to a Root Registry via canonical edges. A canonical edge between Registry R and parent - * Domain D requires both unidirectional pointers to agree - * (`R.canonicalDomainId = D.id` ↔ `D.subregistryId = R.id`) AND D itself to be canonical. Root - * Registries (ENSv1 root, ENSv2 root) are canonical by definition and seeded as such at - * `ensureRegistry` time. + * A Registry is canonical iff it can be traced back to a Root Registry via bi-directionally agreed + * upon canonical edges. A canonical edge between Registry R and parent Domain D requires both + * unidirectional pointers to agree (`R.canonicalDomainId = D.id` ↔ `D.subregistryId = R.id`) + * AND for D itself to be canonical. Root Registries (ENSv1 root, ENSv2 root) are canonical by + * definition and seeded as Canonical at `ensureRegistry`-time. * - * Concretely, this means a v1 Domain whose Bridged Resolver claims its sub-registry leaves its - * v1 children non-canonical. Example: mainnet `linea.eth` has a Bridged Resolver pointing at the - * Linea Registry. `bridge.linea.eth` exists on both mainnet (as a v1 child of mainnet's - * `linea.eth` virtual registry) and Linea (as a child of the bridged Linea Registry); but - * mainnet `linea.eth.subregistryId` points at the Linea Registry, not the mainnet v1 virtual - * registry, so agreement fails for the v1 virtual registry — it stays non-canonical, and so do - * its children. Only the Linea-side `bridge.linea.eth` (the resolution-visible one) is canonical. + * Concretely, this means a v1 Domain with a Bridged Resolver leaves its v1 children as non-canonical. + * Example: mainnet `linea.eth` has a Bridged Resolver pointing at the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry. `bridge.linea.eth` exists on both mainnet (as a v1 child of mainnet's + * `linea.eth` ENSv1VirtualRegistry) and Linea (as a child of the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry); but because mainnet's `linea.eth`'s Subregistry points at the Linea Chain's `linea.eth` + * ENSv1VirtualRegistry agreement fails for `bridge.linea.eth` and it stays non-canonical (and so do + * its theoretical children). Only the Linea-side `bridge.linea.eth` (the resolution-visible one) is + * canonical. This mirrors the ENSv2 definition of Canonicality (nameability is derived from having + * the Root Registry as the oldest ancestor). * * The unidirectional pointers `Registry.canonicalDomainId` and `Domain.subregistryId` are written - * blindly when their onchain events fire. Edge authentication is done on-demand at query time. - * The boolean flags `Registry.canonical` and `Domain.canonical` reflect membership in the canonical - * namegraph for PK-keyed query-time access, and are kept up-to-date by reconciling during updates - * to the uni-directional pointers. + * blindly when their onchain events fire, to store the on-chain state as-is. Edge authentication + * is done on-demand at query time. The boolean flags `Registry.canonical` and `Domain.canonical` + * are materialized from membership in the canonical namegraph, and are kept up-to-date by reconciling + * during updates to the uni-directional pointers. * * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade * the change through the canonical subgraph beneath it. Two paths: * - If the Registry has no descendants (`Registry.__hasChildren = false`, the dominant case * for fresh ENSv1 virtual registries on first wire-up), the cascade is a single-row flag - * flip done via an in-memory PK update. No raw SQL, no flush. + * flip done via an in-memory PK update. No raw SQL, no flush. This optimization no-ops + * the expensive reconciliation CTE for all ENSv1 Domains. * - Otherwise, a single recursive-CTE batch UPDATE walks the canonical subgraph via the * unidirectional pointers + inline agreement check, batch-updating every visited Registry * and its child Domains. This goes through `context.ensDb.sql`, which forces a Ponder cache @@ -44,20 +46,18 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * children AND whose canonicality actually flips — i.e. bridged-resolver attach/detach and * ENSv2 reparenting on already-populated subtrees. * - * `__hasChildren` is a synthetic monotonic sentinel on `Registry` (false → true on the first - * child Domain registered under it; never reset). See `ensureDomainInRegistry` for where it is - * flipped. + * `__hasChildren` is a monotonic sentinel on `Registry` (false → true on the first child Domain + * registered under it; never reset). See `ensureDomainInRegistry` for where it is flipped. */ /** * Idempotently inherit `canonical` on `domainId` from its parent `registryId`, and mark the * parent Registry as having children (`__hasChildren = true`). * - * The parent Registry's `canonical` flag has already been reconciled via the bidirectional - * pointer dance before any child Domain is registered under it (the parent Registry's - * `handleRegistryCanonicalDomainUpdated` / `handleSubregistryUpdated` calls run earlier in the - * same handler). So the new Domain trivially inherits the parent's current flag — no cascade - * required, since a brand-new Domain has no children of its own. + * Because of the invariant that Registry exist before Domain (thankfully true in both protocols), + * when a Domain is added to a Registry, the Registry's `canonical` flag has already been reconciled. + * So the new Domain trivially inherits the parent's current flag with no cascade required, since a + * brand-new Domain has no children of its own (thanks again to the above invariant). * * The `__hasChildren` write flips the parent Registry's sentinel from false to true on the * first child Domain ever registered under it. The sentinel is monotonic (Domain rows aren't @@ -127,7 +127,10 @@ export async function handleRegistryCanonicalDomainUpdated( } /** - * Handles canonicality when a Domain updates its Subregistry. + * Handles canonicality when a Domain updates its Subregistry. Similar to + * `handleRegistryCanonicalDomainUpdated`, the new Subregistry may not yet — `Domain.subregistryId` + * is set blindly. The canonical edge becomes "real" only when `Registry.canonicalDomainId` agrees, + * which may happen later via `handleRegistryCanonicalDomainUpdated`. */ export async function handleSubregistryUpdated( context: IndexingEngineContext, @@ -164,25 +167,18 @@ export async function handleSubregistryUpdated( * runs BEFORE Protocol Acceleration's NewResolver/ResolverUpdated handlers, which overwrite the * DRR row — see `apps/ensindexer/ponder/src/register-handlers.ts` for the ordering. * - * Implied invariants — both currently true for every known Bridged Resolver and relied on by - * the helpers below: + * We allow any originating Domain to set the Bridged Resolver's target Registry as its Subregistry + * (which is correct for aliased forward walks [i.e. domains are correctly addressable by + * "example.fakebase.eth"]) but only set the target Registry's Canonical Domain iff this is the + * expected originating Domain. * - * 1) **One originating Domain per bridged Registry.** Each bridged target Registry (e.g. the - * Basenames `ENSv1VirtualRegistry` for `base.eth`, the Lineanames equivalent for `linea.eth`) - * is the canonical sub-registry of exactly one originating mainnet Domain. If two mainnet - * Domains ever pointed at the same bridged Registry via Bridged Resolvers, detaching one - * would clear the canonical-domain pointer used by the other, orphaning the survivor's - * sub-tree. The 1:1 mapping is encoded in `isBridgedResolver`'s `(resolver address) → - * bridged registryId` table. - * - * 2) **Bridged target Registry is indexed before its originating Domain's Bridged Resolver - * event.** `handleRegistryCanonicalDomainUpdated` throws when the registry row is missing, - * so a Bridged Resolver event firing on the originating Domain before any subname on the - * bridged target chain is indexed (which is what creates the bridged Registry row) would - * crash the indexer. We rely on event ordering: every known Bridged Resolver targets a - * Registry whose first subname predates the Resolver attachment in block order. If a future - * Bridged Resolver violates this, the fix is to call `ensureRegistry(nextBridged.registryId, - * …)` here before the canonical-domain call (mirroring the v1 NewOwner non-TLD path). + * Implied invariant: Bridged target Registry is indexed before its originating Domain's Bridged + * Resolver event. `handleRegistryCanonicalDomainUpdated` throws when the registry row is missing, + * so a Bridged Resolver event firing on the originating Domain before any subname on the + * bridged target chain is indexed (which is what creates the bridged Registry row) would + * crash the indexer. If a future Bridged Resolver violates this, we should mirror the logic + * above and have two uni-directional pointer update helpers (one on ResolverChange [SubregistryUpdated] + * and one on Bridged Registry creation [CanonicalDomainUpdated]). */ export async function handleBridgedResolverChange( context: IndexingEngineContext, @@ -208,18 +204,28 @@ export async function handleBridgedResolverChange( // the previous and the next are identical, no-op // NOTE: this also covers the "neither are bridged resolvers" case (null === null) - if (prevBridged?.registryId === nextBridged?.registryId) return; + if (prevBridged?.targetRegistryId === nextBridged?.targetRegistryId) return; // if the previous resolver was a Bridged Resolver, we need to disconnect both links if (prevBridged) { + // update the domain's indicated subregistry await handleSubregistryUpdated(context, domainId, null); - await handleRegistryCanonicalDomainUpdated(context, prevBridged.registryId, null); + + // only update the Registry's Canonical Domain iff this is the correct originating Domain + if (prevBridged.originDomainId === domainId) { + await handleRegistryCanonicalDomainUpdated(context, prevBridged.targetRegistryId, null); + } } - // if the next resolver is a Bridged Resolver, we need to connect all links + // if the next resolver is a Bridged Resolver, we need to update the Domain's Subregistry if (nextBridged) { - await handleSubregistryUpdated(context, domainId, nextBridged.registryId); - await handleRegistryCanonicalDomainUpdated(context, nextBridged.registryId, domainId); + // update the domain's indicated subregistry + await handleSubregistryUpdated(context, domainId, nextBridged.targetRegistryId); + + // only update the Registry's Canonical Domain iff this is the correct originating Domain + if (nextBridged.originDomainId === domainId) { + await handleRegistryCanonicalDomainUpdated(context, nextBridged.targetRegistryId, domainId); + } } } @@ -267,46 +273,37 @@ async function reconcileRegistryCanonicality( // if the canonicality flag isn't changing, no-op (no cascade, no flush) if (registry.canonical === nextCanonical) return; - // the flag is flipping — cascade through the canonical subgraph (or short-circuit to an - // in-memory single-row update if the start registry has no descendants). - await cascadeCanonicality(context, registryId, nextCanonical, registry.__hasChildren); + if (registry.__hasChildren) { + // if the Registry has children, we use the CTE to bulk-update canonicality for this entire subtree + await cascadeCanonicality(context, registryId, nextCanonical); + } else { + // if Registry has no descendants, we can just update its own canoniality using ponder cache + // (this is the ENSv1 fast-path) + await context.ensDb + .update(ensIndexerSchema.registry, { id: registryId }) + .set({ canonical: nextCanonical }); + } } /** - * Walk the canonical subgraph rooted at `startRegistryId` and set `canonical = newValue` on + * Walk the canonical subgraph rooted at `registryId` and set `canonical = nextCanonical` on * every Registry and Domain it visits. * - * Optimistic short-circuit: if the start Registry's `__hasChildren` sentinel is false, we know - * structurally that there are no descendant rows for the cascade to update (no Domain has - * `registry_id = startRegistryId`, hence no descendant Registry can have an agreeing canonical - * edge into it either). In that case we flip just the start Registry's flag via an in-memory - * PK update — no raw SQL, no Ponder cache flush. This is the dominant case for fresh ENSv1 - * virtual registries on first wire-up. - * - * Otherwise we issue a single SQL statement: one recursive CTE that enumerates the canonical - * subgraph by following unidirectional pointers + agreement check, then two data-modifying CTEs - * that batch-update Registries and their child Domains. The walk visits exactly the rows whose - * canonicality flag must flip (the `IS DISTINCT FROM` filter skips rows already at the target - * value, which matters for the start registry — its flag is set in the same statement — and - * for any descendants that happen to already be consistent). + * Uses one recursive CTE that enumerates the canonical subgraph by following unidirectional + * pointers + agreement check, then two data-modifying CTEs that batch-update Registries and their + * child Domains. The walk visits exactly the rows whose canonicality flag must flip (the + * `IS DISTINCT FROM` filter skips rows already at the target value, which matters for the start + * registry — its flag is set in the same statement — and for any descendants that happen to already + * be consistent). */ async function cascadeCanonicality( context: IndexingEngineContext, - startRegistryId: RegistryId, - newValue: boolean, - startHasChildren: boolean, + registryId: RegistryId, + nextCanonical: boolean, ): Promise { - if (!startHasChildren) { - // no descendants → cascade is a single-row flag flip; do it in-memory - await context.ensDb - .update(ensIndexerSchema.registry, { id: startRegistryId }) - .set({ canonical: newValue }); - return; - } - await context.ensDb.sql.execute(sql` WITH RECURSIVE walk(registry_id) AS ( - SELECT ${startRegistryId}::text + SELECT ${registryId}::text UNION @@ -324,14 +321,14 @@ async function cascadeCanonicality( ), upd_reg AS ( UPDATE ${ensIndexerSchema.registry} - SET canonical = ${newValue} + SET canonical = ${nextCanonical} WHERE id IN (SELECT registry_id FROM walk) - AND canonical IS DISTINCT FROM ${newValue} + AND canonical IS DISTINCT FROM ${nextCanonical} RETURNING id ) UPDATE ${ensIndexerSchema.domain} - SET canonical = ${newValue} + SET canonical = ${nextCanonical} WHERE registry_id IN (SELECT registry_id FROM walk) - AND canonical IS DISTINCT FROM ${newValue}; + AND canonical IS DISTINCT FROM ${nextCanonical}; `); } 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 bb59767e9f..98c7ea7db9 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,31 +1,53 @@ -import { type AccountId, makeENSv1VirtualRegistryId, type RegistryId } from "enssdk"; +import { + type AccountId, + type DomainId, + makeENSv1DomainId, + makeENSv1VirtualRegistryId, + type RegistryId, +} from "enssdk"; -import { DatasourceNames } from "@ensnode/datasources"; +import { DatasourceNames, maybeGetDatasource } from "@ensnode/datasources"; import { + accountIdEqual, type ENSNamespaceId, getDatasourceContract, + getENSv1RootRegistry, getManagedName, - makeContractMatcher, } from "@ensnode/ensnode-sdk"; /** - * Describes a Bridged Resolver's Target + * Describes a Bridged Resolver's origin Domain and target Registry. */ -export interface BridgedResolverTarget { +export interface BridgedResolverConfig { + /** + * The Bridged Resolver connecting the origin Domain to the target Registry. + */ + resolver: AccountId; + + /** + * The DomainId of the _legitimate_ originating Domain on the ENS Root chain whose + * Bridged Resolver attachment canonicalizes the bridged target Registry. Anyone can set a + * known Bridged Resolver (e.g. `BasenamesL1Resolver`) as their resolver, but only the + * originating Domain (e.g. mainnet `base.eth`) is the canonical parent of the bridged + * target Registry. + */ + originDomainId: DomainId; + /** * The RegistryId of the _specific_ (Concrete or Virtual) Registry to which the Bridged Resolver defers. */ - registryId: RegistryId; + targetRegistryId: RegistryId; /** - * The AccountId of the Concrete Registry to which the Bridged Resolver defers. + * The AccountId of the Concrete Registry to which the Bridged Resolver defers, necessary for + * current ENSv1 Protocol Acceleration implementation. + * TODO: refactor Protocol Acceleration to operate over RegistryId instead of AccountId. */ - registry: AccountId; + targetRegistry: AccountId; } /** - * For a given `resolver`, if it is a known Bridged Resolver, return the (shadow)Registry it defers - * resolution to. + * Constructs the known Bridged Resolver Configurations for the provided `namespace`. * * These Bridged Resolvers must abide the following pattern: * 1. They _always_ emit OffchainLookup for any resolve() call to a well-known CCIP-Read Gateway, @@ -47,33 +69,56 @@ export interface BridgedResolverTarget { * TODO: these relationships could/should be encoded in an ENSIP * TODO: once Forward Resolution is updated for ENSv2, this likely just returns RegistryId */ -export function isBridgedResolver( - namespace: ENSNamespaceId, - resolver: AccountId, -): BridgedResolverTarget | null { - const resolverEq = makeContractMatcher(namespace, resolver); +const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { + const configs: BridgedResolverConfig[] = []; - // the ENSRoot's BasenamesL1Resolver bridges to the Basenames (shadow)Registry - if (resolverEq(DatasourceNames.ENSRoot, "BasenamesL1Resolver")) { + const basenames = maybeGetDatasource(namespace, DatasourceNames.Basenames); + if (basenames) { + const resolver = getDatasourceContract( + namespace, + DatasourceNames.ENSRoot, + "BasenamesL1Resolver", + ); const registry = getDatasourceContract(namespace, DatasourceNames.Basenames, "Registry"); const { node } = getManagedName(namespace, registry); - return { - registryId: makeENSv1VirtualRegistryId(registry, node), - registry, - }; + configs.push({ + resolver, + originDomainId: makeENSv1DomainId(getENSv1RootRegistry(namespace), node), + targetRegistry: registry, + targetRegistryId: makeENSv1VirtualRegistryId(registry, node), + }); } - // the ENSRoot's LineanamesL1Resolver bridges to the Lineanames (shadow)Registry - if (resolverEq(DatasourceNames.ENSRoot, "LineanamesL1Resolver")) { + const lineanames = maybeGetDatasource(namespace, DatasourceNames.Lineanames); + if (lineanames) { + const resolver = getDatasourceContract( + namespace, + DatasourceNames.ENSRoot, + "LineanamesL1Resolver", + ); const registry = getDatasourceContract(namespace, DatasourceNames.Lineanames, "Registry"); const { node } = getManagedName(namespace, registry); - return { - registryId: makeENSv1VirtualRegistryId(registry, node), - registry, - }; + configs.push({ + resolver, + originDomainId: makeENSv1DomainId(getENSv1RootRegistry(namespace), node), + targetRegistry: registry, + targetRegistryId: makeENSv1VirtualRegistryId(registry, node), + }); } - // TODO: ThreeDNS + return configs; +}; - return null; +/** + * For a given `resolver`, if it is a known Bridged Resolver, return its Bridged Resolver Config. + */ +export function isBridgedResolver( + namespace: ENSNamespaceId, + resolver: AccountId, +): BridgedResolverConfig | null { + return ( + getBridgedResolverConfigs(namespace).find((config) => + accountIdEqual(config.resolver, resolver), + ) ?? null + ); } diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index 2a79fffbd6..44157eb174 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -12,20 +12,20 @@ import { getDatasourceContract, maybeGetDatasourceContract } from "./datasource- /** * Gets the AccountId representing the ENSv1 Registry in the selected `namespace`. */ -export const getENSv1Registry = (namespace: ENSNamespaceId) => +export const getENSv1RootRegistry = (namespace: ENSNamespaceId) => getDatasourceContract(namespace, DatasourceNames.ENSRoot, "ENSv1Registry"); /** * Gets the ENSv1RegistryId representing the ENSv1 Root Registry in the selected `namespace`. */ export const getENSv1RootRegistryId = (namespace: ENSNamespaceId) => - makeENSv1RegistryId(getENSv1Registry(namespace)); + makeENSv1RegistryId(getENSv1RootRegistry(namespace)); /** * Determines whether `contract` is the ENSv1 Registry in `namespace`. */ export const isENSv1Registry = (namespace: ENSNamespaceId, contract: AccountId) => - accountIdEqual(getENSv1Registry(namespace), contract); + accountIdEqual(getENSv1RootRegistry(namespace), contract); ////////////// // ENSv2 From 7c1bc5ced62f83801797b364da5289560da38428 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 11:47:27 -0500 Subject: [PATCH 24/28] fix: update comments --- apps/ensapi/src/omnigraph-api/schema/domain.ts | 2 +- apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts | 4 ++-- .../src/content/docs/ensdb/concepts/database-schemas.mdx | 2 +- .../ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts | 4 ++-- packages/ensnode-sdk/src/shared/root-registry.ts | 6 ++---- packages/enssdk/src/omnigraph/generated/introspection.ts | 8 -------- packages/enssdk/src/omnigraph/generated/schema.graphql | 6 +++--- 7 files changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 72f8ce19cb..0c804c25a5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -172,7 +172,7 @@ DomainInterfaceRef.implement({ ///////////////// parent: t.field({ description: - "The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical.", + "The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical.", type: DomainInterfaceRef, nullable: true, resolve: async (domain, _args, context) => { diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 3a30c50dd9..39b628745c 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -30,7 +30,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * The unidirectional pointers `Registry.canonicalDomainId` and `Domain.subregistryId` are written * blindly when their onchain events fire, to store the on-chain state as-is. Edge authentication * is done on-demand at query time. The boolean flags `Registry.canonical` and `Domain.canonical` - * are materialized from membership in the canonical namegraph, and are kept up-to-date by reconciling + * are materialized from membership in the canonical nametree, and are kept up-to-date by reconciling * during updates to the uni-directional pointers. * * When `reconcileRegistryCanonicality` determines a Registry's flag actually flips, we cascade @@ -240,7 +240,7 @@ export async function handleBridgedResolverChange( * ∃ Domain P: * P.id = R.canonicalDomainId // R points up to P * AND P.subregistryId = R.id // P points down to R (bidirectional agreement) - * AND P.canonical // P itself is in the canonical namegraph + * AND P.canonical // P itself is in the canonical nametree * * Termination of the cascade walk relies on the canonical subgraph being a tree: each Registry * has at most one canonical parent Domain (enforced by the bidirectional agreement check), so diff --git a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx index 8a8bad5c85..50270421b3 100644 --- a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx @@ -96,7 +96,7 @@ For ENSv1, each domain that has children implicitly owns a "virtual" Registry (a `registryId` at the virtual registry. Concrete `ENSv1Registry` rows (e.g. the mainnet ENS Registry, the Basenames Registry, the Lineanames Registry) sit at the top. ENSv2 namegraphs are rooted in a single `ENSv2Registry` RootRegistry on the ENS Root Chain and are possibly circular directed -graphs. The canonical namegraph is never materialized, only _navigated_ at resolution-time. +graphs. The canonical nametree is never materialized, only _navigated_ at resolution-time. Note also that the Protocol Acceleration plugin is a hard requirement for the ENSv2 plugin. This allows us to rely on the shared logic for indexing: diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index efc2ae585d..7f36ebcfc5 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -219,7 +219,7 @@ export const registry = onchainTable( // the Registry's declared Canonical Domain (uni-directional) canonicalDomainId: t.text().$type(), - // Whether this Registry is part of the canonical namegraph. See canonicality-db-helpers.ts. + // Whether this Registry is part of the canonical nametree. See canonicality-db-helpers.ts. canonical: t.boolean().notNull().default(false), // Synthetic monotonic sentinel: flipped to true the first time a child Domain is registered @@ -285,7 +285,7 @@ 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. + // Whether this Domain is part of the canonical nametree. Mirrors the parent Registry's flag. canonical: t.boolean().notNull().default(false), // NOTE: Domain-Resolver Relations tracked via Protocol Acceleration plugin diff --git a/packages/ensnode-sdk/src/shared/root-registry.ts b/packages/ensnode-sdk/src/shared/root-registry.ts index 44157eb174..0a3a846441 100644 --- a/packages/ensnode-sdk/src/shared/root-registry.ts +++ b/packages/ensnode-sdk/src/shared/root-registry.ts @@ -83,16 +83,14 @@ export const maybeGetENSv2RootRegistryId = (namespace: ENSNamespaceId) => { /** * Gets the RegistryId representing the preferred Root Registry for the selected `namespace` — * the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry. Used as the entry - * point for resolution-time namegraph traversal. Note that both ENSv1 and ENSv2 Roots are - * canonical by axiom in their respective namegraphs; use `isRootRegistryId` to test membership. + * point for resolution-time namegraph traversal. */ export const getRootRegistryId = (namespace: ENSNamespaceId) => maybeGetENSv2RootRegistryId(namespace) ?? getENSv1RootRegistryId(namespace); /** * Determines whether `registryId` is a Root Registry (ENSv1 Root or, when defined, ENSv2 Root) - * for the selected `namespace`. Root Registries are canonical by axiom: they have no parent - * edge to derive canonicality from. + * for the selected `namespace`. */ export const isRootRegistryId = (namespace: ENSNamespaceId, registryId: RegistryId): boolean => registryId === getENSv1RootRegistryId(namespace) || diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 8af494461a..bf0f6a6889 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1612,14 +1612,6 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "DomainsWhereInput", "inputFields": [ - { - "name": "canonical", - "type": { - "kind": "SCALAR", - "name": "Boolean" - }, - "defaultValue": "false" - }, { "name": "name", "type": { diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 90e1f2f9a3..ffd7b2345c 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -234,7 +234,7 @@ interface Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain @@ -358,7 +358,7 @@ type ENSv1Domain implements Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain @@ -469,7 +469,7 @@ type ENSv2Domain implements Domain { owner: Account """ - The direct parent Domain in the canonical namegraph or null if this Domain is a root-level Domain or is not Canonical. + The direct parent Domain in the canonical nametree or null if this Domain is a root-level Domain or is not Canonical. """ parent: Domain From 76fc32b7d52178e1203386364578309aa5df673e Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 11:55:58 -0500 Subject: [PATCH 25/28] docs: address remaining bot review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleRegistryCanonicalDomainUpdated: docstring now matches the single-reconcile body; invariant tag matches the function name - Resolver.bridged changeset: lead with the migration (scalar → Registry interface) instead of the post-change shape Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/resolver-bridged-registry-type.md | 2 +- apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/resolver-bridged-registry-type.md b/.changeset/resolver-bridged-registry-type.md index c7fe7b0d3c..53b533ddcd 100644 --- a/.changeset/resolver-bridged-registry-type.md +++ b/.changeset/resolver-bridged-registry-type.md @@ -2,4 +2,4 @@ "ensapi": minor --- -**Omnigraph (breaking)**: `Resolver.bridged` now returns the bridged target `Registry` (resolved by id) instead of an `AccountId` scalar. Consumers selecting `bridged { ... }` now get the full `Registry` interface and can navigate into the bridged sub-registry's canonical Domain etc.; consumers reading `bridged` as an `AccountId` shape will need to update their selection. +**Omnigraph (breaking)**: `Resolver.bridged` is no longer an `AccountId` scalar; it now returns the bridged target `Registry` interface. Consumers should change their selection from `bridged` (scalar) to `bridged { ... }` (Registry interface) — the new shape exposes the full `Registry` and allows navigation into the bridged sub-registry's canonical Domain etc. diff --git a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts index 39b628745c..7c7347b54a 100644 --- a/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/canonicality-db-helpers.ts @@ -92,8 +92,8 @@ export async function ensureDomainInRegistry( /** * Set `registryId`'s canonical parent Domain (or unset if null) by writing the unidirectional - * `Registry.canonicalDomainId` pointer, then reconciling canonicality for both the previous - * edge (if any) and the next edge. + * `Registry.canonicalDomainId` pointer, then reconciling this Registry's canonicality flag + * (which cascades through its descendants if it flips). * * The new canonical Domain need not exist yet — `Registry.canonicalDomainId` is set blindly. The * canonical edge becomes "real" only when `Domain.subregistryId` agrees, which may happen later @@ -107,7 +107,7 @@ export async function handleRegistryCanonicalDomainUpdated( const registry = await context.ensDb.find(ensIndexerSchema.registry, { id: registryId }); if (!registry) { throw new Error( - `Invariant(setRegistryCanonicalDomain): Registry ${registryId} does not yet exist.`, + `Invariant(handleRegistryCanonicalDomainUpdated): Registry ${registryId} does not yet exist.`, ); } From df23444aceb3e4ea9150084be9a43c35f14f1647 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 11:59:32 -0500 Subject: [PATCH 26/28] fix: address bot review nits + memoize bridged resolver configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canonicality-db-helpers: typo "canoniality"; grammar "may not yet" → "need not exist yet" - get-canonical-path: typo "Canonial" - ensdb/database-schemas docs: align with current canonicality model (boolean flags + on-demand agreement check, no parallel materialization tables) - isBridgedResolver: memoize per-namespace BridgedResolverConfig[] — sits on every NewResolver/ResolverUpdated event during indexing and every forward-resolution hop, configs are pure function of namespace Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/omnigraph-api/lib/get-canonical-path.ts | 2 +- .../src/lib/ensv2/canonicality-db-helpers.ts | 4 ++-- .../docs/ensdb/concepts/database-schemas.mdx | 6 +++++- .../protocol-acceleration/is-bridged-resolver.ts | 16 +++++++++++++++- 4 files changed, 23 insertions(+), 5 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 61a97e562a..04e99febae 100644 --- a/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts +++ b/apps/ensapi/src/omnigraph-api/lib/get-canonical-path.ts @@ -18,7 +18,7 @@ export async function getCanonicalPath(domainId: DomainId): Promise { +const buildBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { const configs: BridgedResolverConfig[] = []; const basenames = maybeGetDatasource(namespace, DatasourceNames.Basenames); @@ -109,6 +109,20 @@ const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverCo return configs; }; +// `isBridgedResolver` sits on hot paths (every NewResolver / ResolverUpdated event during +// indexing, every forward-resolution hop). The configs are a function of `namespace` only and +// never change at runtime, so memoize per-namespace to avoid re-walking the datasource catalog +// + recomputing managed-name + node hashes on every call. +const bridgedResolverConfigsByNamespace = new Map(); +const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { + let configs = bridgedResolverConfigsByNamespace.get(namespace); + if (!configs) { + configs = buildBridgedResolverConfigs(namespace); + bridgedResolverConfigsByNamespace.set(namespace, configs); + } + return configs; +}; + /** * For a given `resolver`, if it is a known Bridged Resolver, return its Bridged Resolver Config. */ From 82e3898a841893362e0e63c3aff394ec9a4a89ad Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 12:11:39 -0500 Subject: [PATCH 27/28] refactor: collapse bridged resolver config cache into single function inline the cache check at entry + cache write at exit instead of splitting build/cached-accessor across two functions. one name for callers, one place to edit when adding a new bridge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../is-bridged-resolver.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) 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 001c7831c6..70892a28a1 100644 --- a/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts +++ b/packages/ensnode-sdk/src/shared/protocol-acceleration/is-bridged-resolver.ts @@ -15,6 +15,9 @@ import { getManagedName, } from "@ensnode/ensnode-sdk"; +// simple cache of BridgedResolverConfig by namespace because it's in an indexing hot-path +const cache = new Map(); + /** * Describes a Bridged Resolver's origin Domain and target Registry. */ @@ -69,7 +72,10 @@ export interface BridgedResolverConfig { * TODO: these relationships could/should be encoded in an ENSIP * TODO: once Forward Resolution is updated for ENSv2, this likely just returns RegistryId */ -const buildBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { +const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { + const cached = cache.get(namespace); + if (cached) return cached; + const configs: BridgedResolverConfig[] = []; const basenames = maybeGetDatasource(namespace, DatasourceNames.Basenames); @@ -106,20 +112,8 @@ const buildBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolver }); } - return configs; -}; + cache.set(namespace, configs); -// `isBridgedResolver` sits on hot paths (every NewResolver / ResolverUpdated event during -// indexing, every forward-resolution hop). The configs are a function of `namespace` only and -// never change at runtime, so memoize per-namespace to avoid re-walking the datasource catalog -// + recomputing managed-name + node hashes on every call. -const bridgedResolverConfigsByNamespace = new Map(); -const getBridgedResolverConfigs = (namespace: ENSNamespaceId): BridgedResolverConfig[] => { - let configs = bridgedResolverConfigsByNamespace.get(namespace); - if (!configs) { - configs = buildBridgedResolverConfigs(namespace); - bridgedResolverConfigsByNamespace.set(namespace, configs); - } return configs; }; From e77f7fc5dcb6e555ebb9c0d1aa23295aaad55760 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 8 May 2026 12:32:47 -0500 Subject: [PATCH 28/28] fix: remove unnecessary changeset --- .changeset/ensnode-sdk-root-registry-bridged-target.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/ensnode-sdk-root-registry-bridged-target.md b/.changeset/ensnode-sdk-root-registry-bridged-target.md index 8e54a8ca63..a638370557 100644 --- a/.changeset/ensnode-sdk-root-registry-bridged-target.md +++ b/.changeset/ensnode-sdk-root-registry-bridged-target.md @@ -3,5 +3,3 @@ --- **Breaking (`@ensnode/ensnode-sdk`)**: `getRootRegistryIds` is removed; use the new `isRootRegistryId(namespace, registryId)` predicate to test root membership instead. `getRootRegistryId` (singular, "preferred root") is unchanged. - -**Breaking (`@ensnode/ensnode-sdk/internal`)**: `BridgedResolverTarget` no longer has a `shadow: boolean` field; it now exposes `registryId: RegistryId` directly (the bridged target Registry's id), removing the need for downstream consumers to derive it.